@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
@@ -0,0 +1,870 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * gorunum:v1-advsearch — the Advanced search dialog.
4
+ *
5
+ * ⚠⚠ The rule this component was built under: **only offer what the backend
6
+ * can actually do.** A control wired to a parameter the server never reads
7
+ * looks identical to one that works and quietly changes nothing, which is
8
+ * worse than not shipping it (the same sentence already governs the filter
9
+ * row — see the header of `lib/fileFilters.ts`).
10
+ *
11
+ * The split is still real, and it is still said ON SCREEN — but as one
12
+ * sentence under the count rather than as two half-page captions over two
13
+ * stacked halves (the reference shell's layout, owner's call 2026-09-12:
14
+ * "görüntü birebir yapalım"). What the halves are:
15
+ *
16
+ * 1. What the SERVER answers — the text, the tag filters and the scope.
17
+ * Those are the whole of filex's search surface (`handlers/search.go`
18
+ * `searchRequest`: q/query, storage_id, limit, scope; the query text may
19
+ * carry `tag:` / `-tag:`). Drawn at the top: one wide field, one row of
20
+ * scope tabs, and the tag boxes.
21
+ * 2. What NARROWS THE ROWS THAT CAME BACK — type, modified, size, folder.
22
+ * No endpoint reads a date, size, mime or path parameter, so these run
23
+ * here, over the hits. Drawn in the two columns, and named for what they
24
+ * are by `advsearch.count.cost` directly under the number they produced:
25
+ * "Type, date, size and folder are applied to the rows that come back,
26
+ * not by the server."
27
+ *
28
+ * ⚠ Moving that sentence, or letting a redesign drop it, turns four honest
29
+ * controls into four that quietly promise a search the server never runs.
30
+ *
31
+ * **Owner IS drawn now, and the reason it was not is gone.** This block used
32
+ * to read "there is no per-node owner on the wire". Migration 00038 made
33
+ * ownership a real fact on the row and the listing projection carries
34
+ * `owner_id` / `owner_name` / `owner_self` on every row that has one, so the
35
+ * Owner select answers from a field the rows ALREADY carry — the same bargain
36
+ * as type, date and size, and it is named in the same sentence under the
37
+ * count. ⚠ It is offered on NAME searches only: the explorer maps a content
38
+ * hit onto the listing shape by hand (`FileExplorer.advHitToNode`) and that
39
+ * mapping does not carry the owner through, so under the content scopes every
40
+ * row would read as "System" — a wrong answer, which is worse than a control
41
+ * that says why it is off.
42
+ *
43
+ * Still not drawn, each for a reason that was MEASURED against the reference
44
+ * build on 2026-09-13 rather than assumed:
45
+ * - **A "Paths" scope tab.** The reference sends `scope:"path"` and its own
46
+ * server answers it with the byte-identical result set `scope:"name"`
47
+ * returns — measured over four queries incl. `Code/api`, which matches a
48
+ * path and no name. Paths are matched INSIDE the name scope (the index
49
+ * queries the `path` field there, `search.nameQuery`), so the tab renames
50
+ * a search rather than adding one.
51
+ * - **A "Tags" scope tab.** `search.ParseScope` is name|content|all; the
52
+ * reference's `scope:"tags"` returns nothing for every query on its own
53
+ * install. Ours has the stronger thing already: two boxes that become
54
+ * `tag:` / `-tag:` tokens the backend resolves against the database.
55
+ * - **"Match whole phrase".** The query language has no phrase operator. The
56
+ * only quoting the parser knows keeps a `tag:"two words"` value together
57
+ * (`search.splitQuery`); quotes around free text stay in the text.
58
+ * - **A storage chooser and a "search in / all storages" scope.** Both need
59
+ * the explorer to send the search somewhere else — `api.search(target, …)`
60
+ * builds the target from where the explorer is standing, and only the
61
+ * multi-storage virtual root searches across drives. A picker drawn here
62
+ * could narrow the rows that came back, which for a single-storage search
63
+ * can only ever mean "the drive you are already on". The endpoint half is
64
+ * ready (see `handlers/search.go`: a hit now carries its storage NAME);
65
+ * the routing half is `FileExplorer.advTarget`, and is not this
66
+ * component's to write.
67
+ *
68
+ * The live count is a REAL query, debounced — the same search the button runs,
69
+ * counted after the client-side half. That costs a round trip, so the dialog
70
+ * prints that it does instead of presenting the number as free.
71
+ */
72
+ import { computed, ref, watch, onBeforeUnmount } from 'vue';
73
+ import type { LocaleCode } from '../types/ExplorerConfig';
74
+ import { useLocale } from '../composables/useLocale';
75
+ import Modal from '../modals/Modal.vue';
76
+ import { actionIconSvg } from '../lib/actionIcons';
77
+ import {
78
+ EMPTY_FILTERS,
79
+ type AroundSpan,
80
+ type ModifiedFilter,
81
+ type PathMode,
82
+ type PeopleFilter,
83
+ type PeopleOption,
84
+ type SizeFilter,
85
+ type TypeFilter,
86
+ } from '../lib/fileFilters';
87
+ import {
88
+ advQueryString,
89
+ advSearchEmpty,
90
+ emptyAdvSearch,
91
+ parseTagList,
92
+ type AdvCountResult,
93
+ type AdvScope,
94
+ type AdvSearchRequest,
95
+ } from '../lib/advSearch';
96
+
97
+ const props = defineProps<{
98
+ open: boolean;
99
+ locale: LocaleCode;
100
+ theme?: 'light' | 'dark' | 'auto';
101
+ /** Seeded from the toolbar field so the dialog continues the search in progress. */
102
+ initialQuery?: string;
103
+ /** Human name of the folder the dialog was opened from ("Documents"). */
104
+ folderLabel?: string;
105
+ /** Adapter-qualified folder the "only here" / "skip here" choice measures
106
+ * against (`qldemo://Documents`). Captured at open: a search rebases the
107
+ * explorer to the storage root, so by the time the filter runs the folder
108
+ * the user meant is no longer where the explorer is standing. */
109
+ pathBase?: string;
110
+ /**
111
+ * Whether the content scopes may be offered.
112
+ *
113
+ * ⚠ Not a preference. Content search exists only on `/api/files/search`,
114
+ * and a hit from there reaches the explorer without the drive it came from —
115
+ * with more than one storage mounted the explorer cannot tell which drive a
116
+ * hit is on, and a row labelled with the wrong drive is worse than a missing
117
+ * feature. With one storage every hit is necessarily that storage's, so the
118
+ * scopes are exact. The caller decides; this component only draws it.
119
+ *
120
+ * ⚠ The ENDPOINT half of that gap is closed as of 2026-09-13: a hit now
121
+ * carries `storage` (the drive's name) as well as its id, the same way the
122
+ * starred and recently-opened handlers have always answered
123
+ * (`handlers/search.go` `describeHits`). What is left is the explorer's own
124
+ * hand-written mapping, `FileExplorer.advHitToNode`, which builds a listing
125
+ * row from the hit and does not read that field — so the caller still has to
126
+ * pass `false` on a multi-storage install until it does.
127
+ */
128
+ contentSearch?: boolean;
129
+ /** Runs the real query and returns what it found. Owned by the explorer,
130
+ * which is the only thing holding the API client. */
131
+ count: (req: AdvSearchRequest) => Promise<AdvCountResult>;
132
+ }>();
133
+
134
+ const emit = defineEmits<{
135
+ (e: 'close'): void;
136
+ (e: 'submit', req: AdvSearchRequest): void;
137
+ }>();
138
+
139
+ const { t } = useLocale(() => props.locale);
140
+
141
+ /* ── form state ──────────────────────────────────────────────────────────── */
142
+
143
+ /**
144
+ * The query box, focused when the dialog opens.
145
+ *
146
+ * ⚠ Not a flourish — it replaces something that broke. Modal autofocuses the
147
+ * first focusable element inside its card, which used to be the × in its own
148
+ * title bar; this dialog folds that bar away (it draws its own head), and a
149
+ * `display:none` button cannot take focus, so opening the dialog left focus on
150
+ * the opener and nothing in the dialog was focused at all. The field is the
151
+ * right answer anyway: a search dialog opens ready to be typed into. The delay
152
+ * clears Modal's own 30ms autofocus attempt rather than racing it.
153
+ */
154
+ const queryEl = ref<HTMLInputElement | null>(null);
155
+
156
+ const text = ref('');
157
+ const scope = ref<AdvScope>('name');
158
+ const tagsRaw = ref('');
159
+ const excludeRaw = ref('');
160
+ const type = ref<TypeFilter>('any');
161
+ const modified = ref<ModifiedFilter>('any');
162
+ const aroundDate = ref('');
163
+ const aroundSpan = ref<AroundSpan>('d1');
164
+ /**
165
+ * Whose rows to keep.
166
+ *
167
+ * ⚠ The same `PeopleFilter` the filter row's People pill uses, narrowed by the
168
+ * same `matchesPeople` predicate — two definitions of "files you put here"
169
+ * would be two products. `any` / `me` / `system` are offered unconditionally
170
+ * because a menu whose members appear and disappear is a menu nobody can
171
+ * learn; the per-account members are the ones that would be inert, so they are
172
+ * the ones that have to come from data (`peopleOptions`, below, reads them off
173
+ * the run that produced the count).
174
+ */
175
+ const people = ref<PeopleFilter>('any');
176
+ const size = ref<SizeFilter>('any');
177
+ /* ⚠ `string | number`, not `string`. Vue's v-model casts the value of an
178
+ `<input type="number">` to a NUMBER on its own — no `.number` modifier
179
+ needed (`runtime-dom`: `castToNumber = number || vnode.props.type ===
180
+ 'number'`) — and hands back the raw string only when it does not parse, so
181
+ an empty field is `''` and a typed one is `1`. Typing these as `string` and
182
+ calling a string method on the value is not a type error the compiler can
183
+ see, and the failure is silent: see `bytes()` below. */
184
+ const sizeFrom = ref<string | number>('');
185
+ const sizeTo = ref<string | number>('');
186
+ const sizeUnit = ref<'kb' | 'mb' | 'gb'>('mb');
187
+ const pathMode = ref<PathMode>('any');
188
+
189
+ const TYPE_OPTIONS: TypeFilter[] = [
190
+ 'any', 'folder', 'document', 'spreadsheet', 'presentation', 'pdf',
191
+ 'image', 'video', 'audio', 'archive', 'code',
192
+ ];
193
+ const MODIFIED_OPTIONS: ModifiedFilter[] = ['any', 'today', '7d', '30d', 'year', 'around'];
194
+ const SIZE_OPTIONS: SizeFilter[] = ['any', 'lt1', '1to10', '10to100', 'gt100', 'range'];
195
+ const SPAN_OPTIONS: AroundSpan[] = ['h1', 'd1', 'w1'];
196
+ const UNIT_FACTOR: Record<'kb' | 'mb' | 'gb', number> = {
197
+ kb: 1024,
198
+ mb: 1024 ** 2,
199
+ gb: 1024 ** 3,
200
+ };
201
+
202
+ /**
203
+ * Scopes in the order they are OFFERED — which is not the same thing as which
204
+ * one is selected. `reset()` picks `name` either way, because it is the only
205
+ * scope the folder-search endpoint can serve; the order below is the reference
206
+ * shell's (widest first), and when content search is off the list collapses to
207
+ * the single scope that exists rather than drawing two tabs that would do the
208
+ * same thing.
209
+ */
210
+ const scopeOptions = computed<AdvScope[]>(() =>
211
+ props.contentSearch ? ['all', 'content', 'name'] : ['name'],
212
+ );
213
+
214
+ /**
215
+ * The tab glyph. Keys from `lib/actionIcons` only — the reference shell puts a
216
+ * mark on every scope tab, and an icon per tab drawn from the shared set keeps
217
+ * this row in the same voice as the menus rather than inventing a second one.
218
+ */
219
+ const SCOPE_ICON: Record<AdvScope, string> = {
220
+ all: 'search',
221
+ content: 'preview',
222
+ name: 'copy-path',
223
+ };
224
+
225
+ /** The three answers to "where does the folder come into it". */
226
+ const PATH_MODES: PathMode[] = ['any', 'here', 'skip'];
227
+
228
+ /** The People members that need no data behind them. Named by the catalogue,
229
+ * not by the directory — which is also why offering them cannot leak one. */
230
+ const PEOPLE_FIXED: PeopleFilter[] = ['any', 'me', 'system'];
231
+
232
+ /**
233
+ * Whether the Owner select can be trusted right now.
234
+ *
235
+ * ⚠ A capability check, not a preference — the same shape as `contentSearch`.
236
+ * A NAME search comes back through the manager's search action, whose rows are
237
+ * the listing projection and carry `owner_id` / `owner_name` / `owner_self`.
238
+ * The content scopes come back through `/api/files/search`, and the explorer
239
+ * maps those hits onto the listing shape by hand; that mapping carries no
240
+ * owner, so every row would read as "System" and both `me` and `system` would
241
+ * answer the wrong question confidently. Off, and it says so, until the
242
+ * mapping carries it (`FileExplorer.advHitToNode`).
243
+ */
244
+ const ownerFilterable = computed(() => scope.value === 'name');
245
+
246
+ /** Leaving the name scope takes the Owner filter with it — a disabled control
247
+ * that goes on filtering is the dishonest half of disabling it. */
248
+ watch(ownerFilterable, (ok) => {
249
+ if (!ok) people.value = 'any';
250
+ });
251
+
252
+ function reset() {
253
+ text.value = '';
254
+ scope.value = 'name';
255
+ tagsRaw.value = '';
256
+ excludeRaw.value = '';
257
+ type.value = 'any';
258
+ modified.value = 'any';
259
+ aroundDate.value = '';
260
+ aroundSpan.value = 'd1';
261
+ size.value = 'any';
262
+ sizeFrom.value = '';
263
+ sizeTo.value = '';
264
+ sizeUnit.value = 'mb';
265
+ pathMode.value = 'any';
266
+ people.value = 'any';
267
+ }
268
+
269
+ /* ── continuing the search that is actually running ───────────────────────
270
+ *
271
+ * ⚠⚠ The bug this exists for. Opening the dialog over a live advanced filter
272
+ * called `reset()`, so it came up saying `Type = any` and printed a count for
273
+ * a search NOBODY had run — measured at "14 matching items" over a listing
274
+ * holding 1. Worse than the wrong number: pressing Search from that state
275
+ * submitted the reset form and silently dropped the filter that was in force.
276
+ * The dialog could restart a search and never continue one.
277
+ *
278
+ * The fix is self-contained on purpose. The applied state lives in the
279
+ * explorer (`advFilters` / `advScope`) and could be handed back as a prop, but
280
+ * then the dialog would only remember in the ONE shell that passed it — the
281
+ * desktop app, the fm.example.com explorer and the work/fishapp embeds all mount
282
+ * this same component, and a continuity that works in one of them is two
283
+ * products. So the dialog remembers what IT submitted.
284
+ *
285
+ * `query` is the honesty check, not a nicety. The toolbar field holds exactly
286
+ * the wire query a submit produced (`applyAdvancedSearch` writes it there),
287
+ * and typing in that field CLEARS the advanced filters (`onToolbarSearch`).
288
+ * So "the seed still equals what I submitted" is the strongest available
289
+ * evidence that the filter we remember is the filter in force; anything else
290
+ * — a cleared box, an edited query, a plain search — falls back to `reset()`.
291
+ */
292
+ interface FormState {
293
+ text: string;
294
+ scope: AdvScope;
295
+ tagsRaw: string;
296
+ excludeRaw: string;
297
+ type: TypeFilter;
298
+ modified: ModifiedFilter;
299
+ aroundDate: string;
300
+ aroundSpan: AroundSpan;
301
+ size: SizeFilter;
302
+ sizeFrom: string | number;
303
+ sizeTo: string | number;
304
+ sizeUnit: 'kb' | 'mb' | 'gb';
305
+ pathMode: PathMode;
306
+ people: PeopleFilter;
307
+ }
308
+
309
+ function snapshot(): FormState {
310
+ return {
311
+ text: text.value,
312
+ scope: scope.value,
313
+ tagsRaw: tagsRaw.value,
314
+ excludeRaw: excludeRaw.value,
315
+ type: type.value,
316
+ modified: modified.value,
317
+ aroundDate: aroundDate.value,
318
+ aroundSpan: aroundSpan.value,
319
+ size: size.value,
320
+ sizeFrom: sizeFrom.value,
321
+ sizeTo: sizeTo.value,
322
+ sizeUnit: sizeUnit.value,
323
+ pathMode: pathMode.value,
324
+ people: people.value,
325
+ };
326
+ }
327
+
328
+ function restore(s: FormState) {
329
+ text.value = s.text;
330
+ scope.value = s.scope;
331
+ tagsRaw.value = s.tagsRaw;
332
+ excludeRaw.value = s.excludeRaw;
333
+ type.value = s.type;
334
+ modified.value = s.modified;
335
+ aroundDate.value = s.aroundDate;
336
+ aroundSpan.value = s.aroundSpan;
337
+ size.value = s.size;
338
+ sizeFrom.value = s.sizeFrom;
339
+ sizeTo.value = s.sizeTo;
340
+ sizeUnit.value = s.sizeUnit;
341
+ pathMode.value = s.pathMode;
342
+ people.value = s.people;
343
+ }
344
+
345
+ /** The form as it stood when Search was last pressed, with the wire query it
346
+ * produced. Not a ref: nothing renders it, and a reactive copy of the whole
347
+ * form would re-run the count watcher for a value the user cannot see. */
348
+ let submitted: { query: string; form: FormState } | null = null;
349
+
350
+ // Opening either CONTINUES the advanced search that is running, or seeds a
351
+ // fresh one from whatever the toolbar held.
352
+ watch(
353
+ () => props.open,
354
+ (v) => {
355
+ if (!v) return;
356
+ const seed = props.initialQuery ?? '';
357
+ if (submitted && submitted.query !== '' && submitted.query === seed) {
358
+ restore(submitted.form);
359
+ } else {
360
+ reset();
361
+ text.value = seed;
362
+ }
363
+ counted.value = null;
364
+ countError.value = false;
365
+ setTimeout(() => queryEl.value?.focus(), 60);
366
+ },
367
+ );
368
+
369
+ /**
370
+ * Bytes for one end of the custom range, or null when it is left open.
371
+ *
372
+ * ⚠⚠ Takes `unknown` on purpose. This read `raw.trim()` and typed the
373
+ * parameter `string`, which is what the ref was declared as — and Vue casts a
374
+ * number input's value to a NUMBER, so `trim` was not a function. The throw
375
+ * happened inside the `request` computed, which is what the count watcher
376
+ * reads, so the watcher's getter threw before it could fire: the dialog kept
377
+ * printing the PREVIOUS count and looked like a filter that simply did not
378
+ * narrow. Measured in a real browser on 2026-09-12 — "to = 0 MB" over two
379
+ * files of 16 and 184 bytes still said "2 matching items". Nothing in the
380
+ * console of the page under test, nothing in vue-tsc, nothing in the unit
381
+ * suite: a filter that is a no-op is indistinguishable from a filter that
382
+ * matched everything unless you check it against rows you know it must drop.
383
+ *
384
+ * An empty field is `''` and must stay "open end", never `Number('') === 0` —
385
+ * a zero ceiling means nothing passes.
386
+ */
387
+ function bytes(raw: unknown): number | null {
388
+ const s = String(raw ?? '').trim();
389
+ if (!s) return null;
390
+ const n = Number(s);
391
+ if (!Number.isFinite(n) || n < 0) return null;
392
+ return Math.round(n * UNIT_FACTOR[sizeUnit.value]);
393
+ }
394
+
395
+ const request = computed<AdvSearchRequest>(() => ({
396
+ ...emptyAdvSearch(props.pathBase ?? ''),
397
+ text: text.value,
398
+ tags: parseTagList(tagsRaw.value),
399
+ excludeTags: parseTagList(excludeRaw.value),
400
+ scope: scope.value,
401
+ filters: {
402
+ ...EMPTY_FILTERS,
403
+ type: type.value,
404
+ modified: modified.value,
405
+ aroundDate: aroundDate.value,
406
+ aroundSpan: aroundSpan.value,
407
+ size: size.value,
408
+ sizeMin: size.value === 'range' ? bytes(sizeFrom.value) : null,
409
+ sizeMax: size.value === 'range' ? bytes(sizeTo.value) : null,
410
+ pathMode: pathMode.value,
411
+ pathBase: props.pathBase ?? '',
412
+ /* ⚠ Read through the guard, not off the ref. The `ownerFilterable` watch
413
+ below already clears the choice when the scope leaves `name`, so this is
414
+ the SECOND lock, not the first: a disabled select cannot be changed but
415
+ it can still hold a value chosen before the scope moved, and a filter
416
+ that outlives its own control narrows a search nobody asked it to. The
417
+ two are worth keeping apart — one is what the user sees change, the
418
+ other is what actually reaches the predicate. */
419
+ people: ownerFilterable.value ? people.value : 'any',
420
+ },
421
+ }));
422
+
423
+ const nothingToAsk = computed(() => advSearchEmpty(request.value));
424
+
425
+ /** Exactly what would go on the wire, shown to the user. The tag box becomes
426
+ * `tag:` tokens and there is no reason to hide that — somebody who learns the
427
+ * syntax here can type it straight into the toolbar next time. */
428
+ const wireQuery = computed(() => advQueryString(request.value));
429
+
430
+ /* ── the live count: a real search, debounced ────────────────────────────── */
431
+
432
+ const counted = ref<AdvCountResult | null>(null);
433
+ const counting = ref(false);
434
+ const countError = ref(false);
435
+
436
+ /**
437
+ * The per-account People members, offered only when a real search actually
438
+ * returned rows belonging to somebody else.
439
+ *
440
+ * ⚠ Derived from the count run, never from a directory. The dialog holds no
441
+ * listing of its own, and offering every account on the install would put
442
+ * names in the menu that cannot narrow anything here — the same reason
443
+ * `peopleOptions` in `lib/fileFilters.ts` derives the pill's members from the
444
+ * rows in hand. A count result that does not report them (the field is
445
+ * optional, and the explorer does not fill it yet) simply offers the three
446
+ * that need no data.
447
+ */
448
+ const peopleOptions = computed<PeopleOption[]>(() => [
449
+ ...PEOPLE_FIXED.map((value) => ({ value })),
450
+ ...(counted.value?.people ?? []),
451
+ ]);
452
+
453
+ /** The label for one member: the fixed three are named by the catalogue, an
454
+ * account by itself. A nameless account falls back to the neutral word rather
455
+ * than printing `u:7` at somebody. */
456
+ function peopleLabel(o: PeopleOption): string {
457
+ if (o.value === 'any' || o.value === 'me' || o.value === 'system') {
458
+ return t(`filter.people.${o.value}`);
459
+ }
460
+ return o.name || t('filter.people.someone');
461
+ }
462
+
463
+ /**
464
+ * ⚠ A member can leave the menu — a later count over different rows may not
465
+ * contain that account at all — and a `<select>` whose value is no longer one
466
+ * of its options renders BLANK while it goes on filtering. Falling back to
467
+ * `any` keeps what is shown and what is applied the same thing.
468
+ */
469
+ watch(peopleOptions, (opts) => {
470
+ if (!opts.some((o) => o.value === people.value)) people.value = 'any';
471
+ });
472
+ let timer: ReturnType<typeof setTimeout> | null = null;
473
+ /** Only the newest run may write the answer — a slow early query landing after
474
+ * a fast later one would print a count for a search nobody is looking at. */
475
+ let seq = 0;
476
+
477
+ function scheduleCount() {
478
+ if (timer) clearTimeout(timer);
479
+ countError.value = false;
480
+ if (nothingToAsk.value) {
481
+ counted.value = null;
482
+ counting.value = false;
483
+ return;
484
+ }
485
+ counting.value = true;
486
+ timer = setTimeout(run, 450);
487
+ }
488
+
489
+ async function run() {
490
+ const mine = ++seq;
491
+ const req = request.value;
492
+ try {
493
+ const res = await props.count(req);
494
+ if (mine !== seq) return;
495
+ counted.value = res;
496
+ } catch {
497
+ if (mine !== seq) return;
498
+ counted.value = null;
499
+ countError.value = true;
500
+ } finally {
501
+ if (mine === seq) counting.value = false;
502
+ }
503
+ }
504
+
505
+ /**
506
+ * ⚠ The guard the `bytes()` bug earned. A throw while building `request` used
507
+ * to happen inside this watcher's own getter, so the watcher never fired and
508
+ * the dialog went on showing the count from before the change — a broken
509
+ * filter wearing the face of a filter that matched everything. A failure is
510
+ * now REPORTED (the count line says it could not run) instead of leaving a
511
+ * stale number on screen. `'ERR'` is a constant, not a timestamp: a value that
512
+ * changed every evaluation would re-trigger the watcher forever.
513
+ */
514
+ watch(
515
+ () => {
516
+ if (!props.open) return '';
517
+ try {
518
+ return JSON.stringify(request.value);
519
+ } catch {
520
+ return 'ERR';
521
+ }
522
+ },
523
+ (v) => {
524
+ if (!v) return;
525
+ if (v === 'ERR') {
526
+ if (timer) clearTimeout(timer);
527
+ seq++;
528
+ counted.value = null;
529
+ counting.value = false;
530
+ countError.value = true;
531
+ return;
532
+ }
533
+ scheduleCount();
534
+ },
535
+ );
536
+
537
+ onBeforeUnmount(() => {
538
+ if (timer) clearTimeout(timer);
539
+ });
540
+
541
+ /**
542
+ * The count sentence.
543
+ *
544
+ * ⚠ This dialog's most common real answer on a small folder IS one, and "1
545
+ * matching items" is what a bare `{n}` message produces. Both keys have a
546
+ * `_one` form, and `t()` picks it from `n` (composables/useLocale →
547
+ * countedKey) — the rule that used to be written out here, once per caller.
548
+ */
549
+ const countLabel = computed(() => {
550
+ const c = counted.value;
551
+ if (!c) return '';
552
+ return t(c.capped ? 'advsearch.count.capped' : 'advsearch.count.result', { n: String(c.count) });
553
+ });
554
+
555
+ function submit() {
556
+ if (nothingToAsk.value) return;
557
+ // Remember BEFORE emitting: the parent closes the dialog synchronously, and
558
+ // the `open` watcher that reads this runs on the next opening.
559
+ submitted = { query: wireQuery.value, form: snapshot() };
560
+ emit('submit', request.value);
561
+ }
562
+ </script>
563
+
564
+ <template>
565
+ <Modal
566
+ :open="open"
567
+ :title="t('advsearch.title')"
568
+ size="lg"
569
+ :theme="theme"
570
+ @close="emit('close')"
571
+ >
572
+ <form class="fe-advsearch" @submit.prevent="submit">
573
+ <!-- ── the dialog's own head ───────────────────────────────────────
574
+ Modal draws a plain title bar; this dialog needs the mark, the
575
+ title and the sentence under it as ONE block across the full
576
+ width, so it draws its own and the stylesheet folds Modal's away
577
+ (`.fe-modal__card:has(.fe-advsearch) .fe-modal__head`). The h2 up
578
+ there stays in the DOM on purpose — it is what `aria-labelledby`
579
+ on the dialog points at, so the name survives the fold. -->
580
+ <header class="fe-advsearch__head">
581
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
582
+ <span class="fe-advsearch__head-icon" aria-hidden="true" v-html="actionIconSvg('search')"></span>
583
+ <div class="fe-advsearch__head-text">
584
+ <p class="fe-advsearch__head-title">{{ t('advsearch.title') }}</p>
585
+ <p class="fe-advsearch__head-sub">{{ t('advsearch.subtitle') }}</p>
586
+ </div>
587
+ <button
588
+ type="button"
589
+ class="fe-advsearch__head-close"
590
+ :title="t('advsearch.close')"
591
+ :aria-label="t('advsearch.close')"
592
+ @click="emit('close')"
593
+ >
594
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
595
+ <span aria-hidden="true" v-html="actionIconSvg('close')"></span>
596
+ </button>
597
+ </header>
598
+
599
+ <!-- ── what the server answers: the text and the scope ─────────────
600
+ One wide field and one row of tabs, because this is the whole of
601
+ the question that leaves the browser. -->
602
+ <label class="fe-advsearch__box">
603
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
604
+ <span class="fe-advsearch__glass" aria-hidden="true" v-html="actionIconSvg('search')"></span>
605
+ <input
606
+ ref="queryEl"
607
+ v-model="text"
608
+ type="search"
609
+ class="fe-advsearch__input"
610
+ :placeholder="t('advsearch.query.placeholder')"
611
+ :aria-label="t('advsearch.query.label')"
612
+ data-testid="advsearch-query"
613
+ />
614
+ </label>
615
+
616
+ <div
617
+ class="fe-advsearch__seg fe-advsearch__seg--tabs"
618
+ role="tablist"
619
+ :aria-label="t('advsearch.scope.label')"
620
+ >
621
+ <button
622
+ v-for="s in scopeOptions"
623
+ :key="s"
624
+ type="button"
625
+ role="tab"
626
+ class="fe-advsearch__seg-btn"
627
+ :class="{ 'is-active': scope === s }"
628
+ :aria-selected="scope === s"
629
+ :data-testid="`advsearch-scope-${s}`"
630
+ @click="scope = s"
631
+ >
632
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
633
+ <span class="fe-advsearch__seg-icon" aria-hidden="true" v-html="actionIconSvg(SCOPE_ICON[s])"></span>
634
+ {{ t(`advsearch.scope.${s}`) }}
635
+ </button>
636
+ </div>
637
+
638
+ <!-- ── the two columns ─────────────────────────────────────────────
639
+ ⚠ Every control below the tabs narrows the rows that CAME BACK —
640
+ no endpoint reads a date, a size, a mime or a path. The dialog
641
+ says so in one sentence under the count rather than shouting it
642
+ over two half-page captions; the sentence is the part that has to
643
+ survive, not the caption. -->
644
+ <div class="fe-advsearch__cols">
645
+ <div class="fe-advsearch__col">
646
+ <div class="fe-advsearch__group">
647
+ <label class="fe-advsearch__field">
648
+ <span class="fe-advsearch__label">{{ t('filter.modified') }}</span>
649
+ <select v-model="modified" class="fe-input" data-testid="advsearch-modified">
650
+ <option v-for="o in MODIFIED_OPTIONS" :key="o" :value="o">
651
+ {{ t(`filter.modified.${o}`) }}
652
+ </option>
653
+ </select>
654
+ </label>
655
+ <div v-if="modified === 'around'" class="fe-advsearch__field">
656
+ <span class="fe-advsearch__sublabel">{{ t('advsearch.around.label') }}</span>
657
+ <div class="fe-advsearch__pair">
658
+ <input
659
+ v-model="aroundDate"
660
+ type="datetime-local"
661
+ class="fe-input"
662
+ :aria-label="t('advsearch.around.date')"
663
+ data-testid="advsearch-around-date"
664
+ />
665
+ <div class="fe-advsearch__seg" role="group" :aria-label="t('advsearch.around.label')">
666
+ <button
667
+ v-for="s in SPAN_OPTIONS"
668
+ :key="s"
669
+ type="button"
670
+ class="fe-advsearch__seg-btn"
671
+ :class="{ 'is-active': aroundSpan === s }"
672
+ :aria-pressed="aroundSpan === s"
673
+ @click="aroundSpan = s"
674
+ >
675
+ {{ t(`advsearch.around.${s}`) }}
676
+ </button>
677
+ </div>
678
+ </div>
679
+ </div>
680
+ </div>
681
+
682
+ <label class="fe-advsearch__field">
683
+ <span class="fe-advsearch__label">{{ t('filter.type') }}</span>
684
+ <select v-model="type" class="fe-input" data-testid="advsearch-type">
685
+ <option v-for="o in TYPE_OPTIONS" :key="o" :value="o">{{ t(`filter.type.${o}`) }}</option>
686
+ </select>
687
+ </label>
688
+
689
+ <!-- Whose it is. Same union and same predicate as the filter row's
690
+ People pill, so "files you put here" means one thing in this
691
+ product. Off under the content scopes, and it says why rather
692
+ than vanishing — see `ownerFilterable`. -->
693
+ <div class="fe-advsearch__group">
694
+ <label class="fe-advsearch__field">
695
+ <span class="fe-advsearch__label">{{ t('filter.people') }}</span>
696
+ <select
697
+ v-model="people"
698
+ class="fe-input"
699
+ :disabled="!ownerFilterable"
700
+ data-testid="advsearch-people"
701
+ >
702
+ <option v-for="o in peopleOptions" :key="o.value" :value="o.value">
703
+ {{ peopleLabel(o) }}
704
+ </option>
705
+ </select>
706
+ </label>
707
+ <p v-if="!ownerFilterable" class="fe-advsearch__hint fe-advsearch__hint--block">
708
+ {{ t('advsearch.people.name_only') }}
709
+ </p>
710
+ </div>
711
+
712
+ <div class="fe-advsearch__group">
713
+ <label class="fe-advsearch__field">
714
+ <span class="fe-advsearch__label">{{ t('advsearch.tags.label') }}</span>
715
+ <input
716
+ v-model="tagsRaw"
717
+ type="text"
718
+ class="fe-input"
719
+ :placeholder="t('advsearch.tags.placeholder')"
720
+ data-testid="advsearch-tags"
721
+ />
722
+ </label>
723
+ <label class="fe-advsearch__field">
724
+ <span class="fe-advsearch__label">{{ t('advsearch.tags.exclude') }}</span>
725
+ <input
726
+ v-model="excludeRaw"
727
+ type="text"
728
+ class="fe-input"
729
+ :placeholder="t('advsearch.tags.exclude_placeholder')"
730
+ data-testid="advsearch-extags"
731
+ />
732
+ </label>
733
+ <p class="fe-advsearch__hint fe-advsearch__hint--block">{{ t('advsearch.tags.hint') }}</p>
734
+ </div>
735
+ </div>
736
+
737
+ <div class="fe-advsearch__col">
738
+ <div class="fe-advsearch__group">
739
+ <label class="fe-advsearch__field">
740
+ <span class="fe-advsearch__label">{{ t('filter.size') }}</span>
741
+ <select v-model="size" class="fe-input" data-testid="advsearch-size">
742
+ <option v-for="o in SIZE_OPTIONS" :key="o" :value="o">{{ t(`filter.size.${o}`) }}</option>
743
+ </select>
744
+ </label>
745
+ <div v-if="size === 'range'" class="fe-advsearch__field">
746
+ <span class="fe-advsearch__sublabel">{{ t('advsearch.size.range_label') }}</span>
747
+ <div class="fe-advsearch__pair">
748
+ <input
749
+ v-model="sizeFrom"
750
+ type="number"
751
+ min="0"
752
+ class="fe-input fe-advsearch__num"
753
+ :placeholder="t('advsearch.size.from')"
754
+ :aria-label="t('advsearch.size.from')"
755
+ data-testid="advsearch-size-from"
756
+ />
757
+ <span class="fe-advsearch__dash" aria-hidden="true">–</span>
758
+ <input
759
+ v-model="sizeTo"
760
+ type="number"
761
+ min="0"
762
+ class="fe-input fe-advsearch__num"
763
+ :placeholder="t('advsearch.size.to')"
764
+ :aria-label="t('advsearch.size.to')"
765
+ data-testid="advsearch-size-to"
766
+ />
767
+ <select v-model="sizeUnit" class="fe-input fe-advsearch__unit" :aria-label="t('filter.size')">
768
+ <option value="kb">{{ t('unit.kb') }}</option>
769
+ <option value="mb">{{ t('unit.mb') }}</option>
770
+ <option value="gb">{{ t('unit.gb') }}</option>
771
+ </select>
772
+ </div>
773
+ </div>
774
+ </div>
775
+
776
+ <!-- The folder. A segmented trio rather than a menu, because the
777
+ three answers are the whole of the choice and the sentence
778
+ under it then names the folder the choice is about — the
779
+ folder captured when the dialog opened, not wherever the
780
+ explorer has since been rebased to. -->
781
+ <div class="fe-advsearch__group">
782
+ <span class="fe-advsearch__label">{{ t('advsearch.where.label') }}</span>
783
+ <div
784
+ class="fe-advsearch__seg fe-advsearch__seg--wide"
785
+ role="group"
786
+ :aria-label="t('advsearch.where.label')"
787
+ data-testid="advsearch-where"
788
+ >
789
+ <button
790
+ v-for="m in PATH_MODES"
791
+ :key="m"
792
+ type="button"
793
+ class="fe-advsearch__seg-btn"
794
+ :class="{ 'is-active': pathMode === m }"
795
+ :aria-pressed="pathMode === m"
796
+ :data-testid="`advsearch-where-${m}`"
797
+ @click="pathMode = m"
798
+ >
799
+ {{ t(`advsearch.where.${m}_short`) }}
800
+ </button>
801
+ </div>
802
+ <span class="fe-advsearch__hint">
803
+ {{ t(`advsearch.where.${pathMode}`, { folder: folderLabel || '' }) }}
804
+ </span>
805
+ </div>
806
+
807
+ <!-- What "contents" means here. This is the 200 KB sentence, kept
808
+ where the reference shell keeps its content options: a reader
809
+ who picks the Content tab has to learn what the index covers
810
+ BEFORE an empty result teaches them. -->
811
+ <div class="fe-advsearch__group">
812
+ <p class="fe-advsearch__grouphead">
813
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
814
+ <span class="fe-advsearch__grouphead-icon" aria-hidden="true" v-html="actionIconSvg('preview')"></span>
815
+ {{ t('advsearch.content.heading') }}
816
+ </p>
817
+ <p class="fe-advsearch__hint fe-advsearch__hint--block">
818
+ {{ contentSearch ? t('advsearch.scope.hint') : t('advsearch.scope.name_only') }}
819
+ </p>
820
+ </div>
821
+ </div>
822
+ </div>
823
+
824
+ <!-- ── the live count ──────────────────────────────────────────────
825
+ A real query, debounced, and the line under it says both what
826
+ that cost and which half of the form the server never saw. -->
827
+ <div class="fe-advsearch__count" data-testid="advsearch-count">
828
+ <div class="fe-advsearch__count-top">
829
+ <p class="fe-advsearch__count-line">
830
+ <span v-if="nothingToAsk">{{ t('advsearch.count.idle') }}</span>
831
+ <span v-else-if="counting">{{ t('advsearch.count.counting') }}</span>
832
+ <span v-else-if="countError">{{ t('advsearch.count.error') }}</span>
833
+ <strong v-else-if="counted">{{ countLabel }}</strong>
834
+ </p>
835
+ <button
836
+ v-if="!nothingToAsk"
837
+ type="button"
838
+ class="fe-advsearch__viewall"
839
+ data-testid="advsearch-viewall"
840
+ @click="submit"
841
+ >{{ t('advsearch.viewall') }}</button>
842
+ </div>
843
+ <p class="fe-advsearch__hint fe-advsearch__hint--block">{{ t('advsearch.count.cost') }}</p>
844
+ <p v-if="wireQuery" class="fe-advsearch__wire">
845
+ {{ t('advsearch.wire') }} <code>{{ wireQuery }}</code>
846
+ </p>
847
+ </div>
848
+ </form>
849
+
850
+ <template #actions>
851
+ <button type="button" class="fe-btn fe-advsearch__reset" @click="reset">
852
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
853
+ <span class="fe-advsearch__btn-icon" aria-hidden="true" v-html="actionIconSvg('refresh')"></span>
854
+ {{ t('advsearch.reset') }}
855
+ </button>
856
+ <button type="button" class="fe-btn" @click="emit('close')">{{ t('advsearch.cancel') }}</button>
857
+ <button
858
+ type="button"
859
+ class="fe-btn fe-btn--primary"
860
+ :disabled="nothingToAsk"
861
+ data-testid="advsearch-submit"
862
+ @click="submit"
863
+ >
864
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
865
+ <span class="fe-advsearch__btn-icon" aria-hidden="true" v-html="actionIconSvg('search')"></span>
866
+ {{ t('advsearch.submit') }}
867
+ </button>
868
+ </template>
869
+ </Modal>
870
+ </template>