@brftech/filex-core 0.44.1 → 0.45.1

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 (50) hide show
  1. package/dist/{ArchiveViewer-D_tJxJhJ.js → ArchiveViewer-QuNXAECN.js} +2 -2
  2. package/dist/{ArchiveViewer-D_tJxJhJ.js.map → ArchiveViewer-QuNXAECN.js.map} +1 -1
  3. package/dist/{CsvViewer-B95fArq0.js → CsvViewer-niuoz1Uk.js} +2 -2
  4. package/dist/{CsvViewer-B95fArq0.js.map → CsvViewer-niuoz1Uk.js.map} +1 -1
  5. package/dist/{DrawioViewer-BAaPMTge.js → DrawioViewer-BtqMiasr.js} +2 -2
  6. package/dist/{DrawioViewer-BAaPMTge.js.map → DrawioViewer-BtqMiasr.js.map} +1 -1
  7. package/dist/{EpubViewer-B-B7bRW7.js → EpubViewer-ERm7L1WN.js} +2 -2
  8. package/dist/{EpubViewer-B-B7bRW7.js.map → EpubViewer-ERm7L1WN.js.map} +1 -1
  9. package/dist/{IpynbViewer-COOTyOcu.js → IpynbViewer-Cmz5n0VR.js} +2 -2
  10. package/dist/{IpynbViewer-COOTyOcu.js.map → IpynbViewer-Cmz5n0VR.js.map} +1 -1
  11. package/dist/{MermaidViewer-CjVCmR3s.js → MermaidViewer-BJ5uOyhc.js} +2 -2
  12. package/dist/{MermaidViewer-CjVCmR3s.js.map → MermaidViewer-BJ5uOyhc.js.map} +1 -1
  13. package/dist/{PsdViewer-xsIYL9Gu.js → PsdViewer-DYCJF2BM.js} +2 -2
  14. package/dist/{PsdViewer-xsIYL9Gu.js.map → PsdViewer-DYCJF2BM.js.map} +1 -1
  15. package/dist/{TiffViewer-CtwnTxqL.js → TiffViewer-DbJv_0El.js} +2 -2
  16. package/dist/{TiffViewer-CtwnTxqL.js.map → TiffViewer-DbJv_0El.js.map} +1 -1
  17. package/dist/{Viewer3D-xYMwndEy.js → Viewer3D-DwZfYoO3.js} +2 -2
  18. package/dist/{Viewer3D-xYMwndEy.js.map → Viewer3D-DwZfYoO3.js.map} +1 -1
  19. package/dist/filex-core.js +298 -291
  20. package/dist/filex-core.umd.cjs +61 -61
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-BBToAjwL.js → index-1RaeWGFO.js} +14218 -13831
  23. package/dist/index-1RaeWGFO.js.map +1 -0
  24. package/dist/index.d.ts +253 -31
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +265 -99
  28. package/src/components/AdvancedSearch.vue +3 -3
  29. package/src/components/CommandPalette.vue +166 -42
  30. package/src/components/ConnectionsPanel.vue +3 -11
  31. package/src/components/SideNav.vue +16 -11
  32. package/src/components/plugin/PluginViewModal.vue +7 -0
  33. package/src/components/plugin/nodes/SurfacePdfFields.vue +16 -1
  34. package/src/components/public/PublicLinkPage.vue +6 -5
  35. package/src/composables/useFileApi.ts +32 -1
  36. package/src/composables/usePluginSurface.ts +12 -0
  37. package/src/composables/useSystemDark.ts +32 -0
  38. package/src/index.ts +16 -0
  39. package/src/lib/connectionGuides.ts +11 -15
  40. package/src/lib/downloadSelection.ts +34 -0
  41. package/src/lib/dragOut.ts +0 -0
  42. package/src/lib/searchHit.ts +129 -0
  43. package/src/lib/storageLine.ts +124 -0
  44. package/src/lib/webdavMount.ts +120 -0
  45. package/src/locales/en.ts +13 -0
  46. package/src/locales/tr.ts +13 -0
  47. package/src/styles/base.css +84 -7
  48. package/src/types/ExplorerConfig.ts +65 -1
  49. package/src/types/Plugins.ts +12 -0
  50. package/dist/index-BBToAjwL.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.44.1",
3
+ "version": "0.45.1",
4
4
  "description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-core.umd.cjs",
@@ -15,7 +15,7 @@
15
15
  * the difference.
16
16
  */
17
17
  import { computed, nextTick, onBeforeUnmount, onMounted, onScopeDispose, ref, watch, watchEffect } from 'vue';
18
- import type { ExplorerConfig, ThemeMode } from './types/ExplorerConfig';
18
+ import type { ExplorerConfig, SearchAccount, ThemeMode } from './types/ExplorerConfig';
19
19
  import type {
20
20
  FileNode,
21
21
  ShareInfo,
@@ -25,7 +25,7 @@ import type {
25
25
  ArchiveCreateFormat,
26
26
  } from './types/FileNode';
27
27
  import { isExternalUsable } from './types/FileNode';
28
- import { useFileApi, type GlobalSearchHit, type ManagerResponse } from './composables/useFileApi';
28
+ import { useFileApi, type GlobalSearchHit, type ManagerResponse, type QuotaSnapshot } from './composables/useFileApi';
29
29
  import {
30
30
  useUploadChunked,
31
31
  isStagedUnsupported,
@@ -34,6 +34,7 @@ import {
34
34
  import { useSelection } from './composables/useSelection';
35
35
  import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
36
36
  import { useLocale, localeTag } from './composables/useLocale';
37
+ import { useSystemDark } from './composables/useSystemDark';
37
38
  import { usePendingOps, type PendingOp } from './composables/usePendingOps';
38
39
  import { usePluginActions } from './composables/usePluginActions'; /* App plugins — docs/APP-PLUGINS-API.md */
39
40
  import { useRealtime } from './composables/useRealtime';
@@ -98,6 +99,7 @@ import {
98
99
  import ShortcutsHelp from './components/ShortcutsHelp.vue';
99
100
  /* /cila:c wiring */
100
101
  import { coverageByStorage, coverageNotice, type CatalogCoverage } from './lib/catalogCoverage';
102
+ import { needsMeasuredDrives, storageLine, type MeasuredDrive } from './lib/storageLine'; /* surucu:d1 — which number the storage line prints */
101
103
  /* wiring:c1 — tema galerisi */
102
104
  import ThemeGallery from './components/ThemeGallery.vue';
103
105
  import {
@@ -187,12 +189,14 @@ import { setNodeStarred } from './lib/star';
187
189
  import { emptyTrashAndFollow, TrashEmptyBusy, type TrashEmptyStatus } from './lib/trashEmpty';
188
190
  import { fetchAllTags, fetchTaggedRows, onTagsChanged, type TagItem, type TagKind } from './lib/tags';
189
191
  import { resolveTransfer, type TransferIntent } from './lib/transfer';
190
- import { downloadArchive } from './lib/downloadSelection'; /* tasi:m1 */
192
+ import { downloadArchive, requestFileLink } from './lib/downloadSelection'; /* tasi:m1, #71 */
193
+ import { hitItem, hitRelPath, hitStorageName, hitToNode, type HitDriveContext } from './lib/searchHit'; /* #47 */
191
194
  import { labelOfWire } from './lib/destinationTree'; /* tasi:m1 */
192
195
  import {
193
196
  activeNativeDrag,
194
197
  beginNativeDrag,
195
198
  canDownloadUrlDrag,
199
+ createDragLinks,
196
200
  dragKey,
197
201
  downloadUrlPayload,
198
202
  endNativeDrag,
@@ -979,6 +983,8 @@ const pluginView = ref<{
979
983
  view: string;
980
984
  surface: PluginSurface;
981
985
  path?: string;
986
+ /** Every row the view was opened on — a menu action on a selection (#64). */
987
+ paths?: string[];
982
988
  /** A `home` view is drawn full-size whatever the surface says. */
983
989
  size?: 'xl';
984
990
  } | null>(null);
@@ -1153,11 +1159,14 @@ async function runPluginAction(action: PluginActionRow, targets: FileNode[]) {
1153
1159
  try {
1154
1160
  const res = await pluginActions.run(action, targets);
1155
1161
  if (res.surface) {
1162
+ // ⚠ `paths` too: the screen's later events must name the same files the
1163
+ // run did, or its second screen talks about targets[0] alone (#64).
1156
1164
  pluginView.value = {
1157
1165
  plugin: action.plugin,
1158
1166
  view: action.view || action.id,
1159
1167
  surface: res.surface,
1160
1168
  path: targets[0]?.path,
1169
+ paths: targets.map((n) => n.path),
1161
1170
  };
1162
1171
  return;
1163
1172
  }
@@ -2110,7 +2119,8 @@ const advPathBase = ref('');
2110
2119
  * its owner) in `describeHits`, so there is nothing left to guess and the
2111
2120
  * scopes are offered everywhere. Kept as a computed rather than deleted: the
2112
2121
  * prop is public API, and an embedder pointed at an older backend still gets
2113
- * hits with no `storage` — which `advHitToNode` falls back for, row by row.
2122
+ * hits with no `storage` — which `hitToNode` (lib/searchHit) falls back for,
2123
+ * row by row.
2114
2124
  */
2115
2125
  const advContentAvailable = computed(() => true);
2116
2126
 
@@ -2118,46 +2128,6 @@ const advContentAvailable = computed(() => true);
2118
2128
  * uses 250 internally; matching it keeps the two scopes comparable. */
2119
2129
  const ADV_CONTENT_LIMIT = 250;
2120
2130
 
2121
- /** Map one raw search hit onto the listing shape.
2122
- *
2123
- * ⚠ The drive comes from the HIT (`describeHits` puts it there), not from the
2124
- * pane we happen to be standing in — a content search spans storages, so
2125
- * `adapter.value` is only the right answer by accident. It stays as the
2126
- * fallback for a backend older than that field. */
2127
- function advHitToNode(h: GlobalSearchHit, storageName: string): FileNode {
2128
- const rel = String(h.path ?? '').replace(/^\/+/, '');
2129
- const drive = typeof h.storage === 'string' && h.storage ? h.storage : storageName;
2130
- const name = String(h.name ?? rel.split('/').pop() ?? '');
2131
- const dot = name.lastIndexOf('.');
2132
- const mtime = typeof h.backend_mtime === 'string' ? h.backend_mtime : h.updated_at;
2133
- const ms = typeof mtime === 'string' ? Date.parse(mtime) : NaN;
2134
- return {
2135
- id: typeof h.id === 'number' ? h.id : undefined,
2136
- path: drive ? `${drive}://${rel}` : rel,
2137
- basename: name,
2138
- relativePath: rel,
2139
- type: h.type === 'dir' ? 'dir' : 'file',
2140
- extension: dot > 0 ? name.slice(dot + 1).toLowerCase() : '',
2141
- size: typeof h.size === 'number' ? h.size : 0,
2142
- // ⚠ Left UNSET when the row carries no parseable timestamp rather than
2143
- // defaulted to 0 or to now: `matchesModified` treats a missing timestamp
2144
- // as "unknown" and drops the row from a date filter, which is the honest
2145
- // answer. Stamping it with `Date.now()` would file every such file under
2146
- // "Today".
2147
- last_modified: Number.isNaN(ms) ? undefined : ms,
2148
- mime_type: typeof h.mime === 'string' ? h.mime : '',
2149
- /* The content snippet the hit came with, so a content match can show why
2150
- it matched. Undefined on name-only hits, exactly as the backend sends. */
2151
- snippet: typeof h.snippet === 'string' && h.snippet ? h.snippet : undefined,
2152
- /* Owner, so the People filter and the Owner column mean the same thing in a
2153
- content result as they do in a folder listing. Undefined rather than
2154
- guessed when the backend does not send it. */
2155
- owner_id: typeof h.owner_id === 'number' ? h.owner_id : undefined,
2156
- owner_name: typeof h.owner_name === 'string' ? h.owner_name : undefined,
2157
- owner_self: h.owner_self === true ? true : undefined,
2158
- };
2159
- }
2160
-
2161
2131
  /** Run one advanced search and hand back the rows, unfiltered — and whether
2162
2132
  * the answer was cut. The name scope has the server's own word for that
2163
2133
  * (`truncated`); /api/files/search's flag does not survive `globalSearch`,
@@ -2177,7 +2147,7 @@ async function advFetchRows(
2177
2147
  const hits = await api.globalSearch(query, { limit: ADV_CONTENT_LIMIT, scope });
2178
2148
  const storageName = adapter.value || (props.config.storages ?? [])[0]?.name || '';
2179
2149
  return {
2180
- rows: filterListing(hits.map((h) => advHitToNode(h, storageName))),
2150
+ rows: filterListing(hits.map((h) => hitToNode(h, storageName))),
2181
2151
  truncated: advSearchTruncated(hits.length, ADV_CONTENT_LIMIT),
2182
2152
  };
2183
2153
  }
@@ -2315,33 +2285,33 @@ function onInspectorShareCreated(payload: { path: string; url: string }) {
2315
2285
  * every surface that has a person behind it draws the same line — this is the
2316
2286
  * shell, not a profile. `quotaMe()` answers null for a server without the
2317
2287
  * route, and null renders nothing at all.
2288
+ *
2289
+ * ⚠ WHICH number the line prints is `lib/storageLine`'s decision, not this
2290
+ * function's. It used to print `used_bytes` for everybody — the person's
2291
+ * upload counter (`SUM(nodes.size) WHERE owner_id = me`, and the upload path
2292
+ * is the only one that sets an owner) — under "Storage", in the sentence
2293
+ * Home's card uses for a drive's size. Measured on a production install
2294
+ * (2026-09-25): card 245.3 GB, line 523.5 MB. Now a person with a quota sees
2295
+ * their share of it; everybody else sees the drives the panel lists, from the
2296
+ * same `homeStorages` the Home cards are drawn from, and the server is asked
2297
+ * only for the drives the host sent no size for (the desktop app sends names
2298
+ * only).
2318
2299
  */
2319
- const quotaSnapshot = ref<{ used: number; total: number; unlimited: boolean } | null>(null);
2300
+ const quotaMine = ref<QuotaSnapshot | null>(null);
2301
+ const quotaDrives = ref<MeasuredDrive[] | null>(null);
2302
+ /* ⚠ A lazy `computed`, and it has to stay one: `homeStorages` is declared
2303
+ further down, so anything that reads it at setup time — a `watch` on this
2304
+ line, say — throws "Cannot access before initialization" and takes the
2305
+ explorer down (the `isNarrow` note in onMounted). */
2306
+ const quotaSnapshot = computed(() => storageLine(quotaMine.value, homeStorages.value, quotaDrives.value));
2320
2307
  async function loadQuota() {
2321
2308
  if (!identitySurfaces.value) {
2322
- quotaSnapshot.value = null;
2309
+ quotaMine.value = null;
2323
2310
  return;
2324
2311
  }
2325
2312
  const q = await api.quotaMe();
2326
- if (!q) {
2327
- quotaSnapshot.value = null;
2328
- return;
2329
- }
2330
- const unlimited = !!q.unlimited || q.quota_bytes <= 0;
2331
- // ⚠ No ceiling AND nothing counted = nothing to say, so say nothing.
2332
- //
2333
- // Measured, not guessed: `used_bytes` is `SUM(nodes.size) WHERE owner_id = me`
2334
- // and `nodes.owner_id` is set by the UPLOAD path only — it is nil for every
2335
- // file a storage sync discovered (handlers/shared.go). On an install whose
2336
- // drives were mounted rather than uploaded into, the honest figure is
2337
- // therefore "0 B", and a line reading "0 B used" under a drive visibly full
2338
- // of files reads as a broken widget, not as a fact about quota. With a quota
2339
- // set the line still earns its place — the ceiling is real and uploads count
2340
- // against it — so only the no-quota-no-usage case is dropped.
2341
- quotaSnapshot.value =
2342
- unlimited && q.used_bytes <= 0
2343
- ? null
2344
- : { used: q.used_bytes, total: q.quota_bytes, unlimited };
2313
+ quotaMine.value = q;
2314
+ quotaDrives.value = needsMeasuredDrives(q, homeStorages.value) ? await api.storageUsage() : null;
2345
2315
  }
2346
2316
 
2347
2317
  /**
@@ -2982,9 +2952,14 @@ function toggleHiddenFiles() {
2982
2952
  * The listing is ours; the storage list is the host's (`config.storages`), so
2983
2953
  * the host is told and re-answers it in its own time. Nothing here waits on
2984
2954
  * that: the folder is on screen again either way.
2955
+ *
2956
+ * surucu:d1 — the storage line is read again too: the person's usage and the
2957
+ * drives' sizes both move, and a host that sends no sizes (the desktop app)
2958
+ * would otherwise keep the figure the panel mounted with.
2985
2959
  */
2986
2960
  function refreshAll() {
2987
2961
  void load();
2962
+ void loadQuota();
2988
2963
  emit('refresh');
2989
2964
  }
2990
2965
 
@@ -3700,34 +3675,74 @@ const showShortcutsHelp = ref(false);
3700
3675
 
3701
3676
  /* bul:s3 — palette "everywhere" search + open-hit navigation */
3702
3677
 
3703
- // Debounce/min-chars live in the palette; this is just the API call.
3704
- function paletteGlobalSearch(q: string): Promise<GlobalSearchHit[]> {
3705
- return api.globalSearch(q, { limit: 8, scope: 'all' });
3678
+ /** What this mount knows about drives, for a hit that does not name its own. */
3679
+ function hitDrives(): HitDriveContext {
3680
+ return { configured: (props.config.storages ?? []).map((s) => s.name), current: adapter.value };
3681
+ }
3682
+
3683
+ /**
3684
+ * #47 — the account a hit belongs to when it is NOT this mount's own, else
3685
+ * null. Only a host that searches several accounts stamps hits at all.
3686
+ */
3687
+ function foreignAccountOf(hit: GlobalSearchHit): SearchAccount | null {
3688
+ const hook = props.config.accountSearch;
3689
+ const acc = hit.account;
3690
+ if (!hook || !acc || acc.id === hook.self.id) return null;
3691
+ return acc;
3692
+ }
3693
+
3694
+ /** #47 — the hit as an item. Another account's hit is addressed by what the
3695
+ * HIT says and nothing else: this mount's drives are not that server's. */
3696
+ function paletteHitItem(hit: GlobalSearchHit) {
3697
+ return hitItem(hit, foreignAccountOf(hit) ? { configured: [], current: '' } : hitDrives());
3698
+ }
3699
+
3700
+ // Debounce/min-chars live in the palette; this is the API call — and, when
3701
+ // the host holds several accounts (#47), one call per account through the
3702
+ // host's hook, each answer stamped with its account. One account failing
3703
+ // costs its own group, not the others'.
3704
+ async function paletteGlobalSearch(q: string): Promise<GlobalSearchHit[]> {
3705
+ const own = api.globalSearch(q, { limit: 8, scope: 'all' });
3706
+ const hook = props.config.accountSearch;
3707
+ if (!hook) return own;
3708
+ let others: SearchAccount[] = [];
3709
+ try {
3710
+ others = (await hook.others()) ?? [];
3711
+ } catch {
3712
+ others = [];
3713
+ }
3714
+ if (others.length === 0) return own;
3715
+ const accounts = [hook.self, ...others];
3716
+ const answers = await Promise.allSettled([
3717
+ own,
3718
+ ...others.map((a) => hook.search(a.id, q, { limit: 8, scope: 'all' })),
3719
+ ]);
3720
+ const out: GlobalSearchHit[] = [];
3721
+ answers.forEach((r, i) => {
3722
+ if (r.status !== 'fulfilled' || !Array.isArray(r.value)) return;
3723
+ for (const h of r.value) out.push({ ...h, account: accounts[i] });
3724
+ });
3725
+ return out;
3706
3726
  }
3707
3727
 
3708
3728
  /**
3709
3729
  * Open a global-search hit: navigate to the file's folder, then select +
3710
3730
  * preview it through the existing openNode mechanics. Hits come back as raw
3711
3731
  * node rows (in-storage relative `path`, numeric `storage_id`), so the
3712
- * storage segment for multi-storage mode is resolved best-effort: an
3713
- * explicit name on the hit (future backends) > the only configured storage
3714
- * > the storage currently open. A wrong guess lands on the existing
3715
- * "folder not found" state, which is already a graceful dead-end.
3732
+ * storage segment for multi-storage mode is resolved by `hitStorageName`
3733
+ * (lib/searchHit): the name on the hit > the only configured storage > the
3734
+ * storage currently open. A wrong guess lands on the existing "folder not
3735
+ * found" state, which is already a graceful dead-end.
3716
3736
  */
3717
3737
  async function openSearchHit(hit: GlobalSearchHit) {
3718
- const rel = String(hit.path ?? '').replace(/^\/+|\/+$/g, '');
3738
+ const rel = hitRelPath(hit);
3719
3739
  if (!rel) return;
3720
3740
  const isDir = hit.type === 'dir';
3721
3741
  const slash = rel.lastIndexOf('/');
3722
3742
  const targetRel = isDir ? rel : slash === -1 ? '' : rel.slice(0, slash);
3723
3743
  let target = targetRel;
3724
3744
  if (multiStorageRoot.value) {
3725
- const configured = props.config.storages ?? [];
3726
- const storageName =
3727
- (typeof hit.storage === 'string' && hit.storage) ||
3728
- (typeof hit.storage_name === 'string' && hit.storage_name) ||
3729
- (configured.length === 1 ? configured[0].name : '') ||
3730
- adapter.value;
3745
+ const storageName = hitStorageName(hit, hitDrives());
3731
3746
  if (!storageName) return;
3732
3747
  target = targetRel ? `${storageName}/${targetRel}` : storageName;
3733
3748
  }
@@ -3740,6 +3755,97 @@ async function openSearchHit(hit: GlobalSearchHit) {
3740
3755
  openNode(node);
3741
3756
  }
3742
3757
  }
3758
+
3759
+ /* === #47 — the palette's hit verbs ====================================
3760
+ *
3761
+ * Open, download and drag out, each answered by what a LISTING row of the
3762
+ * same file already uses: `openSearchHit` → openNode, `downloadSelection`,
3763
+ * `handDragOut`. Another account's hit goes to the host's hook instead — the
3764
+ * explorer holds no credential for that server.
3765
+ */
3766
+
3767
+ function reportHookFailure(op: string) {
3768
+ return (err: unknown) => emit('error', { message: (err as Error)?.message ?? String(err), context: { op } });
3769
+ }
3770
+
3771
+ function onPaletteOpenHit(hit: GlobalSearchHit) {
3772
+ const acc = foreignAccountOf(hit);
3773
+ const hook = props.config.accountSearch;
3774
+ if (acc && hook) {
3775
+ const item = paletteHitItem(hit);
3776
+ if (item) void Promise.resolve(hook.open(acc.id, item)).catch(reportHookFailure('search-open'));
3777
+ return;
3778
+ }
3779
+ void openSearchHit(hit);
3780
+ }
3781
+
3782
+ /** Which verbs a hit row offers. Mirrors what a listing row of it would do. */
3783
+ function paletteHitCan(hit: GlobalSearchHit, action: 'download' | 'drag'): boolean {
3784
+ const item = paletteHitItem(hit);
3785
+ if (!item) return false;
3786
+ if (foreignAccountOf(hit)) {
3787
+ const hook = props.config.accountSearch;
3788
+ return action === 'download' ? !!hook?.download : !!hook?.dragStart;
3789
+ }
3790
+ if (action === 'download') return true;
3791
+ // The shell carries anything; the browser's own path carries ONE file — on
3792
+ // the plain download URL where the session is a cookie, on a minted link
3793
+ // where it is a bearer (#71, lib/dragOut createDragLinks), and not at all
3794
+ // against a server too old to mint one.
3795
+ return (
3796
+ !!dragOut.value ||
3797
+ (item.type === 'file' && (canDownloadUrlDrag(props.config.auth) || dragLinksSupported.value))
3798
+ );
3799
+ }
3800
+
3801
+ /** #71 — the pointer rests on / presses a hit: get its drag-out link ready. */
3802
+ function onPaletteWarmHit(hit: GlobalSearchHit) {
3803
+ if (foreignAccountOf(hit)) return;
3804
+ const item = paletteHitItem(hit);
3805
+ if (item && item.type === 'file') warmDragLink(item.path);
3806
+ }
3807
+
3808
+ function downloadSearchHit(hit: GlobalSearchHit) {
3809
+ const item = paletteHitItem(hit);
3810
+ if (!item) return;
3811
+ const acc = foreignAccountOf(hit);
3812
+ if (acc) {
3813
+ const hook = props.config.accountSearch;
3814
+ if (hook?.download) void Promise.resolve(hook.download(acc.id, item)).catch(reportHookFailure('search-download'));
3815
+ return;
3816
+ }
3817
+ // The same row the advanced search draws for this hit, through the same
3818
+ // download a selected listing row takes: a file is its own body, a folder
3819
+ // one streaming archive.
3820
+ void downloadSelection([hitToNode(hit, hitStorageName(hit, hitDrives()))]);
3821
+ }
3822
+
3823
+ function onPaletteDragHit(hit: GlobalSearchHit, ev: DragEvent) {
3824
+ const item = paletteHitItem(hit);
3825
+ if (!item || !ev.dataTransfer) {
3826
+ ev.preventDefault();
3827
+ return;
3828
+ }
3829
+ ev.dataTransfer.effectAllowed = 'copy';
3830
+ const acc = foreignAccountOf(hit);
3831
+ if (acc) {
3832
+ const hook = props.config.accountSearch;
3833
+ // Always the OS drag: the browser's own path would fetch another
3834
+ // server's file with THIS page's session.
3835
+ ev.preventDefault();
3836
+ if (hook?.dragStart) void Promise.resolve(hook.dragStart(acc.id, [item])).catch(reportHookFailure('drag-out'));
3837
+ return;
3838
+ }
3839
+ // `null` origin: a hit is dragged OUT, never moved — the palette covers the
3840
+ // listing, so there is no folder in this window for it to land on.
3841
+ handDragOut(ev, [item], null, typeof hit.mime === 'string' ? hit.mime : undefined);
3842
+ }
3843
+
3844
+ /** A hit drag let go inside the palette: the shell stops waiting for a drop. */
3845
+ function onPaletteDropInside() {
3846
+ endNativeDrag();
3847
+ cancelShellDrag();
3848
+ }
3743
3849
  /* /bul:s3 */
3744
3850
 
3745
3851
  useKeyboardShortcuts(rootEl, {
@@ -5721,6 +5827,19 @@ function onPaneItemDragStart(pane: 'main' | 'split', node: FileNode, ev: DragEve
5721
5827
  .map((n) => ({ path: n.path, basename: n.basename, type: n.type })); // qualified
5722
5828
  if (items.length === 0) return;
5723
5829
 
5830
+ handDragOut(ev, items, dirWire, node.mime_type);
5831
+ }
5832
+
5833
+ /**
5834
+ * Hand a drag to whatever can carry it out of this window.
5835
+ *
5836
+ * #47 — ONE function for a listing row and a ⌘K search hit, so a hit drags
5837
+ * out exactly the way the same file's row does. `origin` is the folder the
5838
+ * rows came from: set, the drag is ALSO an internal one (dropped on a folder
5839
+ * inside filex it is a move); null, it only goes out (a palette hit — the
5840
+ * palette covers the listing, so there is nowhere inside to drop it).
5841
+ */
5842
+ function handDragOut(ev: DragEvent, items: DragItem[], origin: string | null, mime?: string): void {
5724
5843
  /* wiring:f1 — drag-out (to the desktop / another application).
5725
5844
  When a shell is present the drag is ALWAYS an OS drag: folders and
5726
5845
  multi-selections land as REAL files, one by one, with NO SIZE LIMIT.
@@ -5732,7 +5851,7 @@ function onPaneItemDragStart(pane: 'main' | 'split', node: FileNode, ev: DragEve
5732
5851
  nothing. */
5733
5852
  if (dragOut.value) {
5734
5853
  ev.preventDefault();
5735
- beginNativeDrag(items, dirWire);
5854
+ if (origin !== null) beginNativeDrag(items, origin);
5736
5855
  void Promise.resolve(dragOut.value.start(items)).catch((err) => {
5737
5856
  endNativeDrag();
5738
5857
  cancelShellDrag();
@@ -5742,17 +5861,67 @@ function onPaneItemDragStart(pane: 'main' | 'split', node: FileNode, ev: DragEve
5742
5861
  return;
5743
5862
  }
5744
5863
 
5745
- /* Single file + a cookie session: the browser's own download path
5746
- (DownloadURL) fetches the file onto the desktop at drop time; no
5747
- preparation is needed at all. In a bearer-token setup (the desktop app)
5748
- that path goes out unauthenticated, which is why the local path above
5749
- takes over there — see lib/dragOut.ts. */
5750
- if (items.length === 1 && items[0] && canDownloadUrlDrag(props.config.auth)) {
5751
- const payload = downloadUrlPayload(items[0], api.downloadUrl(items[0].path), node.mime_type);
5864
+ /* Single file, no shell: the browser's own download path (DownloadURL)
5865
+ fetches the file onto the desktop at drop time. Which URL depends on the
5866
+ credential. A cookie session hands over the plain download URL — the
5867
+ cookie travels with it, and it rides whatever route the embed already
5868
+ proxies. A bearer session (the admin SPA) cannot: the download stack
5869
+ sends no Authorization header, so it hands over the short-lived link
5870
+ minted while the pointer rested on the row (#71). None ready yet — the
5871
+ drag simply carries no download; it never waits (see lib/dragOut). */
5872
+ if (items.length === 1 && items[0] && ev.dataTransfer) {
5873
+ const one = items[0];
5874
+ const url = canDownloadUrlDrag(props.config.auth) ? api.downloadUrl(one.path) : dragLinks.take(one.path);
5875
+ const payload = url ? downloadUrlPayload(one, url, mime) : null;
5752
5876
  if (payload) ev.dataTransfer.setData('DownloadURL', payload);
5753
5877
  }
5754
5878
  }
5755
5879
 
5880
+ /* === #71 — a drag-out link for a session the browser cannot sign ========
5881
+ *
5882
+ * `dragstart` fills the dataTransfer synchronously, and the link is a server
5883
+ * round trip, so it is asked for BEFORE the drag: when the pointer comes to
5884
+ * rest on a file row and again on the press (lib/dragOut createDragLinks has
5885
+ * the rules). One delegated listener pair on the root, the way the middle-
5886
+ * click delegation below reads `[data-fe-path]` — every view (list, grid,
5887
+ * gallery) and both panes, with nothing added to any of them; the palette's
5888
+ * hits say so themselves (`warm-hit`), because they are not listing rows.
5889
+ *
5890
+ * Only where it is needed: no shell (the desktop app drags through the OS)
5891
+ * and a credential the browser cannot carry (a cookie session has the plain
5892
+ * URL). Nowhere else does a hover cost a request.
5893
+ */
5894
+ const dragLinksSupported = ref(true);
5895
+ const dragLinks = createDragLinks((p) => requestFileLink(api, p), {
5896
+ onUnsupported: () => {
5897
+ dragLinksSupported.value = false;
5898
+ },
5899
+ });
5900
+ const dragNeedsLink = computed(() => !dragOut.value && !canDownloadUrlDrag(props.config.auth));
5901
+
5902
+ function warmDragLink(path: string | null | undefined) {
5903
+ if (!path || !dragNeedsLink.value || trashActive.value) return;
5904
+ dragLinks.warm(path);
5905
+ }
5906
+
5907
+ function onRowPointerWarm(ev: PointerEvent) {
5908
+ if (ev.pointerType === 'touch' || !dragNeedsLink.value) return;
5909
+ const host = ev.target as Element | null;
5910
+ const el = host && typeof host.closest === 'function' ? host.closest('[data-fe-path]') : null;
5911
+ const p = el?.getAttribute('data-fe-path');
5912
+ if (!p) return;
5913
+ const node = files.value.find((f) => f.path === p) ?? splitDisplayOrder.value.find((f) => f.path === p);
5914
+ if (node && node.type === 'file' && node.basename !== '.trash') warmDragLink(node.path);
5915
+ }
5916
+ onMounted(() => {
5917
+ rootEl.value?.addEventListener('pointerover', onRowPointerWarm, { passive: true });
5918
+ rootEl.value?.addEventListener('pointerdown', onRowPointerWarm, { capture: true, passive: true });
5919
+ });
5920
+ onBeforeUnmount(() => {
5921
+ rootEl.value?.removeEventListener('pointerover', onRowPointerWarm);
5922
+ rootEl.value?.removeEventListener('pointerdown', onRowPointerWarm, { capture: true });
5923
+ });
5924
+
5756
5925
  /* === wiring:f1 — drag-out preparation ===
5757
5926
  *
5758
5927
  * The bytes have to be on disk BEFORE the drag starts (the OS copies from the
@@ -5989,16 +6158,7 @@ provideTableEnv({ locale, theme: themeMode });
5989
6158
  // Resolved mode: an explicit choice wins, otherwise the OS preference — the
5990
6159
  // same logic variables.css encodes in CSS. The inline root variables beat every
5991
6160
  // stylesheet rule, so they must track this resolution at runtime.
5992
- const themeMq =
5993
- typeof window !== 'undefined' && window.matchMedia
5994
- ? window.matchMedia('(prefers-color-scheme: dark)')
5995
- : undefined;
5996
- const themeOsDark = ref(!!themeMq?.matches);
5997
- function onThemeMqChange(e: MediaQueryListEvent) {
5998
- themeOsDark.value = e.matches;
5999
- }
6000
- onMounted(() => themeMq?.addEventListener?.('change', onThemeMqChange));
6001
- onBeforeUnmount(() => themeMq?.removeEventListener?.('change', onThemeMqChange));
6161
+ const themeOsDark = useSystemDark();
6002
6162
  const themeResolvedDark = computed(
6003
6163
  () => themeMode.value === 'dark' || (themeMode.value !== 'light' && themeOsDark.value),
6004
6164
  );
@@ -8419,6 +8579,7 @@ function closeRecoveryKey() {
8419
8579
  :view="pluginView.view"
8420
8580
  :surface="pluginView.surface"
8421
8581
  :path="pluginView.path"
8582
+ :paths="pluginView.paths"
8422
8583
  :size="pluginView.size"
8423
8584
  :storages="(props.config.storages ?? []).map((st) => st.name)"
8424
8585
  :start-at="qualify(paneIsActive ? (splitPaneRef?.getPath() ?? '') : currentPath)"
@@ -8557,8 +8718,13 @@ function closeRecoveryKey() {
8557
8718
  :can-write="canWriteHere && !atVirtualRoot && !trashActive"
8558
8719
  :can-go-up="canGoUp"
8559
8720
  :global-search="paletteGlobalSearch"
8721
+ :hit-can="paletteHitCan /* #47 */"
8560
8722
  @close="showPalette = false"
8561
- @open-hit="openSearchHit"
8723
+ @open-hit="onPaletteOpenHit /* #47 — another account's hit goes to the host */"
8724
+ @download-hit="downloadSearchHit /* #47 */"
8725
+ @drag-hit="onPaletteDragHit /* #47 */"
8726
+ @warm-hit="onPaletteWarmHit /* #71 */"
8727
+ @drop-inside="onPaletteDropInside /* #47 */"
8562
8728
  @open-node="openNode"
8563
8729
  @navigate="(p: string) => load(p)"
8564
8730
  @new-folder="showNewFolder = true"
@@ -35,7 +35,7 @@
35
35
  * Owner select answers from a field the rows ALREADY carry — the same bargain
36
36
  * as type, date and size, and it is named in the same sentence under the
37
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
38
+ * hit onto the listing shape by hand (`lib/searchHit.hitToNode`) and that
39
39
  * mapping does not carry the owner through, so under the content scopes every
40
40
  * row would read as "System" — a wrong answer, which is worse than a control
41
41
  * that says why it is off.
@@ -123,7 +123,7 @@ const props = defineProps<{
123
123
  * carries `storage` (the drive's name) as well as its id, the same way the
124
124
  * starred and recently-opened handlers have always answered
125
125
  * (`handlers/search.go` `describeHits`). What is left is the explorer's own
126
- * hand-written mapping, `FileExplorer.advHitToNode`, which builds a listing
126
+ * hand-written mapping, `lib/searchHit.hitToNode`, which builds a listing
127
127
  * row from the hit and does not read that field — so the caller still has to
128
128
  * pass `false` on a multi-storage install until it does.
129
129
  */
@@ -263,7 +263,7 @@ const PEOPLE_FIXED: PeopleFilter[] = ['any', 'me', 'system'];
263
263
  * maps those hits onto the listing shape by hand; that mapping carries no
264
264
  * owner, so every row would read as "System" and both `me` and `system` would
265
265
  * answer the wrong question confidently. Off, and it says so, until the
266
- * mapping carries it (`FileExplorer.advHitToNode`).
266
+ * mapping carries it (`lib/searchHit.hitToNode`).
267
267
  */
268
268
  const ownerFilterable = computed(() => scope.value === 'name');
269
269