@brftech/filex-core 0.31.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/README.md +26 -2
- 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 +9346 -8069
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +49 -49
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +235 -16
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +776 -246
- package/src/components/CommandPalette.vue +21 -5
- package/src/components/ConnectionsPanel.vue +2 -1
- package/src/components/E2eRecoveryUnlockModal.vue +29 -4
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +12 -1
- package/src/components/GridView.vue +48 -5
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +12 -3
- 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 +28 -13
- package/src/components/SideNav.vue +161 -1
- 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 +244 -58
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +49 -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 +8 -2
- package/src/lib/e2ecrypto.ts +191 -3
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +47 -0
- package/src/lib/themes.ts +7 -7
- package/src/lib/transfer.ts +6 -6
- package/src/locales/en.ts +115 -0
- package/src/locales/index.ts +1 -0
- package/src/locales/resolve.ts +66 -0
- package/src/locales/tr.ts +116 -0
- package/src/modals/ConvertModal.vue +14 -12
- package/src/modals/NewFolderModal.vue +1 -1
- package/src/modals/PermissionsModal.vue +30 -13
- package/src/modals/PreviewModal.vue +9 -9
- package/src/styles/base.css +520 -26
- package/src/styles/variables.css +1 -1
- package/src/types/ExplorerConfig.ts +18 -2
- 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
|
@@ -46,6 +46,14 @@ import RecentlyOpened from './components/RecentlyOpened.vue';
|
|
|
46
46
|
import Breadcrumb from './components/Breadcrumb.vue';
|
|
47
47
|
import ListView from './components/ListView.vue';
|
|
48
48
|
import GridView from './components/GridView.vue';
|
|
49
|
+
import FilterBar from './components/FilterBar.vue' /* surucu:d1 */;
|
|
50
|
+
import ViewSwitcher from './components/ViewSwitcher.vue' /* surucu:d1 */;
|
|
51
|
+
import {
|
|
52
|
+
EMPTY_FILTERS,
|
|
53
|
+
applyFilters,
|
|
54
|
+
filtersActive,
|
|
55
|
+
type DriveFilters,
|
|
56
|
+
} from './lib/fileFilters' /* surucu:d1 */;
|
|
49
57
|
import GalleryView from './components/GalleryView.vue'; /* wiring:d2 */
|
|
50
58
|
import ContextMenu, { type ContextAction } from './components/ContextMenu.vue';
|
|
51
59
|
import UploadProgress from './components/UploadProgress.vue';
|
|
@@ -79,12 +87,12 @@ import { useOperations } from './composables/useOperations';
|
|
|
79
87
|
/* wiring:c4 */
|
|
80
88
|
import OnboardingTour from './components/OnboardingTour.vue';
|
|
81
89
|
/* /wiring:c4 */
|
|
82
|
-
/* wiring:d1 —
|
|
90
|
+
/* wiring:d1 — tabs + per-tab split */
|
|
83
91
|
import TabBar from './components/TabBar.vue';
|
|
84
92
|
import SecondaryPane from './components/SecondaryPane.vue';
|
|
85
93
|
import { useTabs, type TabState } from './composables/useTabs';
|
|
86
94
|
/* /wiring:d1 */
|
|
87
|
-
/* wiring:e2 —
|
|
95
|
+
/* wiring:e2 — end-to-end encrypted folders (docs/E2E-ENCRYPTION.md) */
|
|
88
96
|
import EncryptedFolderModal from './components/EncryptedFolderModal.vue';
|
|
89
97
|
import RecoveryKeyModal from './components/RecoveryKeyModal.vue';
|
|
90
98
|
import E2eRecoveryUnlockModal from './components/E2eRecoveryUnlockModal.vue';
|
|
@@ -92,13 +100,16 @@ import {
|
|
|
92
100
|
createKeyRing,
|
|
93
101
|
createEncryptedFolder,
|
|
94
102
|
upgradeMarkerV1,
|
|
103
|
+
addEscrowSlot,
|
|
104
|
+
declineEscrowSlot,
|
|
105
|
+
escrowOfferState,
|
|
95
106
|
parseMarker,
|
|
96
107
|
unlockWithPassword,
|
|
97
108
|
unlockWithRecoveryKey,
|
|
98
109
|
unlockWithEscrowKey,
|
|
99
110
|
importEscrowPrivateKey,
|
|
100
111
|
markerHasRecovery,
|
|
101
|
-
|
|
112
|
+
escrowAvailability,
|
|
102
113
|
bytesToB64,
|
|
103
114
|
b64ToBytes,
|
|
104
115
|
encryptFile,
|
|
@@ -147,6 +158,7 @@ import ShareModal from './modals/ShareModal.vue';
|
|
|
147
158
|
import PreviewModal from './modals/PreviewModal.vue';
|
|
148
159
|
import ConvertModal from './modals/ConvertModal.vue';
|
|
149
160
|
import PermissionsModal from './modals/PermissionsModal.vue';
|
|
161
|
+
import { resolveLocale } from './locales/resolve';
|
|
150
162
|
|
|
151
163
|
const props = defineProps<{
|
|
152
164
|
config: ExplorerConfig;
|
|
@@ -176,7 +188,7 @@ const api = useFileApi(props.config);
|
|
|
176
188
|
// Locale up-front: the pendingOps onSettled callback below (and the undo-toast
|
|
177
189
|
// helpers) need `t()` at runtime, so the catalogue must be constructed before
|
|
178
190
|
// they are wired. Depends only on props — safe this early.
|
|
179
|
-
const locale = computed(() => props.config.locale
|
|
191
|
+
const locale = computed(() => resolveLocale(props.config.locale));
|
|
180
192
|
const { t } = useLocale(locale);
|
|
181
193
|
|
|
182
194
|
// Live collaboration (WebSocket file-change events + presence), bundled into the
|
|
@@ -228,20 +240,20 @@ const pendingOps = usePendingOps(props.config, api, {
|
|
|
228
240
|
const undo = opUndo.get(op.id);
|
|
229
241
|
opUndo.delete(op.id);
|
|
230
242
|
if (op.status === 'error') {
|
|
231
|
-
flashToast(op.error_message || '
|
|
243
|
+
flashToast(op.error_message || t('toast.failed'));
|
|
232
244
|
} else if (undo) {
|
|
233
245
|
undoToast(`${undo.message} (${op.progress_total})`, undo.fn);
|
|
234
246
|
} else {
|
|
235
247
|
const verb =
|
|
236
248
|
op.op_type === 'copy'
|
|
237
|
-
? '
|
|
249
|
+
? t('toast.copied')
|
|
238
250
|
: op.op_type === 'move'
|
|
239
|
-
? '
|
|
240
|
-
: '
|
|
251
|
+
? t('toast.moved')
|
|
252
|
+
: t('toast.deleted');
|
|
241
253
|
flashToast(`${verb} (${op.progress_total})`);
|
|
242
254
|
}
|
|
243
255
|
void load();
|
|
244
|
-
void splitPaneRef.value?.reload(); /* wiring:d1 —
|
|
256
|
+
void splitPaneRef.value?.reload(); /* wiring:d1 — refresh the secondary pane too */
|
|
245
257
|
},
|
|
246
258
|
});
|
|
247
259
|
|
|
@@ -496,8 +508,16 @@ function onStarChange(n: FileNode, value: boolean) {
|
|
|
496
508
|
* this calls it. A menu cannot render a component, but it must not grow its
|
|
497
509
|
* own fetch either — that is the second path that drifts.
|
|
498
510
|
*/
|
|
499
|
-
/** Which of `targets` can carry a star: files the server knows by id.
|
|
511
|
+
/** Which of `targets` can carry a star: files the server knows by id.
|
|
512
|
+
*
|
|
513
|
+
* Empty when the identity surfaces are suppressed, which is the one
|
|
514
|
+
* chokepoint the context menu, the toolbar and the keyboard action all go
|
|
515
|
+
* through — so the affordance disappears everywhere at once rather than in
|
|
516
|
+
* the two places somebody remembered. Offering to star a file while the
|
|
517
|
+
* Starred view is hidden would write into a list the person cannot open,
|
|
518
|
+
* and under a shared app token that list belongs to everyone at once. */
|
|
500
519
|
function starableNodes(targets: FileNode[]): FileNode[] {
|
|
520
|
+
if (!identitySurfaces.value) return [];
|
|
501
521
|
return targets.filter((n) => typeof n.id === 'number' && n.type === 'file');
|
|
502
522
|
}
|
|
503
523
|
|
|
@@ -642,8 +662,9 @@ const showDelete = ref(false);
|
|
|
642
662
|
const showShare = ref(false);
|
|
643
663
|
const showPreview = ref(false);
|
|
644
664
|
const renameTarget = ref<FileNode | null>(null);
|
|
645
|
-
/* ui-fix —
|
|
646
|
-
* (
|
|
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.) */
|
|
647
668
|
const mutationInPane = ref(false);
|
|
648
669
|
const shareTarget = ref<FileNode | null>(null);
|
|
649
670
|
const activeShare = ref<(ShareInfo & { url: string; filename?: string }) | null>(null);
|
|
@@ -700,7 +721,17 @@ function closeInspector() {
|
|
|
700
721
|
* the "one behaviour on one surface" split this shared package exists to
|
|
701
722
|
* prevent. */
|
|
702
723
|
const uiProfile = computed(() => props.config.uiProfile ?? 'standard');
|
|
703
|
-
|
|
724
|
+
/**
|
|
725
|
+
* ⚠ `drive` answers TRUE here. It is a superset of `simple`, so every question
|
|
726
|
+
* `simple` already answers ("one pane?", "no tab strip?", "list and grid
|
|
727
|
+
* only?") must keep the same answer under it — asking `=== 'simple'` in those
|
|
728
|
+
* places is how the drive profile would silently grow a split pane the day
|
|
729
|
+
* somebody adds a fourth condition. `driveShell` is only for what `drive` adds
|
|
730
|
+
* on TOP.
|
|
731
|
+
*/
|
|
732
|
+
const simpleUi = computed(() => uiProfile.value === 'simple' || uiProfile.value === 'drive');
|
|
733
|
+
/* === surucu:d1 — the Drive shell (GitHub #14, the reporter's mockups) ===== */
|
|
734
|
+
const driveShell = computed(() => uiProfile.value === 'drive');
|
|
704
735
|
|
|
705
736
|
const SIDENAV_LS_KEY = 'filex.sidenav';
|
|
706
737
|
const sideNavExpanded = ref<boolean>(
|
|
@@ -740,6 +771,31 @@ const sideNavEnabled = computed(() => props.config.sideNav ?? !rootPathProp);
|
|
|
740
771
|
const navVisible = computed(
|
|
741
772
|
() => sideNavEnabled.value && (isNarrow.value ? navDrawerOpen.value : true),
|
|
742
773
|
);
|
|
774
|
+
/**
|
|
775
|
+
* Is the navigation panel already offering Trash as a destination?
|
|
776
|
+
*
|
|
777
|
+
* ⚠ Keyed to `sideNavEnabled`, NOT to `navVisible`. Three cases decided here,
|
|
778
|
+
* and each one is a judgement about whether the person has a way to the bin
|
|
779
|
+
* that is not this row:
|
|
780
|
+
*
|
|
781
|
+
* - collapsed to the icon rail → OFFERING. The entry is still rendered, still
|
|
782
|
+
* one click, still labelled for a screen reader; only its text is gone.
|
|
783
|
+
* - the drawer under 560px, closed → OFFERING. The toolbar's panel toggle is
|
|
784
|
+
* on screen at every width, so the destination is one tap away. Keying this
|
|
785
|
+
* to `navVisible` instead would add and remove a row from the LISTING every
|
|
786
|
+
* time somebody opened or closed the drawer — the folder changing under
|
|
787
|
+
* them for a reason that has nothing to do with the folder.
|
|
788
|
+
* - no panel at all (`sideNav: false`, or the default under `rootPath`) → NOT
|
|
789
|
+
* offering, and the row stays. That is the case it was invented for: a
|
|
790
|
+
* listing with no other door to the bin.
|
|
791
|
+
*
|
|
792
|
+
* `trashVisible: false` is handled inside the helper and outranks all of it —
|
|
793
|
+
* it means no Trash anywhere, panel entry included.
|
|
794
|
+
*/
|
|
795
|
+
const navOffersTrash = computed(
|
|
796
|
+
() => sideNavEnabled.value && props.config.trashVisible !== false,
|
|
797
|
+
);
|
|
798
|
+
|
|
743
799
|
/** What the toolbar toggle reports as pressed. */
|
|
744
800
|
const navToggleOn = computed(() =>
|
|
745
801
|
isNarrow.value ? navDrawerOpen.value : sideNavExpanded.value,
|
|
@@ -840,6 +896,130 @@ watchEffect(() => {
|
|
|
840
896
|
if (simpleUi.value && viewMode.value === 'gallery') viewMode.value = 'grid';
|
|
841
897
|
});
|
|
842
898
|
|
|
899
|
+
/* === surucu:d1 — the filter row =========================================
|
|
900
|
+
* Three chips over the listing in hand: Type · Modified · Size.
|
|
901
|
+
*
|
|
902
|
+
* ⚠ CLIENT-SIDE, and that is the honest place for them, not a shortcut. The
|
|
903
|
+
* listing endpoint reads exactly six parameters (`action`, `path`, `filter`,
|
|
904
|
+
* `storage`, `parent`, `cache`) and has no `limit`/`offset` either — so the
|
|
905
|
+
* rows in hand ARE the folder, and filtering them here answers the whole
|
|
906
|
+
* question rather than "the first page of it". Wiring a chip to a `min_size`
|
|
907
|
+
* the server never reads would look identical and change nothing.
|
|
908
|
+
*
|
|
909
|
+
* ⚠ Reset on navigation. A filter that survives a folder change makes the next
|
|
910
|
+
* folder look empty, and the reason is off-screen the moment you scroll.
|
|
911
|
+
*/
|
|
912
|
+
const driveFilters = ref<DriveFilters>({ ...EMPTY_FILTERS });
|
|
913
|
+
const filtersOn = computed(() => driveShell.value && filtersActive(driveFilters.value));
|
|
914
|
+
/** What the views render. Identical reference to `files` when nothing is set. */
|
|
915
|
+
const displayFiles = computed<FileNode[]>(() =>
|
|
916
|
+
driveShell.value ? applyFilters(files.value, driveFilters.value) : files.value,
|
|
917
|
+
);
|
|
918
|
+
function clearDriveFilters() {
|
|
919
|
+
setDriveFilters({ ...EMPTY_FILTERS });
|
|
920
|
+
}
|
|
921
|
+
function setDriveFilters(v: DriveFilters) {
|
|
922
|
+
driveFilters.value = v;
|
|
923
|
+
// A selection the filter just hid would still be what Delete acts on, with
|
|
924
|
+
// nothing on screen to say so.
|
|
925
|
+
selection.clear();
|
|
926
|
+
}
|
|
927
|
+
watch(
|
|
928
|
+
() => `${currentPath.value}|${navView.value}`,
|
|
929
|
+
() => {
|
|
930
|
+
if (filtersActive(driveFilters.value)) driveFilters.value = { ...EMPTY_FILTERS };
|
|
931
|
+
},
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* surucu:d1 — what the header field says it will search: the folder you are
|
|
936
|
+
* standing in, by name. At a storage root that is the storage; in a panel view
|
|
937
|
+
* ("Recent", a tag) it is that view's own name, because searching from there
|
|
938
|
+
* searches what is on screen.
|
|
939
|
+
*/
|
|
940
|
+
const driveScopeLabel = computed(() => {
|
|
941
|
+
if (navView.value === 'tag') return navTag.value;
|
|
942
|
+
if (navView.value) return t(`sidenav.${navView.value}`);
|
|
943
|
+
const rel = currentPath.value.replace(/\/+$/, '');
|
|
944
|
+
const last = rel.split('/').filter(Boolean).pop();
|
|
945
|
+
return last || adapter.value || '';
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* surucu:d1 — the ⌘K escalation. The header field searches THIS folder (it
|
|
950
|
+
* sets `searchQuery`, which the loader answers with `action=search`); this
|
|
951
|
+
* hands the same words to the command palette, which is where "everywhere",
|
|
952
|
+
* the saved searches and the commands live. One box, one shortcut, and the
|
|
953
|
+
* hint printed on the box does what it says.
|
|
954
|
+
*/
|
|
955
|
+
const paletteSeed = ref('');
|
|
956
|
+
function openPaletteWith(q: string) {
|
|
957
|
+
paletteSeed.value = q;
|
|
958
|
+
showPalette.value = true;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* surucu:d1 — "Request files" from the New menu: the access modal on the
|
|
963
|
+
* CURRENT folder, opened on its file-drop tab. The modal already owns that
|
|
964
|
+
* surface; this only gives it a target, since the folder you are standing in
|
|
965
|
+
* is not a selected row and has no FileNode of its own.
|
|
966
|
+
*/
|
|
967
|
+
const permInitialTab = ref<'perms' | 'share' | 'drop' | undefined>(undefined);
|
|
968
|
+
function openFileRequest() {
|
|
969
|
+
const path = qualify(currentPath.value);
|
|
970
|
+
if (!path) return;
|
|
971
|
+
const segs = currentPath.value.split('/').filter(Boolean);
|
|
972
|
+
permTarget.value = {
|
|
973
|
+
path,
|
|
974
|
+
basename: segs.length ? segs[segs.length - 1] : adapter.value,
|
|
975
|
+
type: 'dir',
|
|
976
|
+
};
|
|
977
|
+
permInitialTab.value = 'drop';
|
|
978
|
+
showPerm.value = true;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** surucu:d1 — a link minted from the details panel; same event the share
|
|
982
|
+
* dialog emits, so a host listening for `share-created` hears both. */
|
|
983
|
+
function onInspectorShareCreated(payload: { path: string; url: string }) {
|
|
984
|
+
emit('share-created', { path: payload.path, url: payload.url, pin: null });
|
|
985
|
+
flashToast(t('inspector.copied'));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/* === surucu:d1 — the storage line under the navigation ==================
|
|
989
|
+
* Fetched once per mount, and ONLY in the drive shell: no other profile draws
|
|
990
|
+
* it, and an explorer that has drawn this panel for a year should not start
|
|
991
|
+
* making a request it has no use for. `quotaMe()` answers null for a server
|
|
992
|
+
* without the route or a caller without a person behind it, and null renders
|
|
993
|
+
* nothing at all.
|
|
994
|
+
*/
|
|
995
|
+
const quotaSnapshot = ref<{ used: number; total: number; unlimited: boolean } | null>(null);
|
|
996
|
+
async function loadQuota() {
|
|
997
|
+
if (!driveShell.value || !identitySurfaces.value) {
|
|
998
|
+
quotaSnapshot.value = null;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const q = await api.quotaMe();
|
|
1002
|
+
if (!q) {
|
|
1003
|
+
quotaSnapshot.value = null;
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const unlimited = !!q.unlimited || q.quota_bytes <= 0;
|
|
1007
|
+
// ⚠ No ceiling AND nothing counted = nothing to say, so say nothing.
|
|
1008
|
+
//
|
|
1009
|
+
// Measured, not guessed: `used_bytes` is `SUM(nodes.size) WHERE owner_id = me`
|
|
1010
|
+
// and `nodes.owner_id` is set by the UPLOAD path only — it is nil for every
|
|
1011
|
+
// file a storage sync discovered (handlers/shared.go). On an install whose
|
|
1012
|
+
// drives were mounted rather than uploaded into, the honest figure is
|
|
1013
|
+
// therefore "0 B", and a line reading "0 B used" under a drive visibly full
|
|
1014
|
+
// of files reads as a broken widget, not as a fact about quota. With a quota
|
|
1015
|
+
// set the line still earns its place — the ceiling is real and uploads count
|
|
1016
|
+
// against it — so only the no-quota-no-usage case is dropped.
|
|
1017
|
+
quotaSnapshot.value =
|
|
1018
|
+
unlimited && q.used_bytes <= 0
|
|
1019
|
+
? null
|
|
1020
|
+
: { used: q.used_bytes, total: q.quota_bytes, unlimited };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
843
1023
|
/**
|
|
844
1024
|
* nodeRowToFileNode — the starred / recently-opened endpoints answer with raw
|
|
845
1025
|
* node rows (relative `path`, numeric `storage_id`), not the listing shape.
|
|
@@ -1328,7 +1508,7 @@ async function load(path?: string) {
|
|
|
1328
1508
|
currentPath.value = '';
|
|
1329
1509
|
adapter.value = '';
|
|
1330
1510
|
dirname.value = '';
|
|
1331
|
-
e2eRoot.value = ''; /* wiring:e2 —
|
|
1511
|
+
e2eRoot.value = ''; /* wiring:e2 — no lock screen at the virtual root */
|
|
1332
1512
|
files.value = virtualStorageRows();
|
|
1333
1513
|
return;
|
|
1334
1514
|
}
|
|
@@ -1350,7 +1530,16 @@ async function load(path?: string) {
|
|
|
1350
1530
|
files.value = filterListing(resp.files);
|
|
1351
1531
|
// Inject virtual `.trash` entry at root only — shared helper so the
|
|
1352
1532
|
// split-view secondary pane shows the exact same row (no row-offset).
|
|
1353
|
-
|
|
1533
|
+
// ⚠ …and NOT into a search result. The search response's `dirname` is the
|
|
1534
|
+
// scope it searched, so it is the storage root exactly as an `index` of
|
|
1535
|
+
// that folder would be; only this call site knows which of the two it just
|
|
1536
|
+
// asked for.
|
|
1537
|
+
if (
|
|
1538
|
+
injectTrashRow(files.value, resp.adapter, resp.dirname, props.config.trashVisible !== false, {
|
|
1539
|
+
isSearchResult: !!searchQuery.value,
|
|
1540
|
+
navOffersTrash: navOffersTrash.value,
|
|
1541
|
+
})
|
|
1542
|
+
) {
|
|
1354
1543
|
void hydrateTrashRowShared(files.value, resp.adapter, api);
|
|
1355
1544
|
}
|
|
1356
1545
|
// currentPath is the user-facing form: `s3-test/example` in
|
|
@@ -1366,7 +1555,7 @@ async function load(path?: string) {
|
|
|
1366
1555
|
// show the dedicated not-found state instead of a toast over a stale
|
|
1367
1556
|
// listing that reads as "this folder is empty".
|
|
1368
1557
|
notFoundPath.value = String(requested);
|
|
1369
|
-
e2eRoot.value = ''; /* wiring:e2 —
|
|
1558
|
+
e2eRoot.value = ''; /* wiring:e2 — no lock screen left over on a dead link */
|
|
1370
1559
|
files.value = [];
|
|
1371
1560
|
emit('error', { message: e, context: { path } });
|
|
1372
1561
|
return;
|
|
@@ -1412,7 +1601,7 @@ async function loadTrash() {
|
|
|
1412
1601
|
loading.value = true;
|
|
1413
1602
|
trashOrigin.value = adapter.value || '';
|
|
1414
1603
|
trashMode.value = true;
|
|
1415
|
-
e2eRoot.value = ''; /* wiring:e2 —
|
|
1604
|
+
e2eRoot.value = ''; /* wiring:e2 — the trash view is outside the encrypted context */
|
|
1416
1605
|
selection.clear();
|
|
1417
1606
|
try {
|
|
1418
1607
|
const { entries } = await api.listTrash();
|
|
@@ -1491,10 +1680,10 @@ function wireParent(p: string): string {
|
|
|
1491
1680
|
return slash === -1 ? prefix : prefix + rel.slice(0, slash);
|
|
1492
1681
|
}
|
|
1493
1682
|
|
|
1494
|
-
/* ui-fix —
|
|
1495
|
-
*
|
|
1496
|
-
* (source parent === target)
|
|
1497
|
-
*
|
|
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. */
|
|
1498
1687
|
function sameDir(a: string, b: string): boolean {
|
|
1499
1688
|
const norm = (s: string) => {
|
|
1500
1689
|
const i = s.indexOf('://');
|
|
@@ -1655,6 +1844,9 @@ onMounted(async () => {
|
|
|
1655
1844
|
module-level cache in lib/tags.ts means several explorers on one page
|
|
1656
1845
|
still cost a single query. */
|
|
1657
1846
|
void loadNavTags();
|
|
1847
|
+
/* surucu:d1 — the storage line. After the listing, like the tag list: it is
|
|
1848
|
+
a status, and the folder somebody asked for is the critical path. */
|
|
1849
|
+
void loadQuota();
|
|
1658
1850
|
/* ⚠ The panel is not always on screen at mount. Below 560px it is a DRAWER
|
|
1659
1851
|
that starts closed, so `navVisible` is false and the call above returns
|
|
1660
1852
|
without asking for anything — measured at 390px: the drawer opened with
|
|
@@ -1741,7 +1933,7 @@ async function openSearchHit(hit: GlobalSearchHit) {
|
|
|
1741
1933
|
|
|
1742
1934
|
useKeyboardShortcuts(rootEl, {
|
|
1743
1935
|
onDelete: () => {
|
|
1744
|
-
/* ui-fix —
|
|
1936
|
+
/* ui-fix — the shortcut goes to the active pane too (consistent with the menu). */
|
|
1745
1937
|
if (paneIsActive.value) {
|
|
1746
1938
|
const psel = splitPaneRef.value?.selectedNodes() ?? [];
|
|
1747
1939
|
if (psel.length) {
|
|
@@ -1806,6 +1998,11 @@ useKeyboardShortcuts(rootEl, {
|
|
|
1806
1998
|
onGoUp: () => (paneIsActive.value ? splitPaneRef.value?.goUp() : goUp()) /* wiring:d1 pane-route */,
|
|
1807
1999
|
/* cila:c wiring */
|
|
1808
2000
|
onPathJump: () => {
|
|
2001
|
+
/* surucu:d1 — the shortcut from anywhere else opens a BLANK palette. The
|
|
2002
|
+
seed belongs to the drive header's field; leaving the last one in place
|
|
2003
|
+
would reopen the palette pre-filled with a query the user has since
|
|
2004
|
+
moved on from. */
|
|
2005
|
+
paletteSeed.value = '';
|
|
1809
2006
|
showPalette.value = true;
|
|
1810
2007
|
},
|
|
1811
2008
|
onShowHelp: () => {
|
|
@@ -1816,7 +2013,7 @@ useKeyboardShortcuts(rootEl, {
|
|
|
1816
2013
|
onToggleHidden: () => toggleHiddenFiles(),
|
|
1817
2014
|
onStar: () => void toggleStar(selection.nodes.value) /* yildiz:s1 */,
|
|
1818
2015
|
onToggleInspector: () => toggleInspector() /* koru:k1 */,
|
|
1819
|
-
/* wiring:d1 —
|
|
2016
|
+
/* wiring:d1 — tab actions (registry: tab-new/close/next/prev) */
|
|
1820
2017
|
onTabNew: () => newTabHere(),
|
|
1821
2018
|
onTabClose: () => closeTabById(tabsActiveId.value),
|
|
1822
2019
|
onTabNext: () => nextTab(),
|
|
@@ -1865,9 +2062,10 @@ function openNode(n: FileNode) {
|
|
|
1865
2062
|
void load(target);
|
|
1866
2063
|
return;
|
|
1867
2064
|
}
|
|
1868
|
-
/* wiring:e2 —
|
|
1869
|
-
|
|
1870
|
-
|
|
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). */
|
|
1871
2069
|
if (e2eUnlocked.value && n.type === 'file') {
|
|
1872
2070
|
void e2eOpenPreview(n);
|
|
1873
2071
|
return;
|
|
@@ -1931,7 +2129,7 @@ async function restoreSelection(targets?: FileNode[]) {
|
|
|
1931
2129
|
.map((n) => (n as { id?: number }).id)
|
|
1932
2130
|
.filter((x): x is number => typeof x === 'number');
|
|
1933
2131
|
const { restored } = await api.restoreIds(ids);
|
|
1934
|
-
flashToast(
|
|
2132
|
+
flashToast(t('toast.restored', { n: restored }));
|
|
1935
2133
|
selection.clear();
|
|
1936
2134
|
await loadTrash();
|
|
1937
2135
|
return;
|
|
@@ -1940,7 +2138,7 @@ async function restoreSelection(targets?: FileNode[]) {
|
|
|
1940
2138
|
if (!api.endpoints.restore) return;
|
|
1941
2139
|
const items = nodes.map((n) => n.path); // qualified
|
|
1942
2140
|
const { restored } = await api.restore(items);
|
|
1943
|
-
flashToast(
|
|
2141
|
+
flashToast(t('toast.restored', { n: restored }));
|
|
1944
2142
|
selection.clear();
|
|
1945
2143
|
await load();
|
|
1946
2144
|
} catch (err) {
|
|
@@ -1949,7 +2147,7 @@ async function restoreSelection(targets?: FileNode[]) {
|
|
|
1949
2147
|
}
|
|
1950
2148
|
|
|
1951
2149
|
function previewNode(n: FileNode) {
|
|
1952
|
-
/* wiring:e2 —
|
|
2150
|
+
/* wiring:e2 — the preview is fed from the decrypted blob as well */
|
|
1953
2151
|
if (e2eUnlocked.value && n.type === 'file') {
|
|
1954
2152
|
void e2eOpenPreview(n);
|
|
1955
2153
|
return;
|
|
@@ -1976,8 +2174,9 @@ function openNodeInNewTab(n: FileNode) {
|
|
|
1976
2174
|
void load(target);
|
|
1977
2175
|
return;
|
|
1978
2176
|
}
|
|
1979
|
-
/* wiring:e2 — standalone
|
|
1980
|
-
|
|
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. */
|
|
1981
2180
|
if (e2eActive.value) {
|
|
1982
2181
|
if (e2eUnlocked.value) void e2eOpenPreview(n);
|
|
1983
2182
|
return;
|
|
@@ -2012,10 +2211,10 @@ function openNodeInNewTab(n: FileNode) {
|
|
|
2012
2211
|
void markRecent(n);
|
|
2013
2212
|
}
|
|
2014
2213
|
|
|
2015
|
-
type ContextMode = 'selection' | 'breadcrumb' | 'pane' /* ui-fix —
|
|
2214
|
+
type ContextMode = 'selection' | 'breadcrumb' | 'pane' /* ui-fix — side-pane right-click */;
|
|
2016
2215
|
const ctxMode = ref<ContextMode>('selection');
|
|
2017
2216
|
const breadcrumbCtxPath = ref<string>('');
|
|
2018
|
-
/* ui-fix —
|
|
2217
|
+
/* ui-fix — target nodes of the side pane's right-click menu (pane selection). */
|
|
2019
2218
|
const paneCtxTargets = ref<FileNode[]>([]);
|
|
2020
2219
|
const breadcrumbCtxLabel = ref<string>('');
|
|
2021
2220
|
|
|
@@ -2220,15 +2419,17 @@ function onCrumbContext(payload: { x: number; y: number; adapterPath: string; la
|
|
|
2220
2419
|
ctxRef.value?.show({ clientX: payload.x, clientY: payload.y }, []);
|
|
2221
2420
|
}
|
|
2222
2421
|
|
|
2223
|
-
/* ui-fix —
|
|
2224
|
-
*
|
|
2225
|
-
*
|
|
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'. */
|
|
2226
2426
|
function onPaneContext(node: FileNode | null, ev: MouseEvent) {
|
|
2227
2427
|
activePane.value = 'split';
|
|
2228
2428
|
void refreshKept();
|
|
2229
2429
|
const sel = splitPaneRef.value?.selectedNodes() ?? [];
|
|
2230
|
-
// node=null (
|
|
2231
|
-
//
|
|
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).
|
|
2232
2433
|
paneCtxTargets.value = node ? (sel.length > 0 ? sel : [node]) : [];
|
|
2233
2434
|
ctxMode.value = 'pane';
|
|
2234
2435
|
ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, paneCtxTargets.value);
|
|
@@ -2241,10 +2442,10 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2241
2442
|
{ key: 'copy-path', label: t('breadcrumb.copy_path'), icon: '📋' },
|
|
2242
2443
|
];
|
|
2243
2444
|
}
|
|
2244
|
-
if (ctxMode.value === 'pane' /* ui-fix —
|
|
2445
|
+
if (ctxMode.value === 'pane' /* ui-fix — side-pane menu is EXACTLY the main pane's */) {
|
|
2245
2446
|
const psel = paneCtxTargets.value;
|
|
2246
2447
|
if (psel.length === 0) {
|
|
2247
|
-
//
|
|
2448
|
+
// Right-click on empty space: same as the main pane's canvas menu.
|
|
2248
2449
|
return [
|
|
2249
2450
|
{ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' },
|
|
2250
2451
|
{ key: 'paste', label: t('ctx.paste'), icon: '📋', disabled: !clipboard.value.mode },
|
|
@@ -2275,7 +2476,7 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2275
2476
|
// PRIOR BUG: this used `currentPath === '/'` but the load() branch
|
|
2276
2477
|
// for the virtual root sets currentPath to EMPTY string, not '/'.
|
|
2277
2478
|
// So the guard never fired and every mutation action leaked into
|
|
2278
|
-
// the menu at the
|
|
2479
|
+
// the menu at the storage listing — including new-folder + paste,
|
|
2279
2480
|
// which Ada called out in the most direct possible terms. Use
|
|
2280
2481
|
// the same empty-after-trim test as `atVirtualRoot` above.
|
|
2281
2482
|
const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
@@ -2317,9 +2518,10 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2317
2518
|
|
|
2318
2519
|
// selectionActionList — the SINGLE source of truth for the actions offered on a
|
|
2319
2520
|
// selection. BOTH the right-click context menu AND the top toolbar render this
|
|
2320
|
-
// exact list so they can never drift apart (Ada
|
|
2321
|
-
//
|
|
2322
|
-
// 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().
|
|
2323
2525
|
function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
2324
2526
|
const any = sel.length > 0;
|
|
2325
2527
|
const single = sel.length === 1;
|
|
@@ -2342,11 +2544,11 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2342
2544
|
const accessLabel = locale.value === 'en' ? 'Share / Permissions' : 'Paylaş / İzinler';
|
|
2343
2545
|
return [
|
|
2344
2546
|
{ key: 'open', label: t('ctx.open'), icon: '↗', hidden: !single },
|
|
2345
|
-
{ 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 */,
|
|
2346
2548
|
{ key: 'preview', label: t('ctx.preview'), icon: '👁', hidden: !single, disabled: !isFile },
|
|
2347
2549
|
{ key: 'download', label: t('ctx.download'), icon: '⬇', hidden: !single, disabled: !isFile },
|
|
2348
|
-
{ key: 'convert', label: t('ctx.convert'), icon: '🔄', hidden: !single || !effectiveConvertUrl.value || !w || e2eActive.value /* wiring:e2 — convert ciphertext
|
|
2349
|
-
{ 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) */ },
|
|
2350
2552
|
{ key: 'details', label: t('ctx.details'), icon: 'ℹ', hidden: !any } /* koru:k1 */,
|
|
2351
2553
|
{ key: 'copy-id', label: copyIdLabel, icon: '🆔', hidden: !singleHasId, disabled: !singleHasId },
|
|
2352
2554
|
{ divider: true, key: 'sep1', label: '', hidden: !w },
|
|
@@ -2401,7 +2603,7 @@ async function onContextAction(action: ContextAction, targets: FileNode[]) {
|
|
|
2401
2603
|
}
|
|
2402
2604
|
return;
|
|
2403
2605
|
}
|
|
2404
|
-
if (ctxMode.value === 'pane' /* ui-fix —
|
|
2606
|
+
if (ctxMode.value === 'pane' /* ui-fix — side pane: same dispatch, pane-routed */) {
|
|
2405
2607
|
await dispatchItemAction(action.key, paneCtxTargets.value);
|
|
2406
2608
|
return;
|
|
2407
2609
|
}
|
|
@@ -2497,23 +2699,23 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2497
2699
|
}
|
|
2498
2700
|
break;
|
|
2499
2701
|
case 'cut':
|
|
2500
|
-
/* ui-fix — pane
|
|
2702
|
+
/* ui-fix — in a pane context the clipboard source must be the pane's dir. */
|
|
2501
2703
|
if (ctxMode.value === 'pane') paneCut();
|
|
2502
2704
|
else {
|
|
2503
2705
|
clipboard.value = { mode: 'cut', items: targets, sourcePath: currentPath.value };
|
|
2504
|
-
flashToast('
|
|
2706
|
+
flashToast(t('toast.cut_ready'));
|
|
2505
2707
|
}
|
|
2506
2708
|
break;
|
|
2507
2709
|
case 'copy':
|
|
2508
2710
|
if (ctxMode.value === 'pane') paneCopy();
|
|
2509
2711
|
else {
|
|
2510
2712
|
clipboard.value = { mode: 'copy', items: targets, sourcePath: currentPath.value };
|
|
2511
|
-
flashToast('
|
|
2713
|
+
flashToast(t('toast.copy_ready'));
|
|
2512
2714
|
}
|
|
2513
2715
|
break;
|
|
2514
2716
|
case 'paste':
|
|
2515
|
-
/* ui-fix —
|
|
2516
|
-
* (
|
|
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). */
|
|
2517
2719
|
if (ctxMode.value === 'pane' || paneIsActive.value) await panePaste();
|
|
2518
2720
|
else await paste();
|
|
2519
2721
|
break;
|
|
@@ -2534,7 +2736,7 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2534
2736
|
case 'duplicate':
|
|
2535
2737
|
if (targets[0]) await duplicate(targets[0]);
|
|
2536
2738
|
break;
|
|
2537
|
-
/* wiring:d1 —
|
|
2739
|
+
/* wiring:d1 — right-click "Yeni sekmede aç" (open in new tab) */
|
|
2538
2740
|
case 'open-tab':
|
|
2539
2741
|
if (targets[0]) openNodeInTab(targets[0]);
|
|
2540
2742
|
break;
|
|
@@ -2545,13 +2747,13 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2545
2747
|
function cut() {
|
|
2546
2748
|
if (selection.isEmpty.value) return;
|
|
2547
2749
|
clipboard.value = { mode: 'cut', items: selection.nodes.value, sourcePath: currentPath.value };
|
|
2548
|
-
flashToast('
|
|
2750
|
+
flashToast(t('toast.cut'));
|
|
2549
2751
|
}
|
|
2550
2752
|
|
|
2551
2753
|
function copyToClipboard() {
|
|
2552
2754
|
if (selection.isEmpty.value) return;
|
|
2553
2755
|
clipboard.value = { mode: 'copy', items: selection.nodes.value, sourcePath: currentPath.value };
|
|
2554
|
-
flashToast('
|
|
2756
|
+
flashToast(t('toast.copied'));
|
|
2555
2757
|
}
|
|
2556
2758
|
|
|
2557
2759
|
async function paste() {
|
|
@@ -2562,25 +2764,26 @@ async function paste() {
|
|
|
2562
2764
|
const sourceDir = cb.sourcePath || '';
|
|
2563
2765
|
const sameDir = cb.mode === 'cut' && sourceDir === currentPath.value;
|
|
2564
2766
|
if (sameDir) {
|
|
2565
|
-
flashToast('
|
|
2767
|
+
flashToast(t('toast.same_folder_cut'));
|
|
2566
2768
|
return;
|
|
2567
2769
|
}
|
|
2568
2770
|
|
|
2569
2771
|
const targetWire = qualify(currentPath.value);
|
|
2570
|
-
//
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
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).
|
|
2573
2776
|
const plan = resolveTransfer(items, targetWire, cb.mode === 'cut' ? 'move' : 'copy');
|
|
2574
2777
|
if (cb.mode === 'cut') {
|
|
2575
2778
|
const originWire = qualify(sourceDir) || undefined;
|
|
2576
2779
|
const { op } = await api.moveAsync(items, targetWire, originWire);
|
|
2577
2780
|
registerMoveUndo(op.id, items, targetWire, originWire);
|
|
2578
2781
|
pendingOps.register(op);
|
|
2579
|
-
flashToast(plan.cross ? t('split.cross_move') : '
|
|
2782
|
+
flashToast(plan.cross ? t('split.cross_move') : t('split.move_queued'));
|
|
2580
2783
|
} else {
|
|
2581
2784
|
const { op } = await api.copy(items, targetWire);
|
|
2582
2785
|
pendingOps.register(op);
|
|
2583
|
-
flashToast(plan.cross ? t('split.cross_copy') : '
|
|
2786
|
+
flashToast(plan.cross ? t('split.cross_copy') : t('split.copy_queued'));
|
|
2584
2787
|
}
|
|
2585
2788
|
clipboard.value = { mode: null, items: [], sourcePath: null };
|
|
2586
2789
|
} catch (err) {
|
|
@@ -2598,8 +2801,9 @@ async function duplicate(n: FileNode) {
|
|
|
2598
2801
|
}
|
|
2599
2802
|
|
|
2600
2803
|
function downloadFile(n: FileNode) {
|
|
2601
|
-
/* wiring:e2 —
|
|
2602
|
-
|
|
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). */
|
|
2603
2807
|
if (e2eUnlocked.value) {
|
|
2604
2808
|
void e2eDownload(n);
|
|
2605
2809
|
return;
|
|
@@ -2615,7 +2819,7 @@ function downloadFile(n: FileNode) {
|
|
|
2615
2819
|
// ------- Modals -------
|
|
2616
2820
|
|
|
2617
2821
|
async function submitNewFolder(name: string) {
|
|
2618
|
-
const inPane = mutationInPane.value; /* ui-fix —
|
|
2822
|
+
const inPane = mutationInPane.value; /* ui-fix — new folder in the side pane */
|
|
2619
2823
|
try {
|
|
2620
2824
|
const dirWire = inPane ? qualify(splitPaneRef.value?.getPath() ?? '') : qualify(currentPath.value);
|
|
2621
2825
|
await api.newFolder(dirWire, name);
|
|
@@ -2630,7 +2834,7 @@ async function submitNewFolder(name: string) {
|
|
|
2630
2834
|
async function submitRename(name: string) {
|
|
2631
2835
|
const target = renameTarget.value;
|
|
2632
2836
|
if (!target) return;
|
|
2633
|
-
const inPane = mutationInPane.value; /* ui-fix —
|
|
2837
|
+
const inPane = mutationInPane.value; /* ui-fix — rename from the side pane */
|
|
2634
2838
|
try {
|
|
2635
2839
|
const dirWire = inPane ? qualify(splitPaneRef.value?.getPath() ?? '') : qualify(currentPath.value);
|
|
2636
2840
|
const oldPath = target.path; // qualified
|
|
@@ -2658,7 +2862,7 @@ async function confirmDelete() {
|
|
|
2658
2862
|
// offer Restore here rather than a delete that would just re-trash a path.
|
|
2659
2863
|
if (trashMode.value) {
|
|
2660
2864
|
showDelete.value = false;
|
|
2661
|
-
flashToast('
|
|
2865
|
+
flashToast(t('toast.trash_retention'));
|
|
2662
2866
|
return;
|
|
2663
2867
|
}
|
|
2664
2868
|
/* ui-fix — yan panelden silme: hedefler + dizin + tazeleme pane'e ait. */
|
|
@@ -2690,7 +2894,7 @@ async function confirmDelete() {
|
|
|
2690
2894
|
opUndo.set(op.id, { message: t('toast.trashed'), fn: restoreUndo });
|
|
2691
2895
|
}
|
|
2692
2896
|
pendingOps.register(op);
|
|
2693
|
-
flashToast('
|
|
2897
|
+
flashToast(t('toast.delete_queued'));
|
|
2694
2898
|
} else {
|
|
2695
2899
|
await api.deleteItems(dirWire, items);
|
|
2696
2900
|
if (inPane) await splitPaneRef.value?.reload();
|
|
@@ -2767,9 +2971,9 @@ function onFilePicked(ev: Event) {
|
|
|
2767
2971
|
|
|
2768
2972
|
async function uploadFiles(list: File[]) {
|
|
2769
2973
|
if (list.length === 0) return;
|
|
2770
|
-
/* wiring:e2 —
|
|
2771
|
-
|
|
2772
|
-
|
|
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. */
|
|
2773
2977
|
if (e2eLocked.value) {
|
|
2774
2978
|
flashToast(t('e2e.upload.locked'));
|
|
2775
2979
|
return;
|
|
@@ -2940,9 +3144,10 @@ function isExternalFileDrag(ev: DragEvent): boolean {
|
|
|
2940
3144
|
const dt = ev.dataTransfer;
|
|
2941
3145
|
if (!dt) return false;
|
|
2942
3146
|
if (dt.types && dt.types.includes(FE_DND_MIME)) return false;
|
|
2943
|
-
/* wiring:f1 —
|
|
2944
|
-
|
|
2945
|
-
|
|
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). */
|
|
2946
3151
|
if (activeNativeDrag()) return false;
|
|
2947
3152
|
// Some browsers expose `items` early in the drag, others only on
|
|
2948
3153
|
// drop. When `items` is available we use it as the authoritative
|
|
@@ -2973,9 +3178,10 @@ function onDragLeave() {
|
|
|
2973
3178
|
if (dragCounter.value === 0) dragOver.value = false;
|
|
2974
3179
|
}
|
|
2975
3180
|
function onDragOver(ev: DragEvent) {
|
|
2976
|
-
/* wiring:d1 —
|
|
2977
|
-
|
|
2978
|
-
|
|
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). */
|
|
2979
3185
|
if (hasInternalDrag(ev)) {
|
|
2980
3186
|
ev.preventDefault();
|
|
2981
3187
|
return;
|
|
@@ -2986,8 +3192,9 @@ function onDragOver(ev: DragEvent) {
|
|
|
2986
3192
|
}
|
|
2987
3193
|
}
|
|
2988
3194
|
function onDropUpload(ev: DragEvent) {
|
|
2989
|
-
/* wiring:d1 — split
|
|
2990
|
-
|
|
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). */
|
|
2991
3198
|
if (hasInternalDrag(ev)) {
|
|
2992
3199
|
const d1Origin = internalDragOrigin(ev) || '';
|
|
2993
3200
|
const d1Here = qualify(currentPath.value);
|
|
@@ -3048,13 +3255,14 @@ onMounted(() => {
|
|
|
3048
3255
|
window.addEventListener('drop', onWindowDrop);
|
|
3049
3256
|
window.addEventListener('pointerup', onGlobalPointerUp);
|
|
3050
3257
|
window.addEventListener('blur', onGlobalPointerUp);
|
|
3051
|
-
/* wiring:f1 —
|
|
3052
|
-
toast
|
|
3053
|
-
|
|
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). */
|
|
3054
3261
|
dragOut.value?.onProgress?.((p) => {
|
|
3055
|
-
//
|
|
3056
|
-
//
|
|
3057
|
-
//
|
|
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.
|
|
3058
3266
|
const afterDrop = !!p?.dropped;
|
|
3059
3267
|
if (p?.error === 'drop_not_found') {
|
|
3060
3268
|
flashToast(t('dragout.not_found'));
|
|
@@ -3072,18 +3280,18 @@ onMounted(() => {
|
|
|
3072
3280
|
if (!p?.finished && p?.done === 0) flashToast(t('dragout.preparing'));
|
|
3073
3281
|
});
|
|
3074
3282
|
});
|
|
3075
|
-
/* wiring:f1 —
|
|
3076
|
-
|
|
3077
|
-
|
|
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. */
|
|
3078
3286
|
function onGlobalPointerUp() {
|
|
3079
3287
|
if (activeNativeDrag()) {
|
|
3080
3288
|
endNativeDrag();
|
|
3081
|
-
//
|
|
3082
|
-
//
|
|
3289
|
+
// The drop MAY NOT have landed in our own window; only our own drop paths
|
|
3290
|
+
// cancel the shell's watch (onDropUpload / onItemDropInto).
|
|
3083
3291
|
}
|
|
3084
3292
|
}
|
|
3085
3293
|
|
|
3086
|
-
/**
|
|
3294
|
+
/** The drag ended inside the app: the shell should stop waiting for a drop. */
|
|
3087
3295
|
function cancelShellDrag() {
|
|
3088
3296
|
if (dragOut.value?.cancel) void Promise.resolve(dragOut.value.cancel()).catch(() => undefined);
|
|
3089
3297
|
}
|
|
@@ -3121,14 +3329,15 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
3121
3329
|
.filter((n) => n.basename !== '.trash')
|
|
3122
3330
|
.map((n) => ({ path: n.path, basename: n.basename, type: n.type })); // qualified
|
|
3123
3331
|
|
|
3124
|
-
/* wiring:f1 —
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
desktop/src/dropwatch.ts).
|
|
3130
|
-
|
|
3131
|
-
|
|
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. */
|
|
3132
3341
|
if (dragOut.value && items.length > 0) {
|
|
3133
3342
|
ev.preventDefault();
|
|
3134
3343
|
beginNativeDrag(items, qualify(currentPath.value));
|
|
@@ -3141,14 +3350,15 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
3141
3350
|
}
|
|
3142
3351
|
|
|
3143
3352
|
ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
|
|
3144
|
-
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 */
|
|
3145
3354
|
ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
|
|
3146
3355
|
ev.dataTransfer.effectAllowed = 'move';
|
|
3147
3356
|
|
|
3148
|
-
/*
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
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. */
|
|
3152
3362
|
if (items.length === 1 && items[0] && canDownloadUrlDrag(props.config.auth)) {
|
|
3153
3363
|
const payload = downloadUrlPayload(items[0], api.downloadUrl(items[0].path), node.mime_type);
|
|
3154
3364
|
if (payload) ev.dataTransfer.setData('DownloadURL', payload);
|
|
@@ -3157,13 +3367,13 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
3157
3367
|
if (dragOut.value && items.length > 0) void prepareDragOut(items);
|
|
3158
3368
|
}
|
|
3159
3369
|
|
|
3160
|
-
/* === wiring:f1 —
|
|
3370
|
+
/* === wiring:f1 — drag-out preparation ===
|
|
3161
3371
|
*
|
|
3162
|
-
*
|
|
3163
|
-
*
|
|
3164
|
-
*
|
|
3165
|
-
*
|
|
3166
|
-
*
|
|
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.
|
|
3167
3377
|
*/
|
|
3168
3378
|
const dragOut = computed(() => props.config.dragOut ?? null);
|
|
3169
3379
|
const dragOutReadyKey = ref('');
|
|
@@ -3171,11 +3381,12 @@ const dragOutBusy = ref(false);
|
|
|
3171
3381
|
/** True while a preparation nobody asked for is running. */
|
|
3172
3382
|
let dragOutQuiet = false;
|
|
3173
3383
|
|
|
3174
|
-
/*
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
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. */
|
|
3179
3390
|
const DRAGOUT_PREFETCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
3180
3391
|
const DRAGOUT_PREFETCH_MAX_ITEMS = 10;
|
|
3181
3392
|
let dragOutPrefetchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -3187,8 +3398,8 @@ watch(
|
|
|
3187
3398
|
clearTimeout(dragOutPrefetchTimer);
|
|
3188
3399
|
const nodes = selection.nodes.value;
|
|
3189
3400
|
if (nodes.length === 0 || nodes.length > DRAGOUT_PREFETCH_MAX_ITEMS) return;
|
|
3190
|
-
//
|
|
3191
|
-
//
|
|
3401
|
+
// A folder's size isn't known from the listing; rather than guess it, we
|
|
3402
|
+
// leave it to the first drag.
|
|
3192
3403
|
if (nodes.some((n) => n.type !== 'file')) return;
|
|
3193
3404
|
const total = nodes.reduce((sum, n) => sum + (n.size ?? 0), 0);
|
|
3194
3405
|
if (total > DRAGOUT_PREFETCH_MAX_BYTES) return;
|
|
@@ -3209,8 +3420,9 @@ async function prepareDragOut(items: DragItem[], quiet = false): Promise<void> {
|
|
|
3209
3420
|
const res = await hook.prepare(items);
|
|
3210
3421
|
if (res?.ready) {
|
|
3211
3422
|
dragOutReadyKey.value = key;
|
|
3212
|
-
//
|
|
3213
|
-
//
|
|
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.
|
|
3214
3426
|
if (!quiet) flashToast(t('dragout.ready'));
|
|
3215
3427
|
} else if (res?.error && !quiet) {
|
|
3216
3428
|
flashToast(res.error);
|
|
@@ -3225,12 +3437,12 @@ async function prepareDragOut(items: DragItem[], quiet = false): Promise<void> {
|
|
|
3225
3437
|
|
|
3226
3438
|
async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string, originOverride?: string): Promise<void> {
|
|
3227
3439
|
try {
|
|
3228
|
-
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 */
|
|
3229
3441
|
if (api.endpoints.moveAsync) {
|
|
3230
3442
|
const { op } = await api.moveAsync(sources, targetDir, originWire);
|
|
3231
3443
|
registerMoveUndo(op.id, sources, targetDir, originWire);
|
|
3232
3444
|
pendingOps.register(op);
|
|
3233
|
-
flashToast('
|
|
3445
|
+
flashToast(t('split.move_queued'));
|
|
3234
3446
|
} else {
|
|
3235
3447
|
await api.move(originWire, sources, targetDir);
|
|
3236
3448
|
await load();
|
|
@@ -3256,11 +3468,12 @@ async function onItemDropInto(target: FileNode, ev: DragEvent) {
|
|
|
3256
3468
|
const targetDir = target.path; // qualified
|
|
3257
3469
|
const sources = items
|
|
3258
3470
|
.map((i) => i.path)
|
|
3259
|
-
// ui-fix —
|
|
3260
|
-
//
|
|
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.
|
|
3261
3474
|
.filter((p) => p && p !== targetDir && !targetDir.startsWith(p + '/') && !sameDir(wireParent(p), targetDir));
|
|
3262
|
-
if (sources.length === 0) return; //
|
|
3263
|
-
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 */
|
|
3264
3477
|
}
|
|
3265
3478
|
|
|
3266
3479
|
async function onCrumbDropInto(adapterPath: string, ev: DragEvent) {
|
|
@@ -3272,10 +3485,10 @@ async function onCrumbDropInto(adapterPath: string, ev: DragEvent) {
|
|
|
3272
3485
|
const targetDir = adapterPath; // already qualified by breadcrumb
|
|
3273
3486
|
const sources = items
|
|
3274
3487
|
.map((i) => i.path)
|
|
3275
|
-
// ui-fix —
|
|
3488
|
+
// ui-fix — an in-place drop onto the breadcrumb (the same folder) is a no-op.
|
|
3276
3489
|
.filter((p) => p && p !== targetDir && !targetDir.startsWith(p + '/') && !sameDir(wireParent(p), targetDir));
|
|
3277
3490
|
if (sources.length === 0) return;
|
|
3278
|
-
await transferItems(sources, targetDir, dndOrigin(ev)); /* wiring:d1 —
|
|
3491
|
+
await transferItems(sources, targetDir, dndOrigin(ev)); /* wiring:d1 — storage-aware transfer */
|
|
3279
3492
|
}
|
|
3280
3493
|
|
|
3281
3494
|
function onCancelUpload(job: UploadJob) {
|
|
@@ -3418,7 +3631,7 @@ function quickLookToggle() {
|
|
|
3418
3631
|
const sel = selection.nodes.value;
|
|
3419
3632
|
const n = sel.length === 1 ? sel[0] : null;
|
|
3420
3633
|
if (!n || n.type !== 'file' || n.basename === '.trash') return;
|
|
3421
|
-
/* wiring:e2 — Space peek
|
|
3634
|
+
/* wiring:e2 — in an encrypted folder the Space peek decrypts first, then opens */
|
|
3422
3635
|
if (e2eActive.value) {
|
|
3423
3636
|
if (!e2eUnlocked.value) return;
|
|
3424
3637
|
void (async () => {
|
|
@@ -3462,14 +3675,14 @@ watch(
|
|
|
3462
3675
|
(nodes) => {
|
|
3463
3676
|
if (!quickLookOpen.value) return;
|
|
3464
3677
|
const n = nodes.length === 1 && nodes[0].type === 'file' ? nodes[0] : null;
|
|
3465
|
-
/* wiring:e2 —
|
|
3466
|
-
|
|
3678
|
+
/* wiring:e2 — when arrowing through files, decrypt BEFORE assigning the
|
|
3679
|
+
target as well, otherwise the viewer briefly gets the raw ciphertext URL. */
|
|
3467
3680
|
if (n && n.path !== quickLookTarget.value?.path && e2eUnlocked.value) {
|
|
3468
3681
|
void (async () => {
|
|
3469
3682
|
try {
|
|
3470
3683
|
await e2eFetchDecrypted(n);
|
|
3471
3684
|
} catch {
|
|
3472
|
-
/*
|
|
3685
|
+
/* decryption failed — switch the target anyway, the viewer shows the error */
|
|
3473
3686
|
}
|
|
3474
3687
|
quickLookTarget.value = n;
|
|
3475
3688
|
void markRecent(n);
|
|
@@ -3564,16 +3777,17 @@ onBeforeUnmount(() => {
|
|
|
3564
3777
|
});
|
|
3565
3778
|
/* === /wiring:c4 === */
|
|
3566
3779
|
|
|
3567
|
-
/* === wiring:d1 —
|
|
3780
|
+
/* === wiring:d1 — tabs (tab strip) + per-tab split ===
|
|
3568
3781
|
*
|
|
3569
|
-
* useTabs
|
|
3570
|
-
*
|
|
3571
|
-
*
|
|
3572
|
-
*
|
|
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).
|
|
3573
3787
|
*
|
|
3574
|
-
* Persist: `filex.tabs` — pathPersist scope
|
|
3575
|
-
*
|
|
3576
|
-
*
|
|
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.
|
|
3577
3791
|
*/
|
|
3578
3792
|
const FE_DND_SRC_MIME = 'application/x-brf-files-src';
|
|
3579
3793
|
|
|
@@ -3588,7 +3802,7 @@ const tabsActiveId = tabsApi.activeId;
|
|
|
3588
3802
|
|
|
3589
3803
|
const activeSplit = computed(() => tabsApi.activeTab.value?.split ?? null);
|
|
3590
3804
|
|
|
3591
|
-
//
|
|
3805
|
+
// Tab name is AUTOMATIC = the current folder name (root = storage name / root label).
|
|
3592
3806
|
function tabLabel(path: string): string {
|
|
3593
3807
|
const p = (path || '').replace(/^\/+|\/+$/g, '');
|
|
3594
3808
|
// gezinti:g1 — the virtual views park a sentinel in the path. Translate via
|
|
@@ -3620,12 +3834,13 @@ const tabsVisible = computed(
|
|
|
3620
3834
|
(props.config.tabStrip !== 'auto' && tabItems.value.length > 0)),
|
|
3621
3835
|
);
|
|
3622
3836
|
|
|
3623
|
-
//
|
|
3837
|
+
// The active tab follows the user: navigation + view changes go into the snapshot.
|
|
3624
3838
|
watch(currentPath, (p) => tabsApi.syncActive({ path: p }));
|
|
3625
3839
|
watch(viewMode, (v) => tabsApi.syncActive({ viewMode: v }));
|
|
3626
3840
|
|
|
3627
|
-
//
|
|
3628
|
-
//
|
|
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).
|
|
3629
3844
|
onMounted(() => {
|
|
3630
3845
|
if (!tabsRestored) tabsApi.seed(currentPath.value ?? '', viewMode.value);
|
|
3631
3846
|
});
|
|
@@ -3643,7 +3858,7 @@ function activateTab(id: string) {
|
|
|
3643
3858
|
if (tb) applyTabLocation(tb);
|
|
3644
3859
|
}
|
|
3645
3860
|
function newTabHere() {
|
|
3646
|
-
//
|
|
3861
|
+
// Clones the current location; the view is already there, so no load needed.
|
|
3647
3862
|
tabsApi.openTab(currentPath.value ?? '', { viewMode: viewMode.value, background: false });
|
|
3648
3863
|
}
|
|
3649
3864
|
function closeTabById(id: string) {
|
|
@@ -3659,16 +3874,17 @@ function prevTab() {
|
|
|
3659
3874
|
if (tb) applyTabLocation(tb);
|
|
3660
3875
|
}
|
|
3661
3876
|
|
|
3662
|
-
/**
|
|
3877
|
+
/** Open a folder in a new tab IN THE BACKGROUND (middle-click / right-click / palette). */
|
|
3663
3878
|
function openNodeInTab(n: FileNode) {
|
|
3664
3879
|
if (n.type !== 'dir' || n.basename === '.trash') return;
|
|
3665
3880
|
const target = multiStorageRoot.value ? wireToVirtual(n.path) : stripAdapter(n.path);
|
|
3666
3881
|
tabsApi.openTab(target, { viewMode: viewMode.value, background: true });
|
|
3667
3882
|
}
|
|
3668
3883
|
|
|
3669
|
-
//
|
|
3670
|
-
//
|
|
3671
|
-
// (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.)
|
|
3672
3888
|
function onListAuxClick(ev: MouseEvent) {
|
|
3673
3889
|
if (ev.button !== 1) return;
|
|
3674
3890
|
const host = ev.target as HTMLElement | null;
|
|
@@ -3680,9 +3896,9 @@ function onListAuxClick(ev: MouseEvent) {
|
|
|
3680
3896
|
ev.preventDefault();
|
|
3681
3897
|
openNodeInTab(node);
|
|
3682
3898
|
}
|
|
3683
|
-
//
|
|
3684
|
-
// autoscroll
|
|
3685
|
-
// 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.
|
|
3686
3902
|
function onListMiddleDown(ev: MouseEvent) {
|
|
3687
3903
|
if (ev.button !== 1) return;
|
|
3688
3904
|
const host = ev.target as HTMLElement | null;
|
|
@@ -3699,10 +3915,10 @@ onBeforeUnmount(() => {
|
|
|
3699
3915
|
rootEl.value?.removeEventListener('mousedown', onListMiddleDown);
|
|
3700
3916
|
});
|
|
3701
3917
|
|
|
3702
|
-
// ---- split (tab
|
|
3918
|
+
// ---- split (per-tab secondary pane) --------------------------------
|
|
3703
3919
|
|
|
3704
3920
|
const splitPaneRef = ref<InstanceType<typeof SecondaryPane> | null>(null);
|
|
3705
|
-
//
|
|
3921
|
+
// Split is disabled in narrow mode (the state is kept and comes back on widen).
|
|
3706
3922
|
const splitVisible = computed(
|
|
3707
3923
|
() => !!activeSplit.value && !isNarrow.value && !simpleUi.value /* gezinti:g1 */,
|
|
3708
3924
|
);
|
|
@@ -3723,16 +3939,18 @@ function closeSplit() {
|
|
|
3723
3939
|
function onPaneNavigate(p: string) {
|
|
3724
3940
|
tabsApi.setSplit({ ...(activeSplit.value ?? {}), path: p });
|
|
3725
3941
|
}
|
|
3726
|
-
/* ui-fix —
|
|
3727
|
-
*
|
|
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. */
|
|
3728
3945
|
function onPaneOpenTrash() {
|
|
3729
3946
|
activePane.value = 'main';
|
|
3730
3947
|
void loadTrash();
|
|
3731
3948
|
}
|
|
3732
|
-
/* ui-fix — pane'
|
|
3733
|
-
*
|
|
3734
|
-
* toggle
|
|
3735
|
-
* 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"). */
|
|
3736
3954
|
const paneViewMode = computed<ViewMode>(() => activeSplit.value?.viewMode ?? viewMode.value);
|
|
3737
3955
|
function setPaneViewMode(v: ViewMode) {
|
|
3738
3956
|
if (!activeSplit.value) return;
|
|
@@ -3746,7 +3964,7 @@ function setDisplayedViewMode(v: ViewMode) {
|
|
|
3746
3964
|
else viewMode.value = v;
|
|
3747
3965
|
}
|
|
3748
3966
|
|
|
3749
|
-
//
|
|
3967
|
+
// Active pane: shortcuts go to the active pane; a pane is activated by clicking it.
|
|
3750
3968
|
const activePane = ref<'main' | 'split'>('main');
|
|
3751
3969
|
function setPaneMain() {
|
|
3752
3970
|
activePane.value = 'main';
|
|
@@ -3757,7 +3975,7 @@ watch(splitVisible, (v) => {
|
|
|
3757
3975
|
const paneIsActive = computed(() => activePane.value === 'split' && splitVisible.value);
|
|
3758
3976
|
const mainPaneFocus = computed(() => splitVisible.value && activePane.value === 'main');
|
|
3759
3977
|
|
|
3760
|
-
// Pane
|
|
3978
|
+
// Pane helpers — always wrap the main pane's existing converters.
|
|
3761
3979
|
function paneToUser(wire: string): string {
|
|
3762
3980
|
return multiStorageRoot.value ? wireToVirtual(wire) : stripAdapter(wire);
|
|
3763
3981
|
}
|
|
@@ -3768,20 +3986,20 @@ function paneClamp(p: string): string {
|
|
|
3768
3986
|
return clean;
|
|
3769
3987
|
}
|
|
3770
3988
|
|
|
3771
|
-
//
|
|
3772
|
-
//
|
|
3773
|
-
//
|
|
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.
|
|
3774
3992
|
function paneCut() {
|
|
3775
3993
|
const nodes = splitPaneRef.value?.selectedNodes() ?? [];
|
|
3776
3994
|
if (nodes.length === 0) return;
|
|
3777
3995
|
clipboard.value = { mode: 'cut', items: nodes, sourcePath: splitPaneRef.value?.getPath() ?? '' };
|
|
3778
|
-
flashToast('
|
|
3996
|
+
flashToast(t('toast.cut'));
|
|
3779
3997
|
}
|
|
3780
3998
|
function paneCopy() {
|
|
3781
3999
|
const nodes = splitPaneRef.value?.selectedNodes() ?? [];
|
|
3782
4000
|
if (nodes.length === 0) return;
|
|
3783
4001
|
clipboard.value = { mode: 'copy', items: nodes, sourcePath: splitPaneRef.value?.getPath() ?? '' };
|
|
3784
|
-
flashToast('
|
|
4002
|
+
flashToast(t('toast.copied'));
|
|
3785
4003
|
}
|
|
3786
4004
|
async function panePaste() {
|
|
3787
4005
|
const cb = clipboard.value;
|
|
@@ -3790,28 +4008,29 @@ async function panePaste() {
|
|
|
3790
4008
|
const targetWire = qualify(pane.getPath() ?? '');
|
|
3791
4009
|
const originWire = qualify(cb.sourcePath || '') || undefined;
|
|
3792
4010
|
if (cb.mode === 'cut' && originWire === targetWire) {
|
|
3793
|
-
flashToast('
|
|
4011
|
+
flashToast(t('toast.same_folder_cut'));
|
|
3794
4012
|
return;
|
|
3795
4013
|
}
|
|
3796
4014
|
await transferItems(cb.items.map((n) => n.path), targetWire, originWire, cb.mode === 'copy' ? 'copy' : 'move');
|
|
3797
4015
|
clipboard.value = { mode: null, items: [], sourcePath: null };
|
|
3798
4016
|
}
|
|
3799
4017
|
|
|
3800
|
-
// ----
|
|
4018
|
+
// ---- cross-pane transfer -------------------------------------------
|
|
3801
4019
|
function dndOrigin(ev: DragEvent): string | undefined {
|
|
3802
4020
|
return internalDragOrigin(ev);
|
|
3803
4021
|
}
|
|
3804
4022
|
|
|
3805
4023
|
/**
|
|
3806
|
-
* transferItems —
|
|
4024
|
+
* transferItems — the single gate for cross-pane / clipboard transfers.
|
|
3807
4025
|
*
|
|
3808
|
-
*
|
|
3809
|
-
*
|
|
3810
|
-
*
|
|
3811
|
-
*
|
|
3812
|
-
*
|
|
3813
|
-
*
|
|
3814
|
-
*
|
|
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).
|
|
3815
4034
|
*/
|
|
3816
4035
|
async function transferItems(
|
|
3817
4036
|
sources: string[],
|
|
@@ -3819,8 +4038,8 @@ async function transferItems(
|
|
|
3819
4038
|
originWire?: string,
|
|
3820
4039
|
intent: TransferIntent = 'auto',
|
|
3821
4040
|
): Promise<void> {
|
|
3822
|
-
// ui-fix —
|
|
3823
|
-
// "
|
|
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).
|
|
3824
4043
|
const list = sources.filter(
|
|
3825
4044
|
(p) => p && p !== targetWire && !targetWire.startsWith(p + '/') && !sameDir(wireParent(p), targetWire),
|
|
3826
4045
|
);
|
|
@@ -3832,9 +4051,10 @@ async function transferItems(
|
|
|
3832
4051
|
pendingOps.register(op);
|
|
3833
4052
|
flashToast(plan.cross ? t('split.cross_copy') : t('split.copy_queued'));
|
|
3834
4053
|
} catch (err) {
|
|
3835
|
-
// ⚠
|
|
3836
|
-
//
|
|
3837
|
-
//
|
|
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).
|
|
3838
4058
|
emit('error', { message: (err as Error).message, context: { op: 'transfer', targetWire } });
|
|
3839
4059
|
flashToast((err as Error).message);
|
|
3840
4060
|
return;
|
|
@@ -3851,21 +4071,23 @@ function onPaneTransfer(p: { sources: string[]; targetWire: string; originWire?:
|
|
|
3851
4071
|
}
|
|
3852
4072
|
/* === /wiring:d1 === */
|
|
3853
4073
|
|
|
3854
|
-
/* === wiring:e2 —
|
|
4074
|
+
/* === wiring:e2 — end-to-end encrypted folders ===
|
|
3855
4075
|
*
|
|
3856
|
-
*
|
|
3857
|
-
*
|
|
3858
|
-
*
|
|
3859
|
-
*
|
|
3860
|
-
* (`e2eRing`) —
|
|
3861
|
-
*
|
|
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).
|
|
3862
4084
|
*/
|
|
3863
4085
|
const e2eRing = createKeyRing();
|
|
3864
|
-
//
|
|
4086
|
+
// Maps aren't reactive — a version counter drives the computeds.
|
|
3865
4087
|
const e2eRingVer = ref(0);
|
|
3866
|
-
//
|
|
4088
|
+
// Wire path of the encrypted root we are inside ('' = no encrypted context).
|
|
3867
4089
|
const e2eRoot = ref('');
|
|
3868
|
-
// path →
|
|
4090
|
+
// path → decrypted blob objectURL (preview). Revoked on lock/unmount.
|
|
3869
4091
|
const e2eUrls = new Map<string, string>();
|
|
3870
4092
|
|
|
3871
4093
|
const e2eActive = computed(() => !!e2eRoot.value && !trashMode.value);
|
|
@@ -3878,15 +4100,15 @@ const e2eLocked = computed(() => {
|
|
|
3878
4100
|
return e2eActive.value && !e2eRing.has(e2eRoot.value);
|
|
3879
4101
|
});
|
|
3880
4102
|
|
|
3881
|
-
//
|
|
4103
|
+
// Lock screen form.
|
|
3882
4104
|
const e2ePw = ref('');
|
|
3883
4105
|
const e2eUnlockBusy = ref(false);
|
|
3884
4106
|
const e2eUnlockErr = ref('');
|
|
3885
|
-
//
|
|
4107
|
+
// Encrypted-folder creation modal.
|
|
3886
4108
|
const showEncFolder = ref(false);
|
|
3887
4109
|
const e2eCreateBusy = ref(false);
|
|
3888
4110
|
|
|
3889
|
-
/* wiring:e2 recovery —
|
|
4111
|
+
/* wiring:e2 recovery — recovery key + escrow.
|
|
3890
4112
|
*
|
|
3891
4113
|
* The marker of the folder we are looking at is cached here while the lock
|
|
3892
4114
|
* screen is up: the recovery dialog needs to know which doors this folder
|
|
@@ -3906,11 +4128,42 @@ const e2eUpgradeOffer = ref(false);
|
|
|
3906
4128
|
const e2eUpgradePw = ref('');
|
|
3907
4129
|
const e2eUpgradeBusy = ref(false);
|
|
3908
4130
|
|
|
4131
|
+
/* wiring:e2 escrow-offer — offering an EXISTING folder an escrow slot.
|
|
4132
|
+
*
|
|
4133
|
+
* Adoption covers folders created after it, which on an installation that
|
|
4134
|
+
* has been running for a while is nobody's folders: the ones that matter
|
|
4135
|
+
* already exist. The server cannot reach them — adding a slot needs the
|
|
4136
|
+
* folder master key. Its owner can, from inside, with the password, and the
|
|
4137
|
+
* one moment that password exists in the browser is an unlock. So this
|
|
4138
|
+
* lives exactly where the v1 recovery offer lives, in the same strip, with
|
|
4139
|
+
* the same shape.
|
|
4140
|
+
*
|
|
4141
|
+
* mode distinguishes the two ways in:
|
|
4142
|
+
* 'unlock' right after a successful password unlock — we still hold the
|
|
4143
|
+
* password, so Accept needs no typing, and Not now RECORDS a
|
|
4144
|
+
* refusal so the question is not asked again.
|
|
4145
|
+
* 'manual' the way back, from the unlocked strip, for somebody who said
|
|
4146
|
+
* no earlier. The password is gone by then, so it is typed, and
|
|
4147
|
+
* Cancel records nothing — they already answered once. */
|
|
4148
|
+
const e2eEscrowOffer = ref(false);
|
|
4149
|
+
const e2eEscrowOfferMode = ref<'unlock' | 'manual'>('unlock');
|
|
4150
|
+
const e2eEscrowPw = ref('');
|
|
4151
|
+
const e2eEscrowBusy = ref(false);
|
|
4152
|
+
const e2eEscrowErr = ref('');
|
|
4153
|
+
|
|
3909
4154
|
/** The installation's escrow public key, or null when escrow is off.
|
|
3910
4155
|
* Published in /api/capabilities on purpose — see docs/E2E-ENCRYPTION.md. */
|
|
3911
4156
|
const e2eEscrowPub = computed<string | null>(
|
|
3912
4157
|
() => capabilitiesData.value?.e2e_escrow?.public_key || null,
|
|
3913
4158
|
);
|
|
4159
|
+
/** Where the honest version of "what escrow can and cannot do" lives.
|
|
4160
|
+
* ⚠ Linked from the offer on purpose: the notice says what accepting means,
|
|
4161
|
+
* and the page says what the use-notification can and cannot promise — that
|
|
4162
|
+
* an operator holding the private key can decrypt offline with no request,
|
|
4163
|
+
* no notification and no audit row. Somebody being asked to hand over a key
|
|
4164
|
+
* is entitled to read that before answering, not after. */
|
|
4165
|
+
const e2eEscrowDocsUrl = 'https://docs.filex.sh/E2E-ENCRYPTION#what-escrow-can-and-cannot-do';
|
|
4166
|
+
|
|
3914
4167
|
const e2eEscrowKid = computed<string | null>(
|
|
3915
4168
|
() => capabilitiesData.value?.e2e_escrow?.kid || null,
|
|
3916
4169
|
);
|
|
@@ -3925,7 +4178,7 @@ function e2eRevokeAll() {
|
|
|
3925
4178
|
}
|
|
3926
4179
|
onBeforeUnmount(e2eRevokeAll);
|
|
3927
4180
|
|
|
3928
|
-
/**
|
|
4181
|
+
/** Unlock: fetch the marker from the root, verify the password LOCALLY, put the KEK in memory. */
|
|
3929
4182
|
async function e2eUnlock() {
|
|
3930
4183
|
if (!e2ePw.value || e2eUnlockBusy.value || !e2eRoot.value) return;
|
|
3931
4184
|
e2eUnlockBusy.value = true;
|
|
@@ -3933,7 +4186,7 @@ async function e2eUnlock() {
|
|
|
3933
4186
|
try {
|
|
3934
4187
|
let markerText = '';
|
|
3935
4188
|
try {
|
|
3936
|
-
const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME));
|
|
4189
|
+
const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME), { fresh: true });
|
|
3937
4190
|
URL.revokeObjectURL(url);
|
|
3938
4191
|
markerText = await blob.text();
|
|
3939
4192
|
} catch {
|
|
@@ -3962,6 +4215,18 @@ async function e2eUnlock() {
|
|
|
3962
4215
|
if (marker.v === 1) {
|
|
3963
4216
|
e2eUpgradePw.value = e2ePw.value;
|
|
3964
4217
|
e2eUpgradeOffer.value = true;
|
|
4218
|
+
} else if (escrowOfferState(marker, e2eEscrowKid.value) === 'offer') {
|
|
4219
|
+
/* wiring:e2 escrow-offer — a v2 folder that predates escrow here.
|
|
4220
|
+
* ⚠ Only after the unlock SUCCEEDED, and only by password: accepting
|
|
4221
|
+
* gives the operator a key to this folder, so the person asked has to
|
|
4222
|
+
* be the person who can already open it. A v1 folder is handled by the
|
|
4223
|
+
* branch above, which seals an escrow slot as part of the upgrade and
|
|
4224
|
+
* already discloses that — two offers on one unlock would be two
|
|
4225
|
+
* chances to get the disclosure wrong. */
|
|
4226
|
+
e2eEscrowPw.value = e2ePw.value;
|
|
4227
|
+
e2eEscrowOfferMode.value = 'unlock';
|
|
4228
|
+
e2eEscrowErr.value = '';
|
|
4229
|
+
e2eEscrowOffer.value = true;
|
|
3965
4230
|
}
|
|
3966
4231
|
e2ePw.value = '';
|
|
3967
4232
|
} finally {
|
|
@@ -3969,7 +4234,7 @@ async function e2eUnlock() {
|
|
|
3969
4234
|
}
|
|
3970
4235
|
}
|
|
3971
4236
|
|
|
3972
|
-
/** "Kilitle":
|
|
4237
|
+
/** "Kilitle" (Lock): drop the in-memory key and the decrypted blobs. */
|
|
3973
4238
|
function e2eLock() {
|
|
3974
4239
|
if (!e2eRoot.value) return;
|
|
3975
4240
|
e2eRing.lock(e2eRoot.value);
|
|
@@ -3978,9 +4243,9 @@ function e2eLock() {
|
|
|
3978
4243
|
flashToast(t('e2e.locked_toast'));
|
|
3979
4244
|
}
|
|
3980
4245
|
|
|
3981
|
-
//
|
|
3982
|
-
//
|
|
3983
|
-
//
|
|
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).
|
|
3984
4249
|
const E2E_MIME: Record<string, string> = {
|
|
3985
4250
|
txt: 'text/plain', md: 'text/markdown', log: 'text/plain', csv: 'text/csv',
|
|
3986
4251
|
json: 'application/json', xml: 'application/xml', html: 'text/html',
|
|
@@ -3997,9 +4262,10 @@ function e2eMimeFor(n: FileNode): string {
|
|
|
3997
4262
|
}
|
|
3998
4263
|
|
|
3999
4264
|
/**
|
|
4000
|
-
*
|
|
4001
|
-
*
|
|
4002
|
-
*
|
|
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).
|
|
4003
4269
|
*/
|
|
4004
4270
|
async function e2eFetchDecrypted(n: FileNode): Promise<string | null> {
|
|
4005
4271
|
const cached = e2eUrls.get(n.path);
|
|
@@ -4014,12 +4280,12 @@ async function e2eFetchDecrypted(n: FileNode): Promise<string | null> {
|
|
|
4014
4280
|
return url;
|
|
4015
4281
|
}
|
|
4016
4282
|
|
|
4017
|
-
/** PreviewModal/QuickLook
|
|
4283
|
+
/** URL provider handed to PreviewModal/QuickLook: decrypted blob > raw URL. */
|
|
4018
4284
|
function e2ePreviewSrc(p: string): string {
|
|
4019
4285
|
return e2eUrls.get(p) ?? api.previewUrl(p);
|
|
4020
4286
|
}
|
|
4021
4287
|
|
|
4022
|
-
/**
|
|
4288
|
+
/** Decrypt + read-only in-page preview (openNode/previewNode land here). */
|
|
4023
4289
|
async function e2eOpenPreview(n: FileNode) {
|
|
4024
4290
|
try {
|
|
4025
4291
|
await e2eFetchDecrypted(n);
|
|
@@ -4034,7 +4300,7 @@ async function e2eOpenPreview(n: FileNode) {
|
|
|
4034
4300
|
void markRecent(n);
|
|
4035
4301
|
}
|
|
4036
4302
|
|
|
4037
|
-
/**
|
|
4303
|
+
/** Decrypt + download under the original name. A file with no magic comes down as-is. */
|
|
4038
4304
|
async function e2eDownload(n: FileNode) {
|
|
4039
4305
|
try {
|
|
4040
4306
|
const buf = await api.fetchArrayBuffer(n.path);
|
|
@@ -4057,7 +4323,7 @@ async function e2eDownload(n: FileNode) {
|
|
|
4057
4323
|
}
|
|
4058
4324
|
}
|
|
4059
4325
|
|
|
4060
|
-
/** Upload
|
|
4326
|
+
/** Upload list → encrypted File list (anything over 200MB + the marker name is skipped). */
|
|
4061
4327
|
async function e2eEncryptUploads(list: File[]): Promise<File[]> {
|
|
4062
4328
|
const kek = e2eKek();
|
|
4063
4329
|
if (!kek) return [];
|
|
@@ -4078,10 +4344,10 @@ async function e2eEncryptUploads(list: File[]): Promise<File[]> {
|
|
|
4078
4344
|
return out;
|
|
4079
4345
|
}
|
|
4080
4346
|
|
|
4081
|
-
/** EncryptedFolderModal submit
|
|
4347
|
+
/** EncryptedFolderModal submit: create the folder + upload the marker + leave it unlocked. */
|
|
4082
4348
|
async function submitEncryptedFolder(payload: { name: string; password: string }) {
|
|
4083
4349
|
if (e2eActive.value) {
|
|
4084
|
-
//
|
|
4350
|
+
// Nested encrypted folders blur root detection — not in the MVP.
|
|
4085
4351
|
flashToast(t('e2e.create.nested'));
|
|
4086
4352
|
return;
|
|
4087
4353
|
}
|
|
@@ -4101,7 +4367,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4101
4367
|
});
|
|
4102
4368
|
const newDirWire = wireJoin(dirWire, payload.name);
|
|
4103
4369
|
await api.uploadMultipart(newDirWire, [markerFile]);
|
|
4104
|
-
//
|
|
4370
|
+
// It starts unlocked in the creating session (they just typed the password).
|
|
4105
4371
|
e2eRing.set(newDirWire, fmk);
|
|
4106
4372
|
e2eRingVer.value++;
|
|
4107
4373
|
showEncFolder.value = false;
|
|
@@ -4204,7 +4470,7 @@ async function e2eRecoverUnlock(payload: { mode: 'recovery' | 'escrow'; value: s
|
|
|
4204
4470
|
async function openRecoveryUnlock() {
|
|
4205
4471
|
if (!e2eMarker.value && e2eRoot.value) {
|
|
4206
4472
|
try {
|
|
4207
|
-
const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME));
|
|
4473
|
+
const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME), { fresh: true });
|
|
4208
4474
|
URL.revokeObjectURL(url);
|
|
4209
4475
|
e2eMarker.value = parseMarker(await blob.text());
|
|
4210
4476
|
} catch {
|
|
@@ -4243,6 +4509,101 @@ async function e2eDoUpgrade() {
|
|
|
4243
4509
|
}
|
|
4244
4510
|
}
|
|
4245
4511
|
|
|
4512
|
+
/* wiring:e2 escrow-offer ------------------------------------------- */
|
|
4513
|
+
|
|
4514
|
+
/** Whether this folder could be given an escrow slot, and whether its owner
|
|
4515
|
+
* has already answered. Drives the way-back button in the unlocked strip. */
|
|
4516
|
+
const e2eEscrowOfferState = computed(() => escrowOfferState(e2eMarker.value, e2eEscrowKid.value));
|
|
4517
|
+
|
|
4518
|
+
/** The way back. Somebody who said no last month can say yes today without
|
|
4519
|
+
* deleting and re-creating the folder — but the password is long gone from
|
|
4520
|
+
* memory, so they type it. That is not friction to be smoothed away: it is
|
|
4521
|
+
* the same proof of ownership the offer at unlock had, and handing the
|
|
4522
|
+
* operator a key deserves it. */
|
|
4523
|
+
function e2eOpenEscrowOffer() {
|
|
4524
|
+
e2eEscrowOfferMode.value = 'manual';
|
|
4525
|
+
e2eEscrowPw.value = '';
|
|
4526
|
+
e2eEscrowErr.value = '';
|
|
4527
|
+
e2eEscrowOffer.value = true;
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
/** Write a marker back to the folder and adopt it as the one we are holding.
|
|
4531
|
+
* Both escrow-offer answers change only `.filex-e2e.json`. */
|
|
4532
|
+
async function e2eWriteMarker(next: E2eMarker) {
|
|
4533
|
+
const file = new File([JSON.stringify(next)], E2E_MARKER_NAME, { type: 'application/json' });
|
|
4534
|
+
await api.uploadMultipart(e2eRoot.value, [file]);
|
|
4535
|
+
e2eMarker.value = next;
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4538
|
+
/** Accept: seal this folder's master key to the installation's escrow key.
|
|
4539
|
+
* No file is re-encrypted or moved — only the marker gains a slot. */
|
|
4540
|
+
async function e2eAcceptEscrow() {
|
|
4541
|
+
const marker = e2eMarker.value;
|
|
4542
|
+
const pub = e2eEscrowPub.value;
|
|
4543
|
+
if (!marker || !pub || !e2eRoot.value || e2eEscrowBusy.value) return;
|
|
4544
|
+
if (!e2eEscrowPw.value) {
|
|
4545
|
+
e2eEscrowErr.value = t('e2e.escrowoffer.password_required');
|
|
4546
|
+
return;
|
|
4547
|
+
}
|
|
4548
|
+
e2eEscrowBusy.value = true;
|
|
4549
|
+
e2eEscrowErr.value = '';
|
|
4550
|
+
try {
|
|
4551
|
+
// ⚠ The kid comes from the installation's CURRENT key, via the marker
|
|
4552
|
+
// addEscrowSlot writes — never from anything the UI is displaying. The
|
|
4553
|
+
// sibling bug went the other way (the dialog labelled a folder's slot
|
|
4554
|
+
// with the installation's kid); writing the installation's kid into a
|
|
4555
|
+
// slot sealed to some other key would be the same lie in reverse.
|
|
4556
|
+
const next = await addEscrowSlot(marker, e2eEscrowPw.value, pub);
|
|
4557
|
+
await e2eWriteMarker(next);
|
|
4558
|
+
e2eEscrowOffer.value = false;
|
|
4559
|
+
e2eEscrowPw.value = '';
|
|
4560
|
+
flashToast(t('e2e.escrowoffer.done'));
|
|
4561
|
+
} catch (err) {
|
|
4562
|
+
// A wrong password is the ordinary case here and reads as a typo, not
|
|
4563
|
+
// as a broken folder; anything else is worth reporting.
|
|
4564
|
+
if ((err as Error)?.name === 'E2eDecryptError') {
|
|
4565
|
+
e2eEscrowErr.value = t('e2e.escrowoffer.wrong_password');
|
|
4566
|
+
} else {
|
|
4567
|
+
e2eEscrowErr.value = t('e2e.escrowoffer.failed');
|
|
4568
|
+
emit('error', { message: (err as Error).message, context: { op: 'e2e-escrow-offer' } });
|
|
4569
|
+
}
|
|
4570
|
+
} finally {
|
|
4571
|
+
e2eEscrowBusy.value = false;
|
|
4572
|
+
}
|
|
4573
|
+
}
|
|
4574
|
+
|
|
4575
|
+
/** Not now. Recorded IN THE FOLDER, so the same person on another device is
|
|
4576
|
+
* not asked again and the answer survives a cleared browser. A question
|
|
4577
|
+
* that returns every single unlock is how people learn to click past
|
|
4578
|
+
* security dialogs without reading them. */
|
|
4579
|
+
async function e2eDeclineEscrow() {
|
|
4580
|
+
const marker = e2eMarker.value;
|
|
4581
|
+
if (!marker || !e2eRoot.value || e2eEscrowBusy.value) return;
|
|
4582
|
+
e2eEscrowBusy.value = true;
|
|
4583
|
+
try {
|
|
4584
|
+
await e2eWriteMarker(declineEscrowSlot(marker, new Date().toISOString()));
|
|
4585
|
+
e2eEscrowOffer.value = false;
|
|
4586
|
+
e2eEscrowPw.value = '';
|
|
4587
|
+
flashToast(t('e2e.escrowoffer.declined_toast'));
|
|
4588
|
+
} catch (err) {
|
|
4589
|
+
// ⚠ Fail towards asking again rather than towards silence: if the
|
|
4590
|
+
// refusal could not be written, the honest state is "not answered yet".
|
|
4591
|
+
e2eEscrowErr.value = t('e2e.escrowoffer.decline_failed');
|
|
4592
|
+
emit('error', { message: (err as Error).message, context: { op: 'e2e-escrow-decline' } });
|
|
4593
|
+
} finally {
|
|
4594
|
+
e2eEscrowBusy.value = false;
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
/** Close the way-back panel without answering anything. Distinct from
|
|
4599
|
+
* declining: they answered once already, and re-recording the same refusal
|
|
4600
|
+
* would overwrite the date it was actually made. */
|
|
4601
|
+
function e2eCloseEscrowOffer() {
|
|
4602
|
+
e2eEscrowOffer.value = false;
|
|
4603
|
+
e2eEscrowPw.value = '';
|
|
4604
|
+
e2eEscrowErr.value = '';
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4246
4607
|
/** Decline the offer. The folder keeps working exactly as it did, and the
|
|
4247
4608
|
* prompt returns on the next unlock because the risk has not changed. */
|
|
4248
4609
|
function e2eDeclineUpgrade() {
|
|
@@ -4277,7 +4638,7 @@ function closeRecoveryKey() {
|
|
|
4277
4638
|
@drop="onDropUpload"
|
|
4278
4639
|
@contextmenu="onContextCanvas"
|
|
4279
4640
|
>
|
|
4280
|
-
<!-- wiring:d1 —
|
|
4641
|
+
<!-- wiring:d1 — tab strip: not rendered at all on a SINGLE tab (embeds stay pixel-identical) -->
|
|
4281
4642
|
<TabBar
|
|
4282
4643
|
v-if="tabsVisible"
|
|
4283
4644
|
:tabs="tabItems"
|
|
@@ -4294,7 +4655,7 @@ function closeRecoveryKey() {
|
|
|
4294
4655
|
<!-- /wiring:d1 -->
|
|
4295
4656
|
<Toolbar
|
|
4296
4657
|
ref="toolbarRef"
|
|
4297
|
-
:view-mode="displayedViewMode /* ui-fix —
|
|
4658
|
+
:view-mode="displayedViewMode /* ui-fix — the active pane's mode */"
|
|
4298
4659
|
:search-query="searchQuery"
|
|
4299
4660
|
:trash-active="trashActive"
|
|
4300
4661
|
:actions="toolbarActions"
|
|
@@ -4311,10 +4672,13 @@ function closeRecoveryKey() {
|
|
|
4311
4672
|
:nav-open="navToggleOn /* gezinti:g1 */"
|
|
4312
4673
|
:nav-enabled="sideNavEnabled /* gezinti:g1 */"
|
|
4313
4674
|
:view-modes="allowedViewModes /* gezinti:g1 */"
|
|
4675
|
+
:shell="driveShell ? 'drive' : 'classic' /* surucu:d1 */"
|
|
4676
|
+
:scope-label="driveScopeLabel /* surucu:d1 */"
|
|
4677
|
+
@open-palette="openPaletteWith /* surucu:d1 */"
|
|
4314
4678
|
@toggle-inspector="toggleInspector /* koru:k1 */"
|
|
4315
4679
|
@toggle-nav="toggleSideNav /* gezinti:g1 */"
|
|
4316
4680
|
@open-theme="showThemeGallery = true /* wiring:c1 */"
|
|
4317
|
-
@update:view-mode="setDisplayedViewMode($event) /* ui-fix —
|
|
4681
|
+
@update:view-mode="setDisplayedViewMode($event) /* ui-fix — to the active pane */"
|
|
4318
4682
|
@update:search-query="searchQuery = $event"
|
|
4319
4683
|
@update:density="density = $event"
|
|
4320
4684
|
@open-shortcut-settings="showShortcutSettings = true /* wiring:c2 */"
|
|
@@ -4352,6 +4716,11 @@ function closeRecoveryKey() {
|
|
|
4352
4716
|
:show-identity-surfaces="identitySurfaces"
|
|
4353
4717
|
:can-write="canWriteHere && !atVirtualRoot && !trashActive"
|
|
4354
4718
|
:locale="locale"
|
|
4719
|
+
:new-menu="driveShell /* surucu:d1 */"
|
|
4720
|
+
:can-request-files="canWriteHere && !atVirtualRoot && !trashActive && !navView /* surucu:d1 */"
|
|
4721
|
+
:quota="quotaSnapshot /* surucu:d1 */"
|
|
4722
|
+
:theme="themeMode /* surucu:d1 — the teleported New menu leaves .fe */"
|
|
4723
|
+
@request-files="openFileRequest /* surucu:d1 */"
|
|
4355
4724
|
@toggle="toggleSideNav"
|
|
4356
4725
|
@close="closeNavDrawer"
|
|
4357
4726
|
@open-view="loadNavView"
|
|
@@ -4372,14 +4741,23 @@ function closeRecoveryKey() {
|
|
|
4372
4741
|
:aria-label="t('sidenav.close')"
|
|
4373
4742
|
@click="closeNavDrawer"
|
|
4374
4743
|
></button>
|
|
4375
|
-
<!-- ui-fix —
|
|
4376
|
-
|
|
4377
|
-
breadcrumb
|
|
4378
|
-
|
|
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. -->
|
|
4379
4749
|
<div
|
|
4380
4750
|
class="fe__primary"
|
|
4381
|
-
:class="{ 'fe-pane--focus': mainPaneFocus } /* wiring:d1 —
|
|
4751
|
+
:class="{ 'fe-pane--focus': mainPaneFocus } /* wiring:d1 — active-pane accent */"
|
|
4382
4752
|
>
|
|
4753
|
+
<!-- surucu:d1 — the breadcrumb row. In the drive shell it also carries the
|
|
4754
|
+
two controls that belong to a LISTING rather than to the app (the view
|
|
4755
|
+
switcher and the details toggle), which is where the mockups put them
|
|
4756
|
+
and where the header then has room for one wide search field.
|
|
4757
|
+
Everywhere else the wrapper is `display: contents`, so the breadcrumb
|
|
4758
|
+
is the same flex child of `.fe__primary` it has always been — one
|
|
4759
|
+
Breadcrumb, not a second copy that can drift. -->
|
|
4760
|
+
<div :class="driveShell ? 'fe-subhead' : 'fe-subhead--plain'">
|
|
4383
4761
|
<Breadcrumb
|
|
4384
4762
|
:dirname="dirname"
|
|
4385
4763
|
:adapter="adapter"
|
|
@@ -4392,6 +4770,49 @@ function closeRecoveryKey() {
|
|
|
4392
4770
|
@crumb-context="onCrumbContext"
|
|
4393
4771
|
@crumb-drop="onCrumbDropInto"
|
|
4394
4772
|
/>
|
|
4773
|
+
<div v-if="driveShell" class="fe-subhead__actions">
|
|
4774
|
+
<ViewSwitcher
|
|
4775
|
+
:view-mode="displayedViewMode"
|
|
4776
|
+
:locale="locale"
|
|
4777
|
+
:modes="allowedViewModes"
|
|
4778
|
+
@update:view-mode="setDisplayedViewMode($event)"
|
|
4779
|
+
/>
|
|
4780
|
+
<button
|
|
4781
|
+
type="button"
|
|
4782
|
+
class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
|
|
4783
|
+
:class="{ 'is-active': showInspector }"
|
|
4784
|
+
:aria-pressed="showInspector"
|
|
4785
|
+
:title="t('toolbar.inspector')"
|
|
4786
|
+
:aria-label="t('toolbar.inspector')"
|
|
4787
|
+
data-testid="subhead-inspector"
|
|
4788
|
+
@click="toggleInspector"
|
|
4789
|
+
>
|
|
4790
|
+
<svg
|
|
4791
|
+
class="fe-ficon"
|
|
4792
|
+
viewBox="0 0 24 24"
|
|
4793
|
+
fill="none"
|
|
4794
|
+
stroke="currentColor"
|
|
4795
|
+
stroke-width="1.8"
|
|
4796
|
+
stroke-linecap="round"
|
|
4797
|
+
aria-hidden="true"
|
|
4798
|
+
focusable="false"
|
|
4799
|
+
>
|
|
4800
|
+
<circle cx="12" cy="12" r="9" />
|
|
4801
|
+
<path d="M12 11v5" />
|
|
4802
|
+
<circle cx="12" cy="7.6" r="1" fill="currentColor" stroke="none" />
|
|
4803
|
+
</svg>
|
|
4804
|
+
</button>
|
|
4805
|
+
</div>
|
|
4806
|
+
</div>
|
|
4807
|
+
<FilterBar
|
|
4808
|
+
v-if="driveShell && !atVirtualRoot"
|
|
4809
|
+
:value="driveFilters"
|
|
4810
|
+
:locale="locale"
|
|
4811
|
+
:theme="themeMode"
|
|
4812
|
+
:shown="displayFiles.length"
|
|
4813
|
+
:total="files.length"
|
|
4814
|
+
@update:value="setDriveFilters"
|
|
4815
|
+
/>
|
|
4395
4816
|
|
|
4396
4817
|
<!-- Live presence: who else is viewing this folder (empty → nothing shown).
|
|
4397
4818
|
When the live socket is unavailable the same strip carries a small
|
|
@@ -4410,8 +4831,9 @@ function closeRecoveryKey() {
|
|
|
4410
4831
|
</span>
|
|
4411
4832
|
</div>
|
|
4412
4833
|
|
|
4413
|
-
<!-- wiring:e2 —
|
|
4414
|
-
|
|
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. -->
|
|
4415
4837
|
<!-- wiring:e2 recovery — a v1 folder just opened by password. Offer it
|
|
4416
4838
|
recovery HERE, visibly, rather than doing anything silently: this is
|
|
4417
4839
|
the only moment filex holds the password, and (when the install has
|
|
@@ -4438,9 +4860,76 @@ function closeRecoveryKey() {
|
|
|
4438
4860
|
</button>
|
|
4439
4861
|
</div>
|
|
4440
4862
|
</div>
|
|
4863
|
+
<!-- wiring:e2 escrow-offer — an existing folder that predates escrow on
|
|
4864
|
+
this installation. Shown only after an unlock SUCCEEDED, because
|
|
4865
|
+
accepting hands the operator a permanent second key to this folder
|
|
4866
|
+
and the only person entitled to do that is the one who can already
|
|
4867
|
+
open it. Doing nothing leaves the folder exactly as it is. -->
|
|
4868
|
+
<div v-if="e2eEscrowOffer" class="fe-e2e-upgrade fe-e2e-upgrade--escrow" role="alert">
|
|
4869
|
+
<div class="fe-e2e-upgrade__text">
|
|
4870
|
+
<strong>{{ t('e2e.escrowoffer.title') }}</strong>
|
|
4871
|
+
<p>{{ t('e2e.escrowoffer.body') }}</p>
|
|
4872
|
+
<p class="fe-e2e-upgrade__escrow">
|
|
4873
|
+
{{ t('e2e.escrowoffer.consequence') }}
|
|
4874
|
+
<a :href="e2eEscrowDocsUrl" target="_blank" rel="noopener noreferrer">
|
|
4875
|
+
{{ t('e2e.escrowoffer.learn_more') }}
|
|
4876
|
+
</a>
|
|
4877
|
+
</p>
|
|
4878
|
+
<p v-if="e2eEscrowKid" class="fe-e2e-upgrade__escrow">
|
|
4879
|
+
{{ t('e2e.recover.escrow_kid') }}: <code>{{ e2eEscrowKid }}</code>
|
|
4880
|
+
</p>
|
|
4881
|
+
<!-- The way-back path has no password in memory any more, so it is
|
|
4882
|
+
typed. The same proof of ownership the unlock path already had. -->
|
|
4883
|
+
<label v-if="e2eEscrowOfferMode === 'manual'" class="fe-e2e-upgrade__pw">
|
|
4884
|
+
<span>{{ t('e2e.escrowoffer.password_label') }}</span>
|
|
4885
|
+
<input
|
|
4886
|
+
v-model="e2eEscrowPw"
|
|
4887
|
+
type="password"
|
|
4888
|
+
class="fe-input"
|
|
4889
|
+
autocomplete="current-password"
|
|
4890
|
+
:disabled="e2eEscrowBusy"
|
|
4891
|
+
@keyup.enter="e2eAcceptEscrow"
|
|
4892
|
+
/>
|
|
4893
|
+
</label>
|
|
4894
|
+
<p v-if="e2eEscrowErr" class="fe-form__error" role="alert">{{ e2eEscrowErr }}</p>
|
|
4895
|
+
</div>
|
|
4896
|
+
<div class="fe-e2e-upgrade__actions">
|
|
4897
|
+
<button
|
|
4898
|
+
type="button"
|
|
4899
|
+
class="fe-btn"
|
|
4900
|
+
:disabled="e2eEscrowBusy"
|
|
4901
|
+
@click="e2eEscrowOfferMode === 'manual' ? e2eCloseEscrowOffer() : e2eDeclineEscrow()"
|
|
4902
|
+
>
|
|
4903
|
+
{{
|
|
4904
|
+
e2eEscrowOfferMode === 'manual'
|
|
4905
|
+
? t('e2e.escrowoffer.cancel')
|
|
4906
|
+
: t('e2e.escrowoffer.decline')
|
|
4907
|
+
}}
|
|
4908
|
+
</button>
|
|
4909
|
+
<button
|
|
4910
|
+
type="button"
|
|
4911
|
+
class="fe-btn fe-btn--primary"
|
|
4912
|
+
:disabled="e2eEscrowBusy"
|
|
4913
|
+
@click="e2eAcceptEscrow"
|
|
4914
|
+
>
|
|
4915
|
+
{{ e2eEscrowBusy ? t('e2e.escrowoffer.busy') : t('e2e.escrowoffer.accept') }}
|
|
4916
|
+
</button>
|
|
4917
|
+
</div>
|
|
4918
|
+
</div>
|
|
4441
4919
|
<div v-if="e2eUnlocked" class="fe-e2e-strip" role="status">
|
|
4442
4920
|
<span class="fe-e2e-strip__icon" aria-hidden="true">🔒</span>
|
|
4443
4921
|
<span class="fe-e2e-strip__label">{{ t('e2e.strip.label') }}</span>
|
|
4922
|
+
<!-- The way back for somebody who declined. Quiet, but present: a
|
|
4923
|
+
refusal that could not be reversed without deleting the folder
|
|
4924
|
+
would not be a decision, it would be a trap. -->
|
|
4925
|
+
<button
|
|
4926
|
+
v-if="e2eEscrowOfferState !== 'n/a' && !e2eEscrowOffer"
|
|
4927
|
+
type="button"
|
|
4928
|
+
class="fe-btn fe-e2e-strip__btn"
|
|
4929
|
+
@click="e2eOpenEscrowOffer"
|
|
4930
|
+
>
|
|
4931
|
+
{{ t('e2e.escrowoffer.strip_action') }}
|
|
4932
|
+
</button>
|
|
4444
4933
|
<button type="button" class="fe-btn fe-e2e-strip__btn" @click="e2eLock">
|
|
4445
4934
|
{{ t('e2e.strip.lock') }}
|
|
4446
4935
|
</button>
|
|
@@ -4457,7 +4946,7 @@ function closeRecoveryKey() {
|
|
|
4457
4946
|
keeps the current list, exactly as before. -->
|
|
4458
4947
|
<div v-if="loading && files.length === 0" class="fe__skeleton" role="status">
|
|
4459
4948
|
<span class="fe-sr-only">{{ t('loading') }}</span>
|
|
4460
|
-
<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">
|
|
4461
4950
|
<div v-for="i in 8" :key="i" class="fe-skel-card">
|
|
4462
4951
|
<div class="fe-skel fe-skel--thumb"></div>
|
|
4463
4952
|
<div class="fe-skel fe-skel--label"></div>
|
|
@@ -4535,9 +5024,10 @@ function closeRecoveryKey() {
|
|
|
4535
5024
|
</details>
|
|
4536
5025
|
<!-- /wiring:c4 -->
|
|
4537
5026
|
</div>
|
|
4538
|
-
<!-- wiring:e2 —
|
|
4539
|
-
|
|
4540
|
-
|
|
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. -->
|
|
4541
5031
|
<div v-else-if="e2eLocked" class="fe-state fe-e2e-lock">
|
|
4542
5032
|
<svg
|
|
4543
5033
|
class="fe-state__art"
|
|
@@ -4675,6 +5165,36 @@ function closeRecoveryKey() {
|
|
|
4675
5165
|
</svg>
|
|
4676
5166
|
<p class="fe-state__title">{{ t('empty.trash.title') }}</p>
|
|
4677
5167
|
</div>
|
|
5168
|
+
<!-- surucu:d1 — the folder HAS rows and the filters hid all of them.
|
|
5169
|
+
"This folder is empty" would be false, and the way back is the chip
|
|
5170
|
+
row just above, so the message names it and offers the button. -->
|
|
5171
|
+
<div
|
|
5172
|
+
v-else-if="!loading && filtersOn && displayFiles.length === 0 && files.length > 0"
|
|
5173
|
+
class="fe-state"
|
|
5174
|
+
data-testid="empty-filtered"
|
|
5175
|
+
>
|
|
5176
|
+
<svg
|
|
5177
|
+
class="fe-state__art"
|
|
5178
|
+
viewBox="0 0 120 100"
|
|
5179
|
+
width="110"
|
|
5180
|
+
height="92"
|
|
5181
|
+
fill="none"
|
|
5182
|
+
stroke="currentColor"
|
|
5183
|
+
stroke-width="2"
|
|
5184
|
+
stroke-linecap="round"
|
|
5185
|
+
stroke-linejoin="round"
|
|
5186
|
+
aria-hidden="true"
|
|
5187
|
+
>
|
|
5188
|
+
<path d="M26 28h68L68 58v24l-16 8V58z" />
|
|
5189
|
+
</svg>
|
|
5190
|
+
<p class="fe-state__title">{{ t('filter.empty.title') }}</p>
|
|
5191
|
+
<p class="fe-state__hint">{{ t('filter.empty.hint') }}</p>
|
|
5192
|
+
<div class="fe-state__actions">
|
|
5193
|
+
<button type="button" class="fe-btn" @click="clearDriveFilters">
|
|
5194
|
+
{{ t('filter.clear') }}
|
|
5195
|
+
</button>
|
|
5196
|
+
</div>
|
|
5197
|
+
</div>
|
|
4678
5198
|
<!-- Loaded, zero files, no search: the real empty-folder state. The
|
|
4679
5199
|
upload affordances follow write permission (RBAC viewers only get
|
|
4680
5200
|
the title). -->
|
|
@@ -4707,7 +5227,7 @@ function closeRecoveryKey() {
|
|
|
4707
5227
|
</div>
|
|
4708
5228
|
<ListView
|
|
4709
5229
|
v-else-if="viewMode === 'list'"
|
|
4710
|
-
:files="
|
|
5230
|
+
:files="displayFiles /* surucu:d1 */"
|
|
4711
5231
|
:selected="selection.selected.value"
|
|
4712
5232
|
:clipped="clippedPaths"
|
|
4713
5233
|
:show-parent-path="!!searchQuery"
|
|
@@ -4715,6 +5235,7 @@ function closeRecoveryKey() {
|
|
|
4715
5235
|
:loading="loading"
|
|
4716
5236
|
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
4717
5237
|
:starred-ids="starredIds"
|
|
5238
|
+
:star-enabled="identitySurfaces"
|
|
4718
5239
|
:api-base="props.config.apiBase ?? ''"
|
|
4719
5240
|
:auth-headers="() => buildAuthHeaders()"
|
|
4720
5241
|
:auth-credentials="api.credentialsMode()"
|
|
@@ -4727,7 +5248,8 @@ function closeRecoveryKey() {
|
|
|
4727
5248
|
/>
|
|
4728
5249
|
<GridView
|
|
4729
5250
|
v-else-if="viewMode === 'grid' /* wiring:d2 — v-else → v-else-if (3. mod eklendi) */"
|
|
4730
|
-
:files="
|
|
5251
|
+
:files="displayFiles /* surucu:d1 */"
|
|
5252
|
+
:sections="driveShell /* surucu:d1 */"
|
|
4731
5253
|
:selected="selection.selected.value"
|
|
4732
5254
|
:clipped="clippedPaths"
|
|
4733
5255
|
:show-parent-path="!!searchQuery"
|
|
@@ -4736,6 +5258,7 @@ function closeRecoveryKey() {
|
|
|
4736
5258
|
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
4737
5259
|
:thumb-src="thumbs.src"
|
|
4738
5260
|
:starred-ids="starredIds"
|
|
5261
|
+
:star-enabled="identitySurfaces"
|
|
4739
5262
|
:api-base="props.config.apiBase ?? ''"
|
|
4740
5263
|
:auth-headers="() => buildAuthHeaders()"
|
|
4741
5264
|
:auth-credentials="api.credentialsMode()"
|
|
@@ -4746,10 +5269,10 @@ function closeRecoveryKey() {
|
|
|
4746
5269
|
@item-drop-into="onItemDropInto"
|
|
4747
5270
|
@star-change="onStarChange"
|
|
4748
5271
|
/>
|
|
4749
|
-
<!-- wiring:d2 —
|
|
5272
|
+
<!-- wiring:d2 — gallery view (same event contract as GridView) -->
|
|
4750
5273
|
<GalleryView
|
|
4751
5274
|
v-else
|
|
4752
|
-
:files="
|
|
5275
|
+
:files="displayFiles /* surucu:d1 */"
|
|
4753
5276
|
:selected="selection.selected.value"
|
|
4754
5277
|
:clipped="clippedPaths"
|
|
4755
5278
|
:show-parent-path="!!searchQuery"
|
|
@@ -4757,6 +5280,7 @@ function closeRecoveryKey() {
|
|
|
4757
5280
|
:loading="loading"
|
|
4758
5281
|
:thumb-src="thumbs.src"
|
|
4759
5282
|
:starred-ids="starredIds"
|
|
5283
|
+
:star-enabled="identitySurfaces"
|
|
4760
5284
|
:api-base="props.config.apiBase ?? ''"
|
|
4761
5285
|
:auth-headers="() => buildAuthHeaders()"
|
|
4762
5286
|
:auth-credentials="api.credentialsMode()"
|
|
@@ -4771,9 +5295,9 @@ function closeRecoveryKey() {
|
|
|
4771
5295
|
</div>
|
|
4772
5296
|
</div><!-- /fe__primary ui-fix -->
|
|
4773
5297
|
|
|
4774
|
-
<!-- wiring:d1 — tab
|
|
4775
|
-
:key
|
|
4776
|
-
|
|
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. -->
|
|
4777
5301
|
<SecondaryPane
|
|
4778
5302
|
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
4779
5303
|
v-if="splitVisible && activeSplit"
|
|
@@ -4792,14 +5316,15 @@ function closeRecoveryKey() {
|
|
|
4792
5316
|
:active="paneIsActive"
|
|
4793
5317
|
:view-mode="paneViewMode /* ui-fix */"
|
|
4794
5318
|
:thumb-src="thumbs.src /* ui-fix */"
|
|
4795
|
-
:trash-visible="config.trashVisible !== false /* ui-fix —
|
|
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 */"
|
|
4796
5321
|
@navigate="onPaneNavigate"
|
|
4797
5322
|
@activate="activePane = 'split'"
|
|
4798
5323
|
@close="closeSplit"
|
|
4799
5324
|
@open-tab="(p: string) => tabsApi.openTab(p, { viewMode: viewMode, background: true })"
|
|
4800
5325
|
@transfer="onPaneTransfer"
|
|
4801
|
-
@context="onPaneContext /* ui-fix —
|
|
4802
|
-
@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 */"
|
|
4803
5328
|
/>
|
|
4804
5329
|
<!-- /wiring:d1 -->
|
|
4805
5330
|
|
|
@@ -4815,7 +5340,9 @@ function closeRecoveryKey() {
|
|
|
4815
5340
|
:locale="locale"
|
|
4816
5341
|
:narrow="isNarrow"
|
|
4817
5342
|
:thumb-src="thumbs.src"
|
|
5343
|
+
:tabs="driveShell /* surucu:d1 */"
|
|
4818
5344
|
@close="closeInspector"
|
|
5345
|
+
@share-created="onInspectorShareCreated /* surucu:d1 */"
|
|
4819
5346
|
@manage-permissions="onInspectorManage"
|
|
4820
5347
|
@toast="flashToast"
|
|
4821
5348
|
@changed="() => load()"
|
|
@@ -4868,7 +5395,7 @@ function closeRecoveryKey() {
|
|
|
4868
5395
|
<div v-if="dragOver" class="fe__dragover">
|
|
4869
5396
|
<div class="fe__dragover-card">
|
|
4870
5397
|
<span class="fe-icon">⬆</span>
|
|
4871
|
-
<p>
|
|
5398
|
+
<p>{{ t('dropzone.hint') }}</p>
|
|
4872
5399
|
</div>
|
|
4873
5400
|
</div>
|
|
4874
5401
|
|
|
@@ -4945,12 +5472,12 @@ function closeRecoveryKey() {
|
|
|
4945
5472
|
<NewFolderModal
|
|
4946
5473
|
:open="showNewFolder"
|
|
4947
5474
|
:locale="locale"
|
|
4948
|
-
:encrypted-option="!e2eActive /* wiring:e2 —
|
|
5475
|
+
:encrypted-option="!e2eActive /* wiring:e2 — no nested encrypted folders */"
|
|
4949
5476
|
@close="showNewFolder = false"
|
|
4950
5477
|
@submit="submitNewFolder"
|
|
4951
5478
|
@encrypted="showNewFolder = false; showEncFolder = true /* wiring:e2 */"
|
|
4952
5479
|
/>
|
|
4953
|
-
<!-- wiring:e2 —
|
|
5480
|
+
<!-- wiring:e2 — encrypted-folder creation modal -->
|
|
4954
5481
|
<EncryptedFolderModal
|
|
4955
5482
|
:open="showEncFolder"
|
|
4956
5483
|
:locale="locale"
|
|
@@ -4974,8 +5501,8 @@ function closeRecoveryKey() {
|
|
|
4974
5501
|
:open="showRecoveryUnlock"
|
|
4975
5502
|
:locale="locale"
|
|
4976
5503
|
:has-recovery="markerHasRecovery(e2eMarker)"
|
|
4977
|
-
:
|
|
4978
|
-
:escrow-kid="e2eEscrowKid"
|
|
5504
|
+
:escrow-state="escrowAvailability(e2eMarker, e2eEscrowKid)"
|
|
5505
|
+
:escrow-kid="e2eMarker?.esc?.kid || e2eEscrowKid"
|
|
4979
5506
|
:busy="e2eRecoverBusy"
|
|
4980
5507
|
:error="e2eRecoverErr"
|
|
4981
5508
|
@close="showRecoveryUnlock = false"
|
|
@@ -5010,12 +5537,12 @@ function closeRecoveryKey() {
|
|
|
5010
5537
|
:locale="locale"
|
|
5011
5538
|
:file="previewTarget"
|
|
5012
5539
|
:theme="themeMode"
|
|
5013
|
-
:preview-url="(p) => e2ePreviewSrc(p) /* wiring:e2 —
|
|
5540
|
+
:preview-url="(p) => e2ePreviewSrc(p) /* wiring:e2 — decrypted blob > raw URL */"
|
|
5014
5541
|
:download-url="(p) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
|
|
5015
|
-
:only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO ciphertext
|
|
5542
|
+
:only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO cannot open ciphertext */"
|
|
5016
5543
|
:only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
|
|
5017
|
-
:new-tab-enabled="!e2eActive /* wiring:e2 — standalone
|
|
5018
|
-
: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 */"
|
|
5019
5546
|
:open-mode="previewMode"
|
|
5020
5547
|
:auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
|
|
5021
5548
|
:auth-credentials="api.credentialsMode()"
|
|
@@ -5031,6 +5558,7 @@ function closeRecoveryKey() {
|
|
|
5031
5558
|
:file-name="convertTarget?.basename || convertTarget?.path || ''"
|
|
5032
5559
|
:fetch-bytes="() => api.fetchArrayBuffer(convertTarget?.path ?? '')"
|
|
5033
5560
|
:upload="(f) => api.uploadMultipart(qualify(currentPath), [f]).then(() => {})"
|
|
5561
|
+
:locale="locale"
|
|
5034
5562
|
@close="showConvert = false"
|
|
5035
5563
|
@done="onConvertDone"
|
|
5036
5564
|
/>
|
|
@@ -5042,7 +5570,8 @@ function closeRecoveryKey() {
|
|
|
5042
5570
|
:size="typeof permTarget.size === 'number' ? permTarget.size : undefined"
|
|
5043
5571
|
:locale="locale === 'en' ? 'en' : 'tr'"
|
|
5044
5572
|
:share-max-ttl-days="shareMaxTtlDays"
|
|
5045
|
-
|
|
5573
|
+
:initial-tab="permInitialTab /* surucu:d1 */"
|
|
5574
|
+
@close="showPerm = false; permInitialTab = undefined"
|
|
5046
5575
|
/>
|
|
5047
5576
|
|
|
5048
5577
|
<!-- Recently-opened tray. Anchored to the toolbar trigger via fixed
|
|
@@ -5108,6 +5637,7 @@ function closeRecoveryKey() {
|
|
|
5108
5637
|
|
|
5109
5638
|
<!-- cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) -->
|
|
5110
5639
|
<CommandPalette
|
|
5640
|
+
:initial-query="paletteSeed /* surucu:d1 */"
|
|
5111
5641
|
:open="showPalette"
|
|
5112
5642
|
:locale="locale"
|
|
5113
5643
|
:files="files"
|
|
@@ -5121,7 +5651,7 @@ function closeRecoveryKey() {
|
|
|
5121
5651
|
@navigate="(p: string) => load(p)"
|
|
5122
5652
|
@new-folder="showNewFolder = true"
|
|
5123
5653
|
@upload="triggerUpload"
|
|
5124
|
-
@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 */"
|
|
5125
5655
|
@open-trash="loadTrash"
|
|
5126
5656
|
@refresh="() => load()"
|
|
5127
5657
|
@go-up="goUp"
|