@brftech/filex-core 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{CsvViewer-DxujFEM3.js → CsvViewer-CqWeV8VO.js} +22 -22
- package/dist/CsvViewer-CqWeV8VO.js.map +1 -0
- package/dist/{DrawioViewer-DCvWnX2t.js → DrawioViewer-BNALOB04.js} +16 -16
- package/dist/DrawioViewer-BNALOB04.js.map +1 -0
- package/dist/filex-core.js +5932 -5841
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +43 -43
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +49 -8
- package/package.json +1 -1
- package/src/FileExplorer.vue +267 -235
- package/src/components/CommandPalette.vue +3 -3
- package/src/components/ConnectionsPanel.vue +2 -1
- package/src/components/GalleryView.vue +3 -3
- package/src/components/GridView.vue +4 -4
- package/src/components/ListView.vue +4 -4
- package/src/components/NFSExportsPanel.vue +2 -1
- package/src/components/PendingOpsTray.vue +1 -1
- package/src/components/PresenceBar.vue +3 -2
- package/src/components/S3KeysPanel.vue +2 -1
- package/src/components/SSHKeysPanel.vue +2 -1
- package/src/components/SecondaryPane.vue +13 -12
- package/src/components/StarButton.vue +2 -1
- package/src/components/TabBar.vue +2 -2
- package/src/components/ThemeGallery.vue +1 -1
- package/src/components/TokensPanel.vue +2 -1
- package/src/components/Toolbar.vue +9 -9
- package/src/composables/useFileApi.ts +4 -2
- package/src/composables/useLocale.ts +1 -1
- package/src/composables/useOperations.ts +3 -3
- package/src/composables/usePendingOps.ts +1 -1
- package/src/composables/useTabs.ts +1 -1
- package/src/composables/useUploadChunked.ts +39 -7
- package/src/index.ts +2 -2
- package/src/lib/themes.ts +7 -7
- package/src/lib/transfer.ts +6 -6
- package/src/locales/en.ts +32 -0
- package/src/locales/index.ts +1 -0
- package/src/locales/resolve.ts +66 -0
- package/src/locales/tr.ts +33 -0
- package/src/modals/ConvertModal.vue +14 -12
- package/src/modals/NewFolderModal.vue +1 -1
- package/src/modals/PermissionsModal.vue +12 -11
- package/src/modals/PreviewModal.vue +9 -9
- package/src/styles/base.css +29 -26
- package/src/styles/variables.css +1 -1
- package/src/types/ExplorerConfig.ts +1 -1
- package/src/types/FileNode.ts +1 -1
- package/src/viewers/CsvViewer.vue +4 -4
- package/src/viewers/DrawioViewer.vue +2 -2
- package/dist/CsvViewer-DxujFEM3.js.map +0 -1
- package/dist/DrawioViewer-DCvWnX2t.js.map +0 -1
package/src/FileExplorer.vue
CHANGED
|
@@ -87,12 +87,12 @@ import { useOperations } from './composables/useOperations';
|
|
|
87
87
|
/* wiring:c4 */
|
|
88
88
|
import OnboardingTour from './components/OnboardingTour.vue';
|
|
89
89
|
/* /wiring:c4 */
|
|
90
|
-
/* wiring:d1 —
|
|
90
|
+
/* wiring:d1 — tabs + per-tab split */
|
|
91
91
|
import TabBar from './components/TabBar.vue';
|
|
92
92
|
import SecondaryPane from './components/SecondaryPane.vue';
|
|
93
93
|
import { useTabs, type TabState } from './composables/useTabs';
|
|
94
94
|
/* /wiring:d1 */
|
|
95
|
-
/* wiring:e2 —
|
|
95
|
+
/* wiring:e2 — end-to-end encrypted folders (docs/E2E-ENCRYPTION.md) */
|
|
96
96
|
import EncryptedFolderModal from './components/EncryptedFolderModal.vue';
|
|
97
97
|
import RecoveryKeyModal from './components/RecoveryKeyModal.vue';
|
|
98
98
|
import E2eRecoveryUnlockModal from './components/E2eRecoveryUnlockModal.vue';
|
|
@@ -158,6 +158,7 @@ import ShareModal from './modals/ShareModal.vue';
|
|
|
158
158
|
import PreviewModal from './modals/PreviewModal.vue';
|
|
159
159
|
import ConvertModal from './modals/ConvertModal.vue';
|
|
160
160
|
import PermissionsModal from './modals/PermissionsModal.vue';
|
|
161
|
+
import { resolveLocale } from './locales/resolve';
|
|
161
162
|
|
|
162
163
|
const props = defineProps<{
|
|
163
164
|
config: ExplorerConfig;
|
|
@@ -187,7 +188,7 @@ const api = useFileApi(props.config);
|
|
|
187
188
|
// Locale up-front: the pendingOps onSettled callback below (and the undo-toast
|
|
188
189
|
// helpers) need `t()` at runtime, so the catalogue must be constructed before
|
|
189
190
|
// they are wired. Depends only on props — safe this early.
|
|
190
|
-
const locale = computed(() => props.config.locale
|
|
191
|
+
const locale = computed(() => resolveLocale(props.config.locale));
|
|
191
192
|
const { t } = useLocale(locale);
|
|
192
193
|
|
|
193
194
|
// Live collaboration (WebSocket file-change events + presence), bundled into the
|
|
@@ -239,20 +240,20 @@ const pendingOps = usePendingOps(props.config, api, {
|
|
|
239
240
|
const undo = opUndo.get(op.id);
|
|
240
241
|
opUndo.delete(op.id);
|
|
241
242
|
if (op.status === 'error') {
|
|
242
|
-
flashToast(op.error_message || '
|
|
243
|
+
flashToast(op.error_message || t('toast.failed'));
|
|
243
244
|
} else if (undo) {
|
|
244
245
|
undoToast(`${undo.message} (${op.progress_total})`, undo.fn);
|
|
245
246
|
} else {
|
|
246
247
|
const verb =
|
|
247
248
|
op.op_type === 'copy'
|
|
248
|
-
? '
|
|
249
|
+
? t('toast.copied')
|
|
249
250
|
: op.op_type === 'move'
|
|
250
|
-
? '
|
|
251
|
-
: '
|
|
251
|
+
? t('toast.moved')
|
|
252
|
+
: t('toast.deleted');
|
|
252
253
|
flashToast(`${verb} (${op.progress_total})`);
|
|
253
254
|
}
|
|
254
255
|
void load();
|
|
255
|
-
void splitPaneRef.value?.reload(); /* wiring:d1 —
|
|
256
|
+
void splitPaneRef.value?.reload(); /* wiring:d1 — refresh the secondary pane too */
|
|
256
257
|
},
|
|
257
258
|
});
|
|
258
259
|
|
|
@@ -661,8 +662,9 @@ const showDelete = ref(false);
|
|
|
661
662
|
const showShare = ref(false);
|
|
662
663
|
const showPreview = ref(false);
|
|
663
664
|
const renameTarget = ref<FileNode | null>(null);
|
|
664
|
-
/* ui-fix —
|
|
665
|
-
* (
|
|
665
|
+
/* ui-fix — does the open rename/delete/new-folder modal belong to the side
|
|
666
|
+
* pane? (the menu is identical to the main pane's; this routes the mutation
|
|
667
|
+
* to the right one.) */
|
|
666
668
|
const mutationInPane = ref(false);
|
|
667
669
|
const shareTarget = ref<FileNode | null>(null);
|
|
668
670
|
const activeShare = ref<(ShareInfo & { url: string; filename?: string }) | null>(null);
|
|
@@ -1506,7 +1508,7 @@ async function load(path?: string) {
|
|
|
1506
1508
|
currentPath.value = '';
|
|
1507
1509
|
adapter.value = '';
|
|
1508
1510
|
dirname.value = '';
|
|
1509
|
-
e2eRoot.value = ''; /* wiring:e2 —
|
|
1511
|
+
e2eRoot.value = ''; /* wiring:e2 — no lock screen at the virtual root */
|
|
1510
1512
|
files.value = virtualStorageRows();
|
|
1511
1513
|
return;
|
|
1512
1514
|
}
|
|
@@ -1553,7 +1555,7 @@ async function load(path?: string) {
|
|
|
1553
1555
|
// show the dedicated not-found state instead of a toast over a stale
|
|
1554
1556
|
// listing that reads as "this folder is empty".
|
|
1555
1557
|
notFoundPath.value = String(requested);
|
|
1556
|
-
e2eRoot.value = ''; /* wiring:e2 —
|
|
1558
|
+
e2eRoot.value = ''; /* wiring:e2 — no lock screen left over on a dead link */
|
|
1557
1559
|
files.value = [];
|
|
1558
1560
|
emit('error', { message: e, context: { path } });
|
|
1559
1561
|
return;
|
|
@@ -1599,7 +1601,7 @@ async function loadTrash() {
|
|
|
1599
1601
|
loading.value = true;
|
|
1600
1602
|
trashOrigin.value = adapter.value || '';
|
|
1601
1603
|
trashMode.value = true;
|
|
1602
|
-
e2eRoot.value = ''; /* wiring:e2 —
|
|
1604
|
+
e2eRoot.value = ''; /* wiring:e2 — the trash view is outside the encrypted context */
|
|
1603
1605
|
selection.clear();
|
|
1604
1606
|
try {
|
|
1605
1607
|
const { entries } = await api.listTrash();
|
|
@@ -1678,10 +1680,10 @@ function wireParent(p: string): string {
|
|
|
1678
1680
|
return slash === -1 ? prefix : prefix + rel.slice(0, slash);
|
|
1679
1681
|
}
|
|
1680
1682
|
|
|
1681
|
-
/* ui-fix —
|
|
1682
|
-
*
|
|
1683
|
-
* (source parent === target)
|
|
1684
|
-
*
|
|
1683
|
+
/* ui-fix — do two wire paths point at the SAME directory (safe against a
|
|
1684
|
+
* trailing slash and against the bare `adapter://` root)? Dropping an item
|
|
1685
|
+
* into the folder it is ALREADY in (source parent === target) must be a
|
|
1686
|
+
* no-op; otherwise the backend answers with a "copy onto itself" 400. */
|
|
1685
1687
|
function sameDir(a: string, b: string): boolean {
|
|
1686
1688
|
const norm = (s: string) => {
|
|
1687
1689
|
const i = s.indexOf('://');
|
|
@@ -1931,7 +1933,7 @@ async function openSearchHit(hit: GlobalSearchHit) {
|
|
|
1931
1933
|
|
|
1932
1934
|
useKeyboardShortcuts(rootEl, {
|
|
1933
1935
|
onDelete: () => {
|
|
1934
|
-
/* ui-fix —
|
|
1936
|
+
/* ui-fix — the shortcut goes to the active pane too (consistent with the menu). */
|
|
1935
1937
|
if (paneIsActive.value) {
|
|
1936
1938
|
const psel = splitPaneRef.value?.selectedNodes() ?? [];
|
|
1937
1939
|
if (psel.length) {
|
|
@@ -2011,7 +2013,7 @@ useKeyboardShortcuts(rootEl, {
|
|
|
2011
2013
|
onToggleHidden: () => toggleHiddenFiles(),
|
|
2012
2014
|
onStar: () => void toggleStar(selection.nodes.value) /* yildiz:s1 */,
|
|
2013
2015
|
onToggleInspector: () => toggleInspector() /* koru:k1 */,
|
|
2014
|
-
/* wiring:d1 —
|
|
2016
|
+
/* wiring:d1 — tab actions (registry: tab-new/close/next/prev) */
|
|
2015
2017
|
onTabNew: () => newTabHere(),
|
|
2016
2018
|
onTabClose: () => closeTabById(tabsActiveId.value),
|
|
2017
2019
|
onTabNext: () => nextTab(),
|
|
@@ -2060,9 +2062,10 @@ function openNode(n: FileNode) {
|
|
|
2060
2062
|
void load(target);
|
|
2061
2063
|
return;
|
|
2062
2064
|
}
|
|
2063
|
-
/* wiring:e2 —
|
|
2064
|
-
|
|
2065
|
-
|
|
2065
|
+
/* wiring:e2 — opening a file inside an encrypted folder: while unlocked,
|
|
2066
|
+
decrypt + show a read-only preview (the blob URL feeds the existing
|
|
2067
|
+
viewers); while locked nothing opens at all (the lock screen already
|
|
2068
|
+
hides the listing). */
|
|
2066
2069
|
if (e2eUnlocked.value && n.type === 'file') {
|
|
2067
2070
|
void e2eOpenPreview(n);
|
|
2068
2071
|
return;
|
|
@@ -2126,7 +2129,7 @@ async function restoreSelection(targets?: FileNode[]) {
|
|
|
2126
2129
|
.map((n) => (n as { id?: number }).id)
|
|
2127
2130
|
.filter((x): x is number => typeof x === 'number');
|
|
2128
2131
|
const { restored } = await api.restoreIds(ids);
|
|
2129
|
-
flashToast(
|
|
2132
|
+
flashToast(t('toast.restored', { n: restored }));
|
|
2130
2133
|
selection.clear();
|
|
2131
2134
|
await loadTrash();
|
|
2132
2135
|
return;
|
|
@@ -2135,7 +2138,7 @@ async function restoreSelection(targets?: FileNode[]) {
|
|
|
2135
2138
|
if (!api.endpoints.restore) return;
|
|
2136
2139
|
const items = nodes.map((n) => n.path); // qualified
|
|
2137
2140
|
const { restored } = await api.restore(items);
|
|
2138
|
-
flashToast(
|
|
2141
|
+
flashToast(t('toast.restored', { n: restored }));
|
|
2139
2142
|
selection.clear();
|
|
2140
2143
|
await load();
|
|
2141
2144
|
} catch (err) {
|
|
@@ -2144,7 +2147,7 @@ async function restoreSelection(targets?: FileNode[]) {
|
|
|
2144
2147
|
}
|
|
2145
2148
|
|
|
2146
2149
|
function previewNode(n: FileNode) {
|
|
2147
|
-
/* wiring:e2 —
|
|
2150
|
+
/* wiring:e2 — the preview is fed from the decrypted blob as well */
|
|
2148
2151
|
if (e2eUnlocked.value && n.type === 'file') {
|
|
2149
2152
|
void e2eOpenPreview(n);
|
|
2150
2153
|
return;
|
|
@@ -2171,8 +2174,9 @@ function openNodeInNewTab(n: FileNode) {
|
|
|
2171
2174
|
void load(target);
|
|
2172
2175
|
return;
|
|
2173
2176
|
}
|
|
2174
|
-
/* wiring:e2 — standalone
|
|
2175
|
-
|
|
2177
|
+
/* wiring:e2 — the standalone editor route pulls the RAW (encrypted) bytes
|
|
2178
|
+
from the server, so inside an encrypted folder every "Aç" falls back to
|
|
2179
|
+
the in-page decrypted preview. */
|
|
2176
2180
|
if (e2eActive.value) {
|
|
2177
2181
|
if (e2eUnlocked.value) void e2eOpenPreview(n);
|
|
2178
2182
|
return;
|
|
@@ -2207,10 +2211,10 @@ function openNodeInNewTab(n: FileNode) {
|
|
|
2207
2211
|
void markRecent(n);
|
|
2208
2212
|
}
|
|
2209
2213
|
|
|
2210
|
-
type ContextMode = 'selection' | 'breadcrumb' | 'pane' /* ui-fix —
|
|
2214
|
+
type ContextMode = 'selection' | 'breadcrumb' | 'pane' /* ui-fix — side-pane right-click */;
|
|
2211
2215
|
const ctxMode = ref<ContextMode>('selection');
|
|
2212
2216
|
const breadcrumbCtxPath = ref<string>('');
|
|
2213
|
-
/* ui-fix —
|
|
2217
|
+
/* ui-fix — target nodes of the side pane's right-click menu (pane selection). */
|
|
2214
2218
|
const paneCtxTargets = ref<FileNode[]>([]);
|
|
2215
2219
|
const breadcrumbCtxLabel = ref<string>('');
|
|
2216
2220
|
|
|
@@ -2415,15 +2419,17 @@ function onCrumbContext(payload: { x: number; y: number; adapterPath: string; la
|
|
|
2415
2419
|
ctxRef.value?.show({ clientX: payload.x, clientY: payload.y }, []);
|
|
2416
2420
|
}
|
|
2417
2421
|
|
|
2418
|
-
/* ui-fix —
|
|
2419
|
-
*
|
|
2420
|
-
*
|
|
2422
|
+
/* ui-fix — right-click in the side (secondary) pane: activate the pane, then
|
|
2423
|
+
* open the menu. The menu is EXACTLY the main pane's (selectionActionList is
|
|
2424
|
+
* the single source); actions go to dispatchItemAction and are pane-routed
|
|
2425
|
+
* while ctxMode==='pane'. */
|
|
2421
2426
|
function onPaneContext(node: FileNode | null, ev: MouseEvent) {
|
|
2422
2427
|
activePane.value = 'split';
|
|
2423
2428
|
void refreshKept();
|
|
2424
2429
|
const sel = splitPaneRef.value?.selectedNodes() ?? [];
|
|
2425
|
-
// node=null (
|
|
2426
|
-
//
|
|
2430
|
+
// node=null (right-click on empty space): the selection-less menu
|
|
2431
|
+
// ("Yeni Klasör" + "Yapıştır"). Otherwise the pane selection is the target
|
|
2432
|
+
// (falling back to the clicked node).
|
|
2427
2433
|
paneCtxTargets.value = node ? (sel.length > 0 ? sel : [node]) : [];
|
|
2428
2434
|
ctxMode.value = 'pane';
|
|
2429
2435
|
ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, paneCtxTargets.value);
|
|
@@ -2436,10 +2442,10 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2436
2442
|
{ key: 'copy-path', label: t('breadcrumb.copy_path'), icon: '📋' },
|
|
2437
2443
|
];
|
|
2438
2444
|
}
|
|
2439
|
-
if (ctxMode.value === 'pane' /* ui-fix —
|
|
2445
|
+
if (ctxMode.value === 'pane' /* ui-fix — side-pane menu is EXACTLY the main pane's */) {
|
|
2440
2446
|
const psel = paneCtxTargets.value;
|
|
2441
2447
|
if (psel.length === 0) {
|
|
2442
|
-
//
|
|
2448
|
+
// Right-click on empty space: same as the main pane's canvas menu.
|
|
2443
2449
|
return [
|
|
2444
2450
|
{ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' },
|
|
2445
2451
|
{ key: 'paste', label: t('ctx.paste'), icon: '📋', disabled: !clipboard.value.mode },
|
|
@@ -2470,7 +2476,7 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2470
2476
|
// PRIOR BUG: this used `currentPath === '/'` but the load() branch
|
|
2471
2477
|
// for the virtual root sets currentPath to EMPTY string, not '/'.
|
|
2472
2478
|
// So the guard never fired and every mutation action leaked into
|
|
2473
|
-
// the menu at the
|
|
2479
|
+
// the menu at the storage listing — including new-folder + paste,
|
|
2474
2480
|
// which Ada called out in the most direct possible terms. Use
|
|
2475
2481
|
// the same empty-after-trim test as `atVirtualRoot` above.
|
|
2476
2482
|
const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
@@ -2512,9 +2518,10 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2512
2518
|
|
|
2513
2519
|
// selectionActionList — the SINGLE source of truth for the actions offered on a
|
|
2514
2520
|
// selection. BOTH the right-click context menu AND the top toolbar render this
|
|
2515
|
-
// exact list so they can never drift apart (Ada
|
|
2516
|
-
//
|
|
2517
|
-
// them. Action handling is unified in
|
|
2521
|
+
// exact list so they can never drift apart (Ada, translated from Turkish: "the
|
|
2522
|
+
// right-click menu and the top menu don't match"). The toolbar filters out
|
|
2523
|
+
// dividers/hidden; the context menu shows them. Action handling is unified in
|
|
2524
|
+
// dispatchItemAction().
|
|
2518
2525
|
function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
2519
2526
|
const any = sel.length > 0;
|
|
2520
2527
|
const single = sel.length === 1;
|
|
@@ -2537,11 +2544,11 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2537
2544
|
const accessLabel = locale.value === 'en' ? 'Share / Permissions' : 'Paylaş / İzinler';
|
|
2538
2545
|
return [
|
|
2539
2546
|
{ key: 'open', label: t('ctx.open'), icon: '↗', hidden: !single },
|
|
2540
|
-
{ key: 'open-tab', label: t('ctx.open_new_tab'), icon: '⧉', hidden: !single || sel[0]?.type !== 'dir' } /* wiring:d1 —
|
|
2547
|
+
{ key: 'open-tab', label: t('ctx.open_new_tab'), icon: '⧉', hidden: !single || sel[0]?.type !== 'dir' } /* wiring:d1 — open the folder in a new tab */,
|
|
2541
2548
|
{ key: 'preview', label: t('ctx.preview'), icon: '👁', hidden: !single, disabled: !isFile },
|
|
2542
2549
|
{ key: 'download', label: t('ctx.download'), icon: '⬇', hidden: !single, disabled: !isFile },
|
|
2543
|
-
{ key: 'convert', label: t('ctx.convert'), icon: '🔄', hidden: !single || !effectiveConvertUrl.value || !w || e2eActive.value /* wiring:e2 — convert ciphertext
|
|
2544
|
-
{ key: 'access', label: accessLabel, icon: '🔗', hidden: !single || !w || e2eActive.value /* wiring:e2 —
|
|
2550
|
+
{ key: 'convert', label: t('ctx.convert'), icon: '🔄', hidden: !single || !effectiveConvertUrl.value || !w || e2eActive.value /* wiring:e2 — convert is meaningless on ciphertext */, disabled: !isFile },
|
|
2551
|
+
{ key: 'access', label: accessLabel, icon: '🔗', hidden: !single || !w || e2eActive.value /* wiring:e2 — sharing is off in the MVP (the link would serve ciphertext) */ },
|
|
2545
2552
|
{ key: 'details', label: t('ctx.details'), icon: 'ℹ', hidden: !any } /* koru:k1 */,
|
|
2546
2553
|
{ key: 'copy-id', label: copyIdLabel, icon: '🆔', hidden: !singleHasId, disabled: !singleHasId },
|
|
2547
2554
|
{ divider: true, key: 'sep1', label: '', hidden: !w },
|
|
@@ -2596,7 +2603,7 @@ async function onContextAction(action: ContextAction, targets: FileNode[]) {
|
|
|
2596
2603
|
}
|
|
2597
2604
|
return;
|
|
2598
2605
|
}
|
|
2599
|
-
if (ctxMode.value === 'pane' /* ui-fix —
|
|
2606
|
+
if (ctxMode.value === 'pane' /* ui-fix — side pane: same dispatch, pane-routed */) {
|
|
2600
2607
|
await dispatchItemAction(action.key, paneCtxTargets.value);
|
|
2601
2608
|
return;
|
|
2602
2609
|
}
|
|
@@ -2692,23 +2699,23 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2692
2699
|
}
|
|
2693
2700
|
break;
|
|
2694
2701
|
case 'cut':
|
|
2695
|
-
/* ui-fix — pane
|
|
2702
|
+
/* ui-fix — in a pane context the clipboard source must be the pane's dir. */
|
|
2696
2703
|
if (ctxMode.value === 'pane') paneCut();
|
|
2697
2704
|
else {
|
|
2698
2705
|
clipboard.value = { mode: 'cut', items: targets, sourcePath: currentPath.value };
|
|
2699
|
-
flashToast('
|
|
2706
|
+
flashToast(t('toast.cut_ready'));
|
|
2700
2707
|
}
|
|
2701
2708
|
break;
|
|
2702
2709
|
case 'copy':
|
|
2703
2710
|
if (ctxMode.value === 'pane') paneCopy();
|
|
2704
2711
|
else {
|
|
2705
2712
|
clipboard.value = { mode: 'copy', items: targets, sourcePath: currentPath.value };
|
|
2706
|
-
flashToast('
|
|
2713
|
+
flashToast(t('toast.copy_ready'));
|
|
2707
2714
|
}
|
|
2708
2715
|
break;
|
|
2709
2716
|
case 'paste':
|
|
2710
|
-
/* ui-fix —
|
|
2711
|
-
* (
|
|
2717
|
+
/* ui-fix — pasting from the right-click menu goes to the active pane too
|
|
2718
|
+
* (the keyboard shortcut was already pane-routed; the menu was not). */
|
|
2712
2719
|
if (ctxMode.value === 'pane' || paneIsActive.value) await panePaste();
|
|
2713
2720
|
else await paste();
|
|
2714
2721
|
break;
|
|
@@ -2729,7 +2736,7 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2729
2736
|
case 'duplicate':
|
|
2730
2737
|
if (targets[0]) await duplicate(targets[0]);
|
|
2731
2738
|
break;
|
|
2732
|
-
/* wiring:d1 —
|
|
2739
|
+
/* wiring:d1 — right-click "Yeni sekmede aç" (open in new tab) */
|
|
2733
2740
|
case 'open-tab':
|
|
2734
2741
|
if (targets[0]) openNodeInTab(targets[0]);
|
|
2735
2742
|
break;
|
|
@@ -2740,13 +2747,13 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2740
2747
|
function cut() {
|
|
2741
2748
|
if (selection.isEmpty.value) return;
|
|
2742
2749
|
clipboard.value = { mode: 'cut', items: selection.nodes.value, sourcePath: currentPath.value };
|
|
2743
|
-
flashToast('
|
|
2750
|
+
flashToast(t('toast.cut'));
|
|
2744
2751
|
}
|
|
2745
2752
|
|
|
2746
2753
|
function copyToClipboard() {
|
|
2747
2754
|
if (selection.isEmpty.value) return;
|
|
2748
2755
|
clipboard.value = { mode: 'copy', items: selection.nodes.value, sourcePath: currentPath.value };
|
|
2749
|
-
flashToast('
|
|
2756
|
+
flashToast(t('toast.copied'));
|
|
2750
2757
|
}
|
|
2751
2758
|
|
|
2752
2759
|
async function paste() {
|
|
@@ -2757,25 +2764,26 @@ async function paste() {
|
|
|
2757
2764
|
const sourceDir = cb.sourcePath || '';
|
|
2758
2765
|
const sameDir = cb.mode === 'cut' && sourceDir === currentPath.value;
|
|
2759
2766
|
if (sameDir) {
|
|
2760
|
-
flashToast('
|
|
2767
|
+
flashToast(t('toast.same_folder_cut'));
|
|
2761
2768
|
return;
|
|
2762
2769
|
}
|
|
2763
2770
|
|
|
2764
2771
|
const targetWire = qualify(currentPath.value);
|
|
2765
|
-
//
|
|
2766
|
-
//
|
|
2767
|
-
//
|
|
2772
|
+
// A different storage only changes the message: cut still CUTS
|
|
2773
|
+
// and copy still COPIES, even when the target lives on another storage.
|
|
2774
|
+
// The server does the transfer (the ops queue carries both the source
|
|
2775
|
+
// and the target storage).
|
|
2768
2776
|
const plan = resolveTransfer(items, targetWire, cb.mode === 'cut' ? 'move' : 'copy');
|
|
2769
2777
|
if (cb.mode === 'cut') {
|
|
2770
2778
|
const originWire = qualify(sourceDir) || undefined;
|
|
2771
2779
|
const { op } = await api.moveAsync(items, targetWire, originWire);
|
|
2772
2780
|
registerMoveUndo(op.id, items, targetWire, originWire);
|
|
2773
2781
|
pendingOps.register(op);
|
|
2774
|
-
flashToast(plan.cross ? t('split.cross_move') : '
|
|
2782
|
+
flashToast(plan.cross ? t('split.cross_move') : t('split.move_queued'));
|
|
2775
2783
|
} else {
|
|
2776
2784
|
const { op } = await api.copy(items, targetWire);
|
|
2777
2785
|
pendingOps.register(op);
|
|
2778
|
-
flashToast(plan.cross ? t('split.cross_copy') : '
|
|
2786
|
+
flashToast(plan.cross ? t('split.cross_copy') : t('split.copy_queued'));
|
|
2779
2787
|
}
|
|
2780
2788
|
clipboard.value = { mode: null, items: [], sourcePath: null };
|
|
2781
2789
|
} catch (err) {
|
|
@@ -2793,8 +2801,9 @@ async function duplicate(n: FileNode) {
|
|
|
2793
2801
|
}
|
|
2794
2802
|
|
|
2795
2803
|
function downloadFile(n: FileNode) {
|
|
2796
|
-
/* wiring:e2 —
|
|
2797
|
-
|
|
2804
|
+
/* wiring:e2 — downloading inside an encrypted folder: fetch the bytes,
|
|
2805
|
+
decrypt them, save under the original name (handing the user raw
|
|
2806
|
+
ciphertext would be pointless). */
|
|
2798
2807
|
if (e2eUnlocked.value) {
|
|
2799
2808
|
void e2eDownload(n);
|
|
2800
2809
|
return;
|
|
@@ -2810,7 +2819,7 @@ function downloadFile(n: FileNode) {
|
|
|
2810
2819
|
// ------- Modals -------
|
|
2811
2820
|
|
|
2812
2821
|
async function submitNewFolder(name: string) {
|
|
2813
|
-
const inPane = mutationInPane.value; /* ui-fix —
|
|
2822
|
+
const inPane = mutationInPane.value; /* ui-fix — new folder in the side pane */
|
|
2814
2823
|
try {
|
|
2815
2824
|
const dirWire = inPane ? qualify(splitPaneRef.value?.getPath() ?? '') : qualify(currentPath.value);
|
|
2816
2825
|
await api.newFolder(dirWire, name);
|
|
@@ -2825,7 +2834,7 @@ async function submitNewFolder(name: string) {
|
|
|
2825
2834
|
async function submitRename(name: string) {
|
|
2826
2835
|
const target = renameTarget.value;
|
|
2827
2836
|
if (!target) return;
|
|
2828
|
-
const inPane = mutationInPane.value; /* ui-fix —
|
|
2837
|
+
const inPane = mutationInPane.value; /* ui-fix — rename from the side pane */
|
|
2829
2838
|
try {
|
|
2830
2839
|
const dirWire = inPane ? qualify(splitPaneRef.value?.getPath() ?? '') : qualify(currentPath.value);
|
|
2831
2840
|
const oldPath = target.path; // qualified
|
|
@@ -2853,7 +2862,7 @@ async function confirmDelete() {
|
|
|
2853
2862
|
// offer Restore here rather than a delete that would just re-trash a path.
|
|
2854
2863
|
if (trashMode.value) {
|
|
2855
2864
|
showDelete.value = false;
|
|
2856
|
-
flashToast('
|
|
2865
|
+
flashToast(t('toast.trash_retention'));
|
|
2857
2866
|
return;
|
|
2858
2867
|
}
|
|
2859
2868
|
/* ui-fix — yan panelden silme: hedefler + dizin + tazeleme pane'e ait. */
|
|
@@ -2885,7 +2894,7 @@ async function confirmDelete() {
|
|
|
2885
2894
|
opUndo.set(op.id, { message: t('toast.trashed'), fn: restoreUndo });
|
|
2886
2895
|
}
|
|
2887
2896
|
pendingOps.register(op);
|
|
2888
|
-
flashToast('
|
|
2897
|
+
flashToast(t('toast.delete_queued'));
|
|
2889
2898
|
} else {
|
|
2890
2899
|
await api.deleteItems(dirWire, items);
|
|
2891
2900
|
if (inPane) await splitPaneRef.value?.reload();
|
|
@@ -2962,9 +2971,9 @@ function onFilePicked(ev: Event) {
|
|
|
2962
2971
|
|
|
2963
2972
|
async function uploadFiles(list: File[]) {
|
|
2964
2973
|
if (list.length === 0) return;
|
|
2965
|
-
/* wiring:e2 —
|
|
2966
|
-
|
|
2967
|
-
|
|
2974
|
+
/* wiring:e2 — uploads into an encrypted folder are encrypted transparently.
|
|
2975
|
+
No upload while locked (that would be a plaintext-leak door); anything
|
|
2976
|
+
over 200MB hits the MVP single-shot limit and is skipped with a warning. */
|
|
2968
2977
|
if (e2eLocked.value) {
|
|
2969
2978
|
flashToast(t('e2e.upload.locked'));
|
|
2970
2979
|
return;
|
|
@@ -3135,9 +3144,10 @@ function isExternalFileDrag(ev: DragEvent): boolean {
|
|
|
3135
3144
|
const dt = ev.dataTransfer;
|
|
3136
3145
|
if (!dt) return false;
|
|
3137
3146
|
if (dt.types && dt.types.includes(FE_DND_MIME)) return false;
|
|
3138
|
-
/* wiring:f1 —
|
|
3139
|
-
|
|
3140
|
-
|
|
3147
|
+
/* wiring:f1 — an OS drag we started ourselves also carries 'Files', but it
|
|
3148
|
+
is NOT an upload: it would mean downloading the same bytes from the
|
|
3149
|
+
server and posting them straight back (a copy instead of a move, at
|
|
3150
|
+
twice the traffic). */
|
|
3141
3151
|
if (activeNativeDrag()) return false;
|
|
3142
3152
|
// Some browsers expose `items` early in the drag, others only on
|
|
3143
3153
|
// drop. When `items` is available we use it as the authoritative
|
|
@@ -3168,9 +3178,10 @@ function onDragLeave() {
|
|
|
3168
3178
|
if (dragCounter.value === 0) dragOver.value = false;
|
|
3169
3179
|
}
|
|
3170
3180
|
function onDragOver(ev: DragEvent) {
|
|
3171
|
-
/* wiring:d1 —
|
|
3172
|
-
|
|
3173
|
-
|
|
3181
|
+
/* wiring:d1 — internal drags must be droppable on the root body too, so that
|
|
3182
|
+
dropping from the split pane onto the main pane's EMPTY SPACE works (the
|
|
3183
|
+
origin can't be read during dragover — the decision is made at drop time;
|
|
3184
|
+
a same-folder drop stays a no-op). */
|
|
3174
3185
|
if (hasInternalDrag(ev)) {
|
|
3175
3186
|
ev.preventDefault();
|
|
3176
3187
|
return;
|
|
@@ -3181,8 +3192,9 @@ function onDragOver(ev: DragEvent) {
|
|
|
3181
3192
|
}
|
|
3182
3193
|
}
|
|
3183
3194
|
function onDropUpload(ev: DragEvent) {
|
|
3184
|
-
/* wiring:d1 — split
|
|
3185
|
-
|
|
3195
|
+
/* wiring:d1 — dropping from the split pane onto the main pane's empty space
|
|
3196
|
+
= transfer into the current folder (items from the same folder stay a
|
|
3197
|
+
no-op, so the old behaviour is preserved). */
|
|
3186
3198
|
if (hasInternalDrag(ev)) {
|
|
3187
3199
|
const d1Origin = internalDragOrigin(ev) || '';
|
|
3188
3200
|
const d1Here = qualify(currentPath.value);
|
|
@@ -3243,13 +3255,14 @@ onMounted(() => {
|
|
|
3243
3255
|
window.addEventListener('drop', onWindowDrop);
|
|
3244
3256
|
window.addEventListener('pointerup', onGlobalPointerUp);
|
|
3245
3257
|
window.addEventListener('blur', onGlobalPointerUp);
|
|
3246
|
-
/* wiring:f1 —
|
|
3247
|
-
toast
|
|
3248
|
-
|
|
3258
|
+
/* wiring:f1 — while the shell prepares, say "preparing" exactly once; a
|
|
3259
|
+
toast per file would show noise rather than progress. The finish is
|
|
3260
|
+
announced by the 'ready' toast (prepareDragOut). */
|
|
3249
3261
|
dragOut.value?.onProgress?.((p) => {
|
|
3250
|
-
//
|
|
3251
|
-
//
|
|
3252
|
-
//
|
|
3262
|
+
// Work that happens AFTER the drop (the placeholder path) is always
|
|
3263
|
+
// announced — the user dropped a file into a folder and has a right to
|
|
3264
|
+
// know what happened there. Silence only applies to the pre-preparation
|
|
3265
|
+
// nobody asked for.
|
|
3253
3266
|
const afterDrop = !!p?.dropped;
|
|
3254
3267
|
if (p?.error === 'drop_not_found') {
|
|
3255
3268
|
flashToast(t('dragout.not_found'));
|
|
@@ -3267,18 +3280,18 @@ onMounted(() => {
|
|
|
3267
3280
|
if (!p?.finished && p?.done === 0) flashToast(t('dragout.preparing'));
|
|
3268
3281
|
});
|
|
3269
3282
|
});
|
|
3270
|
-
/* wiring:f1 —
|
|
3271
|
-
|
|
3272
|
-
|
|
3283
|
+
/* wiring:f1 — an OS drag never fires 'dragend' for us (the HTML5 drag never
|
|
3284
|
+
started). We drop the record when the mouse is released; otherwise the next
|
|
3285
|
+
ordinary drag would think it was carrying the previous selection. */
|
|
3273
3286
|
function onGlobalPointerUp() {
|
|
3274
3287
|
if (activeNativeDrag()) {
|
|
3275
3288
|
endNativeDrag();
|
|
3276
|
-
//
|
|
3277
|
-
//
|
|
3289
|
+
// The drop MAY NOT have landed in our own window; only our own drop paths
|
|
3290
|
+
// cancel the shell's watch (onDropUpload / onItemDropInto).
|
|
3278
3291
|
}
|
|
3279
3292
|
}
|
|
3280
3293
|
|
|
3281
|
-
/**
|
|
3294
|
+
/** The drag ended inside the app: the shell should stop waiting for a drop. */
|
|
3282
3295
|
function cancelShellDrag() {
|
|
3283
3296
|
if (dragOut.value?.cancel) void Promise.resolve(dragOut.value.cancel()).catch(() => undefined);
|
|
3284
3297
|
}
|
|
@@ -3316,14 +3329,15 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
3316
3329
|
.filter((n) => n.basename !== '.trash')
|
|
3317
3330
|
.map((n) => ({ path: n.path, basename: n.basename, type: n.type })); // qualified
|
|
3318
3331
|
|
|
3319
|
-
/* wiring:f1 —
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
desktop/src/dropwatch.ts).
|
|
3325
|
-
|
|
3326
|
-
|
|
3332
|
+
/* wiring:f1 — drag-out (to the desktop / another application).
|
|
3333
|
+
When a shell is present the drag is ALWAYS an OS drag: folders and
|
|
3334
|
+
multi-selections land as REAL files, one by one, with NO SIZE LIMIT.
|
|
3335
|
+
If the bytes are ready, real files are handed over; if not, the shell
|
|
3336
|
+
hands over an empty "placeholder", finds out where it was dropped and
|
|
3337
|
+
downloads THERE (see desktop/src/dropwatch.ts). If the drop lands INSIDE
|
|
3338
|
+
the app the drag is still a server-side move — the payload stays with us —
|
|
3339
|
+
and the shell is told to "give up" so it doesn't watch the drives for
|
|
3340
|
+
nothing. */
|
|
3327
3341
|
if (dragOut.value && items.length > 0) {
|
|
3328
3342
|
ev.preventDefault();
|
|
3329
3343
|
beginNativeDrag(items, qualify(currentPath.value));
|
|
@@ -3336,14 +3350,15 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
3336
3350
|
}
|
|
3337
3351
|
|
|
3338
3352
|
ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
|
|
3339
|
-
ev.dataTransfer.setData(FE_DND_SRC_MIME, qualify(currentPath.value)); /* wiring:d1 —
|
|
3353
|
+
ev.dataTransfer.setData(FE_DND_SRC_MIME, qualify(currentPath.value)); /* wiring:d1 — cross-pane origin stamp */
|
|
3340
3354
|
ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
|
|
3341
3355
|
ev.dataTransfer.effectAllowed = 'move';
|
|
3342
3356
|
|
|
3343
|
-
/*
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3357
|
+
/* Single file + a cookie session: the browser's own download path
|
|
3358
|
+
(DownloadURL) fetches the file onto the desktop at drop time; no
|
|
3359
|
+
preparation is needed at all. In a bearer-token setup (the desktop app)
|
|
3360
|
+
that path goes out unauthenticated, which is why the local path above
|
|
3361
|
+
takes over there — see lib/dragOut.ts. */
|
|
3347
3362
|
if (items.length === 1 && items[0] && canDownloadUrlDrag(props.config.auth)) {
|
|
3348
3363
|
const payload = downloadUrlPayload(items[0], api.downloadUrl(items[0].path), node.mime_type);
|
|
3349
3364
|
if (payload) ev.dataTransfer.setData('DownloadURL', payload);
|
|
@@ -3352,13 +3367,13 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
3352
3367
|
if (dragOut.value && items.length > 0) void prepareDragOut(items);
|
|
3353
3368
|
}
|
|
3354
3369
|
|
|
3355
|
-
/* === wiring:f1 —
|
|
3370
|
+
/* === wiring:f1 — drag-out preparation ===
|
|
3356
3371
|
*
|
|
3357
|
-
*
|
|
3358
|
-
*
|
|
3359
|
-
*
|
|
3360
|
-
*
|
|
3361
|
-
*
|
|
3372
|
+
* The bytes have to be on disk BEFORE the drag starts (the OS copies from the
|
|
3373
|
+
* path at drop time), so preparation is a separate step. When it finishes the
|
|
3374
|
+
* user is told "ready"; the second drag is now an OS drag and starts
|
|
3375
|
+
* instantly. For files kept on this computer (synced) preparation finishes
|
|
3376
|
+
* instantly on the first go too — the copy is already local.
|
|
3362
3377
|
*/
|
|
3363
3378
|
const dragOut = computed(() => props.config.dragOut ?? null);
|
|
3364
3379
|
const dragOutReadyKey = ref('');
|
|
@@ -3366,11 +3381,12 @@ const dragOutBusy = ref(false);
|
|
|
3366
3381
|
/** True while a preparation nobody asked for is running. */
|
|
3367
3382
|
let dragOutQuiet = false;
|
|
3368
3383
|
|
|
3369
|
-
/*
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3384
|
+
/* Small selections are prepared the moment they are SELECTED, because once
|
|
3385
|
+
preparation is done the next drag is an OS drag: picking a document and
|
|
3386
|
+
dragging it to the desktop therefore works on the FIRST try. The ceiling is
|
|
3387
|
+
deliberately low — someone click-browsing shouldn't download a movie on
|
|
3388
|
+
every row; anything above the limit is prepared on the first drag and the
|
|
3389
|
+
second drag starts instantly. */
|
|
3374
3390
|
const DRAGOUT_PREFETCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
3375
3391
|
const DRAGOUT_PREFETCH_MAX_ITEMS = 10;
|
|
3376
3392
|
let dragOutPrefetchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -3382,8 +3398,8 @@ watch(
|
|
|
3382
3398
|
clearTimeout(dragOutPrefetchTimer);
|
|
3383
3399
|
const nodes = selection.nodes.value;
|
|
3384
3400
|
if (nodes.length === 0 || nodes.length > DRAGOUT_PREFETCH_MAX_ITEMS) return;
|
|
3385
|
-
//
|
|
3386
|
-
//
|
|
3401
|
+
// A folder's size isn't known from the listing; rather than guess it, we
|
|
3402
|
+
// leave it to the first drag.
|
|
3387
3403
|
if (nodes.some((n) => n.type !== 'file')) return;
|
|
3388
3404
|
const total = nodes.reduce((sum, n) => sum + (n.size ?? 0), 0);
|
|
3389
3405
|
if (total > DRAGOUT_PREFETCH_MAX_BYTES) return;
|
|
@@ -3404,8 +3420,9 @@ async function prepareDragOut(items: DragItem[], quiet = false): Promise<void> {
|
|
|
3404
3420
|
const res = await hook.prepare(items);
|
|
3405
3421
|
if (res?.ready) {
|
|
3406
3422
|
dragOutReadyKey.value = key;
|
|
3407
|
-
//
|
|
3408
|
-
//
|
|
3423
|
+
// The quiet round is triggered by a selection; the user ASKED for
|
|
3424
|
+
// nothing, so there's nothing to tell them either. A round that starts
|
|
3425
|
+
// from an actual drag does speak up.
|
|
3409
3426
|
if (!quiet) flashToast(t('dragout.ready'));
|
|
3410
3427
|
} else if (res?.error && !quiet) {
|
|
3411
3428
|
flashToast(res.error);
|
|
@@ -3420,12 +3437,12 @@ async function prepareDragOut(items: DragItem[], quiet = false): Promise<void> {
|
|
|
3420
3437
|
|
|
3421
3438
|
async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string, originOverride?: string): Promise<void> {
|
|
3422
3439
|
try {
|
|
3423
|
-
const originWire = originOverride ?? qualify(currentPath.value); /* wiring:d1 —
|
|
3440
|
+
const originWire = originOverride ?? qualify(currentPath.value); /* wiring:d1 — the real source folder for a drag coming from the split pane */
|
|
3424
3441
|
if (api.endpoints.moveAsync) {
|
|
3425
3442
|
const { op } = await api.moveAsync(sources, targetDir, originWire);
|
|
3426
3443
|
registerMoveUndo(op.id, sources, targetDir, originWire);
|
|
3427
3444
|
pendingOps.register(op);
|
|
3428
|
-
flashToast('
|
|
3445
|
+
flashToast(t('split.move_queued'));
|
|
3429
3446
|
} else {
|
|
3430
3447
|
await api.move(originWire, sources, targetDir);
|
|
3431
3448
|
await load();
|
|
@@ -3451,11 +3468,12 @@ async function onItemDropInto(target: FileNode, ev: DragEvent) {
|
|
|
3451
3468
|
const targetDir = target.path; // qualified
|
|
3452
3469
|
const sources = items
|
|
3453
3470
|
.map((i) => i.path)
|
|
3454
|
-
// ui-fix —
|
|
3455
|
-
//
|
|
3471
|
+
// ui-fix — skip items already inside targetDir (parent===target): an
|
|
3472
|
+
// in-place drop is a no-op and must not trigger the backend's "copy onto
|
|
3473
|
+
// itself" 400.
|
|
3456
3474
|
.filter((p) => p && p !== targetDir && !targetDir.startsWith(p + '/') && !sameDir(wireParent(p), targetDir));
|
|
3457
|
-
if (sources.length === 0) return; //
|
|
3458
|
-
await transferItems(sources, targetDir, dndOrigin(ev)); /* wiring:d1 —
|
|
3475
|
+
if (sources.length === 0) return; // silent no-op (in-place drop)
|
|
3476
|
+
await transferItems(sources, targetDir, dndOrigin(ev)); /* wiring:d1 — storage-aware transfer */
|
|
3459
3477
|
}
|
|
3460
3478
|
|
|
3461
3479
|
async function onCrumbDropInto(adapterPath: string, ev: DragEvent) {
|
|
@@ -3467,10 +3485,10 @@ async function onCrumbDropInto(adapterPath: string, ev: DragEvent) {
|
|
|
3467
3485
|
const targetDir = adapterPath; // already qualified by breadcrumb
|
|
3468
3486
|
const sources = items
|
|
3469
3487
|
.map((i) => i.path)
|
|
3470
|
-
// ui-fix —
|
|
3488
|
+
// ui-fix — an in-place drop onto the breadcrumb (the same folder) is a no-op.
|
|
3471
3489
|
.filter((p) => p && p !== targetDir && !targetDir.startsWith(p + '/') && !sameDir(wireParent(p), targetDir));
|
|
3472
3490
|
if (sources.length === 0) return;
|
|
3473
|
-
await transferItems(sources, targetDir, dndOrigin(ev)); /* wiring:d1 —
|
|
3491
|
+
await transferItems(sources, targetDir, dndOrigin(ev)); /* wiring:d1 — storage-aware transfer */
|
|
3474
3492
|
}
|
|
3475
3493
|
|
|
3476
3494
|
function onCancelUpload(job: UploadJob) {
|
|
@@ -3613,7 +3631,7 @@ function quickLookToggle() {
|
|
|
3613
3631
|
const sel = selection.nodes.value;
|
|
3614
3632
|
const n = sel.length === 1 ? sel[0] : null;
|
|
3615
3633
|
if (!n || n.type !== 'file' || n.basename === '.trash') return;
|
|
3616
|
-
/* wiring:e2 — Space peek
|
|
3634
|
+
/* wiring:e2 — in an encrypted folder the Space peek decrypts first, then opens */
|
|
3617
3635
|
if (e2eActive.value) {
|
|
3618
3636
|
if (!e2eUnlocked.value) return;
|
|
3619
3637
|
void (async () => {
|
|
@@ -3657,14 +3675,14 @@ watch(
|
|
|
3657
3675
|
(nodes) => {
|
|
3658
3676
|
if (!quickLookOpen.value) return;
|
|
3659
3677
|
const n = nodes.length === 1 && nodes[0].type === 'file' ? nodes[0] : null;
|
|
3660
|
-
/* wiring:e2 —
|
|
3661
|
-
|
|
3678
|
+
/* wiring:e2 — when arrowing through files, decrypt BEFORE assigning the
|
|
3679
|
+
target as well, otherwise the viewer briefly gets the raw ciphertext URL. */
|
|
3662
3680
|
if (n && n.path !== quickLookTarget.value?.path && e2eUnlocked.value) {
|
|
3663
3681
|
void (async () => {
|
|
3664
3682
|
try {
|
|
3665
3683
|
await e2eFetchDecrypted(n);
|
|
3666
3684
|
} catch {
|
|
3667
|
-
/*
|
|
3685
|
+
/* decryption failed — switch the target anyway, the viewer shows the error */
|
|
3668
3686
|
}
|
|
3669
3687
|
quickLookTarget.value = n;
|
|
3670
3688
|
void markRecent(n);
|
|
@@ -3759,16 +3777,17 @@ onBeforeUnmount(() => {
|
|
|
3759
3777
|
});
|
|
3760
3778
|
/* === /wiring:c4 === */
|
|
3761
3779
|
|
|
3762
|
-
/* === wiring:d1 —
|
|
3780
|
+
/* === wiring:d1 — tabs (tab strip) + per-tab split ===
|
|
3763
3781
|
*
|
|
3764
|
-
* useTabs
|
|
3765
|
-
*
|
|
3766
|
-
*
|
|
3767
|
-
*
|
|
3782
|
+
* useTabs is a layer ON TOP of the existing location state
|
|
3783
|
+
* (currentPath/viewMode): the active tab watches navigations and updates its
|
|
3784
|
+
* snapshot; switching tabs calls the existing load(path) path — no new fetch
|
|
3785
|
+
* logic. The strip is not rendered at all on a single tab (embeds stay
|
|
3786
|
+
* pixel-identical).
|
|
3768
3787
|
*
|
|
3769
|
-
* Persist: `filex.tabs` — pathPersist scope
|
|
3770
|
-
*
|
|
3771
|
-
*
|
|
3788
|
+
* Persist: `filex.tabs` — it follows the pathPersist scope logic: with mode
|
|
3789
|
+
* 'none' persistence is off; the rootPath confine is added to the key so that
|
|
3790
|
+
* embeds with different confines don't overwrite each other's tabs.
|
|
3772
3791
|
*/
|
|
3773
3792
|
const FE_DND_SRC_MIME = 'application/x-brf-files-src';
|
|
3774
3793
|
|
|
@@ -3783,7 +3802,7 @@ const tabsActiveId = tabsApi.activeId;
|
|
|
3783
3802
|
|
|
3784
3803
|
const activeSplit = computed(() => tabsApi.activeTab.value?.split ?? null);
|
|
3785
3804
|
|
|
3786
|
-
//
|
|
3805
|
+
// Tab name is AUTOMATIC = the current folder name (root = storage name / root label).
|
|
3787
3806
|
function tabLabel(path: string): string {
|
|
3788
3807
|
const p = (path || '').replace(/^\/+|\/+$/g, '');
|
|
3789
3808
|
// gezinti:g1 — the virtual views park a sentinel in the path. Translate via
|
|
@@ -3815,12 +3834,13 @@ const tabsVisible = computed(
|
|
|
3815
3834
|
(props.config.tabStrip !== 'auto' && tabItems.value.length > 0)),
|
|
3816
3835
|
);
|
|
3817
3836
|
|
|
3818
|
-
//
|
|
3837
|
+
// The active tab follows the user: navigation + view changes go into the snapshot.
|
|
3819
3838
|
watch(currentPath, (p) => tabsApi.syncActive({ path: p }));
|
|
3820
3839
|
watch(viewMode, (v) => tabsApi.syncActive({ viewMode: v }));
|
|
3821
3840
|
|
|
3822
|
-
//
|
|
3823
|
-
//
|
|
3841
|
+
// The first tab is seeded as soon as the first location is known (don't touch
|
|
3842
|
+
// it if a restore happened — the active snapshot is already synced by the
|
|
3843
|
+
// currentPath watcher after the first load).
|
|
3824
3844
|
onMounted(() => {
|
|
3825
3845
|
if (!tabsRestored) tabsApi.seed(currentPath.value ?? '', viewMode.value);
|
|
3826
3846
|
});
|
|
@@ -3838,7 +3858,7 @@ function activateTab(id: string) {
|
|
|
3838
3858
|
if (tb) applyTabLocation(tb);
|
|
3839
3859
|
}
|
|
3840
3860
|
function newTabHere() {
|
|
3841
|
-
//
|
|
3861
|
+
// Clones the current location; the view is already there, so no load needed.
|
|
3842
3862
|
tabsApi.openTab(currentPath.value ?? '', { viewMode: viewMode.value, background: false });
|
|
3843
3863
|
}
|
|
3844
3864
|
function closeTabById(id: string) {
|
|
@@ -3854,16 +3874,17 @@ function prevTab() {
|
|
|
3854
3874
|
if (tb) applyTabLocation(tb);
|
|
3855
3875
|
}
|
|
3856
3876
|
|
|
3857
|
-
/**
|
|
3877
|
+
/** Open a folder in a new tab IN THE BACKGROUND (middle-click / right-click / palette). */
|
|
3858
3878
|
function openNodeInTab(n: FileNode) {
|
|
3859
3879
|
if (n.type !== 'dir' || n.basename === '.trash') return;
|
|
3860
3880
|
const target = multiStorageRoot.value ? wireToVirtual(n.path) : stripAdapter(n.path);
|
|
3861
3881
|
tabsApi.openTab(target, { viewMode: viewMode.value, background: true });
|
|
3862
3882
|
}
|
|
3863
3883
|
|
|
3864
|
-
//
|
|
3865
|
-
//
|
|
3866
|
-
// (SecondaryPane
|
|
3884
|
+
// Middle-click delegation: ListView/GridView rows carry data-fe-path, so a
|
|
3885
|
+
// single auxclick listener at the root is enough instead of adding a
|
|
3886
|
+
// keydown/emit chain of our own. (SecondaryPane handles its own rows via
|
|
3887
|
+
// stopPropagation.)
|
|
3867
3888
|
function onListAuxClick(ev: MouseEvent) {
|
|
3868
3889
|
if (ev.button !== 1) return;
|
|
3869
3890
|
const host = ev.target as HTMLElement | null;
|
|
@@ -3875,9 +3896,9 @@ function onListAuxClick(ev: MouseEvent) {
|
|
|
3875
3896
|
ev.preventDefault();
|
|
3876
3897
|
openNodeInTab(node);
|
|
3877
3898
|
}
|
|
3878
|
-
//
|
|
3879
|
-
// autoscroll
|
|
3880
|
-
// preventDefault autoscroll
|
|
3899
|
+
// Cancel the middle-button mousedown over rows: in a scrollable body Chromium's
|
|
3900
|
+
// autoscroll kicks in and auxclick is NEVER produced (diagnosed live) —
|
|
3901
|
+
// preventDefault suppresses autoscroll and auxclick flows again.
|
|
3881
3902
|
function onListMiddleDown(ev: MouseEvent) {
|
|
3882
3903
|
if (ev.button !== 1) return;
|
|
3883
3904
|
const host = ev.target as HTMLElement | null;
|
|
@@ -3894,10 +3915,10 @@ onBeforeUnmount(() => {
|
|
|
3894
3915
|
rootEl.value?.removeEventListener('mousedown', onListMiddleDown);
|
|
3895
3916
|
});
|
|
3896
3917
|
|
|
3897
|
-
// ---- split (tab
|
|
3918
|
+
// ---- split (per-tab secondary pane) --------------------------------
|
|
3898
3919
|
|
|
3899
3920
|
const splitPaneRef = ref<InstanceType<typeof SecondaryPane> | null>(null);
|
|
3900
|
-
//
|
|
3921
|
+
// Split is disabled in narrow mode (the state is kept and comes back on widen).
|
|
3901
3922
|
const splitVisible = computed(
|
|
3902
3923
|
() => !!activeSplit.value && !isNarrow.value && !simpleUi.value /* gezinti:g1 */,
|
|
3903
3924
|
);
|
|
@@ -3918,16 +3939,18 @@ function closeSplit() {
|
|
|
3918
3939
|
function onPaneNavigate(p: string) {
|
|
3919
3940
|
tabsApi.setSplit({ ...(activeSplit.value ?? {}), path: p });
|
|
3920
3941
|
}
|
|
3921
|
-
/* ui-fix —
|
|
3922
|
-
*
|
|
3942
|
+
/* ui-fix — when the trash row is opened from the side pane: the trash view
|
|
3943
|
+
* (with its restore actions) belongs to the main pane → activate the main
|
|
3944
|
+
* pane and open it there. */
|
|
3923
3945
|
function onPaneOpenTrash() {
|
|
3924
3946
|
activePane.value = 'main';
|
|
3925
3947
|
void loadTrash();
|
|
3926
3948
|
}
|
|
3927
|
-
/* ui-fix — pane'
|
|
3928
|
-
*
|
|
3929
|
-
* toggle
|
|
3930
|
-
* B
|
|
3949
|
+
/* ui-fix — the pane's OWN view mode: it inherits the main pane's when the
|
|
3950
|
+
* split opens, and is independent afterwards. The toolbar's view switcher and
|
|
3951
|
+
* the palette toggle write to the ACTIVE pane (Ada, translated from Turkish:
|
|
3952
|
+
* "if I say change the icon while B is focused, B is the one that has to
|
|
3953
|
+
* change"). */
|
|
3931
3954
|
const paneViewMode = computed<ViewMode>(() => activeSplit.value?.viewMode ?? viewMode.value);
|
|
3932
3955
|
function setPaneViewMode(v: ViewMode) {
|
|
3933
3956
|
if (!activeSplit.value) return;
|
|
@@ -3941,7 +3964,7 @@ function setDisplayedViewMode(v: ViewMode) {
|
|
|
3941
3964
|
else viewMode.value = v;
|
|
3942
3965
|
}
|
|
3943
3966
|
|
|
3944
|
-
//
|
|
3967
|
+
// Active pane: shortcuts go to the active pane; a pane is activated by clicking it.
|
|
3945
3968
|
const activePane = ref<'main' | 'split'>('main');
|
|
3946
3969
|
function setPaneMain() {
|
|
3947
3970
|
activePane.value = 'main';
|
|
@@ -3952,7 +3975,7 @@ watch(splitVisible, (v) => {
|
|
|
3952
3975
|
const paneIsActive = computed(() => activePane.value === 'split' && splitVisible.value);
|
|
3953
3976
|
const mainPaneFocus = computed(() => splitVisible.value && activePane.value === 'main');
|
|
3954
3977
|
|
|
3955
|
-
// Pane
|
|
3978
|
+
// Pane helpers — always wrap the main pane's existing converters.
|
|
3956
3979
|
function paneToUser(wire: string): string {
|
|
3957
3980
|
return multiStorageRoot.value ? wireToVirtual(wire) : stripAdapter(wire);
|
|
3958
3981
|
}
|
|
@@ -3963,20 +3986,20 @@ function paneClamp(p: string): string {
|
|
|
3963
3986
|
return clean;
|
|
3964
3987
|
}
|
|
3965
3988
|
|
|
3966
|
-
//
|
|
3967
|
-
//
|
|
3968
|
-
//
|
|
3989
|
+
// The clipboard follows the active pane: cut/copy is fed from the pane
|
|
3990
|
+
// selection and paste lands in the pane's folder. The state is SHARED with the
|
|
3991
|
+
// main pane's — so cut-and-paste between panes works for free.
|
|
3969
3992
|
function paneCut() {
|
|
3970
3993
|
const nodes = splitPaneRef.value?.selectedNodes() ?? [];
|
|
3971
3994
|
if (nodes.length === 0) return;
|
|
3972
3995
|
clipboard.value = { mode: 'cut', items: nodes, sourcePath: splitPaneRef.value?.getPath() ?? '' };
|
|
3973
|
-
flashToast('
|
|
3996
|
+
flashToast(t('toast.cut'));
|
|
3974
3997
|
}
|
|
3975
3998
|
function paneCopy() {
|
|
3976
3999
|
const nodes = splitPaneRef.value?.selectedNodes() ?? [];
|
|
3977
4000
|
if (nodes.length === 0) return;
|
|
3978
4001
|
clipboard.value = { mode: 'copy', items: nodes, sourcePath: splitPaneRef.value?.getPath() ?? '' };
|
|
3979
|
-
flashToast('
|
|
4002
|
+
flashToast(t('toast.copied'));
|
|
3980
4003
|
}
|
|
3981
4004
|
async function panePaste() {
|
|
3982
4005
|
const cb = clipboard.value;
|
|
@@ -3985,28 +4008,29 @@ async function panePaste() {
|
|
|
3985
4008
|
const targetWire = qualify(pane.getPath() ?? '');
|
|
3986
4009
|
const originWire = qualify(cb.sourcePath || '') || undefined;
|
|
3987
4010
|
if (cb.mode === 'cut' && originWire === targetWire) {
|
|
3988
|
-
flashToast('
|
|
4011
|
+
flashToast(t('toast.same_folder_cut'));
|
|
3989
4012
|
return;
|
|
3990
4013
|
}
|
|
3991
4014
|
await transferItems(cb.items.map((n) => n.path), targetWire, originWire, cb.mode === 'copy' ? 'copy' : 'move');
|
|
3992
4015
|
clipboard.value = { mode: null, items: [], sourcePath: null };
|
|
3993
4016
|
}
|
|
3994
4017
|
|
|
3995
|
-
// ----
|
|
4018
|
+
// ---- cross-pane transfer -------------------------------------------
|
|
3996
4019
|
function dndOrigin(ev: DragEvent): string | undefined {
|
|
3997
4020
|
return internalDragOrigin(ev);
|
|
3998
4021
|
}
|
|
3999
4022
|
|
|
4000
4023
|
/**
|
|
4001
|
-
* transferItems —
|
|
4024
|
+
* transferItems — the single gate for cross-pane / clipboard transfers.
|
|
4002
4025
|
*
|
|
4003
|
-
*
|
|
4004
|
-
*
|
|
4005
|
-
*
|
|
4006
|
-
*
|
|
4007
|
-
*
|
|
4008
|
-
*
|
|
4009
|
-
*
|
|
4026
|
+
* `resolveTransfer` decides what to do (lib/transfer.ts): a drag MOVES within
|
|
4027
|
+
* the same storage and COPIES across storages; a cut/copy coming from
|
|
4028
|
+
* the clipboard does exactly what it says — cut MOVES even when the target is
|
|
4029
|
+
* another storage (the server transfers the bytes and deletes the source).
|
|
4030
|
+
* ⚠ Cross-storage transfers used to silently fall back to a copy: the user
|
|
4031
|
+
* said "cut" and found the file in two places. When it's done the secondary
|
|
4032
|
+
* pane is refreshed too (the main pane is already refreshed via
|
|
4033
|
+
* moveSourcesAsync / pendingOps onSettled).
|
|
4010
4034
|
*/
|
|
4011
4035
|
async function transferItems(
|
|
4012
4036
|
sources: string[],
|
|
@@ -4014,8 +4038,8 @@ async function transferItems(
|
|
|
4014
4038
|
originWire?: string,
|
|
4015
4039
|
intent: TransferIntent = 'auto',
|
|
4016
4040
|
): Promise<void> {
|
|
4017
|
-
// ui-fix —
|
|
4018
|
-
// "
|
|
4041
|
+
// ui-fix — an in-place drop (source parent === target) is a no-op: this
|
|
4042
|
+
// avoids the backend's "copy onto itself" 400 (cross-pane + clipboard paths).
|
|
4019
4043
|
const list = sources.filter(
|
|
4020
4044
|
(p) => p && p !== targetWire && !targetWire.startsWith(p + '/') && !sameDir(wireParent(p), targetWire),
|
|
4021
4045
|
);
|
|
@@ -4027,9 +4051,10 @@ async function transferItems(
|
|
|
4027
4051
|
pendingOps.register(op);
|
|
4028
4052
|
flashToast(plan.cross ? t('split.cross_copy') : t('split.copy_queued'));
|
|
4029
4053
|
} catch (err) {
|
|
4030
|
-
// ⚠
|
|
4031
|
-
//
|
|
4032
|
-
//
|
|
4054
|
+
// ⚠ Show the server's own message. There used to be a hard-coded
|
|
4055
|
+
// "cross-storage is not supported" text here; it IS supported now, so
|
|
4056
|
+
// that text would mask the real cause (permissions, a read-only storage,
|
|
4057
|
+
// a full quota).
|
|
4033
4058
|
emit('error', { message: (err as Error).message, context: { op: 'transfer', targetWire } });
|
|
4034
4059
|
flashToast((err as Error).message);
|
|
4035
4060
|
return;
|
|
@@ -4046,21 +4071,23 @@ function onPaneTransfer(p: { sources: string[]; targetWire: string; originWire?:
|
|
|
4046
4071
|
}
|
|
4047
4072
|
/* === /wiring:d1 === */
|
|
4048
4073
|
|
|
4049
|
-
/* === wiring:e2 —
|
|
4074
|
+
/* === wiring:e2 — end-to-end encrypted folders ===
|
|
4050
4075
|
*
|
|
4051
|
-
*
|
|
4052
|
-
*
|
|
4053
|
-
*
|
|
4054
|
-
*
|
|
4055
|
-
* (`e2eRing`) —
|
|
4056
|
-
*
|
|
4076
|
+
* Crypto scheme + threat model: docs/E2E-ENCRYPTION.md and lib/e2ecrypto.ts.
|
|
4077
|
+
* Only the orchestration lives here: `e2e_root` in the backend listing
|
|
4078
|
+
* response drives the lock screen; the password is verified against the
|
|
4079
|
+
* marker IN THE BROWSER (nothing goes to the server); the derived folder key
|
|
4080
|
+
* (KEK) lives ONLY in memory (`e2eRing`) — it is never written to
|
|
4081
|
+
* localStorage/sessionStorage. Uploads are encrypted transparently and
|
|
4082
|
+
* previews/downloads decrypted transparently (a blob URL to the existing
|
|
4083
|
+
* viewers).
|
|
4057
4084
|
*/
|
|
4058
4085
|
const e2eRing = createKeyRing();
|
|
4059
|
-
//
|
|
4086
|
+
// Maps aren't reactive — a version counter drives the computeds.
|
|
4060
4087
|
const e2eRingVer = ref(0);
|
|
4061
|
-
//
|
|
4088
|
+
// Wire path of the encrypted root we are inside ('' = no encrypted context).
|
|
4062
4089
|
const e2eRoot = ref('');
|
|
4063
|
-
// path →
|
|
4090
|
+
// path → decrypted blob objectURL (preview). Revoked on lock/unmount.
|
|
4064
4091
|
const e2eUrls = new Map<string, string>();
|
|
4065
4092
|
|
|
4066
4093
|
const e2eActive = computed(() => !!e2eRoot.value && !trashMode.value);
|
|
@@ -4073,15 +4100,15 @@ const e2eLocked = computed(() => {
|
|
|
4073
4100
|
return e2eActive.value && !e2eRing.has(e2eRoot.value);
|
|
4074
4101
|
});
|
|
4075
4102
|
|
|
4076
|
-
//
|
|
4103
|
+
// Lock screen form.
|
|
4077
4104
|
const e2ePw = ref('');
|
|
4078
4105
|
const e2eUnlockBusy = ref(false);
|
|
4079
4106
|
const e2eUnlockErr = ref('');
|
|
4080
|
-
//
|
|
4107
|
+
// Encrypted-folder creation modal.
|
|
4081
4108
|
const showEncFolder = ref(false);
|
|
4082
4109
|
const e2eCreateBusy = ref(false);
|
|
4083
4110
|
|
|
4084
|
-
/* wiring:e2 recovery —
|
|
4111
|
+
/* wiring:e2 recovery — recovery key + escrow.
|
|
4085
4112
|
*
|
|
4086
4113
|
* The marker of the folder we are looking at is cached here while the lock
|
|
4087
4114
|
* screen is up: the recovery dialog needs to know which doors this folder
|
|
@@ -4151,7 +4178,7 @@ function e2eRevokeAll() {
|
|
|
4151
4178
|
}
|
|
4152
4179
|
onBeforeUnmount(e2eRevokeAll);
|
|
4153
4180
|
|
|
4154
|
-
/**
|
|
4181
|
+
/** Unlock: fetch the marker from the root, verify the password LOCALLY, put the KEK in memory. */
|
|
4155
4182
|
async function e2eUnlock() {
|
|
4156
4183
|
if (!e2ePw.value || e2eUnlockBusy.value || !e2eRoot.value) return;
|
|
4157
4184
|
e2eUnlockBusy.value = true;
|
|
@@ -4207,7 +4234,7 @@ async function e2eUnlock() {
|
|
|
4207
4234
|
}
|
|
4208
4235
|
}
|
|
4209
4236
|
|
|
4210
|
-
/** "Kilitle":
|
|
4237
|
+
/** "Kilitle" (Lock): drop the in-memory key and the decrypted blobs. */
|
|
4211
4238
|
function e2eLock() {
|
|
4212
4239
|
if (!e2eRoot.value) return;
|
|
4213
4240
|
e2eRing.lock(e2eRoot.value);
|
|
@@ -4216,9 +4243,9 @@ function e2eLock() {
|
|
|
4216
4243
|
flashToast(t('e2e.locked_toast'));
|
|
4217
4244
|
}
|
|
4218
4245
|
|
|
4219
|
-
//
|
|
4220
|
-
//
|
|
4221
|
-
//
|
|
4246
|
+
// Extension → preview MIME: so the decrypted blob renders correctly in
|
|
4247
|
+
// <img>/<video>/<object> tags (the server knows the encrypted file as
|
|
4248
|
+
// octet-stream, so the type coming from there is useless).
|
|
4222
4249
|
const E2E_MIME: Record<string, string> = {
|
|
4223
4250
|
txt: 'text/plain', md: 'text/markdown', log: 'text/plain', csv: 'text/csv',
|
|
4224
4251
|
json: 'application/json', xml: 'application/xml', html: 'text/html',
|
|
@@ -4235,9 +4262,10 @@ function e2eMimeFor(n: FileNode): string {
|
|
|
4235
4262
|
}
|
|
4236
4263
|
|
|
4237
4264
|
/**
|
|
4238
|
-
*
|
|
4239
|
-
*
|
|
4240
|
-
*
|
|
4265
|
+
* Fetch the file + decrypt it + cache its objectURL. Returns null for a file
|
|
4266
|
+
* with no magic (e.g. written in the clear over DAV) — the caller then falls
|
|
4267
|
+
* back to the normal raw flow. A wrong key / corrupt data throws
|
|
4268
|
+
* E2eDecryptError (the caller toasts it).
|
|
4241
4269
|
*/
|
|
4242
4270
|
async function e2eFetchDecrypted(n: FileNode): Promise<string | null> {
|
|
4243
4271
|
const cached = e2eUrls.get(n.path);
|
|
@@ -4252,12 +4280,12 @@ async function e2eFetchDecrypted(n: FileNode): Promise<string | null> {
|
|
|
4252
4280
|
return url;
|
|
4253
4281
|
}
|
|
4254
4282
|
|
|
4255
|
-
/** PreviewModal/QuickLook
|
|
4283
|
+
/** URL provider handed to PreviewModal/QuickLook: decrypted blob > raw URL. */
|
|
4256
4284
|
function e2ePreviewSrc(p: string): string {
|
|
4257
4285
|
return e2eUrls.get(p) ?? api.previewUrl(p);
|
|
4258
4286
|
}
|
|
4259
4287
|
|
|
4260
|
-
/**
|
|
4288
|
+
/** Decrypt + read-only in-page preview (openNode/previewNode land here). */
|
|
4261
4289
|
async function e2eOpenPreview(n: FileNode) {
|
|
4262
4290
|
try {
|
|
4263
4291
|
await e2eFetchDecrypted(n);
|
|
@@ -4272,7 +4300,7 @@ async function e2eOpenPreview(n: FileNode) {
|
|
|
4272
4300
|
void markRecent(n);
|
|
4273
4301
|
}
|
|
4274
4302
|
|
|
4275
|
-
/**
|
|
4303
|
+
/** Decrypt + download under the original name. A file with no magic comes down as-is. */
|
|
4276
4304
|
async function e2eDownload(n: FileNode) {
|
|
4277
4305
|
try {
|
|
4278
4306
|
const buf = await api.fetchArrayBuffer(n.path);
|
|
@@ -4295,7 +4323,7 @@ async function e2eDownload(n: FileNode) {
|
|
|
4295
4323
|
}
|
|
4296
4324
|
}
|
|
4297
4325
|
|
|
4298
|
-
/** Upload
|
|
4326
|
+
/** Upload list → encrypted File list (anything over 200MB + the marker name is skipped). */
|
|
4299
4327
|
async function e2eEncryptUploads(list: File[]): Promise<File[]> {
|
|
4300
4328
|
const kek = e2eKek();
|
|
4301
4329
|
if (!kek) return [];
|
|
@@ -4316,10 +4344,10 @@ async function e2eEncryptUploads(list: File[]): Promise<File[]> {
|
|
|
4316
4344
|
return out;
|
|
4317
4345
|
}
|
|
4318
4346
|
|
|
4319
|
-
/** EncryptedFolderModal submit
|
|
4347
|
+
/** EncryptedFolderModal submit: create the folder + upload the marker + leave it unlocked. */
|
|
4320
4348
|
async function submitEncryptedFolder(payload: { name: string; password: string }) {
|
|
4321
4349
|
if (e2eActive.value) {
|
|
4322
|
-
//
|
|
4350
|
+
// Nested encrypted folders blur root detection — not in the MVP.
|
|
4323
4351
|
flashToast(t('e2e.create.nested'));
|
|
4324
4352
|
return;
|
|
4325
4353
|
}
|
|
@@ -4339,7 +4367,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4339
4367
|
});
|
|
4340
4368
|
const newDirWire = wireJoin(dirWire, payload.name);
|
|
4341
4369
|
await api.uploadMultipart(newDirWire, [markerFile]);
|
|
4342
|
-
//
|
|
4370
|
+
// It starts unlocked in the creating session (they just typed the password).
|
|
4343
4371
|
e2eRing.set(newDirWire, fmk);
|
|
4344
4372
|
e2eRingVer.value++;
|
|
4345
4373
|
showEncFolder.value = false;
|
|
@@ -4610,7 +4638,7 @@ function closeRecoveryKey() {
|
|
|
4610
4638
|
@drop="onDropUpload"
|
|
4611
4639
|
@contextmenu="onContextCanvas"
|
|
4612
4640
|
>
|
|
4613
|
-
<!-- wiring:d1 —
|
|
4641
|
+
<!-- wiring:d1 — tab strip: not rendered at all on a SINGLE tab (embeds stay pixel-identical) -->
|
|
4614
4642
|
<TabBar
|
|
4615
4643
|
v-if="tabsVisible"
|
|
4616
4644
|
:tabs="tabItems"
|
|
@@ -4627,7 +4655,7 @@ function closeRecoveryKey() {
|
|
|
4627
4655
|
<!-- /wiring:d1 -->
|
|
4628
4656
|
<Toolbar
|
|
4629
4657
|
ref="toolbarRef"
|
|
4630
|
-
:view-mode="displayedViewMode /* ui-fix —
|
|
4658
|
+
:view-mode="displayedViewMode /* ui-fix — the active pane's mode */"
|
|
4631
4659
|
:search-query="searchQuery"
|
|
4632
4660
|
:trash-active="trashActive"
|
|
4633
4661
|
:actions="toolbarActions"
|
|
@@ -4650,7 +4678,7 @@ function closeRecoveryKey() {
|
|
|
4650
4678
|
@toggle-inspector="toggleInspector /* koru:k1 */"
|
|
4651
4679
|
@toggle-nav="toggleSideNav /* gezinti:g1 */"
|
|
4652
4680
|
@open-theme="showThemeGallery = true /* wiring:c1 */"
|
|
4653
|
-
@update:view-mode="setDisplayedViewMode($event) /* ui-fix —
|
|
4681
|
+
@update:view-mode="setDisplayedViewMode($event) /* ui-fix — to the active pane */"
|
|
4654
4682
|
@update:search-query="searchQuery = $event"
|
|
4655
4683
|
@update:density="density = $event"
|
|
4656
4684
|
@open-shortcut-settings="showShortcutSettings = true /* wiring:c2 */"
|
|
@@ -4713,13 +4741,14 @@ function closeRecoveryKey() {
|
|
|
4713
4741
|
:aria-label="t('sidenav.close')"
|
|
4714
4742
|
@click="closeNavDrawer"
|
|
4715
4743
|
></button>
|
|
4716
|
-
<!-- ui-fix —
|
|
4717
|
-
|
|
4718
|
-
breadcrumb
|
|
4719
|
-
|
|
4744
|
+
<!-- ui-fix — the left pane's header (breadcrumb + status strips + body)
|
|
4745
|
+
in one wrapper: in split mode this wrapper fits the left half, so the
|
|
4746
|
+
breadcrumb spans its own pane rather than the whole page (symmetric
|
|
4747
|
+
with SecondaryPane's own crumbs). The active-pane accent lives on
|
|
4748
|
+
this wrapper too. -->
|
|
4720
4749
|
<div
|
|
4721
4750
|
class="fe__primary"
|
|
4722
|
-
:class="{ 'fe-pane--focus': mainPaneFocus } /* wiring:d1 —
|
|
4751
|
+
:class="{ 'fe-pane--focus': mainPaneFocus } /* wiring:d1 — active-pane accent */"
|
|
4723
4752
|
>
|
|
4724
4753
|
<!-- surucu:d1 — the breadcrumb row. In the drive shell it also carries the
|
|
4725
4754
|
two controls that belong to a LISTING rather than to the app (the view
|
|
@@ -4802,8 +4831,9 @@ function closeRecoveryKey() {
|
|
|
4802
4831
|
</span>
|
|
4803
4832
|
</div>
|
|
4804
4833
|
|
|
4805
|
-
<!-- wiring:e2 —
|
|
4806
|
-
|
|
4834
|
+
<!-- wiring:e2 — unlocked strip: visible in an encrypted folder while the
|
|
4835
|
+
key is in memory; "Kilitle" (Lock) drops the key and the decrypted
|
|
4836
|
+
blobs. -->
|
|
4807
4837
|
<!-- wiring:e2 recovery — a v1 folder just opened by password. Offer it
|
|
4808
4838
|
recovery HERE, visibly, rather than doing anything silently: this is
|
|
4809
4839
|
the only moment filex holds the password, and (when the install has
|
|
@@ -4916,7 +4946,7 @@ function closeRecoveryKey() {
|
|
|
4916
4946
|
keeps the current list, exactly as before. -->
|
|
4917
4947
|
<div v-if="loading && files.length === 0" class="fe__skeleton" role="status">
|
|
4918
4948
|
<span class="fe-sr-only">{{ t('loading') }}</span>
|
|
4919
|
-
<div v-if="viewMode !== 'list' /* wiring:d2 —
|
|
4949
|
+
<div v-if="viewMode !== 'list' /* wiring:d2 — the gallery uses the grid skeleton too */" class="fe-skel-grid" aria-hidden="true">
|
|
4920
4950
|
<div v-for="i in 8" :key="i" class="fe-skel-card">
|
|
4921
4951
|
<div class="fe-skel fe-skel--thumb"></div>
|
|
4922
4952
|
<div class="fe-skel fe-skel--label"></div>
|
|
@@ -4994,9 +5024,10 @@ function closeRecoveryKey() {
|
|
|
4994
5024
|
</details>
|
|
4995
5025
|
<!-- /wiring:c4 -->
|
|
4996
5026
|
</div>
|
|
4997
|
-
<!-- wiring:e2 —
|
|
4998
|
-
|
|
4999
|
-
|
|
5027
|
+
<!-- wiring:e2 — encrypted-folder lock screen: the listing is not
|
|
5028
|
+
rendered until the correct password is entered. The password is
|
|
5029
|
+
verified against the marker in the browser; it never reaches the
|
|
5030
|
+
server. -->
|
|
5000
5031
|
<div v-else-if="e2eLocked" class="fe-state fe-e2e-lock">
|
|
5001
5032
|
<svg
|
|
5002
5033
|
class="fe-state__art"
|
|
@@ -5238,7 +5269,7 @@ function closeRecoveryKey() {
|
|
|
5238
5269
|
@item-drop-into="onItemDropInto"
|
|
5239
5270
|
@star-change="onStarChange"
|
|
5240
5271
|
/>
|
|
5241
|
-
<!-- wiring:d2 —
|
|
5272
|
+
<!-- wiring:d2 — gallery view (same event contract as GridView) -->
|
|
5242
5273
|
<GalleryView
|
|
5243
5274
|
v-else
|
|
5244
5275
|
:files="displayFiles /* surucu:d1 */"
|
|
@@ -5264,9 +5295,9 @@ function closeRecoveryKey() {
|
|
|
5264
5295
|
</div>
|
|
5265
5296
|
</div><!-- /fe__primary ui-fix -->
|
|
5266
5297
|
|
|
5267
|
-
<!-- wiring:d1 — tab
|
|
5268
|
-
:key
|
|
5269
|
-
|
|
5298
|
+
<!-- wiring:d1 — per-tab split: the secondary pane on the right (off in
|
|
5299
|
+
narrow mode). :key is bound to the tab id — on a tab switch the pane
|
|
5300
|
+
remounts cleanly with its own location. -->
|
|
5270
5301
|
<SecondaryPane
|
|
5271
5302
|
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
5272
5303
|
v-if="splitVisible && activeSplit"
|
|
@@ -5285,15 +5316,15 @@ function closeRecoveryKey() {
|
|
|
5285
5316
|
:active="paneIsActive"
|
|
5286
5317
|
:view-mode="paneViewMode /* ui-fix */"
|
|
5287
5318
|
:thumb-src="thumbs.src /* ui-fix */"
|
|
5288
|
-
:trash-visible="config.trashVisible !== false /* ui-fix —
|
|
5289
|
-
:nav-offers-trash="navOffersTrash /* surucu:d1 —
|
|
5319
|
+
:trash-visible="config.trashVisible !== false /* ui-fix — trash row symmetry */"
|
|
5320
|
+
:nav-offers-trash="navOffersTrash /* surucu:d1 — the same door is not opened twice */"
|
|
5290
5321
|
@navigate="onPaneNavigate"
|
|
5291
5322
|
@activate="activePane = 'split'"
|
|
5292
5323
|
@close="closeSplit"
|
|
5293
5324
|
@open-tab="(p: string) => tabsApi.openTab(p, { viewMode: viewMode, background: true })"
|
|
5294
5325
|
@transfer="onPaneTransfer"
|
|
5295
|
-
@context="onPaneContext /* ui-fix —
|
|
5296
|
-
@open-trash="onPaneOpenTrash /* ui-fix —
|
|
5326
|
+
@context="onPaneContext /* ui-fix — side-pane right-click menu */"
|
|
5327
|
+
@open-trash="onPaneOpenTrash /* ui-fix — the trash opens in the main pane */"
|
|
5297
5328
|
/>
|
|
5298
5329
|
<!-- /wiring:d1 -->
|
|
5299
5330
|
|
|
@@ -5364,7 +5395,7 @@ function closeRecoveryKey() {
|
|
|
5364
5395
|
<div v-if="dragOver" class="fe__dragover">
|
|
5365
5396
|
<div class="fe__dragover-card">
|
|
5366
5397
|
<span class="fe-icon">⬆</span>
|
|
5367
|
-
<p>
|
|
5398
|
+
<p>{{ t('dropzone.hint') }}</p>
|
|
5368
5399
|
</div>
|
|
5369
5400
|
</div>
|
|
5370
5401
|
|
|
@@ -5441,12 +5472,12 @@ function closeRecoveryKey() {
|
|
|
5441
5472
|
<NewFolderModal
|
|
5442
5473
|
:open="showNewFolder"
|
|
5443
5474
|
:locale="locale"
|
|
5444
|
-
:encrypted-option="!e2eActive /* wiring:e2 —
|
|
5475
|
+
:encrypted-option="!e2eActive /* wiring:e2 — no nested encrypted folders */"
|
|
5445
5476
|
@close="showNewFolder = false"
|
|
5446
5477
|
@submit="submitNewFolder"
|
|
5447
5478
|
@encrypted="showNewFolder = false; showEncFolder = true /* wiring:e2 */"
|
|
5448
5479
|
/>
|
|
5449
|
-
<!-- wiring:e2 —
|
|
5480
|
+
<!-- wiring:e2 — encrypted-folder creation modal -->
|
|
5450
5481
|
<EncryptedFolderModal
|
|
5451
5482
|
:open="showEncFolder"
|
|
5452
5483
|
:locale="locale"
|
|
@@ -5506,12 +5537,12 @@ function closeRecoveryKey() {
|
|
|
5506
5537
|
:locale="locale"
|
|
5507
5538
|
:file="previewTarget"
|
|
5508
5539
|
:theme="themeMode"
|
|
5509
|
-
:preview-url="(p) => e2ePreviewSrc(p) /* wiring:e2 —
|
|
5540
|
+
:preview-url="(p) => e2ePreviewSrc(p) /* wiring:e2 — decrypted blob > raw URL */"
|
|
5510
5541
|
:download-url="(p) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
|
|
5511
|
-
:only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO ciphertext
|
|
5542
|
+
:only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO cannot open ciphertext */"
|
|
5512
5543
|
:only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
|
|
5513
|
-
:new-tab-enabled="!e2eActive /* wiring:e2 — standalone
|
|
5514
|
-
:save-text-endpoint="e2eActive ? null : api.endpoints.saveText || null /* wiring:e2 —
|
|
5544
|
+
:new-tab-enabled="!e2eActive /* wiring:e2 — the standalone route pulls raw bytes */"
|
|
5545
|
+
:save-text-endpoint="e2eActive ? null : api.endpoints.saveText || null /* wiring:e2 — a plaintext save would be a leak */"
|
|
5515
5546
|
:open-mode="previewMode"
|
|
5516
5547
|
:auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
|
|
5517
5548
|
:auth-credentials="api.credentialsMode()"
|
|
@@ -5527,6 +5558,7 @@ function closeRecoveryKey() {
|
|
|
5527
5558
|
:file-name="convertTarget?.basename || convertTarget?.path || ''"
|
|
5528
5559
|
:fetch-bytes="() => api.fetchArrayBuffer(convertTarget?.path ?? '')"
|
|
5529
5560
|
:upload="(f) => api.uploadMultipart(qualify(currentPath), [f]).then(() => {})"
|
|
5561
|
+
:locale="locale"
|
|
5530
5562
|
@close="showConvert = false"
|
|
5531
5563
|
@done="onConvertDone"
|
|
5532
5564
|
/>
|
|
@@ -5619,7 +5651,7 @@ function closeRecoveryKey() {
|
|
|
5619
5651
|
@navigate="(p: string) => load(p)"
|
|
5620
5652
|
@new-folder="showNewFolder = true"
|
|
5621
5653
|
@upload="triggerUpload"
|
|
5622
|
-
@toggle-view="setDisplayedViewMode(displayedViewMode === 'list' ? 'grid' : displayedViewMode === 'grid' ? 'gallery' : 'list') /* wiring:d2 + ui-fix — 3
|
|
5654
|
+
@toggle-view="setDisplayedViewMode(displayedViewMode === 'list' ? 'grid' : displayedViewMode === 'grid' ? 'gallery' : 'list') /* wiring:d2 + ui-fix — 3-mode cycle, to the active pane */"
|
|
5623
5655
|
@open-trash="loadTrash"
|
|
5624
5656
|
@refresh="() => load()"
|
|
5625
5657
|
@go-up="goUp"
|