@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.
- package/dist/{ArchiveViewer-D_tJxJhJ.js → ArchiveViewer-QuNXAECN.js} +2 -2
- package/dist/{ArchiveViewer-D_tJxJhJ.js.map → ArchiveViewer-QuNXAECN.js.map} +1 -1
- package/dist/{CsvViewer-B95fArq0.js → CsvViewer-niuoz1Uk.js} +2 -2
- package/dist/{CsvViewer-B95fArq0.js.map → CsvViewer-niuoz1Uk.js.map} +1 -1
- package/dist/{DrawioViewer-BAaPMTge.js → DrawioViewer-BtqMiasr.js} +2 -2
- package/dist/{DrawioViewer-BAaPMTge.js.map → DrawioViewer-BtqMiasr.js.map} +1 -1
- package/dist/{EpubViewer-B-B7bRW7.js → EpubViewer-ERm7L1WN.js} +2 -2
- package/dist/{EpubViewer-B-B7bRW7.js.map → EpubViewer-ERm7L1WN.js.map} +1 -1
- package/dist/{IpynbViewer-COOTyOcu.js → IpynbViewer-Cmz5n0VR.js} +2 -2
- package/dist/{IpynbViewer-COOTyOcu.js.map → IpynbViewer-Cmz5n0VR.js.map} +1 -1
- package/dist/{MermaidViewer-CjVCmR3s.js → MermaidViewer-BJ5uOyhc.js} +2 -2
- package/dist/{MermaidViewer-CjVCmR3s.js.map → MermaidViewer-BJ5uOyhc.js.map} +1 -1
- package/dist/{PsdViewer-xsIYL9Gu.js → PsdViewer-DYCJF2BM.js} +2 -2
- package/dist/{PsdViewer-xsIYL9Gu.js.map → PsdViewer-DYCJF2BM.js.map} +1 -1
- package/dist/{TiffViewer-CtwnTxqL.js → TiffViewer-DbJv_0El.js} +2 -2
- package/dist/{TiffViewer-CtwnTxqL.js.map → TiffViewer-DbJv_0El.js.map} +1 -1
- package/dist/{Viewer3D-xYMwndEy.js → Viewer3D-DwZfYoO3.js} +2 -2
- package/dist/{Viewer3D-xYMwndEy.js.map → Viewer3D-DwZfYoO3.js.map} +1 -1
- package/dist/filex-core.js +298 -291
- package/dist/filex-core.umd.cjs +61 -61
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/{index-BBToAjwL.js → index-1RaeWGFO.js} +14218 -13831
- package/dist/index-1RaeWGFO.js.map +1 -0
- package/dist/index.d.ts +253 -31
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +265 -99
- package/src/components/AdvancedSearch.vue +3 -3
- package/src/components/CommandPalette.vue +166 -42
- package/src/components/ConnectionsPanel.vue +3 -11
- package/src/components/SideNav.vue +16 -11
- package/src/components/plugin/PluginViewModal.vue +7 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +16 -1
- package/src/components/public/PublicLinkPage.vue +6 -5
- package/src/composables/useFileApi.ts +32 -1
- package/src/composables/usePluginSurface.ts +12 -0
- package/src/composables/useSystemDark.ts +32 -0
- package/src/index.ts +16 -0
- package/src/lib/connectionGuides.ts +11 -15
- package/src/lib/downloadSelection.ts +34 -0
- package/src/lib/dragOut.ts +0 -0
- package/src/lib/searchHit.ts +129 -0
- package/src/lib/storageLine.ts +124 -0
- package/src/lib/webdavMount.ts +120 -0
- package/src/locales/en.ts +13 -0
- package/src/locales/tr.ts +13 -0
- package/src/styles/base.css +84 -7
- package/src/types/ExplorerConfig.ts +65 -1
- package/src/types/Plugins.ts +12 -0
- 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.
|
|
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",
|
package/src/FileExplorer.vue
CHANGED
|
@@ -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 `
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
2309
|
+
quotaMine.value = null;
|
|
2323
2310
|
return;
|
|
2324
2311
|
}
|
|
2325
2312
|
const q = await api.quotaMe();
|
|
2326
|
-
|
|
2327
|
-
|
|
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
|
-
|
|
3704
|
-
function
|
|
3705
|
-
return
|
|
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
|
|
3713
|
-
*
|
|
3714
|
-
*
|
|
3715
|
-
*
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
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
|
|
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="
|
|
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 (`
|
|
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, `
|
|
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 (`
|
|
266
|
+
* mapping carries it (`lib/searchHit.hitToNode`).
|
|
267
267
|
*/
|
|
268
268
|
const ownerFilterable = computed(() => scope.value === 'name');
|
|
269
269
|
|