@brftech/filex-core 0.30.1 → 0.32.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 +37 -2
- package/dist/filex-core.js +10571 -8278
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +95 -87
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +548 -66
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1104 -26
- package/src/components/Breadcrumb.vue +4 -2
- package/src/components/CommandPalette.vue +18 -2
- package/src/components/E2eRecoveryUnlockModal.vue +193 -0
- package/src/components/EncryptedFolderModal.vue +10 -0
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +48 -0
- package/src/components/GridView.vue +85 -3
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +11 -1
- package/src/components/RecoveryKeyModal.vue +133 -0
- package/src/components/SecondaryPane.vue +15 -1
- package/src/components/SideNav.vue +329 -6
- package/src/components/StarButton.vue +27 -15
- package/src/components/Toolbar.vue +235 -49
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +83 -0
- package/src/composables/useKeyboardShortcuts.ts +7 -0
- package/src/index.ts +33 -1
- package/src/lib/e2ecrypto.ts +716 -70
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +103 -1
- package/src/lib/star.ts +42 -0
- package/src/lib/tags.ts +105 -0
- package/src/locales/en.ts +154 -2
- package/src/locales/tr.ts +154 -2
- package/src/modals/PermissionsModal.vue +18 -2
- package/src/styles/base.css +743 -0
- package/src/types/ExplorerConfig.ts +53 -1
- package/src/types/FileNode.ts +23 -0
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';
|
|
@@ -86,16 +94,30 @@ import { useTabs, type TabState } from './composables/useTabs';
|
|
|
86
94
|
/* /wiring:d1 */
|
|
87
95
|
/* wiring:e2 — uçtan uca şifreli klasörler (docs/E2E-ENCRYPTION.md) */
|
|
88
96
|
import EncryptedFolderModal from './components/EncryptedFolderModal.vue';
|
|
97
|
+
import RecoveryKeyModal from './components/RecoveryKeyModal.vue';
|
|
98
|
+
import E2eRecoveryUnlockModal from './components/E2eRecoveryUnlockModal.vue';
|
|
89
99
|
import {
|
|
90
100
|
createKeyRing,
|
|
91
|
-
|
|
101
|
+
createEncryptedFolder,
|
|
102
|
+
upgradeMarkerV1,
|
|
103
|
+
addEscrowSlot,
|
|
104
|
+
declineEscrowSlot,
|
|
105
|
+
escrowOfferState,
|
|
92
106
|
parseMarker,
|
|
93
|
-
|
|
107
|
+
unlockWithPassword,
|
|
108
|
+
unlockWithRecoveryKey,
|
|
109
|
+
unlockWithEscrowKey,
|
|
110
|
+
importEscrowPrivateKey,
|
|
111
|
+
markerHasRecovery,
|
|
112
|
+
escrowAvailability,
|
|
113
|
+
bytesToB64,
|
|
114
|
+
b64ToBytes,
|
|
94
115
|
encryptFile,
|
|
95
116
|
decryptFile,
|
|
96
117
|
hasMagic,
|
|
97
118
|
E2E_MARKER_NAME,
|
|
98
119
|
E2E_MAX_FILE_BYTES,
|
|
120
|
+
type E2eMarker,
|
|
99
121
|
} from './lib/e2ecrypto';
|
|
100
122
|
/* /wiring:e2 */
|
|
101
123
|
|
|
@@ -104,12 +126,17 @@ import {
|
|
|
104
126
|
panes or split view shows mismatched rows). */
|
|
105
127
|
import {
|
|
106
128
|
filterListing,
|
|
107
|
-
|
|
129
|
+
virtualSegmentLabel,
|
|
130
|
+
makeTagSegment,
|
|
131
|
+
tagOfPath,
|
|
132
|
+
VIRTUAL_SEGMENTS,
|
|
108
133
|
showHiddenFiles,
|
|
109
134
|
setShowHiddenFiles,
|
|
110
135
|
injectTrashRow,
|
|
111
136
|
hydrateTrashRow as hydrateTrashRowShared,
|
|
112
137
|
} from './lib/listing';
|
|
138
|
+
import { setNodeStarred } from './lib/star';
|
|
139
|
+
import { fetchAllTags, fetchTaggedRows, invalidateTagCache } from './lib/tags';
|
|
113
140
|
import { resolveTransfer, type TransferIntent } from './lib/transfer';
|
|
114
141
|
import {
|
|
115
142
|
activeNativeDrag,
|
|
@@ -310,17 +337,38 @@ const trashActive = computed(() => trashMode.value);
|
|
|
310
337
|
* pattern is trashMode's, generalised — including the part that matters most,
|
|
311
338
|
* that load() clears the mode, or the view sticks and every later navigation
|
|
312
339
|
* renders under the wrong heading. */
|
|
313
|
-
type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash';
|
|
340
|
+
type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash' | 'tag';
|
|
314
341
|
const navView = ref<NavView>('');
|
|
315
342
|
/** Where the view was entered from, so "up" goes back there. */
|
|
316
343
|
const navViewOrigin = ref<string>('');
|
|
317
|
-
/**
|
|
318
|
-
const
|
|
344
|
+
/** The tag being browsed while navView === 'tag' ('' otherwise). */
|
|
345
|
+
const navTag = ref<string>('');
|
|
346
|
+
/** Sentinel parked in `dirname` so the breadcrumb can label the view. The tag
|
|
347
|
+
* view's sentinel is built per tag (`makeTagSegment`) — see lib/listing. */
|
|
348
|
+
const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> = {
|
|
319
349
|
recent: '.recent',
|
|
320
350
|
starred: '.starred',
|
|
321
351
|
shared: '.shared',
|
|
322
352
|
};
|
|
323
353
|
|
|
354
|
+
/**
|
|
355
|
+
* A path that is a virtual view rather than a folder. Used by load() so a
|
|
356
|
+
* sentinel reaching it — a restored tab, a pasted `#.tag~invoices`, a reload,
|
|
357
|
+
* the breadcrumb's own crumb — opens the VIEW instead of asking the backend
|
|
358
|
+
* for a folder called `.starred` and landing on "not found". (That was already
|
|
359
|
+
* true of the four shipped views; the tag view would have inherited it.)
|
|
360
|
+
*/
|
|
361
|
+
function virtualViewOf(path: string): { kind: Exclude<NavView, ''>; tag: string } | null {
|
|
362
|
+
const clean = String(path ?? '').replace(/^\/+|\/+$/g, '');
|
|
363
|
+
if (!clean) return null;
|
|
364
|
+
const tag = tagOfPath(clean);
|
|
365
|
+
if (tag) return { kind: 'tag', tag };
|
|
366
|
+
const key = VIRTUAL_SEGMENTS[clean];
|
|
367
|
+
if (!key) return null;
|
|
368
|
+
const kind = clean.slice(1) as Exclude<NavView, '' | 'tag'>;
|
|
369
|
+
return { kind, tag: '' };
|
|
370
|
+
}
|
|
371
|
+
|
|
324
372
|
// When the caller can see exactly ONE storage, the multi-storage root is a
|
|
325
373
|
// one-row list that carries no information — the user clicks through it every
|
|
326
374
|
// single time. Treat that storage as the floor instead: open it directly and
|
|
@@ -448,6 +496,80 @@ function onStarChange(n: FileNode, value: boolean) {
|
|
|
448
496
|
starredIds.value = next;
|
|
449
497
|
}
|
|
450
498
|
|
|
499
|
+
/* === yildiz:s1 — starring as an ACTION ================================
|
|
500
|
+
* The star shipped as an indicator in ONE view: `StarButton` was rendered by
|
|
501
|
+
* ListView and nowhere else, so a user in grid view (the mode the navigation
|
|
502
|
+
* panel's own screenshots show) had a Starred view with no way to fill it.
|
|
503
|
+
* It is a verb, like tagging — so it is a menu entry beside Tags, a chip on
|
|
504
|
+
* every card, and a key.
|
|
505
|
+
*
|
|
506
|
+
* ⚠ ONE implementation of the request: `lib/star.ts`. StarButton calls it,
|
|
507
|
+
* this calls it. A menu cannot render a component, but it must not grow its
|
|
508
|
+
* own fetch either — that is the second path that drifts.
|
|
509
|
+
*/
|
|
510
|
+
/** Which of `targets` can carry a star: files the server knows by id.
|
|
511
|
+
*
|
|
512
|
+
* Empty when the identity surfaces are suppressed, which is the one
|
|
513
|
+
* chokepoint the context menu, the toolbar and the keyboard action all go
|
|
514
|
+
* through — so the affordance disappears everywhere at once rather than in
|
|
515
|
+
* the two places somebody remembered. Offering to star a file while the
|
|
516
|
+
* Starred view is hidden would write into a list the person cannot open,
|
|
517
|
+
* and under a shared app token that list belongs to everyone at once. */
|
|
518
|
+
function starableNodes(targets: FileNode[]): FileNode[] {
|
|
519
|
+
if (!identitySurfaces.value) return [];
|
|
520
|
+
return targets.filter((n) => typeof n.id === 'number' && n.type === 'file');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** True when EVERY starable target is starred — i.e. the action reads
|
|
524
|
+
* "Unstar". A mixed selection reads "Star" and stars the rest, which is the
|
|
525
|
+
* behaviour that needs no explanation. */
|
|
526
|
+
function selectionAllStarred(targets: FileNode[]): boolean {
|
|
527
|
+
const list = starableNodes(targets);
|
|
528
|
+
return list.length > 0 && list.every((n) => starredIds.value.has(n.id as number));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Toggle the star on a selection. Optimistic like the button, and rolled back
|
|
533
|
+
* per node on failure — a partial failure must not leave the set lying about
|
|
534
|
+
* what the server holds.
|
|
535
|
+
*/
|
|
536
|
+
async function toggleStar(targets: FileNode[]) {
|
|
537
|
+
const list = starableNodes(targets);
|
|
538
|
+
if (list.length === 0) return;
|
|
539
|
+
const next = !selectionAllStarred(list);
|
|
540
|
+
const opts = {
|
|
541
|
+
apiBase: props.config.apiBase ?? '',
|
|
542
|
+
authHeaders: () => buildAuthHeaders(),
|
|
543
|
+
authCredentials: api.credentialsMode(),
|
|
544
|
+
};
|
|
545
|
+
const set = new Set(starredIds.value);
|
|
546
|
+
for (const n of list) {
|
|
547
|
+
if (next) set.add(n.id as number);
|
|
548
|
+
else set.delete(n.id as number);
|
|
549
|
+
}
|
|
550
|
+
starredIds.value = set;
|
|
551
|
+
let failed = 0;
|
|
552
|
+
await Promise.all(
|
|
553
|
+
list.map(async (n) => {
|
|
554
|
+
try {
|
|
555
|
+
await setNodeStarred(n.id as number, next, opts);
|
|
556
|
+
} catch {
|
|
557
|
+
failed += 1;
|
|
558
|
+
const rollback = new Set(starredIds.value);
|
|
559
|
+
if (next) rollback.delete(n.id as number);
|
|
560
|
+
else rollback.add(n.id as number);
|
|
561
|
+
starredIds.value = rollback;
|
|
562
|
+
}
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
if (failed > 0) flashToast(t('star.failed'));
|
|
566
|
+
// Starring is what fills the Starred view; if that IS the view on screen,
|
|
567
|
+
// an unstar has to remove the row instead of leaving a listing that
|
|
568
|
+
// disagrees with its own heading.
|
|
569
|
+
if (navView.value === 'starred') await loadNavView('starred');
|
|
570
|
+
}
|
|
571
|
+
/* === /yildiz:s1 === */
|
|
572
|
+
|
|
451
573
|
async function markRecent(n: FileNode) {
|
|
452
574
|
if (typeof n.id !== 'number') return;
|
|
453
575
|
try {
|
|
@@ -470,6 +592,17 @@ function openTagPickerFor(n: FileNode) {
|
|
|
470
592
|
showTagPicker.value = true;
|
|
471
593
|
}
|
|
472
594
|
|
|
595
|
+
/* etiket:t1 — the user just changed a node's tags, so the cached "every tag
|
|
596
|
+
* that exists" list is wrong RIGHT NOW, which is the only staleness anybody
|
|
597
|
+
* notices. Drop it and re-ask; if a tag view is on screen, refresh it too —
|
|
598
|
+
* removing a file's tag has to remove it from the listing that is named after
|
|
599
|
+
* that tag. */
|
|
600
|
+
function onNodeTagsChanged() {
|
|
601
|
+
invalidateTagCache();
|
|
602
|
+
void loadNavTags(true);
|
|
603
|
+
if (navView.value === 'tag' && navTag.value) void loadTagView(navTag.value);
|
|
604
|
+
}
|
|
605
|
+
|
|
473
606
|
function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
|
|
474
607
|
// RecentlyOpened emits the bare row — synthesize a FileNode shaped
|
|
475
608
|
// enough for openNode to route into the editor / preview.
|
|
@@ -586,7 +719,17 @@ function closeInspector() {
|
|
|
586
719
|
* the "one behaviour on one surface" split this shared package exists to
|
|
587
720
|
* prevent. */
|
|
588
721
|
const uiProfile = computed(() => props.config.uiProfile ?? 'standard');
|
|
589
|
-
|
|
722
|
+
/**
|
|
723
|
+
* ⚠ `drive` answers TRUE here. It is a superset of `simple`, so every question
|
|
724
|
+
* `simple` already answers ("one pane?", "no tab strip?", "list and grid
|
|
725
|
+
* only?") must keep the same answer under it — asking `=== 'simple'` in those
|
|
726
|
+
* places is how the drive profile would silently grow a split pane the day
|
|
727
|
+
* somebody adds a fourth condition. `driveShell` is only for what `drive` adds
|
|
728
|
+
* on TOP.
|
|
729
|
+
*/
|
|
730
|
+
const simpleUi = computed(() => uiProfile.value === 'simple' || uiProfile.value === 'drive');
|
|
731
|
+
/* === surucu:d1 — the Drive shell (GitHub #14, the reporter's mockups) ===== */
|
|
732
|
+
const driveShell = computed(() => uiProfile.value === 'drive');
|
|
590
733
|
|
|
591
734
|
const SIDENAV_LS_KEY = 'filex.sidenav';
|
|
592
735
|
const sideNavExpanded = ref<boolean>(
|
|
@@ -626,6 +769,31 @@ const sideNavEnabled = computed(() => props.config.sideNav ?? !rootPathProp);
|
|
|
626
769
|
const navVisible = computed(
|
|
627
770
|
() => sideNavEnabled.value && (isNarrow.value ? navDrawerOpen.value : true),
|
|
628
771
|
);
|
|
772
|
+
/**
|
|
773
|
+
* Is the navigation panel already offering Trash as a destination?
|
|
774
|
+
*
|
|
775
|
+
* ⚠ Keyed to `sideNavEnabled`, NOT to `navVisible`. Three cases decided here,
|
|
776
|
+
* and each one is a judgement about whether the person has a way to the bin
|
|
777
|
+
* that is not this row:
|
|
778
|
+
*
|
|
779
|
+
* - collapsed to the icon rail → OFFERING. The entry is still rendered, still
|
|
780
|
+
* one click, still labelled for a screen reader; only its text is gone.
|
|
781
|
+
* - the drawer under 560px, closed → OFFERING. The toolbar's panel toggle is
|
|
782
|
+
* on screen at every width, so the destination is one tap away. Keying this
|
|
783
|
+
* to `navVisible` instead would add and remove a row from the LISTING every
|
|
784
|
+
* time somebody opened or closed the drawer — the folder changing under
|
|
785
|
+
* them for a reason that has nothing to do with the folder.
|
|
786
|
+
* - no panel at all (`sideNav: false`, or the default under `rootPath`) → NOT
|
|
787
|
+
* offering, and the row stays. That is the case it was invented for: a
|
|
788
|
+
* listing with no other door to the bin.
|
|
789
|
+
*
|
|
790
|
+
* `trashVisible: false` is handled inside the helper and outranks all of it —
|
|
791
|
+
* it means no Trash anywhere, panel entry included.
|
|
792
|
+
*/
|
|
793
|
+
const navOffersTrash = computed(
|
|
794
|
+
() => sideNavEnabled.value && props.config.trashVisible !== false,
|
|
795
|
+
);
|
|
796
|
+
|
|
629
797
|
/** What the toolbar toggle reports as pressed. */
|
|
630
798
|
const navToggleOn = computed(() =>
|
|
631
799
|
isNarrow.value ? navDrawerOpen.value : sideNavExpanded.value,
|
|
@@ -659,6 +827,32 @@ const sharedStorageNames = ref<string[]>([]);
|
|
|
659
827
|
* storage form), and /api/tokens caps every scope against the caller's own role.
|
|
660
828
|
*/
|
|
661
829
|
const connectionsEnabled = computed(() => props.config.connections ?? !simpleUi.value);
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Is this caller an integration rather than a person (backend migration 00030)?
|
|
833
|
+
*
|
|
834
|
+
* A filex API token authenticates AS its owner, so from here a shared embed
|
|
835
|
+
* token and somebody's own token are indistinguishable — only the server knows
|
|
836
|
+
* which kind it is, and it says so in `capabilities.caller_kind`. A host that
|
|
837
|
+
* already knows can say so with `config.callerKind`, which wins — purely to
|
|
838
|
+
* spare the flash of a Starred row that appears and then disappears when
|
|
839
|
+
* capabilities land.
|
|
840
|
+
*
|
|
841
|
+
* ⚠ Defaults to "person" in every unknown state — no config, capabilities not
|
|
842
|
+
* back yet, or a server too old to answer. The cost of guessing wrong that way
|
|
843
|
+
* is one row too many for a moment; guessing the other way would hide Recent
|
|
844
|
+
* and API keys from every ordinary user of every older server.
|
|
845
|
+
*/
|
|
846
|
+
const callerIsApp = computed(
|
|
847
|
+
() => (props.config.callerKind ?? capabilitiesData.value?.caller_kind) === 'app',
|
|
848
|
+
);
|
|
849
|
+
/**
|
|
850
|
+
* The identity-bearing surfaces: API keys, Recent, Starred, Shared with me.
|
|
851
|
+
* ⚠ Suppression is per token KIND, never per role — a viewer is still a person
|
|
852
|
+
* with their own recents. And it is not the whole panel: Upload, the storages,
|
|
853
|
+
* Trash and "How to connect" stay useful inside an embed.
|
|
854
|
+
*/
|
|
855
|
+
const identitySurfaces = computed(() => !callerIsApp.value);
|
|
662
856
|
const showConnections = ref(false);
|
|
663
857
|
const showTokens = ref(false);
|
|
664
858
|
function openConnections() {
|
|
@@ -666,6 +860,10 @@ function openConnections() {
|
|
|
666
860
|
showConnections.value = true;
|
|
667
861
|
}
|
|
668
862
|
function openTokens() {
|
|
863
|
+
// Belt and braces: the panel entry is gone for an app token, but a host may
|
|
864
|
+
// also open this overlay from its own chrome, and /api/tokens would answer
|
|
865
|
+
// that caller with a 403 it has nowhere to show.
|
|
866
|
+
if (callerIsApp.value) return;
|
|
669
867
|
showConnections.value = false;
|
|
670
868
|
showTokens.value = true;
|
|
671
869
|
}
|
|
@@ -696,6 +894,130 @@ watchEffect(() => {
|
|
|
696
894
|
if (simpleUi.value && viewMode.value === 'gallery') viewMode.value = 'grid';
|
|
697
895
|
});
|
|
698
896
|
|
|
897
|
+
/* === surucu:d1 — the filter row =========================================
|
|
898
|
+
* Three chips over the listing in hand: Type · Modified · Size.
|
|
899
|
+
*
|
|
900
|
+
* ⚠ CLIENT-SIDE, and that is the honest place for them, not a shortcut. The
|
|
901
|
+
* listing endpoint reads exactly six parameters (`action`, `path`, `filter`,
|
|
902
|
+
* `storage`, `parent`, `cache`) and has no `limit`/`offset` either — so the
|
|
903
|
+
* rows in hand ARE the folder, and filtering them here answers the whole
|
|
904
|
+
* question rather than "the first page of it". Wiring a chip to a `min_size`
|
|
905
|
+
* the server never reads would look identical and change nothing.
|
|
906
|
+
*
|
|
907
|
+
* ⚠ Reset on navigation. A filter that survives a folder change makes the next
|
|
908
|
+
* folder look empty, and the reason is off-screen the moment you scroll.
|
|
909
|
+
*/
|
|
910
|
+
const driveFilters = ref<DriveFilters>({ ...EMPTY_FILTERS });
|
|
911
|
+
const filtersOn = computed(() => driveShell.value && filtersActive(driveFilters.value));
|
|
912
|
+
/** What the views render. Identical reference to `files` when nothing is set. */
|
|
913
|
+
const displayFiles = computed<FileNode[]>(() =>
|
|
914
|
+
driveShell.value ? applyFilters(files.value, driveFilters.value) : files.value,
|
|
915
|
+
);
|
|
916
|
+
function clearDriveFilters() {
|
|
917
|
+
setDriveFilters({ ...EMPTY_FILTERS });
|
|
918
|
+
}
|
|
919
|
+
function setDriveFilters(v: DriveFilters) {
|
|
920
|
+
driveFilters.value = v;
|
|
921
|
+
// A selection the filter just hid would still be what Delete acts on, with
|
|
922
|
+
// nothing on screen to say so.
|
|
923
|
+
selection.clear();
|
|
924
|
+
}
|
|
925
|
+
watch(
|
|
926
|
+
() => `${currentPath.value}|${navView.value}`,
|
|
927
|
+
() => {
|
|
928
|
+
if (filtersActive(driveFilters.value)) driveFilters.value = { ...EMPTY_FILTERS };
|
|
929
|
+
},
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* surucu:d1 — what the header field says it will search: the folder you are
|
|
934
|
+
* standing in, by name. At a storage root that is the storage; in a panel view
|
|
935
|
+
* ("Recent", a tag) it is that view's own name, because searching from there
|
|
936
|
+
* searches what is on screen.
|
|
937
|
+
*/
|
|
938
|
+
const driveScopeLabel = computed(() => {
|
|
939
|
+
if (navView.value === 'tag') return navTag.value;
|
|
940
|
+
if (navView.value) return t(`sidenav.${navView.value}`);
|
|
941
|
+
const rel = currentPath.value.replace(/\/+$/, '');
|
|
942
|
+
const last = rel.split('/').filter(Boolean).pop();
|
|
943
|
+
return last || adapter.value || '';
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* surucu:d1 — the ⌘K escalation. The header field searches THIS folder (it
|
|
948
|
+
* sets `searchQuery`, which the loader answers with `action=search`); this
|
|
949
|
+
* hands the same words to the command palette, which is where "everywhere",
|
|
950
|
+
* the saved searches and the commands live. One box, one shortcut, and the
|
|
951
|
+
* hint printed on the box does what it says.
|
|
952
|
+
*/
|
|
953
|
+
const paletteSeed = ref('');
|
|
954
|
+
function openPaletteWith(q: string) {
|
|
955
|
+
paletteSeed.value = q;
|
|
956
|
+
showPalette.value = true;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* surucu:d1 — "Request files" from the New menu: the access modal on the
|
|
961
|
+
* CURRENT folder, opened on its file-drop tab. The modal already owns that
|
|
962
|
+
* surface; this only gives it a target, since the folder you are standing in
|
|
963
|
+
* is not a selected row and has no FileNode of its own.
|
|
964
|
+
*/
|
|
965
|
+
const permInitialTab = ref<'perms' | 'share' | 'drop' | undefined>(undefined);
|
|
966
|
+
function openFileRequest() {
|
|
967
|
+
const path = qualify(currentPath.value);
|
|
968
|
+
if (!path) return;
|
|
969
|
+
const segs = currentPath.value.split('/').filter(Boolean);
|
|
970
|
+
permTarget.value = {
|
|
971
|
+
path,
|
|
972
|
+
basename: segs.length ? segs[segs.length - 1] : adapter.value,
|
|
973
|
+
type: 'dir',
|
|
974
|
+
};
|
|
975
|
+
permInitialTab.value = 'drop';
|
|
976
|
+
showPerm.value = true;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** surucu:d1 — a link minted from the details panel; same event the share
|
|
980
|
+
* dialog emits, so a host listening for `share-created` hears both. */
|
|
981
|
+
function onInspectorShareCreated(payload: { path: string; url: string }) {
|
|
982
|
+
emit('share-created', { path: payload.path, url: payload.url, pin: null });
|
|
983
|
+
flashToast(t('inspector.copied'));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/* === surucu:d1 — the storage line under the navigation ==================
|
|
987
|
+
* Fetched once per mount, and ONLY in the drive shell: no other profile draws
|
|
988
|
+
* it, and an explorer that has drawn this panel for a year should not start
|
|
989
|
+
* making a request it has no use for. `quotaMe()` answers null for a server
|
|
990
|
+
* without the route or a caller without a person behind it, and null renders
|
|
991
|
+
* nothing at all.
|
|
992
|
+
*/
|
|
993
|
+
const quotaSnapshot = ref<{ used: number; total: number; unlimited: boolean } | null>(null);
|
|
994
|
+
async function loadQuota() {
|
|
995
|
+
if (!driveShell.value || !identitySurfaces.value) {
|
|
996
|
+
quotaSnapshot.value = null;
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const q = await api.quotaMe();
|
|
1000
|
+
if (!q) {
|
|
1001
|
+
quotaSnapshot.value = null;
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const unlimited = !!q.unlimited || q.quota_bytes <= 0;
|
|
1005
|
+
// ⚠ No ceiling AND nothing counted = nothing to say, so say nothing.
|
|
1006
|
+
//
|
|
1007
|
+
// Measured, not guessed: `used_bytes` is `SUM(nodes.size) WHERE owner_id = me`
|
|
1008
|
+
// and `nodes.owner_id` is set by the UPLOAD path only — it is nil for every
|
|
1009
|
+
// file a storage sync discovered (handlers/shared.go). On an install whose
|
|
1010
|
+
// drives were mounted rather than uploaded into, the honest figure is
|
|
1011
|
+
// therefore "0 B", and a line reading "0 B used" under a drive visibly full
|
|
1012
|
+
// of files reads as a broken widget, not as a fact about quota. With a quota
|
|
1013
|
+
// set the line still earns its place — the ceiling is real and uploads count
|
|
1014
|
+
// against it — so only the no-quota-no-usage case is dropped.
|
|
1015
|
+
quotaSnapshot.value =
|
|
1016
|
+
unlimited && q.used_bytes <= 0
|
|
1017
|
+
? null
|
|
1018
|
+
: { used: q.used_bytes, total: q.quota_bytes, unlimited };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
699
1021
|
/**
|
|
700
1022
|
* nodeRowToFileNode — the starred / recently-opened endpoints answer with raw
|
|
701
1023
|
* node rows (relative `path`, numeric `storage_id`), not the listing shape.
|
|
@@ -776,17 +1098,27 @@ async function fetchNavRows(kind: 'recent' | 'starred' | 'shared'): Promise<File
|
|
|
776
1098
|
/** Open one of the panel views in the main pane. */
|
|
777
1099
|
async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
778
1100
|
closeNavDrawer();
|
|
1101
|
+
if (kind === 'tag') {
|
|
1102
|
+
// The tag view needs a name; the panel calls loadTagView directly.
|
|
1103
|
+
if (navTag.value) await loadTagView(navTag.value);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
779
1106
|
if (kind === 'trash') {
|
|
780
1107
|
await loadTrash();
|
|
781
1108
|
// ⚠ After loadTrash, not before: loadTrash goes through load()-adjacent
|
|
782
1109
|
// state and the mode has to be the last word, or the panel row for Trash
|
|
783
1110
|
// never lights up.
|
|
784
1111
|
navView.value = 'trash';
|
|
1112
|
+
navTag.value = '';
|
|
785
1113
|
return;
|
|
786
1114
|
}
|
|
787
1115
|
loading.value = true;
|
|
788
|
-
|
|
1116
|
+
// ⚠ Only when coming from a real folder. Stepping Starred → Recent used to
|
|
1117
|
+
// record `.starred` as the origin, so "up" out of Recent landed in Starred
|
|
1118
|
+
// and the user had to press it twice to get back to their files.
|
|
1119
|
+
if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
|
|
789
1120
|
navView.value = kind;
|
|
1121
|
+
navTag.value = '';
|
|
790
1122
|
trashMode.value = false;
|
|
791
1123
|
e2eRoot.value = '';
|
|
792
1124
|
selection.clear();
|
|
@@ -808,6 +1140,78 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
808
1140
|
}
|
|
809
1141
|
}
|
|
810
1142
|
|
|
1143
|
+
/* === etiket:t1 — the tag view ==========================================
|
|
1144
|
+
* "Tagged files should show up inside the tag." A tag is not a folder: its
|
|
1145
|
+
* files live all over the tree and in every storage, so this is the same
|
|
1146
|
+
* shape as Starred — a per-user endpoint answering with node rows, each
|
|
1147
|
+
* carrying its own qualified path, so opening one navigates normally.
|
|
1148
|
+
*
|
|
1149
|
+
* The sentinel is `.tag~<name>` (lib/listing). Every surface that renders a
|
|
1150
|
+
* path segment — tab strip, breadcrumb, inspector heading, the address-bar
|
|
1151
|
+
* hash — goes through `virtualSegmentLabel`, so none of them can print the
|
|
1152
|
+
* sentinel the way the strip once printed `.shared`.
|
|
1153
|
+
*/
|
|
1154
|
+
async function loadTagView(tag: string) {
|
|
1155
|
+
closeNavDrawer();
|
|
1156
|
+
const name = String(tag ?? '').trim();
|
|
1157
|
+
if (!name) return;
|
|
1158
|
+
loading.value = true;
|
|
1159
|
+
if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
|
|
1160
|
+
navView.value = 'tag';
|
|
1161
|
+
navTag.value = name;
|
|
1162
|
+
trashMode.value = false;
|
|
1163
|
+
e2eRoot.value = '';
|
|
1164
|
+
selection.clear();
|
|
1165
|
+
try {
|
|
1166
|
+
const rows = await fetchTaggedRows(name, {
|
|
1167
|
+
apiBase: props.config.apiBase ?? '',
|
|
1168
|
+
authHeaders: () => buildAuthHeaders(),
|
|
1169
|
+
authCredentials: api.credentialsMode(),
|
|
1170
|
+
});
|
|
1171
|
+
files.value = rows.map(nodeRowToFileNode).filter((n): n is FileNode => n !== null);
|
|
1172
|
+
const seg = makeTagSegment(name);
|
|
1173
|
+
dirname.value = seg;
|
|
1174
|
+
currentPath.value = seg;
|
|
1175
|
+
// Spans every storage, like Starred/Recent/Shared — so no storage crumb.
|
|
1176
|
+
adapter.value = '';
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1179
|
+
files.value = [];
|
|
1180
|
+
emit('error', { message: msg, context: { op: `nav-view:tag:${name}` } });
|
|
1181
|
+
flashToast(msg);
|
|
1182
|
+
} finally {
|
|
1183
|
+
loading.value = false;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* The tags that exist, for the panel's Tags section.
|
|
1189
|
+
*
|
|
1190
|
+
* ⚠ WHEN this loads was a deliberate decision, not a default: `tags/all` is a
|
|
1191
|
+
* distinct-scan and the panel renders in every mounted explorer (a page can
|
|
1192
|
+
* hold several). It is therefore NOT fetched during mount — it is asked for
|
|
1193
|
+
* once the first listing is on screen, through a module-level cache that
|
|
1194
|
+
* dedupes concurrent callers and reuses the answer for a minute
|
|
1195
|
+
* (lib/tags.ts). N explorers on a page cost ONE query; a navigation costs
|
|
1196
|
+
* none. The cache is dropped the instant the user edits tags, which is the
|
|
1197
|
+
* only staleness anybody can notice.
|
|
1198
|
+
*/
|
|
1199
|
+
const navTags = ref<string[]>([]);
|
|
1200
|
+
const navTagsLoaded = ref(false);
|
|
1201
|
+
|
|
1202
|
+
async function loadNavTags(force = false) {
|
|
1203
|
+
if (!navVisible.value) return; // no panel → nobody can see the list
|
|
1204
|
+
navTags.value = await fetchAllTags({
|
|
1205
|
+
apiBase: props.config.apiBase ?? '',
|
|
1206
|
+
authHeaders: () => buildAuthHeaders(),
|
|
1207
|
+
authCredentials: api.credentialsMode(),
|
|
1208
|
+
force,
|
|
1209
|
+
});
|
|
1210
|
+
navTagsLoaded.value = true;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/* === /etiket:t1 === */
|
|
1214
|
+
|
|
811
1215
|
/** Panel to a storage root. */
|
|
812
1216
|
function openNavStorage(name: string) {
|
|
813
1217
|
closeNavDrawer();
|
|
@@ -840,7 +1244,11 @@ const inspectorDirLabel = computed(() => {
|
|
|
840
1244
|
if (trashMode.value) return t('node.trash');
|
|
841
1245
|
const p = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
842
1246
|
if (!p) return adapter.value || t('breadcrumb.root');
|
|
843
|
-
|
|
1247
|
+
const seg = p.split('/').pop() || p;
|
|
1248
|
+
/* etiket:t1 — a THIRD surface that renders a path segment, and it had the
|
|
1249
|
+
same hole the tab strip did: in a virtual view the details panel headed
|
|
1250
|
+
itself ".starred". Same shared resolver, so it cannot drift again. */
|
|
1251
|
+
return virtualSegmentLabel(seg, t) || seg;
|
|
844
1252
|
});
|
|
845
1253
|
function onInspectorManage(n: FileNode) {
|
|
846
1254
|
permTarget.value = n;
|
|
@@ -1025,6 +1433,48 @@ function toggleHiddenFiles() {
|
|
|
1025
1433
|
}
|
|
1026
1434
|
|
|
1027
1435
|
async function load(path?: string) {
|
|
1436
|
+
/* === etiket:t1 — a sentinel is a VIEW, not a folder ===================
|
|
1437
|
+
* A restored tab, a reload on `#.trash` / `#.starred` / `#.tag~invoices`,
|
|
1438
|
+
* or the breadcrumb crumb for the view you are standing in all arrive here
|
|
1439
|
+
* as a plain path. Without this they went to the backend as a FOLDER NAME
|
|
1440
|
+
* and came back 404, so a view that exists and is merely empty greeted the
|
|
1441
|
+
* user with "Folder not found — this folder does not exist, was moved, or
|
|
1442
|
+
* you do not have access to it" (measured on `#.trash` and `#.starred`,
|
|
1443
|
+
* v0.30.1). The trash is not missing; it is empty, and it has a state that
|
|
1444
|
+
* says so.
|
|
1445
|
+
*
|
|
1446
|
+
* ⚠ Through `virtualViewOf` → the ONE map in lib/listing.ts, never a second
|
|
1447
|
+
* list of names here: two copies of that mapping are what printed `.shared`
|
|
1448
|
+
* in the tab strip two days ago, and the tag view adds a dynamic third kind.
|
|
1449
|
+
*
|
|
1450
|
+
* ⚠ Only a sentinel this build KNOWS is intercepted. Anything else keeps
|
|
1451
|
+
* going — a user may genuinely own a folder called `.config`, and with
|
|
1452
|
+
* hidden files shown they can open it.
|
|
1453
|
+
*
|
|
1454
|
+
* ⚠ And only when the view is actually REACHABLE here. Under `rootPath` the
|
|
1455
|
+
* panel is off on purpose (the views span storages and would list files
|
|
1456
|
+
* outside the folder the embed was confined to), so a stale hash from
|
|
1457
|
+
* another deployment must not smuggle them in: it falls back to the root —
|
|
1458
|
+
* which the floor clamp below then turns into the confined folder.
|
|
1459
|
+
*
|
|
1460
|
+
* ⚠ No recursion: neither loader calls load(), and the fallback passes '',
|
|
1461
|
+
* which is not a sentinel.
|
|
1462
|
+
*/
|
|
1463
|
+
const asView = virtualViewOf(path ?? currentPath.value ?? '');
|
|
1464
|
+
if (asView) {
|
|
1465
|
+
const reachable =
|
|
1466
|
+
asView.kind === 'trash' ? props.config.trashVisible !== false : sideNavEnabled.value;
|
|
1467
|
+
if (!reachable) {
|
|
1468
|
+
// Clear it explicitly: if the fallback lands on the path we are already
|
|
1469
|
+
// on, watch(currentPath) never fires and the dead hash would survive to
|
|
1470
|
+
// the next reload (the same trap leaveNotFound documents).
|
|
1471
|
+
writePersistedPath('');
|
|
1472
|
+
return await load('');
|
|
1473
|
+
}
|
|
1474
|
+
if (asView.kind === 'tag') await loadTagView(asView.tag);
|
|
1475
|
+
else await loadNavView(asView.kind);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1028
1478
|
loading.value = true;
|
|
1029
1479
|
// Any normal navigation exits trash mode (the trash view is entered only
|
|
1030
1480
|
// by opening the virtual `.trash` row, which calls loadTrash()).
|
|
@@ -1033,6 +1483,7 @@ async function load(path?: string) {
|
|
|
1033
1483
|
this the mode sticks and the breadcrumb keeps saying "Starred" over a
|
|
1034
1484
|
folder listing. */
|
|
1035
1485
|
navView.value = '';
|
|
1486
|
+
navTag.value = '';
|
|
1036
1487
|
let requested = path ?? currentPath.value ?? '';
|
|
1037
1488
|
try {
|
|
1038
1489
|
notFoundPath.value = '';
|
|
@@ -1077,7 +1528,16 @@ async function load(path?: string) {
|
|
|
1077
1528
|
files.value = filterListing(resp.files);
|
|
1078
1529
|
// Inject virtual `.trash` entry at root only — shared helper so the
|
|
1079
1530
|
// split-view secondary pane shows the exact same row (no row-offset).
|
|
1080
|
-
|
|
1531
|
+
// ⚠ …and NOT into a search result. The search response's `dirname` is the
|
|
1532
|
+
// scope it searched, so it is the storage root exactly as an `index` of
|
|
1533
|
+
// that folder would be; only this call site knows which of the two it just
|
|
1534
|
+
// asked for.
|
|
1535
|
+
if (
|
|
1536
|
+
injectTrashRow(files.value, resp.adapter, resp.dirname, props.config.trashVisible !== false, {
|
|
1537
|
+
isSearchResult: !!searchQuery.value,
|
|
1538
|
+
navOffersTrash: navOffersTrash.value,
|
|
1539
|
+
})
|
|
1540
|
+
) {
|
|
1081
1541
|
void hydrateTrashRowShared(files.value, resp.adapter, api);
|
|
1082
1542
|
}
|
|
1083
1543
|
// currentPath is the user-facing form: `s3-test/example` in
|
|
@@ -1376,6 +1836,28 @@ onMounted(async () => {
|
|
|
1376
1836
|
/* gezinti:g1 — which storages are grant-only, so the panel can mark them
|
|
1377
1837
|
before anybody opens the shared view. */
|
|
1378
1838
|
void loadSharedStorages();
|
|
1839
|
+
/* etiket:t1 — the panel's tag list. AFTER the first listing has been
|
|
1840
|
+
awaited above, never racing it: `tags/all` is a distinct-scan and the
|
|
1841
|
+
folder the user asked for is the only thing on the critical path. The
|
|
1842
|
+
module-level cache in lib/tags.ts means several explorers on one page
|
|
1843
|
+
still cost a single query. */
|
|
1844
|
+
void loadNavTags();
|
|
1845
|
+
/* surucu:d1 — the storage line. After the listing, like the tag list: it is
|
|
1846
|
+
a status, and the folder somebody asked for is the critical path. */
|
|
1847
|
+
void loadQuota();
|
|
1848
|
+
/* ⚠ The panel is not always on screen at mount. Below 560px it is a DRAWER
|
|
1849
|
+
that starts closed, so `navVisible` is false and the call above returns
|
|
1850
|
+
without asking for anything — measured at 390px: the drawer opened with
|
|
1851
|
+
no Tags section at all. Ask again the first time the panel appears.
|
|
1852
|
+
⚠ Registered HERE and not beside loadNavTags: `watch` evaluates its
|
|
1853
|
+
source immediately, `navVisible` reads `isNarrow`, and `isNarrow` is
|
|
1854
|
+
declared further down the file — so a watcher created at setup time threw
|
|
1855
|
+
"Cannot access 'isNarrow' before initialization" and took the whole
|
|
1856
|
+
explorer down with it (measured: blank pane, two TDZ errors in the
|
|
1857
|
+
console). In onMounted every ref exists. */
|
|
1858
|
+
watch(navVisible, (visible) => {
|
|
1859
|
+
if (visible && !navTagsLoaded.value) void loadNavTags();
|
|
1860
|
+
});
|
|
1379
1861
|
if (hashPersistEnabled()) {
|
|
1380
1862
|
window.addEventListener('hashchange', onHashChange);
|
|
1381
1863
|
}
|
|
@@ -1514,6 +1996,11 @@ useKeyboardShortcuts(rootEl, {
|
|
|
1514
1996
|
onGoUp: () => (paneIsActive.value ? splitPaneRef.value?.goUp() : goUp()) /* wiring:d1 pane-route */,
|
|
1515
1997
|
/* cila:c wiring */
|
|
1516
1998
|
onPathJump: () => {
|
|
1999
|
+
/* surucu:d1 — the shortcut from anywhere else opens a BLANK palette. The
|
|
2000
|
+
seed belongs to the drive header's field; leaving the last one in place
|
|
2001
|
+
would reopen the palette pre-filled with a query the user has since
|
|
2002
|
+
moved on from. */
|
|
2003
|
+
paletteSeed.value = '';
|
|
1517
2004
|
showPalette.value = true;
|
|
1518
2005
|
},
|
|
1519
2006
|
onShowHelp: () => {
|
|
@@ -1522,6 +2009,7 @@ useKeyboardShortcuts(rootEl, {
|
|
|
1522
2009
|
/* /cila:c wiring */
|
|
1523
2010
|
onQuickLook: () => quickLookToggle() /* wiring:c2 */,
|
|
1524
2011
|
onToggleHidden: () => toggleHiddenFiles(),
|
|
2012
|
+
onStar: () => void toggleStar(selection.nodes.value) /* yildiz:s1 */,
|
|
1525
2013
|
onToggleInspector: () => toggleInspector() /* koru:k1 */,
|
|
1526
2014
|
/* wiring:d1 — sekme aksiyonları (registry: tab-new/close/next/prev) */
|
|
1527
2015
|
onTabNew: () => newTabHere(),
|
|
@@ -2033,6 +2521,9 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2033
2521
|
const isFile = single && sel[0]?.type === 'file';
|
|
2034
2522
|
const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
|
|
2035
2523
|
const singleHasId = single && typeof sel[0]?.id === 'number';
|
|
2524
|
+
/* yildiz:s1 */
|
|
2525
|
+
const canStar = starableNodes(sel).length > 0;
|
|
2526
|
+
const allStarred = selectionAllStarred(sel);
|
|
2036
2527
|
const copyIdLabel = locale.value === 'en' ? 'Copy node id' : "Node id'yi kopyala";
|
|
2037
2528
|
// RBAC: gate mutating actions when the caller lacks edit on the target. The
|
|
2038
2529
|
// "İzinler" (permissions) action shows only for owners on RBAC-on storages.
|
|
@@ -2058,7 +2549,17 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2058
2549
|
{ key: 'cut', label: t('ctx.cut'), icon: '✂', hidden: !any || !w, disabled: !any },
|
|
2059
2550
|
{ key: 'copy', label: t('ctx.copy'), icon: '❐', hidden: !any, disabled: !any },
|
|
2060
2551
|
{ key: 'paste', label: t('ctx.paste'), icon: '📋', hidden: !w, disabled: !clipboard.value.mode },
|
|
2061
|
-
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId },
|
|
2552
|
+
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
|
|
2553
|
+
/* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
|
|
2554
|
+
Beside Tags on purpose: they are the same kind of verb, and this is the
|
|
2555
|
+
ONLY star a grid/gallery user reaches with the keyboard. Works on a
|
|
2556
|
+
multi-selection; the label follows the selection's state. */
|
|
2557
|
+
{
|
|
2558
|
+
key: 'star',
|
|
2559
|
+
label: allStarred ? t('ctx.unstar') : t('ctx.star'),
|
|
2560
|
+
icon: allStarred ? '★' : '☆',
|
|
2561
|
+
hidden: !canStar,
|
|
2562
|
+
},
|
|
2062
2563
|
{ key: 'tags', label: tagsLabel, icon: '🏷', hidden: !singleHasId, disabled: !singleHasId },
|
|
2063
2564
|
...keepActionsFor(sel),
|
|
2064
2565
|
{ divider: true, key: 'sep2', label: '', hidden: !w },
|
|
@@ -2177,6 +2678,9 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2177
2678
|
);
|
|
2178
2679
|
}
|
|
2179
2680
|
break;
|
|
2681
|
+
case 'star':
|
|
2682
|
+
await toggleStar(targets);
|
|
2683
|
+
break;
|
|
2180
2684
|
case 'tags':
|
|
2181
2685
|
if (targets[0]) openTagPickerFor(targets[0]);
|
|
2182
2686
|
break;
|
|
@@ -3285,8 +3789,8 @@ function tabLabel(path: string): string {
|
|
|
3285
3789
|
// gezinti:g1 — the virtual views park a sentinel in the path. Translate via
|
|
3286
3790
|
// the SHARED map: this special-cased only '.trash' when recent/starred/shared
|
|
3287
3791
|
// arrived, so the strip read ".shared" at users (reported 2026-09-04).
|
|
3288
|
-
const
|
|
3289
|
-
if (
|
|
3792
|
+
const virtualLabel = virtualSegmentLabel(p.split('/').pop() || p, t);
|
|
3793
|
+
if (virtualLabel) return virtualLabel;
|
|
3290
3794
|
if (!p) return multiStorageRoot.value ? t('breadcrumb.root') : adapter.value || t('breadcrumb.root');
|
|
3291
3795
|
return p.split('/').pop() || p;
|
|
3292
3796
|
}
|
|
@@ -3577,6 +4081,66 @@ const e2eUnlockErr = ref('');
|
|
|
3577
4081
|
const showEncFolder = ref(false);
|
|
3578
4082
|
const e2eCreateBusy = ref(false);
|
|
3579
4083
|
|
|
4084
|
+
/* wiring:e2 recovery — kurtarma anahtarı + escrow.
|
|
4085
|
+
*
|
|
4086
|
+
* The marker of the folder we are looking at is cached here while the lock
|
|
4087
|
+
* screen is up: the recovery dialog needs to know which doors this folder
|
|
4088
|
+
* actually has (a pre-0.31 folder has none) before offering them. */
|
|
4089
|
+
const e2eMarker = ref<E2eMarker | null>(null);
|
|
4090
|
+
const showRecoveryUnlock = ref(false);
|
|
4091
|
+
const e2eRecoverBusy = ref(false);
|
|
4092
|
+
const e2eRecoverErr = ref<string | null>(null);
|
|
4093
|
+
// The shown-once key. Held only while its dialog is open.
|
|
4094
|
+
const showRecoveryKey = ref(false);
|
|
4095
|
+
const recoveryKeyValue = ref('');
|
|
4096
|
+
const recoveryKeyVariant = ref<'created' | 'upgraded'>('created');
|
|
4097
|
+
const recoveryKeyFolder = ref('');
|
|
4098
|
+
// A v1 folder that just opened by password: offer to give it recovery now,
|
|
4099
|
+
// because this is the only moment filex holds the password.
|
|
4100
|
+
const e2eUpgradeOffer = ref(false);
|
|
4101
|
+
const e2eUpgradePw = ref('');
|
|
4102
|
+
const e2eUpgradeBusy = ref(false);
|
|
4103
|
+
|
|
4104
|
+
/* wiring:e2 escrow-offer — offering an EXISTING folder an escrow slot.
|
|
4105
|
+
*
|
|
4106
|
+
* Adoption covers folders created after it, which on an installation that
|
|
4107
|
+
* has been running for a while is nobody's folders: the ones that matter
|
|
4108
|
+
* already exist. The server cannot reach them — adding a slot needs the
|
|
4109
|
+
* folder master key. Its owner can, from inside, with the password, and the
|
|
4110
|
+
* one moment that password exists in the browser is an unlock. So this
|
|
4111
|
+
* lives exactly where the v1 recovery offer lives, in the same strip, with
|
|
4112
|
+
* the same shape.
|
|
4113
|
+
*
|
|
4114
|
+
* mode distinguishes the two ways in:
|
|
4115
|
+
* 'unlock' right after a successful password unlock — we still hold the
|
|
4116
|
+
* password, so Accept needs no typing, and Not now RECORDS a
|
|
4117
|
+
* refusal so the question is not asked again.
|
|
4118
|
+
* 'manual' the way back, from the unlocked strip, for somebody who said
|
|
4119
|
+
* no earlier. The password is gone by then, so it is typed, and
|
|
4120
|
+
* Cancel records nothing — they already answered once. */
|
|
4121
|
+
const e2eEscrowOffer = ref(false);
|
|
4122
|
+
const e2eEscrowOfferMode = ref<'unlock' | 'manual'>('unlock');
|
|
4123
|
+
const e2eEscrowPw = ref('');
|
|
4124
|
+
const e2eEscrowBusy = ref(false);
|
|
4125
|
+
const e2eEscrowErr = ref('');
|
|
4126
|
+
|
|
4127
|
+
/** The installation's escrow public key, or null when escrow is off.
|
|
4128
|
+
* Published in /api/capabilities on purpose — see docs/E2E-ENCRYPTION.md. */
|
|
4129
|
+
const e2eEscrowPub = computed<string | null>(
|
|
4130
|
+
() => capabilitiesData.value?.e2e_escrow?.public_key || null,
|
|
4131
|
+
);
|
|
4132
|
+
/** Where the honest version of "what escrow can and cannot do" lives.
|
|
4133
|
+
* ⚠ Linked from the offer on purpose: the notice says what accepting means,
|
|
4134
|
+
* and the page says what the use-notification can and cannot promise — that
|
|
4135
|
+
* an operator holding the private key can decrypt offline with no request,
|
|
4136
|
+
* no notification and no audit row. Somebody being asked to hand over a key
|
|
4137
|
+
* is entitled to read that before answering, not after. */
|
|
4138
|
+
const e2eEscrowDocsUrl = 'https://docs.filex.sh/E2E-ENCRYPTION#what-escrow-can-and-cannot-do';
|
|
4139
|
+
|
|
4140
|
+
const e2eEscrowKid = computed<string | null>(
|
|
4141
|
+
() => capabilitiesData.value?.e2e_escrow?.kid || null,
|
|
4142
|
+
);
|
|
4143
|
+
|
|
3580
4144
|
function e2eKek(): CryptoKey | null {
|
|
3581
4145
|
return e2eRing.get(e2eRoot.value) ?? null;
|
|
3582
4146
|
}
|
|
@@ -3595,7 +4159,7 @@ async function e2eUnlock() {
|
|
|
3595
4159
|
try {
|
|
3596
4160
|
let markerText = '';
|
|
3597
4161
|
try {
|
|
3598
|
-
const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME));
|
|
4162
|
+
const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME), { fresh: true });
|
|
3599
4163
|
URL.revokeObjectURL(url);
|
|
3600
4164
|
markerText = await blob.text();
|
|
3601
4165
|
} catch {
|
|
@@ -3607,13 +4171,36 @@ async function e2eUnlock() {
|
|
|
3607
4171
|
e2eUnlockErr.value = t('e2e.unlock.marker_missing');
|
|
3608
4172
|
return;
|
|
3609
4173
|
}
|
|
3610
|
-
|
|
3611
|
-
|
|
4174
|
+
e2eMarker.value = marker;
|
|
4175
|
+
const fmk = await unlockWithPassword(marker, e2ePw.value);
|
|
4176
|
+
if (!fmk) {
|
|
3612
4177
|
e2eUnlockErr.value = t('e2e.unlock.wrong');
|
|
3613
4178
|
return;
|
|
3614
4179
|
}
|
|
3615
|
-
e2eRing.set(e2eRoot.value,
|
|
4180
|
+
e2eRing.set(e2eRoot.value, fmk);
|
|
3616
4181
|
e2eRingVer.value++;
|
|
4182
|
+
/* wiring:e2 recovery — a folder from before recovery existed has no way
|
|
4183
|
+
* back in but its password. This is the ONE moment we hold that password,
|
|
4184
|
+
* so ask now. Asking is all we do: the folder keeps working untouched if
|
|
4185
|
+
* the user says no, and saying yes is the only path that also hands the
|
|
4186
|
+
* operator an escrow key (when the install has one), which is why the
|
|
4187
|
+
* prompt says so rather than doing it quietly. */
|
|
4188
|
+
if (marker.v === 1) {
|
|
4189
|
+
e2eUpgradePw.value = e2ePw.value;
|
|
4190
|
+
e2eUpgradeOffer.value = true;
|
|
4191
|
+
} else if (escrowOfferState(marker, e2eEscrowKid.value) === 'offer') {
|
|
4192
|
+
/* wiring:e2 escrow-offer — a v2 folder that predates escrow here.
|
|
4193
|
+
* ⚠ Only after the unlock SUCCEEDED, and only by password: accepting
|
|
4194
|
+
* gives the operator a key to this folder, so the person asked has to
|
|
4195
|
+
* be the person who can already open it. A v1 folder is handled by the
|
|
4196
|
+
* branch above, which seals an escrow slot as part of the upgrade and
|
|
4197
|
+
* already discloses that — two offers on one unlock would be two
|
|
4198
|
+
* chances to get the disclosure wrong. */
|
|
4199
|
+
e2eEscrowPw.value = e2ePw.value;
|
|
4200
|
+
e2eEscrowOfferMode.value = 'unlock';
|
|
4201
|
+
e2eEscrowErr.value = '';
|
|
4202
|
+
e2eEscrowOffer.value = true;
|
|
4203
|
+
}
|
|
3617
4204
|
e2ePw.value = '';
|
|
3618
4205
|
} finally {
|
|
3619
4206
|
e2eUnlockBusy.value = false;
|
|
@@ -3740,17 +4327,28 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3740
4327
|
try {
|
|
3741
4328
|
const dirWire = qualify(currentPath.value);
|
|
3742
4329
|
await api.newFolder(dirWire, payload.name);
|
|
3743
|
-
|
|
4330
|
+
/* wiring:e2 recovery — the folder gets a recovery key at birth, and an
|
|
4331
|
+
* escrow slot when the installation has one. Both are decided HERE and
|
|
4332
|
+
* never again: the wrapped copies are written into the marker now, so a
|
|
4333
|
+
* folder created without escrow can never be opened by an escrow key. */
|
|
4334
|
+
const { marker, fmk, recoveryKey } = await createEncryptedFolder(payload.password, {
|
|
4335
|
+
escrowPublicKey: e2eEscrowPub.value,
|
|
4336
|
+
});
|
|
3744
4337
|
const markerFile = new File([JSON.stringify(marker)], E2E_MARKER_NAME, {
|
|
3745
4338
|
type: 'application/json',
|
|
3746
4339
|
});
|
|
3747
4340
|
const newDirWire = wireJoin(dirWire, payload.name);
|
|
3748
4341
|
await api.uploadMultipart(newDirWire, [markerFile]);
|
|
3749
4342
|
// Oluşturan oturumda kilit açık başlar (parolayı az önce kendisi girdi).
|
|
3750
|
-
e2eRing.set(newDirWire,
|
|
4343
|
+
e2eRing.set(newDirWire, fmk);
|
|
3751
4344
|
e2eRingVer.value++;
|
|
3752
4345
|
showEncFolder.value = false;
|
|
3753
|
-
|
|
4346
|
+
// ⚠ Show the key only after the marker is safely uploaded. Showing it
|
|
4347
|
+
// first would promise recovery for a folder that failed to be created.
|
|
4348
|
+
recoveryKeyValue.value = recoveryKey;
|
|
4349
|
+
recoveryKeyFolder.value = payload.name;
|
|
4350
|
+
recoveryKeyVariant.value = 'created';
|
|
4351
|
+
showRecoveryKey.value = true;
|
|
3754
4352
|
await load();
|
|
3755
4353
|
} catch (err) {
|
|
3756
4354
|
emit('error', { message: (err as Error).message, context: { op: 'e2e-create' } });
|
|
@@ -3759,6 +4357,238 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3759
4357
|
e2eCreateBusy.value = false;
|
|
3760
4358
|
}
|
|
3761
4359
|
}
|
|
4360
|
+
|
|
4361
|
+
/* --- wiring:e2 recovery ------------------------------------------------
|
|
4362
|
+
*
|
|
4363
|
+
* Two more ways into a locked folder, and one way to give an old folder
|
|
4364
|
+
* those ways. The password path above is untouched, and nothing here runs
|
|
4365
|
+
* without an explicit user action.
|
|
4366
|
+
*/
|
|
4367
|
+
|
|
4368
|
+
/** Unlock without the password: user recovery key, or the operator's escrow
|
|
4369
|
+
* key. The escrow branch announces itself to the server first. */
|
|
4370
|
+
async function e2eRecoverUnlock(payload: { mode: 'recovery' | 'escrow'; value: string }) {
|
|
4371
|
+
if (!e2eMarker.value || !e2eRoot.value) return;
|
|
4372
|
+
e2eRecoverBusy.value = true;
|
|
4373
|
+
e2eRecoverErr.value = null;
|
|
4374
|
+
try {
|
|
4375
|
+
let fmk: CryptoKey | null = null;
|
|
4376
|
+
if (payload.mode === 'recovery') {
|
|
4377
|
+
fmk = await unlockWithRecoveryKey(e2eMarker.value, payload.value);
|
|
4378
|
+
if (!fmk) {
|
|
4379
|
+
e2eRecoverErr.value = t('e2e.recover.wrong_recovery');
|
|
4380
|
+
return;
|
|
4381
|
+
}
|
|
4382
|
+
} else {
|
|
4383
|
+
let priv: CryptoKey;
|
|
4384
|
+
try {
|
|
4385
|
+
priv = await importEscrowPrivateKey(payload.value);
|
|
4386
|
+
} catch {
|
|
4387
|
+
e2eRecoverErr.value = t('e2e.recover.bad_escrow_key');
|
|
4388
|
+
return;
|
|
4389
|
+
}
|
|
4390
|
+
fmk = await unlockWithEscrowKey(e2eMarker.value, priv);
|
|
4391
|
+
if (!fmk) {
|
|
4392
|
+
e2eRecoverErr.value = t('e2e.recover.wrong_escrow');
|
|
4393
|
+
return;
|
|
4394
|
+
}
|
|
4395
|
+
/* ⚠ Announce BEFORE unlocking, and treat a failure to announce as a
|
|
4396
|
+
* failure to unlock. The server hands out a nonce sealed to the escrow
|
|
4397
|
+
* public key; returning it proves the key was really here, and that is
|
|
4398
|
+
* what earns the owner their notification.
|
|
4399
|
+
*
|
|
4400
|
+
* ⚠⚠ This is not enforcement and must never be described as such. An
|
|
4401
|
+
* operator holding the escrow private key can decrypt the same folder
|
|
4402
|
+
* offline, with a script, and this code will never run. Refusing to
|
|
4403
|
+
* unlock on a failed announcement only keeps the honest path honest. */
|
|
4404
|
+
try {
|
|
4405
|
+
const ch = await api.e2eEscrowChallenge(e2eRoot.value);
|
|
4406
|
+
const nonce = new Uint8Array(
|
|
4407
|
+
await crypto.subtle.decrypt(
|
|
4408
|
+
{ name: 'RSA-OAEP' },
|
|
4409
|
+
priv,
|
|
4410
|
+
b64ToBytes(ch.challenge).buffer as ArrayBuffer,
|
|
4411
|
+
),
|
|
4412
|
+
);
|
|
4413
|
+
await api.e2eEscrowUsed({
|
|
4414
|
+
path: e2eRoot.value,
|
|
4415
|
+
id: ch.id,
|
|
4416
|
+
nonce: bytesToB64(nonce),
|
|
4417
|
+
});
|
|
4418
|
+
} catch (err) {
|
|
4419
|
+
e2eRecoverErr.value = t('e2e.recover.notify_failed');
|
|
4420
|
+
emit('error', {
|
|
4421
|
+
message: (err as Error).message,
|
|
4422
|
+
context: { op: 'e2e-escrow-notify' },
|
|
4423
|
+
});
|
|
4424
|
+
return;
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
e2eRing.set(e2eRoot.value, fmk);
|
|
4428
|
+
e2eRingVer.value++;
|
|
4429
|
+
showRecoveryUnlock.value = false;
|
|
4430
|
+
flashToast(
|
|
4431
|
+
payload.mode === 'escrow' ? t('e2e.recover.escrow_done') : t('e2e.recover.recovery_done'),
|
|
4432
|
+
);
|
|
4433
|
+
} catch (err) {
|
|
4434
|
+
e2eRecoverErr.value = (err as Error).message;
|
|
4435
|
+
} finally {
|
|
4436
|
+
e2eRecoverBusy.value = false;
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4440
|
+
/** Open the recovery dialog from the lock screen. The marker was cached by
|
|
4441
|
+
* the last unlock attempt; fetch it if the user came straight here. */
|
|
4442
|
+
async function openRecoveryUnlock() {
|
|
4443
|
+
if (!e2eMarker.value && e2eRoot.value) {
|
|
4444
|
+
try {
|
|
4445
|
+
const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME), { fresh: true });
|
|
4446
|
+
URL.revokeObjectURL(url);
|
|
4447
|
+
e2eMarker.value = parseMarker(await blob.text());
|
|
4448
|
+
} catch {
|
|
4449
|
+
e2eMarker.value = null;
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
e2eRecoverErr.value = null;
|
|
4453
|
+
showRecoveryUnlock.value = true;
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
/** Give a pre-0.31 folder a recovery key, in place, using the password the
|
|
4457
|
+
* user just typed. The files are NOT rewritten — only the marker is. */
|
|
4458
|
+
async function e2eDoUpgrade() {
|
|
4459
|
+
if (!e2eMarker.value || !e2eRoot.value || !e2eUpgradePw.value) return;
|
|
4460
|
+
e2eUpgradeBusy.value = true;
|
|
4461
|
+
try {
|
|
4462
|
+
const up = await upgradeMarkerV1(e2eMarker.value, e2eUpgradePw.value, {
|
|
4463
|
+
escrowPublicKey: e2eEscrowPub.value,
|
|
4464
|
+
});
|
|
4465
|
+
const markerFile = new File([JSON.stringify(up.marker)], E2E_MARKER_NAME, {
|
|
4466
|
+
type: 'application/json',
|
|
4467
|
+
});
|
|
4468
|
+
await api.uploadMultipart(e2eRoot.value, [markerFile]);
|
|
4469
|
+
e2eMarker.value = up.marker;
|
|
4470
|
+
e2eUpgradeOffer.value = false;
|
|
4471
|
+
e2eUpgradePw.value = '';
|
|
4472
|
+
recoveryKeyValue.value = up.recoveryKey;
|
|
4473
|
+
recoveryKeyFolder.value = wireBasename(e2eRoot.value);
|
|
4474
|
+
recoveryKeyVariant.value = 'upgraded';
|
|
4475
|
+
showRecoveryKey.value = true;
|
|
4476
|
+
} catch (err) {
|
|
4477
|
+
emit('error', { message: (err as Error).message, context: { op: 'e2e-upgrade' } });
|
|
4478
|
+
flashToast(t('e2e.upgrade.failed'));
|
|
4479
|
+
} finally {
|
|
4480
|
+
e2eUpgradeBusy.value = false;
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
/* wiring:e2 escrow-offer ------------------------------------------- */
|
|
4485
|
+
|
|
4486
|
+
/** Whether this folder could be given an escrow slot, and whether its owner
|
|
4487
|
+
* has already answered. Drives the way-back button in the unlocked strip. */
|
|
4488
|
+
const e2eEscrowOfferState = computed(() => escrowOfferState(e2eMarker.value, e2eEscrowKid.value));
|
|
4489
|
+
|
|
4490
|
+
/** The way back. Somebody who said no last month can say yes today without
|
|
4491
|
+
* deleting and re-creating the folder — but the password is long gone from
|
|
4492
|
+
* memory, so they type it. That is not friction to be smoothed away: it is
|
|
4493
|
+
* the same proof of ownership the offer at unlock had, and handing the
|
|
4494
|
+
* operator a key deserves it. */
|
|
4495
|
+
function e2eOpenEscrowOffer() {
|
|
4496
|
+
e2eEscrowOfferMode.value = 'manual';
|
|
4497
|
+
e2eEscrowPw.value = '';
|
|
4498
|
+
e2eEscrowErr.value = '';
|
|
4499
|
+
e2eEscrowOffer.value = true;
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
/** Write a marker back to the folder and adopt it as the one we are holding.
|
|
4503
|
+
* Both escrow-offer answers change only `.filex-e2e.json`. */
|
|
4504
|
+
async function e2eWriteMarker(next: E2eMarker) {
|
|
4505
|
+
const file = new File([JSON.stringify(next)], E2E_MARKER_NAME, { type: 'application/json' });
|
|
4506
|
+
await api.uploadMultipart(e2eRoot.value, [file]);
|
|
4507
|
+
e2eMarker.value = next;
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
/** Accept: seal this folder's master key to the installation's escrow key.
|
|
4511
|
+
* No file is re-encrypted or moved — only the marker gains a slot. */
|
|
4512
|
+
async function e2eAcceptEscrow() {
|
|
4513
|
+
const marker = e2eMarker.value;
|
|
4514
|
+
const pub = e2eEscrowPub.value;
|
|
4515
|
+
if (!marker || !pub || !e2eRoot.value || e2eEscrowBusy.value) return;
|
|
4516
|
+
if (!e2eEscrowPw.value) {
|
|
4517
|
+
e2eEscrowErr.value = t('e2e.escrowoffer.password_required');
|
|
4518
|
+
return;
|
|
4519
|
+
}
|
|
4520
|
+
e2eEscrowBusy.value = true;
|
|
4521
|
+
e2eEscrowErr.value = '';
|
|
4522
|
+
try {
|
|
4523
|
+
// ⚠ The kid comes from the installation's CURRENT key, via the marker
|
|
4524
|
+
// addEscrowSlot writes — never from anything the UI is displaying. The
|
|
4525
|
+
// sibling bug went the other way (the dialog labelled a folder's slot
|
|
4526
|
+
// with the installation's kid); writing the installation's kid into a
|
|
4527
|
+
// slot sealed to some other key would be the same lie in reverse.
|
|
4528
|
+
const next = await addEscrowSlot(marker, e2eEscrowPw.value, pub);
|
|
4529
|
+
await e2eWriteMarker(next);
|
|
4530
|
+
e2eEscrowOffer.value = false;
|
|
4531
|
+
e2eEscrowPw.value = '';
|
|
4532
|
+
flashToast(t('e2e.escrowoffer.done'));
|
|
4533
|
+
} catch (err) {
|
|
4534
|
+
// A wrong password is the ordinary case here and reads as a typo, not
|
|
4535
|
+
// as a broken folder; anything else is worth reporting.
|
|
4536
|
+
if ((err as Error)?.name === 'E2eDecryptError') {
|
|
4537
|
+
e2eEscrowErr.value = t('e2e.escrowoffer.wrong_password');
|
|
4538
|
+
} else {
|
|
4539
|
+
e2eEscrowErr.value = t('e2e.escrowoffer.failed');
|
|
4540
|
+
emit('error', { message: (err as Error).message, context: { op: 'e2e-escrow-offer' } });
|
|
4541
|
+
}
|
|
4542
|
+
} finally {
|
|
4543
|
+
e2eEscrowBusy.value = false;
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
/** Not now. Recorded IN THE FOLDER, so the same person on another device is
|
|
4548
|
+
* not asked again and the answer survives a cleared browser. A question
|
|
4549
|
+
* that returns every single unlock is how people learn to click past
|
|
4550
|
+
* security dialogs without reading them. */
|
|
4551
|
+
async function e2eDeclineEscrow() {
|
|
4552
|
+
const marker = e2eMarker.value;
|
|
4553
|
+
if (!marker || !e2eRoot.value || e2eEscrowBusy.value) return;
|
|
4554
|
+
e2eEscrowBusy.value = true;
|
|
4555
|
+
try {
|
|
4556
|
+
await e2eWriteMarker(declineEscrowSlot(marker, new Date().toISOString()));
|
|
4557
|
+
e2eEscrowOffer.value = false;
|
|
4558
|
+
e2eEscrowPw.value = '';
|
|
4559
|
+
flashToast(t('e2e.escrowoffer.declined_toast'));
|
|
4560
|
+
} catch (err) {
|
|
4561
|
+
// ⚠ Fail towards asking again rather than towards silence: if the
|
|
4562
|
+
// refusal could not be written, the honest state is "not answered yet".
|
|
4563
|
+
e2eEscrowErr.value = t('e2e.escrowoffer.decline_failed');
|
|
4564
|
+
emit('error', { message: (err as Error).message, context: { op: 'e2e-escrow-decline' } });
|
|
4565
|
+
} finally {
|
|
4566
|
+
e2eEscrowBusy.value = false;
|
|
4567
|
+
}
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4570
|
+
/** Close the way-back panel without answering anything. Distinct from
|
|
4571
|
+
* declining: they answered once already, and re-recording the same refusal
|
|
4572
|
+
* would overwrite the date it was actually made. */
|
|
4573
|
+
function e2eCloseEscrowOffer() {
|
|
4574
|
+
e2eEscrowOffer.value = false;
|
|
4575
|
+
e2eEscrowPw.value = '';
|
|
4576
|
+
e2eEscrowErr.value = '';
|
|
4577
|
+
}
|
|
4578
|
+
|
|
4579
|
+
/** Decline the offer. The folder keeps working exactly as it did, and the
|
|
4580
|
+
* prompt returns on the next unlock because the risk has not changed. */
|
|
4581
|
+
function e2eDeclineUpgrade() {
|
|
4582
|
+
e2eUpgradeOffer.value = false;
|
|
4583
|
+
e2eUpgradePw.value = '';
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4586
|
+
/** Drop the shown-once key from memory the moment its dialog closes. */
|
|
4587
|
+
function closeRecoveryKey() {
|
|
4588
|
+
showRecoveryKey.value = false;
|
|
4589
|
+
recoveryKeyValue.value = '';
|
|
4590
|
+
recoveryKeyFolder.value = '';
|
|
4591
|
+
}
|
|
3762
4592
|
/* === /wiring:e2 === */
|
|
3763
4593
|
</script>
|
|
3764
4594
|
|
|
@@ -3814,6 +4644,9 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3814
4644
|
:nav-open="navToggleOn /* gezinti:g1 */"
|
|
3815
4645
|
:nav-enabled="sideNavEnabled /* gezinti:g1 */"
|
|
3816
4646
|
:view-modes="allowedViewModes /* gezinti:g1 */"
|
|
4647
|
+
:shell="driveShell ? 'drive' : 'classic' /* surucu:d1 */"
|
|
4648
|
+
:scope-label="driveScopeLabel /* surucu:d1 */"
|
|
4649
|
+
@open-palette="openPaletteWith /* surucu:d1 */"
|
|
3817
4650
|
@toggle-inspector="toggleInspector /* koru:k1 */"
|
|
3818
4651
|
@toggle-nav="toggleSideNav /* gezinti:g1 */"
|
|
3819
4652
|
@open-theme="showThemeGallery = true /* wiring:c1 */"
|
|
@@ -3844,16 +4677,26 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3844
4677
|
:expanded="sideNavExpanded"
|
|
3845
4678
|
:narrow="isNarrow"
|
|
3846
4679
|
:active-view="navView"
|
|
4680
|
+
:active-tag="navTag"
|
|
4681
|
+
:tags="navTags"
|
|
4682
|
+
:tags-loaded="navTagsLoaded"
|
|
3847
4683
|
:active-storage="adapter"
|
|
3848
4684
|
:storages="config.storages ?? []"
|
|
3849
4685
|
:shared-storages="sharedStorageNames"
|
|
3850
4686
|
:trash-visible="config.trashVisible !== false"
|
|
3851
4687
|
:show-connections="connectionsEnabled"
|
|
4688
|
+
:show-identity-surfaces="identitySurfaces"
|
|
3852
4689
|
:can-write="canWriteHere && !atVirtualRoot && !trashActive"
|
|
3853
4690
|
:locale="locale"
|
|
4691
|
+
:new-menu="driveShell /* surucu:d1 */"
|
|
4692
|
+
:can-request-files="canWriteHere && !atVirtualRoot && !trashActive && !navView /* surucu:d1 */"
|
|
4693
|
+
:quota="quotaSnapshot /* surucu:d1 */"
|
|
4694
|
+
:theme="themeMode /* surucu:d1 — the teleported New menu leaves .fe */"
|
|
4695
|
+
@request-files="openFileRequest /* surucu:d1 */"
|
|
3854
4696
|
@toggle="toggleSideNav"
|
|
3855
4697
|
@close="closeNavDrawer"
|
|
3856
4698
|
@open-view="loadNavView"
|
|
4699
|
+
@open-tag="loadTagView"
|
|
3857
4700
|
@open-storage="openNavStorage"
|
|
3858
4701
|
@upload="triggerUpload"
|
|
3859
4702
|
@new-folder="showNewFolder = true"
|
|
@@ -3878,6 +4721,14 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3878
4721
|
class="fe__primary"
|
|
3879
4722
|
:class="{ 'fe-pane--focus': mainPaneFocus } /* wiring:d1 — aktif panel vurgusu */"
|
|
3880
4723
|
>
|
|
4724
|
+
<!-- surucu:d1 — the breadcrumb row. In the drive shell it also carries the
|
|
4725
|
+
two controls that belong to a LISTING rather than to the app (the view
|
|
4726
|
+
switcher and the details toggle), which is where the mockups put them
|
|
4727
|
+
and where the header then has room for one wide search field.
|
|
4728
|
+
Everywhere else the wrapper is `display: contents`, so the breadcrumb
|
|
4729
|
+
is the same flex child of `.fe__primary` it has always been — one
|
|
4730
|
+
Breadcrumb, not a second copy that can drift. -->
|
|
4731
|
+
<div :class="driveShell ? 'fe-subhead' : 'fe-subhead--plain'">
|
|
3881
4732
|
<Breadcrumb
|
|
3882
4733
|
:dirname="dirname"
|
|
3883
4734
|
:adapter="adapter"
|
|
@@ -3890,6 +4741,49 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3890
4741
|
@crumb-context="onCrumbContext"
|
|
3891
4742
|
@crumb-drop="onCrumbDropInto"
|
|
3892
4743
|
/>
|
|
4744
|
+
<div v-if="driveShell" class="fe-subhead__actions">
|
|
4745
|
+
<ViewSwitcher
|
|
4746
|
+
:view-mode="displayedViewMode"
|
|
4747
|
+
:locale="locale"
|
|
4748
|
+
:modes="allowedViewModes"
|
|
4749
|
+
@update:view-mode="setDisplayedViewMode($event)"
|
|
4750
|
+
/>
|
|
4751
|
+
<button
|
|
4752
|
+
type="button"
|
|
4753
|
+
class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
|
|
4754
|
+
:class="{ 'is-active': showInspector }"
|
|
4755
|
+
:aria-pressed="showInspector"
|
|
4756
|
+
:title="t('toolbar.inspector')"
|
|
4757
|
+
:aria-label="t('toolbar.inspector')"
|
|
4758
|
+
data-testid="subhead-inspector"
|
|
4759
|
+
@click="toggleInspector"
|
|
4760
|
+
>
|
|
4761
|
+
<svg
|
|
4762
|
+
class="fe-ficon"
|
|
4763
|
+
viewBox="0 0 24 24"
|
|
4764
|
+
fill="none"
|
|
4765
|
+
stroke="currentColor"
|
|
4766
|
+
stroke-width="1.8"
|
|
4767
|
+
stroke-linecap="round"
|
|
4768
|
+
aria-hidden="true"
|
|
4769
|
+
focusable="false"
|
|
4770
|
+
>
|
|
4771
|
+
<circle cx="12" cy="12" r="9" />
|
|
4772
|
+
<path d="M12 11v5" />
|
|
4773
|
+
<circle cx="12" cy="7.6" r="1" fill="currentColor" stroke="none" />
|
|
4774
|
+
</svg>
|
|
4775
|
+
</button>
|
|
4776
|
+
</div>
|
|
4777
|
+
</div>
|
|
4778
|
+
<FilterBar
|
|
4779
|
+
v-if="driveShell && !atVirtualRoot"
|
|
4780
|
+
:value="driveFilters"
|
|
4781
|
+
:locale="locale"
|
|
4782
|
+
:theme="themeMode"
|
|
4783
|
+
:shown="displayFiles.length"
|
|
4784
|
+
:total="files.length"
|
|
4785
|
+
@update:value="setDriveFilters"
|
|
4786
|
+
/>
|
|
3893
4787
|
|
|
3894
4788
|
<!-- Live presence: who else is viewing this folder (empty → nothing shown).
|
|
3895
4789
|
When the live socket is unavailable the same strip carries a small
|
|
@@ -3910,9 +4804,102 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3910
4804
|
|
|
3911
4805
|
<!-- wiring:e2 — kilit açık şeridi: şifreli klasörde anahtar bellekteyken
|
|
3912
4806
|
görünür; "Kilitle" anahtarı ve çözülmüş blob'ları atar. -->
|
|
4807
|
+
<!-- wiring:e2 recovery — a v1 folder just opened by password. Offer it
|
|
4808
|
+
recovery HERE, visibly, rather than doing anything silently: this is
|
|
4809
|
+
the only moment filex holds the password, and (when the install has
|
|
4810
|
+
escrow) accepting also gives the operator a key. -->
|
|
4811
|
+
<div v-if="e2eUpgradeOffer" class="fe-e2e-upgrade" role="alert">
|
|
4812
|
+
<div class="fe-e2e-upgrade__text">
|
|
4813
|
+
<strong>{{ t('e2e.upgrade.title') }}</strong>
|
|
4814
|
+
<p>{{ t('e2e.upgrade.body') }}</p>
|
|
4815
|
+
<p v-if="e2eEscrowKid" class="fe-e2e-upgrade__escrow">
|
|
4816
|
+
{{ t('e2e.upgrade.escrow_note') }}
|
|
4817
|
+
</p>
|
|
4818
|
+
</div>
|
|
4819
|
+
<div class="fe-e2e-upgrade__actions">
|
|
4820
|
+
<button type="button" class="fe-btn" :disabled="e2eUpgradeBusy" @click="e2eDeclineUpgrade">
|
|
4821
|
+
{{ t('e2e.upgrade.decline') }}
|
|
4822
|
+
</button>
|
|
4823
|
+
<button
|
|
4824
|
+
type="button"
|
|
4825
|
+
class="fe-btn fe-btn--primary"
|
|
4826
|
+
:disabled="e2eUpgradeBusy"
|
|
4827
|
+
@click="e2eDoUpgrade"
|
|
4828
|
+
>
|
|
4829
|
+
{{ e2eUpgradeBusy ? t('e2e.upgrade.busy') : t('e2e.upgrade.accept') }}
|
|
4830
|
+
</button>
|
|
4831
|
+
</div>
|
|
4832
|
+
</div>
|
|
4833
|
+
<!-- wiring:e2 escrow-offer — an existing folder that predates escrow on
|
|
4834
|
+
this installation. Shown only after an unlock SUCCEEDED, because
|
|
4835
|
+
accepting hands the operator a permanent second key to this folder
|
|
4836
|
+
and the only person entitled to do that is the one who can already
|
|
4837
|
+
open it. Doing nothing leaves the folder exactly as it is. -->
|
|
4838
|
+
<div v-if="e2eEscrowOffer" class="fe-e2e-upgrade fe-e2e-upgrade--escrow" role="alert">
|
|
4839
|
+
<div class="fe-e2e-upgrade__text">
|
|
4840
|
+
<strong>{{ t('e2e.escrowoffer.title') }}</strong>
|
|
4841
|
+
<p>{{ t('e2e.escrowoffer.body') }}</p>
|
|
4842
|
+
<p class="fe-e2e-upgrade__escrow">
|
|
4843
|
+
{{ t('e2e.escrowoffer.consequence') }}
|
|
4844
|
+
<a :href="e2eEscrowDocsUrl" target="_blank" rel="noopener noreferrer">
|
|
4845
|
+
{{ t('e2e.escrowoffer.learn_more') }}
|
|
4846
|
+
</a>
|
|
4847
|
+
</p>
|
|
4848
|
+
<p v-if="e2eEscrowKid" class="fe-e2e-upgrade__escrow">
|
|
4849
|
+
{{ t('e2e.recover.escrow_kid') }}: <code>{{ e2eEscrowKid }}</code>
|
|
4850
|
+
</p>
|
|
4851
|
+
<!-- The way-back path has no password in memory any more, so it is
|
|
4852
|
+
typed. The same proof of ownership the unlock path already had. -->
|
|
4853
|
+
<label v-if="e2eEscrowOfferMode === 'manual'" class="fe-e2e-upgrade__pw">
|
|
4854
|
+
<span>{{ t('e2e.escrowoffer.password_label') }}</span>
|
|
4855
|
+
<input
|
|
4856
|
+
v-model="e2eEscrowPw"
|
|
4857
|
+
type="password"
|
|
4858
|
+
class="fe-input"
|
|
4859
|
+
autocomplete="current-password"
|
|
4860
|
+
:disabled="e2eEscrowBusy"
|
|
4861
|
+
@keyup.enter="e2eAcceptEscrow"
|
|
4862
|
+
/>
|
|
4863
|
+
</label>
|
|
4864
|
+
<p v-if="e2eEscrowErr" class="fe-form__error" role="alert">{{ e2eEscrowErr }}</p>
|
|
4865
|
+
</div>
|
|
4866
|
+
<div class="fe-e2e-upgrade__actions">
|
|
4867
|
+
<button
|
|
4868
|
+
type="button"
|
|
4869
|
+
class="fe-btn"
|
|
4870
|
+
:disabled="e2eEscrowBusy"
|
|
4871
|
+
@click="e2eEscrowOfferMode === 'manual' ? e2eCloseEscrowOffer() : e2eDeclineEscrow()"
|
|
4872
|
+
>
|
|
4873
|
+
{{
|
|
4874
|
+
e2eEscrowOfferMode === 'manual'
|
|
4875
|
+
? t('e2e.escrowoffer.cancel')
|
|
4876
|
+
: t('e2e.escrowoffer.decline')
|
|
4877
|
+
}}
|
|
4878
|
+
</button>
|
|
4879
|
+
<button
|
|
4880
|
+
type="button"
|
|
4881
|
+
class="fe-btn fe-btn--primary"
|
|
4882
|
+
:disabled="e2eEscrowBusy"
|
|
4883
|
+
@click="e2eAcceptEscrow"
|
|
4884
|
+
>
|
|
4885
|
+
{{ e2eEscrowBusy ? t('e2e.escrowoffer.busy') : t('e2e.escrowoffer.accept') }}
|
|
4886
|
+
</button>
|
|
4887
|
+
</div>
|
|
4888
|
+
</div>
|
|
3913
4889
|
<div v-if="e2eUnlocked" class="fe-e2e-strip" role="status">
|
|
3914
4890
|
<span class="fe-e2e-strip__icon" aria-hidden="true">🔒</span>
|
|
3915
4891
|
<span class="fe-e2e-strip__label">{{ t('e2e.strip.label') }}</span>
|
|
4892
|
+
<!-- The way back for somebody who declined. Quiet, but present: a
|
|
4893
|
+
refusal that could not be reversed without deleting the folder
|
|
4894
|
+
would not be a decision, it would be a trap. -->
|
|
4895
|
+
<button
|
|
4896
|
+
v-if="e2eEscrowOfferState !== 'n/a' && !e2eEscrowOffer"
|
|
4897
|
+
type="button"
|
|
4898
|
+
class="fe-btn fe-e2e-strip__btn"
|
|
4899
|
+
@click="e2eOpenEscrowOffer"
|
|
4900
|
+
>
|
|
4901
|
+
{{ t('e2e.escrowoffer.strip_action') }}
|
|
4902
|
+
</button>
|
|
3916
4903
|
<button type="button" class="fe-btn fe-e2e-strip__btn" @click="e2eLock">
|
|
3917
4904
|
{{ t('e2e.strip.lock') }}
|
|
3918
4905
|
</button>
|
|
@@ -4048,6 +5035,13 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4048
5035
|
</button>
|
|
4049
5036
|
</form>
|
|
4050
5037
|
<p v-if="e2eUnlockErr" class="fe-form__error" role="alert">{{ e2eUnlockErr }}</p>
|
|
5038
|
+
<!-- wiring:e2 recovery — the second door. Always offered: whether
|
|
5039
|
+
this folder actually has one is answered inside the dialog,
|
|
5040
|
+
which can say "this folder predates recovery keys" instead of
|
|
5041
|
+
leaving the user guessing why there is no link. -->
|
|
5042
|
+
<button type="button" class="fe-e2e-optlink" @click="openRecoveryUnlock">
|
|
5043
|
+
{{ t('e2e.locked.use_recovery') }}
|
|
5044
|
+
</button>
|
|
4051
5045
|
</div>
|
|
4052
5046
|
<!-- /wiring:e2 -->
|
|
4053
5047
|
<!-- Search with zero hits — its own message, not "folder is empty". -->
|
|
@@ -4098,6 +5092,10 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4098
5092
|
<template v-else-if="navView === 'starred'">
|
|
4099
5093
|
<path d="M60 26l9 18.6 20.4 3-14.8 14.4 3.5 20.4L60 72.8 41.9 82.4l3.5-20.4L30.6 47.6l20.4-3z" />
|
|
4100
5094
|
</template>
|
|
5095
|
+
<template v-else-if="navView === 'tag'">
|
|
5096
|
+
<path d="M30 30h24l32 32-24 24-32-32z" />
|
|
5097
|
+
<circle cx="43" cy="43" r="4.5" />
|
|
5098
|
+
</template>
|
|
4101
5099
|
<template v-else>
|
|
4102
5100
|
<circle cx="84" cy="34" r="9" />
|
|
4103
5101
|
<circle cx="36" cy="52" r="9" />
|
|
@@ -4105,8 +5103,15 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4105
5103
|
<path d="M44.5 47.5l31-9M44.5 56.5l31 9" />
|
|
4106
5104
|
</template>
|
|
4107
5105
|
</svg>
|
|
4108
|
-
|
|
4109
|
-
|
|
5106
|
+
<!-- etiket:t1 — the tag view's empty state names the TAG. "Nothing
|
|
5107
|
+
here" would be the fourth identical sentence and would not say
|
|
5108
|
+
which of the user's tags is the empty one. -->
|
|
5109
|
+
<p class="fe-state__title">
|
|
5110
|
+
{{ navView === 'tag' ? t('empty.tag.title', { tag: navTag }) : t(`empty.${navView}.title`) }}
|
|
5111
|
+
</p>
|
|
5112
|
+
<p class="fe-state__hint">
|
|
5113
|
+
{{ navView === 'tag' ? t('empty.tag.hint') : t(`empty.${navView}.hint`) }}
|
|
5114
|
+
</p>
|
|
4110
5115
|
</div>
|
|
4111
5116
|
<!-- Empty trash view. -->
|
|
4112
5117
|
<div v-else-if="!loading && files.length === 0 && trashMode" class="fe-state">
|
|
@@ -4129,6 +5134,36 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4129
5134
|
</svg>
|
|
4130
5135
|
<p class="fe-state__title">{{ t('empty.trash.title') }}</p>
|
|
4131
5136
|
</div>
|
|
5137
|
+
<!-- surucu:d1 — the folder HAS rows and the filters hid all of them.
|
|
5138
|
+
"This folder is empty" would be false, and the way back is the chip
|
|
5139
|
+
row just above, so the message names it and offers the button. -->
|
|
5140
|
+
<div
|
|
5141
|
+
v-else-if="!loading && filtersOn && displayFiles.length === 0 && files.length > 0"
|
|
5142
|
+
class="fe-state"
|
|
5143
|
+
data-testid="empty-filtered"
|
|
5144
|
+
>
|
|
5145
|
+
<svg
|
|
5146
|
+
class="fe-state__art"
|
|
5147
|
+
viewBox="0 0 120 100"
|
|
5148
|
+
width="110"
|
|
5149
|
+
height="92"
|
|
5150
|
+
fill="none"
|
|
5151
|
+
stroke="currentColor"
|
|
5152
|
+
stroke-width="2"
|
|
5153
|
+
stroke-linecap="round"
|
|
5154
|
+
stroke-linejoin="round"
|
|
5155
|
+
aria-hidden="true"
|
|
5156
|
+
>
|
|
5157
|
+
<path d="M26 28h68L68 58v24l-16 8V58z" />
|
|
5158
|
+
</svg>
|
|
5159
|
+
<p class="fe-state__title">{{ t('filter.empty.title') }}</p>
|
|
5160
|
+
<p class="fe-state__hint">{{ t('filter.empty.hint') }}</p>
|
|
5161
|
+
<div class="fe-state__actions">
|
|
5162
|
+
<button type="button" class="fe-btn" @click="clearDriveFilters">
|
|
5163
|
+
{{ t('filter.clear') }}
|
|
5164
|
+
</button>
|
|
5165
|
+
</div>
|
|
5166
|
+
</div>
|
|
4132
5167
|
<!-- Loaded, zero files, no search: the real empty-folder state. The
|
|
4133
5168
|
upload affordances follow write permission (RBAC viewers only get
|
|
4134
5169
|
the title). -->
|
|
@@ -4161,7 +5196,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4161
5196
|
</div>
|
|
4162
5197
|
<ListView
|
|
4163
5198
|
v-else-if="viewMode === 'list'"
|
|
4164
|
-
:files="
|
|
5199
|
+
:files="displayFiles /* surucu:d1 */"
|
|
4165
5200
|
:selected="selection.selected.value"
|
|
4166
5201
|
:clipped="clippedPaths"
|
|
4167
5202
|
:show-parent-path="!!searchQuery"
|
|
@@ -4169,6 +5204,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4169
5204
|
:loading="loading"
|
|
4170
5205
|
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
4171
5206
|
:starred-ids="starredIds"
|
|
5207
|
+
:star-enabled="identitySurfaces"
|
|
4172
5208
|
:api-base="props.config.apiBase ?? ''"
|
|
4173
5209
|
:auth-headers="() => buildAuthHeaders()"
|
|
4174
5210
|
:auth-credentials="api.credentialsMode()"
|
|
@@ -4181,7 +5217,8 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4181
5217
|
/>
|
|
4182
5218
|
<GridView
|
|
4183
5219
|
v-else-if="viewMode === 'grid' /* wiring:d2 — v-else → v-else-if (3. mod eklendi) */"
|
|
4184
|
-
:files="
|
|
5220
|
+
:files="displayFiles /* surucu:d1 */"
|
|
5221
|
+
:sections="driveShell /* surucu:d1 */"
|
|
4185
5222
|
:selected="selection.selected.value"
|
|
4186
5223
|
:clipped="clippedPaths"
|
|
4187
5224
|
:show-parent-path="!!searchQuery"
|
|
@@ -4189,27 +5226,39 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4189
5226
|
:loading="loading"
|
|
4190
5227
|
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
4191
5228
|
:thumb-src="thumbs.src"
|
|
5229
|
+
:starred-ids="starredIds"
|
|
5230
|
+
:star-enabled="identitySurfaces"
|
|
5231
|
+
:api-base="props.config.apiBase ?? ''"
|
|
5232
|
+
:auth-headers="() => buildAuthHeaders()"
|
|
5233
|
+
:auth-credentials="api.credentialsMode()"
|
|
4192
5234
|
@click-card="(n, m) => selection.click(n.path, m)"
|
|
4193
5235
|
@dbl-card="openNode"
|
|
4194
5236
|
@context-card="onContextTarget"
|
|
4195
5237
|
@item-drag-start="onItemDragStart"
|
|
4196
5238
|
@item-drop-into="onItemDropInto"
|
|
5239
|
+
@star-change="onStarChange"
|
|
4197
5240
|
/>
|
|
4198
5241
|
<!-- wiring:d2 — galeri görünümü (GridView ile aynı event sözleşmesi) -->
|
|
4199
5242
|
<GalleryView
|
|
4200
5243
|
v-else
|
|
4201
|
-
:files="
|
|
5244
|
+
:files="displayFiles /* surucu:d1 */"
|
|
4202
5245
|
:selected="selection.selected.value"
|
|
4203
5246
|
:clipped="clippedPaths"
|
|
4204
5247
|
:show-parent-path="!!searchQuery"
|
|
4205
5248
|
:locale="locale"
|
|
4206
5249
|
:loading="loading"
|
|
4207
5250
|
:thumb-src="thumbs.src"
|
|
5251
|
+
:starred-ids="starredIds"
|
|
5252
|
+
:star-enabled="identitySurfaces"
|
|
5253
|
+
:api-base="props.config.apiBase ?? ''"
|
|
5254
|
+
:auth-headers="() => buildAuthHeaders()"
|
|
5255
|
+
:auth-credentials="api.credentialsMode()"
|
|
4208
5256
|
@click-card="(n, m) => selection.click(n.path, m)"
|
|
4209
5257
|
@dbl-card="openNode"
|
|
4210
5258
|
@context-card="onContextTarget"
|
|
4211
5259
|
@item-drag-start="onItemDragStart"
|
|
4212
5260
|
@item-drop-into="onItemDropInto"
|
|
5261
|
+
@star-change="onStarChange"
|
|
4213
5262
|
/>
|
|
4214
5263
|
<!-- /wiring:d2 -->
|
|
4215
5264
|
</div>
|
|
@@ -4237,6 +5286,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4237
5286
|
:view-mode="paneViewMode /* ui-fix */"
|
|
4238
5287
|
:thumb-src="thumbs.src /* ui-fix */"
|
|
4239
5288
|
:trash-visible="config.trashVisible !== false /* ui-fix — çöp satırı simetrisi */"
|
|
5289
|
+
:nav-offers-trash="navOffersTrash /* surucu:d1 — aynı kapı iki kez açılmaz */"
|
|
4240
5290
|
@navigate="onPaneNavigate"
|
|
4241
5291
|
@activate="activePane = 'split'"
|
|
4242
5292
|
@close="closeSplit"
|
|
@@ -4259,7 +5309,9 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4259
5309
|
:locale="locale"
|
|
4260
5310
|
:narrow="isNarrow"
|
|
4261
5311
|
:thumb-src="thumbs.src"
|
|
5312
|
+
:tabs="driveShell /* surucu:d1 */"
|
|
4262
5313
|
@close="closeInspector"
|
|
5314
|
+
@share-created="onInspectorShareCreated /* surucu:d1 */"
|
|
4263
5315
|
@manage-permissions="onInspectorManage"
|
|
4264
5316
|
@toast="flashToast"
|
|
4265
5317
|
@changed="() => load()"
|
|
@@ -4399,9 +5451,32 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4399
5451
|
:open="showEncFolder"
|
|
4400
5452
|
:locale="locale"
|
|
4401
5453
|
:busy="e2eCreateBusy"
|
|
5454
|
+
:escrow-kid="e2eEscrowKid"
|
|
4402
5455
|
@close="showEncFolder = false"
|
|
4403
5456
|
@submit="submitEncryptedFolder"
|
|
4404
5457
|
/>
|
|
5458
|
+
<!-- wiring:e2 recovery — the key, shown exactly once. -->
|
|
5459
|
+
<RecoveryKeyModal
|
|
5460
|
+
:open="showRecoveryKey"
|
|
5461
|
+
:locale="locale"
|
|
5462
|
+
:recovery-key="recoveryKeyValue"
|
|
5463
|
+
:folder-name="recoveryKeyFolder"
|
|
5464
|
+
:escrow-kid="e2eEscrowKid"
|
|
5465
|
+
:variant="recoveryKeyVariant"
|
|
5466
|
+
@close="closeRecoveryKey"
|
|
5467
|
+
/>
|
|
5468
|
+
<!-- wiring:e2 recovery — the way back in without the password. -->
|
|
5469
|
+
<E2eRecoveryUnlockModal
|
|
5470
|
+
:open="showRecoveryUnlock"
|
|
5471
|
+
:locale="locale"
|
|
5472
|
+
:has-recovery="markerHasRecovery(e2eMarker)"
|
|
5473
|
+
:escrow-state="escrowAvailability(e2eMarker, e2eEscrowKid)"
|
|
5474
|
+
:escrow-kid="e2eMarker?.esc?.kid || e2eEscrowKid"
|
|
5475
|
+
:busy="e2eRecoverBusy"
|
|
5476
|
+
:error="e2eRecoverErr"
|
|
5477
|
+
@close="showRecoveryUnlock = false"
|
|
5478
|
+
@submit="e2eRecoverUnlock"
|
|
5479
|
+
/>
|
|
4405
5480
|
<!-- /wiring:e2 -->
|
|
4406
5481
|
<RenameModal
|
|
4407
5482
|
:open="showRename"
|
|
@@ -4463,7 +5538,8 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4463
5538
|
:size="typeof permTarget.size === 'number' ? permTarget.size : undefined"
|
|
4464
5539
|
:locale="locale === 'en' ? 'en' : 'tr'"
|
|
4465
5540
|
:share-max-ttl-days="shareMaxTtlDays"
|
|
4466
|
-
|
|
5541
|
+
:initial-tab="permInitialTab /* surucu:d1 */"
|
|
5542
|
+
@close="showPerm = false; permInitialTab = undefined"
|
|
4467
5543
|
/>
|
|
4468
5544
|
|
|
4469
5545
|
<!-- Recently-opened tray. Anchored to the toolbar trigger via fixed
|
|
@@ -4519,6 +5595,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4519
5595
|
:api-base="props.config.apiBase ?? ''"
|
|
4520
5596
|
:auth-headers="() => buildAuthHeaders()"
|
|
4521
5597
|
:auth-credentials="api.credentialsMode()"
|
|
5598
|
+
@change="onNodeTagsChanged"
|
|
4522
5599
|
@error="(msg: string) => emit('error', { message: msg, context: { op: 'tags' } })"
|
|
4523
5600
|
/>
|
|
4524
5601
|
</div>
|
|
@@ -4528,6 +5605,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4528
5605
|
|
|
4529
5606
|
<!-- cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) -->
|
|
4530
5607
|
<CommandPalette
|
|
5608
|
+
:initial-query="paletteSeed /* surucu:d1 */"
|
|
4531
5609
|
:open="showPalette"
|
|
4532
5610
|
:locale="locale"
|
|
4533
5611
|
:files="files"
|