@brftech/filex-core 0.26.1 → 0.27.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/index.d.ts CHANGED
@@ -829,6 +829,57 @@ export declare interface ExplorerConfig {
829
829
  driver?: string;
830
830
  readOnly?: boolean;
831
831
  }>;
832
+ /**
833
+ * Desktop-shell hook — dragging rows OUT of the window onto the OS.
834
+ *
835
+ * Present only in the filex desktop app. A web page cannot hand the OS a
836
+ * list of files: Chromium carries one `DownloadURL` per drag, so the browser
837
+ * gets a single-file drag-out for free (the explorer sets it itself) and
838
+ * folders/multi-selections need real local paths — which is what the shell
839
+ * provides here.
840
+ *
841
+ * The bytes have to exist BEFORE the drag starts — the OS copies from a path
842
+ * at drop time — and the shell has two ways to satisfy that, which is why
843
+ * this is more than one call:
844
+ *
845
+ * `prepare` — fetch local copies up front. The explorer calls it for small
846
+ * selections as soon as they are selected, so the common drag hands over
847
+ * real, complete files (correct even when the drop target is an
848
+ * application that reads the file immediately).
849
+ * `start` — begin the OS drag, whatever the size. The shell may hand the
850
+ * OS empty placeholders and download into wherever they land afterwards,
851
+ * so this is never gated on `prepare` having finished.
852
+ * `cancel` — the drag ended INSIDE the explorer (an internal move). The
853
+ * shell stops waiting for a drop it will never see.
854
+ *
855
+ * `onProgress` drives the explorer's toast; `error: 'drop_not_found'` means
856
+ * the drop went somewhere the shell cannot write to (an application rather
857
+ * than a folder) and nothing was transferred.
858
+ */
859
+ dragOut?: {
860
+ prepare: (items: Array<{
861
+ path: string;
862
+ basename: string;
863
+ type: 'file' | 'dir';
864
+ }>) => Promise<{
865
+ ready: boolean;
866
+ error?: string;
867
+ }>;
868
+ start: (items: Array<{
869
+ path: string;
870
+ basename: string;
871
+ type: 'file' | 'dir';
872
+ }>) => void | Promise<void>;
873
+ cancel?: () => void | Promise<void>;
874
+ onProgress?: (cb: (p: {
875
+ done: number;
876
+ total: number;
877
+ name?: string;
878
+ dropped?: string;
879
+ finished?: boolean;
880
+ error?: string;
881
+ }) => void) => void;
882
+ };
832
883
  /**
833
884
  * Desktop-shell hook — selective sync ("keep on this computer").
834
885
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.26.1",
3
+ "version": "0.27.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",
@@ -106,6 +106,19 @@ import {
106
106
  injectTrashRow,
107
107
  hydrateTrashRow as hydrateTrashRowShared,
108
108
  } from './lib/listing';
109
+ import { resolveTransfer, type TransferIntent } from './lib/transfer';
110
+ import {
111
+ activeNativeDrag,
112
+ beginNativeDrag,
113
+ canDownloadUrlDrag,
114
+ dragKey,
115
+ downloadUrlPayload,
116
+ endNativeDrag,
117
+ hasInternalDrag,
118
+ internalDragItems,
119
+ internalDragOrigin,
120
+ type DragItem,
121
+ } from './lib/dragOut';
109
122
 
110
123
  import NewFolderModal from './modals/NewFolderModal.vue';
111
124
  import RenameModal from './modals/RenameModal.vue';
@@ -1934,17 +1947,21 @@ async function paste() {
1934
1947
  return;
1935
1948
  }
1936
1949
 
1950
+ const targetWire = qualify(currentPath.value);
1951
+ // Depo farkı yalnız mesajı değiştirir: kes KESTİR, kopyala KOPYALAR —
1952
+ // hedef başka depo olsa da. Aktarımı sunucu yapar (ops kuyruğu hem
1953
+ // kaynak hem hedef depoyu taşır).
1954
+ const plan = resolveTransfer(items, targetWire, cb.mode === 'cut' ? 'move' : 'copy');
1937
1955
  if (cb.mode === 'cut') {
1938
- const targetWire = qualify(currentPath.value);
1939
1956
  const originWire = qualify(sourceDir) || undefined;
1940
1957
  const { op } = await api.moveAsync(items, targetWire, originWire);
1941
1958
  registerMoveUndo(op.id, items, targetWire, originWire);
1942
1959
  pendingOps.register(op);
1943
- flashToast('Taşıma kuyruğa alındı');
1960
+ flashToast(plan.cross ? t('split.cross_move') : 'Taşıma kuyruğa alındı');
1944
1961
  } else {
1945
- const { op } = await api.copy(items, qualify(currentPath.value));
1962
+ const { op } = await api.copy(items, targetWire);
1946
1963
  pendingOps.register(op);
1947
- flashToast('Kopyalama kuyruğa alındı');
1964
+ flashToast(plan.cross ? t('split.cross_copy') : 'Kopyalama kuyruğa alındı');
1948
1965
  }
1949
1966
  clipboard.value = { mode: null, items: [], sourcePath: null };
1950
1967
  } catch (err) {
@@ -2304,6 +2321,10 @@ function isExternalFileDrag(ev: DragEvent): boolean {
2304
2321
  const dt = ev.dataTransfer;
2305
2322
  if (!dt) return false;
2306
2323
  if (dt.types && dt.types.includes(FE_DND_MIME)) return false;
2324
+ /* wiring:f1 — kendi başlattığımız işletim sistemi sürüklemesi 'Files' taşır
2325
+ ama YÜKLEME değildir: aynı baytları sunucudan indirip geri yüklemek
2326
+ (taşıma yerine kopya, üstelik iki kat trafik) olurdu. */
2327
+ if (activeNativeDrag()) return false;
2307
2328
  // Some browsers expose `items` early in the drag, others only on
2308
2329
  // drop. When `items` is available we use it as the authoritative
2309
2330
  // signal — `kind === 'file'` means a real OS file. When unavailable
@@ -2336,7 +2357,7 @@ function onDragOver(ev: DragEvent) {
2336
2357
  /* wiring:d1 — iç sürüklemeler kök gövdeye de bırakılabilir olmalı ki split
2337
2358
  panelinden ana panelin BOŞLUĞUNA bırakmak çalışsın (origin dragover'da
2338
2359
  okunamaz — karar drop anında verilir; aynı-klasör drop'u no-op kalır). */
2339
- if (ev.dataTransfer?.types.includes(FE_DND_MIME)) {
2360
+ if (hasInternalDrag(ev)) {
2340
2361
  ev.preventDefault();
2341
2362
  return;
2342
2363
  }
@@ -2348,28 +2369,28 @@ function onDragOver(ev: DragEvent) {
2348
2369
  function onDropUpload(ev: DragEvent) {
2349
2370
  /* wiring:d1 — split panelinden ana panelin boşluğuna bırakma = geçerli
2350
2371
  klasöre aktar (aynı klasörden gelenler no-op, eski davranış korunur). */
2351
- if (ev.dataTransfer?.types.includes(FE_DND_MIME)) {
2352
- const d1Origin = ev.dataTransfer.getData(FE_DND_SRC_MIME) || '';
2372
+ if (hasInternalDrag(ev)) {
2373
+ const d1Origin = internalDragOrigin(ev) || '';
2353
2374
  const d1Here = qualify(currentPath.value);
2354
- if (d1Origin && d1Here && d1Origin !== d1Here && !trashMode.value && canWriteHere.value) {
2375
+ const d1Items = internalDragItems(ev);
2376
+ if (d1Items && d1Origin && d1Here && d1Origin !== d1Here && !trashMode.value && canWriteHere.value) {
2355
2377
  ev.preventDefault();
2356
2378
  dragCounter.value = 0;
2357
2379
  dragOver.value = false;
2358
- try {
2359
- const d1Items = JSON.parse(ev.dataTransfer.getData(FE_DND_MIME)) as Array<{ path: string }>;
2360
- void transferItems(d1Items.map((i) => i.path), d1Here, d1Origin);
2361
- } catch {
2362
- /* bozuk payload — yok say */
2363
- }
2380
+ endNativeDrag();
2381
+ cancelShellDrag();
2382
+ void transferItems(d1Items.map((i) => i.path), d1Here, d1Origin);
2364
2383
  return;
2365
2384
  }
2366
2385
  }
2367
2386
  /* /wiring:d1 */
2368
2387
  // Internal row drag — nothing to do here, the row drop handler
2369
2388
  // in GridView/ListView already resolved the move.
2370
- if (ev.dataTransfer?.types.includes(FE_DND_MIME)) {
2389
+ if (hasInternalDrag(ev)) {
2371
2390
  dragCounter.value = 0;
2372
2391
  dragOver.value = false;
2392
+ endNativeDrag();
2393
+ cancelShellDrag();
2373
2394
  return;
2374
2395
  }
2375
2396
  // Browser-internal image drag without real files — bail before
@@ -2406,11 +2427,54 @@ function onWindowDrop(ev: DragEvent) {
2406
2427
  onMounted(() => {
2407
2428
  window.addEventListener('dragover', onWindowDragOver);
2408
2429
  window.addEventListener('drop', onWindowDrop);
2430
+ window.addEventListener('pointerup', onGlobalPointerUp);
2431
+ window.addEventListener('blur', onGlobalPointerUp);
2432
+ /* wiring:f1 — kabuk hazırlarken tek bir "hazırlanıyor" der; her dosyada
2433
+ toast atmak ilerlemeyi değil gürültüyü gösterirdi. Bitişi 'hazır'
2434
+ toast'ı (prepareDragOut) duyurur. */
2435
+ dragOut.value?.onProgress?.((p) => {
2436
+ // Bırakma SONRASI iş (yer tutucu yolu) her zaman duyurulur — kullanıcı
2437
+ // dosyayı bir klasöre bıraktı, orada ne olduğunu bilmeye hakkı var.
2438
+ // Sessizlik yalnız kimsenin istemediği ön-hazırlık için geçerli.
2439
+ const afterDrop = !!p?.dropped;
2440
+ if (p?.error === 'drop_not_found') {
2441
+ flashToast(t('dragout.not_found'));
2442
+ return;
2443
+ }
2444
+ if (p?.error) {
2445
+ if (afterDrop || !dragOutQuiet) flashToast(p.error);
2446
+ return;
2447
+ }
2448
+ if (afterDrop) {
2449
+ flashToast(p?.finished ? t('dragout.done') : t('dragout.downloading'));
2450
+ return;
2451
+ }
2452
+ if (dragOutQuiet) return;
2453
+ if (!p?.finished && p?.done === 0) flashToast(t('dragout.preparing'));
2454
+ });
2409
2455
  });
2456
+ /* wiring:f1 — işletim sistemi sürüklemesi bizde 'dragend' üretmez (HTML5
2457
+ sürüklemesi hiç başlamadı). Fare bırakıldığında kaydı düşürüyoruz; aksi
2458
+ halde sonraki normal sürükleme bir önceki seçimi taşıdığını sanırdı. */
2459
+ function onGlobalPointerUp() {
2460
+ if (activeNativeDrag()) {
2461
+ endNativeDrag();
2462
+ // Bırakma bizim penceremizde OLMAYABİLİR de; kabuğun izlemesini yalnız
2463
+ // kendi bırakma yollarımız iptal eder (onDropUpload / onItemDropInto).
2464
+ }
2465
+ }
2466
+
2467
+ /** Sürükleme uygulama içinde bitti: kabuk artık bir bırakma beklemesin. */
2468
+ function cancelShellDrag() {
2469
+ if (dragOut.value?.cancel) void Promise.resolve(dragOut.value.cancel()).catch(() => undefined);
2470
+ }
2471
+
2410
2472
  onBeforeUnmount(() => {
2411
2473
  window.removeEventListener('dragover', onWindowDragOver);
2412
2474
  window.removeEventListener('drop', onWindowDrop);
2413
2475
  window.removeEventListener('hashchange', onHashChange);
2476
+ window.removeEventListener('pointerup', onGlobalPointerUp);
2477
+ window.removeEventListener('blur', onGlobalPointerUp);
2414
2478
  });
2415
2479
 
2416
2480
  const clippedPaths = computed<Set<string>>(() => {
@@ -2437,10 +2501,107 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
2437
2501
  .filter((n) => !clippedPaths.value.has(n.path))
2438
2502
  .filter((n) => n.basename !== '.trash')
2439
2503
  .map((n) => ({ path: n.path, basename: n.basename, type: n.type })); // qualified
2504
+
2505
+ /* wiring:f1 — dışarı sürükleme (masaüstü / başka uygulama).
2506
+ Kabuk varsa sürükleme HER ZAMAN işletim sistemi sürüklemesidir: klasörler
2507
+ ve çoklu seçim ayrı ayrı GERÇEK dosya olarak düşer, BOYUT SINIRI YOK.
2508
+ Baytlar hazırsa gerçek dosyalar verilir; değilse kabuk boş "yer tutucu"
2509
+ verir, nereye bırakıldığını bulur ve indirmeyi ORAYA yapar (bkz.
2510
+ desktop/src/dropwatch.ts). Uygulama İÇİNDE bırakılırsa sürükleme yine
2511
+ sunucu tarafı taşımadır — payload bizde durur — ve kabuğa "vazgeç" denir
2512
+ ki sürücüleri boşuna dinlemesin. */
2513
+ if (dragOut.value && items.length > 0) {
2514
+ ev.preventDefault();
2515
+ beginNativeDrag(items, qualify(currentPath.value));
2516
+ void Promise.resolve(dragOut.value.start(items)).catch((err) => {
2517
+ endNativeDrag();
2518
+ cancelShellDrag();
2519
+ emit('error', { message: (err as Error).message, context: { op: 'drag-out' } });
2520
+ });
2521
+ return;
2522
+ }
2523
+
2440
2524
  ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
2441
2525
  ev.dataTransfer.setData(FE_DND_SRC_MIME, qualify(currentPath.value)); /* wiring:d1 — paneller arası origin damgası */
2442
2526
  ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
2443
2527
  ev.dataTransfer.effectAllowed = 'move';
2528
+
2529
+ /* Tek dosya + çerezli oturum: tarayıcının kendi indirme yolu (DownloadURL)
2530
+ bırakma anında dosyayı masaüstüne indirir; hiçbir hazırlık gerekmez.
2531
+ Bearer token'lı kurulumda (masaüstü uygulaması) bu yol kimliksiz gider,
2532
+ o yüzden orada üstteki yerel yol devrededir — bkz. lib/dragOut.ts. */
2533
+ if (items.length === 1 && items[0] && canDownloadUrlDrag(props.config.auth)) {
2534
+ const payload = downloadUrlPayload(items[0], api.downloadUrl(items[0].path), node.mime_type);
2535
+ if (payload) ev.dataTransfer.setData('DownloadURL', payload);
2536
+ }
2537
+
2538
+ if (dragOut.value && items.length > 0) void prepareDragOut(items);
2539
+ }
2540
+
2541
+ /* === wiring:f1 — dışarı sürükleme hazırlığı ===
2542
+ *
2543
+ * Baytlar sürükleme BAŞLAMADAN diskte olmak zorunda (işletim sistemi bırakma
2544
+ * anında yoldan kopyalar), o yüzden hazırlık ayrı bir adımdır. Bitince
2545
+ * kullanıcıya "hazır" denir; ikinci sürükleme artık işletim sistemi
2546
+ * sürüklemesidir ve anında başlar. Bu bilgisayarda tutulan (senkron) dosyalar
2547
+ * için hazırlık ilk seferde de anında biter — kopya zaten yerelde.
2548
+ */
2549
+ const dragOut = computed(() => props.config.dragOut ?? null);
2550
+ const dragOutReadyKey = ref('');
2551
+ const dragOutBusy = ref(false);
2552
+ /** True while a preparation nobody asked for is running. */
2553
+ let dragOutQuiet = false;
2554
+
2555
+ /* Küçük seçimler SEÇİLDİĞİ anda hazırlanır, çünkü hazırlık bittikten sonraki
2556
+ sürükleme işletim sistemi sürüklemesidir: bir belgeyi seçip masaüstüne
2557
+ sürüklemek böylece İLK denemede çalışır. Tavan bilerek düşük — tıklayarak
2558
+ gezinen biri her satırda film indirmemeli; sınırın üstündekiler ilk
2559
+ sürüklemede hazırlanır, ikinci sürükleme anında başlar. */
2560
+ const DRAGOUT_PREFETCH_MAX_BYTES = 8 * 1024 * 1024;
2561
+ const DRAGOUT_PREFETCH_MAX_ITEMS = 10;
2562
+ let dragOutPrefetchTimer: ReturnType<typeof setTimeout> | undefined;
2563
+
2564
+ watch(
2565
+ () => selection.selected.value,
2566
+ () => {
2567
+ if (!dragOut.value) return;
2568
+ clearTimeout(dragOutPrefetchTimer);
2569
+ const nodes = selection.nodes.value;
2570
+ if (nodes.length === 0 || nodes.length > DRAGOUT_PREFETCH_MAX_ITEMS) return;
2571
+ // Klasörün boyutu listelemede bilinmez; onu tahmin etmek yerine ilk
2572
+ // sürüklemeye bırakıyoruz.
2573
+ if (nodes.some((n) => n.type !== 'file')) return;
2574
+ const total = nodes.reduce((sum, n) => sum + (n.size ?? 0), 0);
2575
+ if (total > DRAGOUT_PREFETCH_MAX_BYTES) return;
2576
+ const items = nodes.map((n) => ({ path: n.path, basename: n.basename, type: n.type }));
2577
+ dragOutPrefetchTimer = setTimeout(() => void prepareDragOut(items, true), 400);
2578
+ },
2579
+ { deep: true },
2580
+ );
2581
+
2582
+ async function prepareDragOut(items: DragItem[], quiet = false): Promise<void> {
2583
+ const hook = dragOut.value;
2584
+ if (!hook || dragOutBusy.value) return;
2585
+ const key = dragKey(items);
2586
+ if (key === dragOutReadyKey.value) return;
2587
+ dragOutBusy.value = true;
2588
+ dragOutQuiet = quiet;
2589
+ try {
2590
+ const res = await hook.prepare(items);
2591
+ if (res?.ready) {
2592
+ dragOutReadyKey.value = key;
2593
+ // Sessiz tur seçimle tetiklenir; kullanıcı bir şey İSTEMEDİ, o yüzden
2594
+ // ona bir şey söylemek de gerekmez. Sürüklemeyle başlayan tur söyler.
2595
+ if (!quiet) flashToast(t('dragout.ready'));
2596
+ } else if (res?.error && !quiet) {
2597
+ flashToast(res.error);
2598
+ }
2599
+ } catch (err) {
2600
+ emit('error', { message: (err as Error).message, context: { op: 'drag-out-prepare' } });
2601
+ } finally {
2602
+ dragOutBusy.value = false;
2603
+ dragOutQuiet = false;
2604
+ }
2444
2605
  }
2445
2606
 
2446
2607
  async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string, originOverride?: string): Promise<void> {
@@ -2468,15 +2629,10 @@ async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: s
2468
2629
 
2469
2630
  async function onItemDropInto(target: FileNode, ev: DragEvent) {
2470
2631
  if (target.type !== 'dir') return;
2471
- const raw = ev.dataTransfer?.getData(FE_DND_MIME);
2472
- if (!raw) return;
2473
- let items: Array<{ path: string }> = [];
2474
- try {
2475
- items = JSON.parse(raw);
2476
- } catch {
2477
- return;
2478
- }
2479
- if (items.length === 0) return;
2632
+ const items = internalDragItems(ev);
2633
+ if (!items || items.length === 0) return;
2634
+ endNativeDrag();
2635
+ cancelShellDrag();
2480
2636
 
2481
2637
  const targetDir = target.path; // qualified
2482
2638
  const sources = items
@@ -2489,15 +2645,10 @@ async function onItemDropInto(target: FileNode, ev: DragEvent) {
2489
2645
  }
2490
2646
 
2491
2647
  async function onCrumbDropInto(adapterPath: string, ev: DragEvent) {
2492
- const raw = ev.dataTransfer?.getData(FE_DND_MIME);
2493
- if (!raw) return;
2494
- let items: Array<{ path: string }> = [];
2495
- try {
2496
- items = JSON.parse(raw);
2497
- } catch {
2498
- return;
2499
- }
2500
- if (items.length === 0) return;
2648
+ const items = internalDragItems(ev);
2649
+ if (!items || items.length === 0) return;
2650
+ endNativeDrag();
2651
+ cancelShellDrag();
2501
2652
 
2502
2653
  const targetDir = adapterPath; // already qualified by breadcrumb
2503
2654
  const sources = items
@@ -3012,33 +3163,31 @@ async function panePaste() {
3012
3163
  flashToast('Aynı klasöre kesilemez');
3013
3164
  return;
3014
3165
  }
3015
- await transferItems(cb.items.map((n) => n.path), targetWire, originWire, cb.mode === 'copy');
3166
+ await transferItems(cb.items.map((n) => n.path), targetWire, originWire, cb.mode === 'copy' ? 'copy' : 'move');
3016
3167
  clipboard.value = { mode: null, items: [], sourcePath: null };
3017
3168
  }
3018
3169
 
3019
3170
  // ---- paneller arası aktarım ----------------------------------------
3020
-
3021
- function wireAdapterOf(p: string): string {
3022
- const i = p.indexOf('://');
3023
- return i === -1 ? '' : p.slice(0, i);
3024
- }
3025
3171
  function dndOrigin(ev: DragEvent): string | undefined {
3026
- const v = ev.dataTransfer?.getData(FE_DND_SRC_MIME);
3027
- return v || undefined;
3172
+ return internalDragOrigin(ev);
3028
3173
  }
3029
3174
 
3030
3175
  /**
3031
3176
  * transferItems — panel-arası / pano aktarımının tek kapısı.
3032
- * Aynı depo → TAŞI (mevcut moveSourcesAsync yolu: kuyruk + geri al).
3033
- * Farklı depo KOPYALA dene; backend cross-storage desteklemiyorsa
3034
- * i18n'li hata toast'ı. Bitince ikincil panel de tazelenir (ana panel
3035
- * moveSourcesAsync / pendingOps onSettled üzerinden zaten tazelenir).
3177
+ *
3178
+ * Ne yapılacağını `resolveTransfer` söyler (lib/transfer.ts): sürükleme aynı
3179
+ * depoda TAŞI, depolar arasında KOPYALA; panodan gelen kes/kopyala ise ne
3180
+ * dendiyse odur kes, hedef başka depo olsa da TAŞIR (sunucu baytları
3181
+ * aktarıp kaynağı siler). ⚠ Eskiden depolar arası her aktarım sessizce
3182
+ * kopyaya düşüyordu: kullanıcı "kes" deyip dosyayı iki yerde buluyordu.
3183
+ * Bitince ikincil panel de tazelenir (ana panel moveSourcesAsync /
3184
+ * pendingOps onSettled üzerinden zaten tazelenir).
3036
3185
  */
3037
3186
  async function transferItems(
3038
3187
  sources: string[],
3039
3188
  targetWire: string,
3040
3189
  originWire?: string,
3041
- forceCopy = false,
3190
+ intent: TransferIntent = 'auto',
3042
3191
  ): Promise<void> {
3043
3192
  // ui-fix — yerinde bırakma (source parent === target) no-op: backend
3044
3193
  // "kendine kopyala" 400'ü engellenir (paneller arası + pano yolu).
@@ -3046,19 +3195,22 @@ async function transferItems(
3046
3195
  (p) => p && p !== targetWire && !targetWire.startsWith(p + '/') && !sameDir(wireParent(p), targetWire),
3047
3196
  );
3048
3197
  if (list.length === 0 || !targetWire) return;
3049
- const targetAdapter = wireAdapterOf(targetWire);
3050
- const cross = list.some((p) => wireAdapterOf(p) !== targetAdapter);
3051
- if (cross || forceCopy) {
3198
+ const plan = resolveTransfer(list, targetWire, intent);
3199
+ if (plan.kind === 'copy') {
3052
3200
  try {
3053
3201
  const { op } = await api.copy(list, targetWire);
3054
3202
  pendingOps.register(op);
3055
- flashToast(cross ? t('split.cross_copy') : t('split.copy_queued'));
3203
+ flashToast(plan.cross ? t('split.cross_copy') : t('split.copy_queued'));
3056
3204
  } catch (err) {
3205
+ // ⚠ Sunucunun kendi mesajını göster. Burada sabit bir "depolar arası
3206
+ // desteklenmiyor" metni vardı; artık DESTEKLENİYOR, yani o metin
3207
+ // gerçek sebebi (izin, salt-okunur depo, dolu kota) örterdi.
3057
3208
  emit('error', { message: (err as Error).message, context: { op: 'transfer', targetWire } });
3058
- flashToast(cross ? t('split.cross_failed') : (err as Error).message);
3209
+ flashToast((err as Error).message);
3059
3210
  return;
3060
3211
  }
3061
3212
  } else {
3213
+ if (plan.cross) flashToast(t('split.cross_move'));
3062
3214
  await moveSourcesAsync(list, targetWire, 'move-transfer', originWire);
3063
3215
  }
3064
3216
  void splitPaneRef.value?.reload();
@@ -20,6 +20,7 @@
20
20
  * navigates, Escape cancels.
21
21
  */
22
22
  import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
23
+ import { hasInternalDrag } from '../lib/dragOut';
23
24
  import type { LocaleCode } from '../types/ExplorerConfig';
24
25
  import { useLocale } from '../composables/useLocale';
25
26
 
@@ -199,14 +200,14 @@ function onContext(ev: MouseEvent, crumb: Crumb) {
199
200
  }
200
201
 
201
202
  function onCrumbDragOver(ev: DragEvent) {
202
- if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
203
+ if (!hasInternalDrag(ev)) return;
203
204
  ev.preventDefault();
204
205
  ev.stopPropagation();
205
206
  if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
206
207
  }
207
208
 
208
209
  function onCrumbDrop(ev: DragEvent, crumb: Crumb) {
209
- if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
210
+ if (!hasInternalDrag(ev)) return;
210
211
  ev.preventDefault();
211
212
  ev.stopPropagation();
212
213
  emit('crumb-drop', crumb.adapterPath, ev);
@@ -9,6 +9,7 @@
9
9
  * below and size+date revealed on hover/focus. GridView itself is untouched.
10
10
  */
11
11
  import { ref } from 'vue';
12
+ import { hasInternalDrag } from '../lib/dragOut';
12
13
  import type { FileNode } from '../types/FileNode';
13
14
  import type { LocaleCode } from '../types/ExplorerConfig';
14
15
  import { useLocale } from '../composables/useLocale';
@@ -74,7 +75,7 @@ const dropTargetPath = ref<string | null>(null);
74
75
 
75
76
  function onItemDragOver(n: FileNode, ev: DragEvent) {
76
77
  if (n.type !== 'dir') return;
77
- if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
78
+ if (!hasInternalDrag(ev)) return;
78
79
  ev.preventDefault();
79
80
  ev.stopPropagation();
80
81
  if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
@@ -88,7 +89,7 @@ function onItemDragLeave(n: FileNode) {
88
89
  function onItemDrop(n: FileNode, ev: DragEvent) {
89
90
  dropTargetPath.value = null;
90
91
  if (n.type !== 'dir') return;
91
- if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
92
+ if (!hasInternalDrag(ev)) return;
92
93
  ev.preventDefault();
93
94
  ev.stopPropagation();
94
95
  emit('item-drop-into', n, ev);
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { ref } from 'vue'; /* wiring:c4 */
6
6
  import type { FileNode } from '../types/FileNode';
7
+ import { hasInternalDrag } from '../lib/dragOut';
7
8
  import type { LocaleCode } from '../types/ExplorerConfig';
8
9
  import { useLocale } from '../composables/useLocale';
9
10
  import { fileIconSvg } from '../lib/fileIcons';
@@ -75,7 +76,7 @@ const dropTargetPath = ref<string | null>(null);
75
76
 
76
77
  function onItemDragOver(n: FileNode, ev: DragEvent) {
77
78
  if (n.type !== 'dir') return;
78
- if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
79
+ if (!hasInternalDrag(ev)) return;
79
80
  ev.preventDefault();
80
81
  ev.stopPropagation();
81
82
  if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
@@ -90,7 +91,7 @@ function onItemDragLeave(n: FileNode) {
90
91
  function onItemDrop(n: FileNode, ev: DragEvent) {
91
92
  dropTargetPath.value = null; /* wiring:c4 */
92
93
  if (n.type !== 'dir') return;
93
- if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
94
+ if (!hasInternalDrag(ev)) return;
94
95
  ev.preventDefault();
95
96
  ev.stopPropagation();
96
97
  emit('item-drop-into', n, ev);
@@ -7,6 +7,7 @@
7
7
  * across List and Grid.
8
8
  */
9
9
  import { computed, ref } from 'vue';
10
+ import { hasInternalDrag } from '../lib/dragOut';
10
11
  import type { FileNode } from '../types/FileNode';
11
12
  import type { LocaleCode } from '../types/ExplorerConfig';
12
13
  import { useLocale } from '../composables/useLocale';
@@ -95,7 +96,7 @@ const dropTargetPath = ref<string | null>(null);
95
96
 
96
97
  function onItemDragOver(n: FileNode, ev: DragEvent) {
97
98
  if (n.type !== 'dir') return;
98
- if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
99
+ if (!hasInternalDrag(ev)) return;
99
100
  ev.preventDefault();
100
101
  ev.stopPropagation();
101
102
  if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
@@ -110,7 +111,7 @@ function onItemDragLeave(n: FileNode) {
110
111
  function onItemDrop(n: FileNode, ev: DragEvent) {
111
112
  dropTargetPath.value = null; /* wiring:c4 */
112
113
  if (n.type !== 'dir') return;
113
- if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
114
+ if (!hasInternalDrag(ev)) return;
114
115
  ev.preventDefault();
115
116
  ev.stopPropagation();
116
117
  emit('item-drop-into', n, ev);
@@ -31,6 +31,7 @@ import GalleryView from './GalleryView.vue';
31
31
  // undo) correctly across panes.
32
32
  const FE_DND_MIME = 'application/x-brf-files';
33
33
  const FE_DND_SRC_MIME = 'application/x-brf-files-src';
34
+ import { hasInternalDrag, internalDragItems, internalDragOrigin } from '../lib/dragOut';
34
35
 
35
36
  const props = defineProps<{
36
37
  api: FileApi;
@@ -256,19 +257,13 @@ function onRowDragStart(n: FileNode, ev: DragEvent) {
256
257
  const dropBg = ref(false);
257
258
 
258
259
  function acceptDrag(ev: DragEvent): boolean {
259
- return !!ev.dataTransfer?.types.includes(FE_DND_MIME);
260
+ return hasInternalDrag(ev);
260
261
  }
261
262
 
262
263
  function handleDropPayload(ev: DragEvent, targetWire: string) {
263
- const raw = ev.dataTransfer?.getData(FE_DND_MIME);
264
- if (!raw || !targetWire) return;
265
- let items: Array<{ path: string }> = [];
266
- try {
267
- items = JSON.parse(raw);
268
- } catch {
269
- return;
270
- }
271
- const origin = ev.dataTransfer?.getData(FE_DND_SRC_MIME) || undefined;
264
+ const items = internalDragItems(ev);
265
+ if (!items || !targetWire) return;
266
+ const origin = internalDragOrigin(ev);
272
267
  const sources = items
273
268
  .map((i) => i.path)
274
269
  .filter((p) => p && p !== targetWire && !targetWire.startsWith(p + '/'));
Binary file
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What a transfer between two places actually means.
3
+ *
4
+ * Three gestures land on the same wire call — drag a row onto a folder, paste
5
+ * after Ctrl+X, paste after Ctrl+C — and they do NOT mean the same thing once
6
+ * the two ends live in different depolar:
7
+ *
8
+ * • Ctrl+C → paste is a copy, wherever it lands.
9
+ * • Ctrl+X → paste is a move, wherever it lands. Across depolar the server
10
+ * streams the bytes over and then deletes the original; before v0.27.0 the
11
+ * explorer quietly downgraded this to a copy and the user was left with the
12
+ * file in both places, believing they had moved it.
13
+ * • Dragging is a move inside one depo and a COPY across two — the rule
14
+ * Explorer and Finder have taught everyone: a drag between drives copies.
15
+ *
16
+ * Kept as a pure function so both the pane path and the clipboard path ask the
17
+ * same question, and so the answer is testable without a server.
18
+ */
19
+
20
+ /** `alpha://a/b` → `alpha`; a bare path → `''`. */
21
+ export function wireAdapterOf(p: string): string {
22
+ const i = String(p ?? '').indexOf('://');
23
+ return i === -1 ? '' : p.slice(0, i);
24
+ }
25
+
26
+ /** What the caller asked for. `auto` is a drag: let the depolar decide. */
27
+ export type TransferIntent = 'auto' | 'copy' | 'move';
28
+
29
+ export interface TransferPlan {
30
+ /** What to actually ask the server for. */
31
+ kind: 'copy' | 'move';
32
+ /** True when at least one source lives in another depo than the target. */
33
+ cross: boolean;
34
+ }
35
+
36
+ export function resolveTransfer(
37
+ sources: string[],
38
+ targetWire: string,
39
+ intent: TransferIntent = 'auto',
40
+ ): TransferPlan {
41
+ const target = wireAdapterOf(targetWire);
42
+ // A source with no prefix is a legacy embedder's bare path: it can only mean
43
+ // "the same depo I am looking at", so it never counts as crossing.
44
+ const cross = sources.some((p) => {
45
+ const a = wireAdapterOf(p);
46
+ return a !== '' && target !== '' && a !== target;
47
+ });
48
+ if (intent === 'copy') return { kind: 'copy', cross };
49
+ if (intent === 'move') return { kind: 'move', cross };
50
+ return { kind: cross ? 'copy' : 'move', cross };
51
+ }
package/src/locales/en.ts CHANGED
@@ -379,7 +379,12 @@ export const en: Record<string, string> = {
379
379
  'split.retry': 'Retry',
380
380
  'split.copy_queued': 'Copy queued',
381
381
  'split.cross_copy': 'Different storages — copy queued instead',
382
- 'split.cross_failed': 'Cross-storage copy is not supported by this server',
382
+ 'dragout.downloading': 'Downloading into the folder you dropped on…',
383
+ 'dragout.not_found': 'Could not find where it was dropped — a drop onto an application (rather than a folder) cannot be filled in',
384
+ 'dragout.done': 'Download finished',
385
+ 'dragout.ready': 'Files are ready — drag them to your desktop',
386
+ 'dragout.preparing': 'Getting them ready to drag…',
387
+ 'split.cross_move': 'Different storages — move queued (bytes travel, then the original is removed)',
383
388
  /* === /wiring:d1 === */
384
389
  /* wiring:d2 — gallery view */
385
390
  'toolbar.view.gallery': 'Gallery',
package/src/locales/tr.ts CHANGED
@@ -379,7 +379,12 @@ export const tr: Record<string, string> = {
379
379
  'split.retry': 'Yeniden dene',
380
380
  'split.copy_queued': 'Kopyalama kuyruğa alındı',
381
381
  'split.cross_copy': 'Depolar farklı — kopyalama kuyruğa alındı',
382
- 'split.cross_failed': 'Depolar arası kopyalama bu sunucuda desteklenmiyor',
382
+ 'dragout.downloading': 'Bırakılan klasöre indiriliyor…',
383
+ 'dragout.not_found': 'Bırakılan yer bulunamadı — dosya bir klasöre değil bir uygulamaya bırakıldıysa indirme yapılamaz',
384
+ 'dragout.done': 'İndirme tamamlandı',
385
+ 'dragout.ready': 'Dosyalar hazır — masaüstüne sürükleyebilirsin',
386
+ 'dragout.preparing': 'Sürükleme için hazırlanıyor…',
387
+ 'split.cross_move': 'Depolar farklı — taşıma kuyruğa alındı (baytlar aktarılıp kaynak silinir)',
383
388
  /* === /wiring:d1 === */
384
389
  /* wiring:d2 — galeri görünümü */
385
390
  'toolbar.view.gallery': 'Galeri',