@brftech/filex-core 0.1.56

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/ArchiveViewer-CJdaw5x3.js +99 -0
  4. package/dist/ArchiveViewer-CJdaw5x3.js.map +1 -0
  5. package/dist/CsvViewer-QYx_tePQ.js +132 -0
  6. package/dist/CsvViewer-QYx_tePQ.js.map +1 -0
  7. package/dist/DrawioViewer-GWt0TyB7.js +141 -0
  8. package/dist/DrawioViewer-GWt0TyB7.js.map +1 -0
  9. package/dist/EpubViewer-Je0ywklQ.js +146 -0
  10. package/dist/EpubViewer-Je0ywklQ.js.map +1 -0
  11. package/dist/IpynbViewer-TsB4gqP-.js +175 -0
  12. package/dist/IpynbViewer-TsB4gqP-.js.map +1 -0
  13. package/dist/MermaidViewer-CXfeDS-7.js +128 -0
  14. package/dist/MermaidViewer-CXfeDS-7.js.map +1 -0
  15. package/dist/PsdViewer-VtPpxKVe.js +113 -0
  16. package/dist/PsdViewer-VtPpxKVe.js.map +1 -0
  17. package/dist/TiffViewer-OINn9bbb.js +133 -0
  18. package/dist/TiffViewer-OINn9bbb.js.map +1 -0
  19. package/dist/UTIF-Cjsrlm0l.js +3104 -0
  20. package/dist/UTIF-Cjsrlm0l.js.map +1 -0
  21. package/dist/Viewer3D-xvvyiWHd.js +61 -0
  22. package/dist/Viewer3D-xvvyiWHd.js.map +1 -0
  23. package/dist/_commonjsHelpers-DaMA6jEr.js +9 -0
  24. package/dist/_commonjsHelpers-DaMA6jEr.js.map +1 -0
  25. package/dist/filex-core.js +26 -0
  26. package/dist/filex-core.js.map +1 -0
  27. package/dist/filex-core.umd.cjs +285 -0
  28. package/dist/filex-core.umd.cjs.map +1 -0
  29. package/dist/index-CmSYzt6L.js +5404 -0
  30. package/dist/index-CmSYzt6L.js.map +1 -0
  31. package/dist/index-UFULWo35.js +10736 -0
  32. package/dist/index-UFULWo35.js.map +1 -0
  33. package/dist/index.d.ts +1089 -0
  34. package/dist/katex-yuB6V-q6.js +11616 -0
  35. package/dist/katex-yuB6V-q6.js.map +1 -0
  36. package/dist/papaparse.min-VB1HBwYX.js +441 -0
  37. package/dist/papaparse.min-VB1HBwYX.js.map +1 -0
  38. package/dist/style.css +1 -0
  39. package/dist/useViewerFetch-czqbd2Lj.js +25 -0
  40. package/dist/useViewerFetch-czqbd2Lj.js.map +1 -0
  41. package/package.json +113 -0
  42. package/src/FileExplorer.vue +1872 -0
  43. package/src/components/Breadcrumb.vue +282 -0
  44. package/src/components/ContextMenu.vue +152 -0
  45. package/src/components/GridView.vue +166 -0
  46. package/src/components/ListView.vue +212 -0
  47. package/src/components/PendingOpsTray.vue +91 -0
  48. package/src/components/RecentlyOpened.vue +155 -0
  49. package/src/components/StarButton.vue +101 -0
  50. package/src/components/TagPicker.vue +184 -0
  51. package/src/components/Toolbar.vue +220 -0
  52. package/src/components/UploadProgress.vue +78 -0
  53. package/src/composables/useFileApi.ts +692 -0
  54. package/src/composables/useKeyboardShortcuts.ts +144 -0
  55. package/src/composables/useLocale.ts +60 -0
  56. package/src/composables/useMonacoLoader.ts +98 -0
  57. package/src/composables/usePendingOps.ts +200 -0
  58. package/src/composables/useSelection.ts +81 -0
  59. package/src/composables/useUploadChunked.ts +250 -0
  60. package/src/composables/useViewerFetch.ts +58 -0
  61. package/src/index.ts +80 -0
  62. package/src/locales/en.ts +122 -0
  63. package/src/locales/index.ts +12 -0
  64. package/src/locales/tr.ts +122 -0
  65. package/src/modals/ConvertModal.vue +254 -0
  66. package/src/modals/DeleteConfirmModal.vue +32 -0
  67. package/src/modals/Modal.vue +104 -0
  68. package/src/modals/NewFolderModal.vue +64 -0
  69. package/src/modals/PermissionsModal.vue +815 -0
  70. package/src/modals/PreviewModal.vue +1070 -0
  71. package/src/modals/RenameModal.vue +58 -0
  72. package/src/modals/ShareModal.vue +135 -0
  73. package/src/styles/base.css +1224 -0
  74. package/src/styles/variables.css +90 -0
  75. package/src/types/ExplorerConfig.ts +282 -0
  76. package/src/types/FileNode.ts +136 -0
  77. package/src/types/index.ts +27 -0
  78. package/src/types/peers.d.ts +45 -0
  79. package/src/viewers/ArchiveViewer.vue +186 -0
  80. package/src/viewers/CsvViewer.vue +319 -0
  81. package/src/viewers/DrawioViewer.vue +243 -0
  82. package/src/viewers/EpubViewer.vue +307 -0
  83. package/src/viewers/IpynbViewer.vue +365 -0
  84. package/src/viewers/MermaidViewer.vue +259 -0
  85. package/src/viewers/PdfViewer.vue +562 -0
  86. package/src/viewers/PsdViewer.vue +264 -0
  87. package/src/viewers/TiffViewer.vue +273 -0
  88. package/src/viewers/Viewer3D.vue +120 -0
@@ -0,0 +1,1872 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FileExplorer — the public Vue component, panel + PWA + standalone use.
4
+ *
5
+ * Orchestrates:
6
+ * - Directory listing (useFileApi)
7
+ * - Chunked multipart upload (useUploadChunked) + drag & drop
8
+ * - Selection + keyboard shortcuts
9
+ * - Context menu (Teleport-based) with per-target actions
10
+ * - Modal flows: newFolder / rename / delete / share / preview
11
+ * - Eager Monaco preload so the code-edit path is snappy
12
+ *
13
+ * All backend endpoints arrive via the `config` prop. Auth is bearer
14
+ * (PWA / OIDC) / CSRF (panel) / basic / none — `useFileApi` swallows
15
+ * the difference.
16
+ */
17
+ import { computed, customRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
18
+ import type { ExplorerConfig } from './types/ExplorerConfig';
19
+ import type {
20
+ FileNode,
21
+ ShareInfo,
22
+ ViewMode,
23
+ ClipboardState,
24
+ Capabilities,
25
+ } from './types/FileNode';
26
+ import { isExternalUsable } from './types/FileNode';
27
+ import { useFileApi } from './composables/useFileApi';
28
+ import { useUploadChunked, type UploadJob } from './composables/useUploadChunked';
29
+ import { useSelection } from './composables/useSelection';
30
+ import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
31
+ import { useLocale } from './composables/useLocale';
32
+ import { usePendingOps, type PendingOp } from './composables/usePendingOps';
33
+ import { preloadEditor } from './composables/useMonacoLoader';
34
+
35
+ import Toolbar, { type SelectionMode } from './components/Toolbar.vue';
36
+ import StarButton from './components/StarButton.vue';
37
+ import TagPicker from './components/TagPicker.vue';
38
+ import RecentlyOpened from './components/RecentlyOpened.vue';
39
+ import Breadcrumb from './components/Breadcrumb.vue';
40
+ import ListView from './components/ListView.vue';
41
+ import GridView from './components/GridView.vue';
42
+ import ContextMenu, { type ContextAction } from './components/ContextMenu.vue';
43
+ import UploadProgress from './components/UploadProgress.vue';
44
+ import PendingOpsTray from './components/PendingOpsTray.vue';
45
+
46
+ import NewFolderModal from './modals/NewFolderModal.vue';
47
+ import RenameModal from './modals/RenameModal.vue';
48
+ import DeleteConfirmModal from './modals/DeleteConfirmModal.vue';
49
+ import ShareModal from './modals/ShareModal.vue';
50
+ import PreviewModal from './modals/PreviewModal.vue';
51
+ import ConvertModal from './modals/ConvertModal.vue';
52
+ import PermissionsModal from './modals/PermissionsModal.vue';
53
+
54
+ const props = defineProps<{
55
+ config: ExplorerConfig;
56
+ }>();
57
+
58
+ const emit = defineEmits<{
59
+ (e: 'share-created', payload: { path: string; url: string; pin: string | null }): void;
60
+ (e: 'file-opened', file: { path: string; basename: string }): void;
61
+ (e: 'error', err: { message: string; context?: unknown }): void;
62
+ (e: 'upload-progress', p: { uploadId: string; percent: number; done: boolean }): void;
63
+ (
64
+ e: 'selection-change',
65
+ items: Array<{ path: string; basename: string; type: 'file' | 'dir' }>,
66
+ ): void;
67
+ }>();
68
+
69
+ // --------------------------------------------------------------------
70
+ // State
71
+ // --------------------------------------------------------------------
72
+
73
+ const api = useFileApi(props.config);
74
+ const chunked = useUploadChunked(props.config, api);
75
+ const pendingOps = usePendingOps(props.config, api, {
76
+ onSettled: (op: PendingOp) => {
77
+ if (op.status === 'error') {
78
+ flashToast(op.error_message || 'İşlem başarısız');
79
+ } else {
80
+ const verb =
81
+ op.op_type === 'copy'
82
+ ? 'Kopyalandı'
83
+ : op.op_type === 'move'
84
+ ? 'Taşındı'
85
+ : 'Silindi';
86
+ flashToast(`${verb} (${op.progress_total})`);
87
+ }
88
+ void load();
89
+ },
90
+ });
91
+
92
+ const loading = ref(false);
93
+ // rootPath confinement (UX): when set, the explorer treats this folder as its
94
+ // floor — it opens there, never lists the drives root, and can't navigate
95
+ // above it. Security is enforced server-side (X-Filex-Root / token root scope);
96
+ // this is purely the clean-embed presentation. `rootFloor` is the virtual form
97
+ // (`<storage>/<rel>`) used for path comparisons in multi-storage mode.
98
+ const rootPathProp = (props.config.rootPath || '').trim(); // qualified `<adapter>://<rel>`
99
+ const rootFloor = rootPathProp.replace('://', '/').replace(/^\/+|\/+$/g, '');
100
+ const initialFloorPath = rootFloor || props.config.initialPath || '';
101
+ const currentPath = ref<string>(initialFloorPath);
102
+ const adapter = ref<string>(props.config.defaultAdapter || 'brf');
103
+ const dirname = ref<string>(initialFloorPath);
104
+ const files = ref<FileNode[]>([]);
105
+ // RBAC effective level for the current directory ('' = ACL not enforced on
106
+ // this storage → no gating). Drives which write/manage actions are offered.
107
+ const dirPerm = ref<string>('');
108
+
109
+ const VIEW_MODE_KEY = 'brf-file-explorer:view-mode';
110
+ const viewMode = customRef<ViewMode>((track, trigger) => {
111
+ let value: ViewMode = (() => {
112
+ try {
113
+ const stored = localStorage.getItem(VIEW_MODE_KEY);
114
+ if (stored === 'list' || stored === 'grid') return stored;
115
+ } catch {
116
+ /* private mode */
117
+ }
118
+ return props.config.viewMode ?? 'list';
119
+ })();
120
+ return {
121
+ get() {
122
+ track();
123
+ return value;
124
+ },
125
+ set(next) {
126
+ if (next === value) return;
127
+ value = next;
128
+ try {
129
+ localStorage.setItem(VIEW_MODE_KEY, next);
130
+ } catch {
131
+ /* quota */
132
+ }
133
+ trigger();
134
+ },
135
+ };
136
+ });
137
+ const searchQuery = ref('');
138
+ // trashMode — true while viewing the filex trash (soft-deleted nodes from the
139
+ // backend trash endpoint), entered by opening the virtual `.trash` row and
140
+ // exited by any normal navigation (load() resets it). Replaces a brittle
141
+ // `currentPath.startsWith('fileman/.trash')` check that never matched the
142
+ // filex backend's storage layout, so trash always looked empty.
143
+ const trashMode = ref(false);
144
+ // The storage the trash view was entered from, so "up" returns there (not the
145
+ // global root). Set in loadTrash().
146
+ const trashOrigin = ref<string>('');
147
+ const trashActive = computed(() => trashMode.value);
148
+ const locale = computed(() => props.config.locale || 'tr');
149
+
150
+ // canGoUp/goUp — toolbar's "↑ Up one level" button. In single-storage
151
+ // mode "" means the storage root; in multi-storage mode "" means
152
+ // the global root (storage list). Both → no parent → button hidden.
153
+ const canGoUp = computed(() => {
154
+ const p = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
155
+ if (rootFloor && p === rootFloor) return false; // at the confined floor — nowhere above
156
+ return p.length > 0;
157
+ });
158
+
159
+ // True when the explorer is showing the synthetic storage list and
160
+ // there's no real backend folder to mutate. New Folder / Upload /
161
+ // Paste are hidden in this state.
162
+ const atVirtualRoot = computed(() => {
163
+ if (!multiStorageRoot.value) return false;
164
+ return !((currentPath.value ?? '').replace(/^\/+|\/+$/g, ''));
165
+ });
166
+
167
+ function goUp() {
168
+ // Leaving the trash view returns to the storage it was opened from, not the
169
+ // global storage-list root.
170
+ if (trashMode.value) {
171
+ void load(trashOrigin.value);
172
+ return;
173
+ }
174
+ const cur = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
175
+ if (!cur || cur === rootFloor) return;
176
+ const idx = cur.lastIndexOf('/');
177
+ let parent = idx === -1 ? '' : cur.slice(0, idx);
178
+ // Never step above the confined floor.
179
+ if (rootFloor && !(parent === rootFloor || parent.startsWith(rootFloor + '/'))) parent = rootFloor;
180
+ void load(parent);
181
+ }
182
+
183
+ const { t } = useLocale(locale);
184
+
185
+ const selection = useSelection(() => files.value);
186
+ watch(
187
+ () => [...selection.selected.value],
188
+ () => {
189
+ emit(
190
+ 'selection-change',
191
+ selection.nodes.value.map((n) => ({ path: n.path, basename: n.basename, type: n.type })),
192
+ );
193
+ },
194
+ );
195
+
196
+ const clipboard = ref<ClipboardState>({ mode: null, items: [], sourcePath: null });
197
+
198
+ const capabilitiesData = ref<Capabilities | null>(null);
199
+
200
+ // Creative UI state: starred / tags / recently-opened. The component
201
+ // helpers (StarButton, TagPicker, RecentlyOpened) handle their own
202
+ // API calls — the explorer just tracks the cross-row state needed to
203
+ // render inline stars and keep the recents tray in sync.
204
+ const starredIds = ref(new Set<number>());
205
+ const showRecents = ref(false);
206
+ const showTagPicker = ref(false);
207
+ const tagPickerNode = ref<FileNode | null>(null);
208
+ const recentRefreshKey = ref(0);
209
+
210
+ async function loadStarred() {
211
+ try {
212
+ const headers = await buildAuthHeaders();
213
+ const base = props.config.apiBase ?? '';
214
+ const res = await fetch(`${base}/api/files/manager/star/list?limit=500`, {
215
+ headers,
216
+ credentials: 'include',
217
+ });
218
+ if (!res.ok) return;
219
+ const body = await res.json();
220
+ const rows: { id?: number }[] = Array.isArray(body)
221
+ ? body
222
+ : Array.isArray(body?.entries)
223
+ ? body.entries
224
+ : Array.isArray(body?.nodes)
225
+ ? body.nodes
226
+ : [];
227
+ starredIds.value = new Set(rows.map((n) => n.id).filter((id): id is number => typeof id === 'number'));
228
+ } catch {
229
+ // Silent — backend may be older without the meta routes.
230
+ }
231
+ }
232
+
233
+ function onStarChange(n: FileNode, value: boolean) {
234
+ if (typeof n.id !== 'number') return;
235
+ const next = new Set(starredIds.value);
236
+ if (value) next.add(n.id);
237
+ else next.delete(n.id);
238
+ starredIds.value = next;
239
+ }
240
+
241
+ async function markRecent(n: FileNode) {
242
+ if (typeof n.id !== 'number') return;
243
+ try {
244
+ const base = props.config.apiBase ?? '';
245
+ await fetch(`${base}/api/files/manager/recent`, {
246
+ method: 'POST',
247
+ headers: await buildAuthHeaders({ 'Content-Type': 'application/json' }),
248
+ credentials: 'include',
249
+ body: JSON.stringify({ node_id: n.id }),
250
+ });
251
+ recentRefreshKey.value += 1;
252
+ } catch {
253
+ // Silent — the open succeeds, recent tracking is best-effort.
254
+ }
255
+ }
256
+
257
+ function openTagPickerFor(n: FileNode) {
258
+ if (typeof n.id !== 'number') return;
259
+ tagPickerNode.value = n;
260
+ showTagPicker.value = true;
261
+ }
262
+
263
+ function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
264
+ // RecentlyOpened emits the bare row — synthesize a FileNode shaped
265
+ // enough for openNode to route into the editor / preview.
266
+ const node = {
267
+ type: 'file',
268
+ path: entry.path,
269
+ basename: entry.name,
270
+ extension: (entry.name.split('.').pop() || '').toLowerCase(),
271
+ id: entry.id,
272
+ } as unknown as FileNode;
273
+ showRecents.value = false;
274
+ openNode(node);
275
+ }
276
+
277
+ // Resolution order for each external viewer: explicit config override → live
278
+ // backend probe. The probe is the source of truth: an operator can flip the
279
+ // service "on" but if last_check failed (state='error') we still hide the
280
+ // entry so users don't get 503s on click. Explicit config wins because
281
+ // embedders sometimes terminate TLS in front of filex and the backend can't
282
+ // see the public URL.
283
+ const effectiveOnlyOfficeBase = computed<string | null>(() => {
284
+ if (props.config.onlyOfficeBase) return props.config.onlyOfficeBase;
285
+ const ext = capabilitiesData.value?.external?.onlyoffice;
286
+ if (ext && !isExternalUsable(ext)) return null;
287
+ return capabilitiesData.value?.onlyoffice_url || null;
288
+ });
289
+
290
+ const effectiveOnlyOfficeConfigEndpoint = computed<string | null>(() => {
291
+ if (!effectiveOnlyOfficeBase.value) return null;
292
+ return api.endpoints.onlyOfficeConfig || null;
293
+ });
294
+
295
+ const effectiveDrawioUrl = computed<string | null>(() => {
296
+ const override = props.config.drawioUrl || props.config.drawioBase;
297
+ if (override) return override;
298
+ const ext = capabilitiesData.value?.external?.drawio;
299
+ if (ext && !isExternalUsable(ext)) return null;
300
+ return capabilitiesData.value?.drawio_url || null;
301
+ });
302
+
303
+ // Universal converter (p2r3/convert fork). convert_url is only populated by
304
+ // the backend when the "convert" external service is enabled, so a simple
305
+ // presence check is enough gating.
306
+ const effectiveConvertUrl = computed<string | null>(
307
+ () => props.config.convertBase || capabilitiesData.value?.convert_url || null,
308
+ );
309
+
310
+ // Upload
311
+ const uploadJobs = ref<UploadJob[]>([]);
312
+ const fileInputEl = ref<HTMLInputElement | null>(null);
313
+
314
+ // Modals
315
+ const showNewFolder = ref(false);
316
+ const showRename = ref(false);
317
+ const showDelete = ref(false);
318
+ const showShare = ref(false);
319
+ const showPreview = ref(false);
320
+ const renameTarget = ref<FileNode | null>(null);
321
+ const shareTarget = ref<FileNode | null>(null);
322
+ const activeShare = ref<(ShareInfo & { url: string; filename?: string }) | null>(null);
323
+ const previewTarget = ref<FileNode | null>(null);
324
+ const previewMode = ref<'edit' | 'view'>('edit');
325
+ const showConvert = ref(false);
326
+ const convertTarget = ref<FileNode | null>(null);
327
+ const showPerm = ref(false);
328
+ const permTarget = ref<FileNode | null>(null);
329
+
330
+ // RBAC helpers. '' means ACL is not enforced on this storage → full access
331
+ // (the pre-RBAC default). Otherwise 'editor'/'owner' may write; only 'owner'
332
+ // manages permissions. Enforcement is server-side; this just shapes the menu.
333
+ function permCanEdit(p: string | undefined): boolean {
334
+ // undefined = ACL not enforced (dev / unwired) → full access. In production
335
+ // the backend always sends a level; 'none'/'viewer' cannot write, only
336
+ // 'editor'/'owner' can.
337
+ return p === undefined || p === 'editor' || p === 'owner';
338
+ }
339
+ function permIsOwner(p: string | undefined): boolean {
340
+ return p === 'owner';
341
+ }
342
+ // Effective perm for a selection: a single entry's own perm (falls back to the
343
+ // directory perm), else the directory perm for multi-select / background.
344
+ function selPerm(sel: FileNode[]): string {
345
+ if (sel.length === 1 && typeof sel[0]?.perm === 'string') return sel[0].perm as string;
346
+ return dirPerm.value;
347
+ }
348
+ // Can the current user write into the directory being viewed? Gates the
349
+ // toolbar New Folder / Upload / Paste + drag-drop upload.
350
+ const canWriteHere = computed(() => permCanEdit(dirPerm.value));
351
+
352
+ // Context menu
353
+ const ctxRef = ref<InstanceType<typeof ContextMenu> | null>(null);
354
+ const rootEl = ref<HTMLElement | null>(null);
355
+ const toolbarRef = ref<InstanceType<typeof Toolbar> | null>(null);
356
+
357
+ // Toast (tiny, no lib)
358
+ const toast = ref<string | null>(null);
359
+ let toastTimer: ReturnType<typeof setTimeout> | undefined;
360
+ function flashToast(msg: string) {
361
+ toast.value = msg;
362
+ if (toastTimer) clearTimeout(toastTimer);
363
+ toastTimer = setTimeout(() => (toast.value = null), 2500);
364
+ }
365
+
366
+ // --------------------------------------------------------------------
367
+ // Data loading
368
+ // --------------------------------------------------------------------
369
+
370
+ // multiStorageRoot — when on, "/" is a virtual folder listing every
371
+ // configured storage as a clickable dir. Path semantics shift:
372
+ //
373
+ // "" → global root, list storages
374
+ // "<storage>" → that storage's root (api: `<storage>://`)
375
+ // "<storage>/<rel>" → deeper folder (api: `<storage>://<rel>`)
376
+ //
377
+ // `qualify()` is overridden inside this mode to translate the
378
+ // slash-separated user path into the wire `<adapter>://<rel>` form.
379
+ const multiStorageRoot = computed(() => props.config.multiStorageRoot === true);
380
+
381
+ function splitVirtualPath(p: string): { adapter: string; rel: string } {
382
+ const clean = p.replace(/^\/+|\/+$/g, '');
383
+ if (!clean) return { adapter: '', rel: '' };
384
+ const slash = clean.indexOf('/');
385
+ if (slash === -1) return { adapter: clean, rel: '' };
386
+ return { adapter: clean.slice(0, slash), rel: clean.slice(slash + 1) };
387
+ }
388
+
389
+ function virtualToWire(p: string): string {
390
+ // Convert `s3-test/example` → `s3-test://example`. Pass-through
391
+ // when the input already carries `://` (legacy callers).
392
+ if (p.includes('://')) return p;
393
+ const { adapter, rel } = splitVirtualPath(p);
394
+ if (!adapter) return ''; // global root — no wire form
395
+ return rel ? `${adapter}://${rel}` : `${adapter}://`;
396
+ }
397
+
398
+ function wireToVirtual(p: string): string {
399
+ // Convert `s3-test://example` → `s3-test/example`.
400
+ const idx = p.indexOf('://');
401
+ if (idx === -1) return p.replace(/^\/+|\/+$/g, '');
402
+ const adapter = p.slice(0, idx);
403
+ const rel = p.slice(idx + 3).replace(/^\/+|\/+$/g, '');
404
+ return rel ? `${adapter}/${rel}` : adapter;
405
+ }
406
+
407
+ function virtualStorageRows(): FileNode[] {
408
+ // Synthesize a FileNode for every configured storage. Used as the
409
+ // "/" listing in multi-storage mode.
410
+ const list = props.config.storages ?? [];
411
+ return list.map((s) => ({
412
+ type: 'dir',
413
+ path: s.name, // virtual path (no adapter prefix)
414
+ basename: s.label || s.name,
415
+ extension: '',
416
+ storage: s.name,
417
+ visibility: 'private',
418
+ file_size: 0,
419
+ mime_type: 'inode/storage',
420
+ extra_metadata: { driver: s.driver, readOnly: s.readOnly },
421
+ } as unknown as FileNode));
422
+ }
423
+
424
+ async function load(path?: string) {
425
+ loading.value = true;
426
+ // Any normal navigation exits trash mode (the trash view is entered only
427
+ // by opening the virtual `.trash` row, which calls loadTrash()).
428
+ trashMode.value = false;
429
+ try {
430
+ let requested = path ?? currentPath.value ?? '';
431
+ // Clamp to the confined floor: an empty/above-floor request (incl. a stale
432
+ // persisted path or the drives root) snaps back to rootPath. This both
433
+ // suppresses the multi-storage drives list and blocks up-navigation.
434
+ if (rootFloor) {
435
+ const p = String(requested).replace(/^\/+|\/+$/g, '');
436
+ if (!p || !(p === rootFloor || p.startsWith(rootFloor + '/'))) requested = rootFloor;
437
+ }
438
+
439
+ // Multi-storage virtual root — synthesize a list of storages
440
+ // instead of calling the backend.
441
+ if (multiStorageRoot.value && !virtualToWire(requested)) {
442
+ currentPath.value = '';
443
+ adapter.value = '';
444
+ dirname.value = '';
445
+ files.value = virtualStorageRows();
446
+ return;
447
+ }
448
+
449
+ const target = multiStorageRoot.value
450
+ ? virtualToWire(requested)
451
+ : qualify(requested);
452
+
453
+ const resp = searchQuery.value
454
+ ? await api.search(target, searchQuery.value)
455
+ : await api.index(target);
456
+ adapter.value = resp.adapter;
457
+ dirname.value = resp.dirname;
458
+ dirPerm.value = (resp.perm as string) || '';
459
+ files.value = (resp.files || []).filter((f) => {
460
+ if (f.path.includes('.thumbs')) return false;
461
+ if (f.path.includes('.versions') || f.basename === '.versions') return false;
462
+ if (f.basename === '.trash') return false;
463
+ if (f.basename === '.keepdir') return false;
464
+ return true;
465
+ });
466
+ // Inject virtual `.trash` entry at root only.
467
+ const dirRel = stripAdapter(resp.dirname);
468
+ const inRoot = dirRel === 'fileman' || dirRel === '';
469
+ const isTrashListing = dirRel.startsWith('fileman/.trash');
470
+ const trashEntryEnabled = props.config.trashVisible !== false;
471
+ if (!isTrashListing && inRoot && trashEntryEnabled) {
472
+ files.value.unshift({
473
+ type: 'dir',
474
+ path: `${resp.adapter}://fileman/.trash`,
475
+ basename: '.trash',
476
+ extension: '',
477
+ storage: resp.adapter,
478
+ visibility: 'private',
479
+ file_size: 0,
480
+ mime_type: 'inode/directory',
481
+ extra_metadata: {},
482
+ } as unknown as FileNode);
483
+ }
484
+ // currentPath is the user-facing form: `s3-test/example` in
485
+ // multi-storage mode, the bare relative path otherwise.
486
+ currentPath.value = multiStorageRoot.value
487
+ ? wireToVirtual(resp.dirname)
488
+ : stripAdapter(resp.dirname);
489
+ } catch (err) {
490
+ const e = err instanceof Error ? err.message : String(err);
491
+ emit('error', { message: e, context: { path } });
492
+ flashToast(e);
493
+ } finally {
494
+ loading.value = false;
495
+ }
496
+ }
497
+
498
+ function stripAdapter(p: string): string {
499
+ const idx = p.indexOf('://');
500
+ return idx === -1 ? p : p.slice(idx + 3);
501
+ }
502
+
503
+ // loadTrash — show the backend trash (soft-deleted nodes) as a flat listing.
504
+ // Entered by opening the virtual `.trash` row. Each row keeps its node `id`
505
+ // so restore can target it. Permanent delete is admin-only / auto-purge, so
506
+ // the only mutation offered here is Restore.
507
+ async function loadTrash() {
508
+ loading.value = true;
509
+ trashOrigin.value = adapter.value || '';
510
+ trashMode.value = true;
511
+ selection.clear();
512
+ try {
513
+ const { entries } = await api.listTrash();
514
+ files.value = entries.map(
515
+ (e) =>
516
+ ({
517
+ type: 'file',
518
+ id: e.id,
519
+ path: e.storage_name ? `${e.storage_name}://${e.path}` : e.path,
520
+ basename: e.name,
521
+ extension: e.name.includes('.') ? e.name.split('.').pop() || '' : '',
522
+ storage: e.storage_name || '',
523
+ visibility: 'private',
524
+ file_size: e.size,
525
+ mime_type: e.mime || '',
526
+ extra_metadata: { deleted_at: e.deleted_at, ttl_days: e.ttl_days ?? null },
527
+ }) as unknown as FileNode,
528
+ );
529
+ dirname.value = '.trash';
530
+ currentPath.value = '.trash';
531
+ } catch (err) {
532
+ const msg = err instanceof Error ? err.message : String(err);
533
+ emit('error', { message: msg, context: { op: 'trash-list' } });
534
+ flashToast(msg);
535
+ } finally {
536
+ loading.value = false;
537
+ }
538
+ }
539
+
540
+ /**
541
+ * qualify — return `<adapter>://<rel>` for backend calls.
542
+ *
543
+ * The backend's manager handler picks a storage by parsing the
544
+ * adapter prefix. Without one it falls back to `storages[0]`,
545
+ * which 404s on every non-default storage (S3/SFTP/WebDAV in a
546
+ * multi-storage install). All API callers (rename/move/delete/
547
+ * upload/preview/download/share/copy) must use a qualified path.
548
+ *
549
+ * In multi-storage mode `currentPath` is `<storage>/<rel>` (no
550
+ * `://`), so qualify forwards through `virtualToWire` which
551
+ * splits the first segment off as the adapter. In single-storage
552
+ * mode the legacy bare-relative path is glued onto `adapter.value`.
553
+ *
554
+ * `stripAdapter()` stays for cosmetic display logic only
555
+ * (breadcrumb root check, inRoot computation, openPageBase).
556
+ */
557
+ function qualify(p: string): string {
558
+ if (p && p.includes('://')) return p;
559
+ if (multiStorageRoot.value) {
560
+ const wire = virtualToWire(p ?? '');
561
+ if (wire) return wire;
562
+ return adapter.value ? `${adapter.value}://` : '';
563
+ }
564
+ if (!p) return `${adapter.value}://`;
565
+ return `${adapter.value}://${p.replace(/^\/+/, '')}`;
566
+ }
567
+
568
+ watch(
569
+ () => searchQuery.value,
570
+ () => void load(),
571
+ );
572
+
573
+ // ----------------------------------------------------------------
574
+ // Path persistence
575
+ // ----------------------------------------------------------------
576
+ const PATH_LS_KEY = 'brf-file-explorer:path';
577
+
578
+ function persistMode(): 'hash' | 'localStorage' | 'none' {
579
+ return props.config.pathPersist ?? 'hash';
580
+ }
581
+
582
+ function readPersistedPath(): string {
583
+ if (typeof window === 'undefined') return '';
584
+ const mode = persistMode();
585
+ if (mode === 'none') return '';
586
+ if (mode === 'localStorage') {
587
+ try {
588
+ return localStorage.getItem(PATH_LS_KEY) || '';
589
+ } catch {
590
+ return '';
591
+ }
592
+ }
593
+ const h = window.location.hash || '';
594
+ if (!h.startsWith('#')) return '';
595
+ return decodeURIComponent(h.slice(1)).replace(/^\/+|\/+$/g, '');
596
+ }
597
+
598
+ let hashSyncSuppressed = false;
599
+
600
+ function writePersistedPath(path: string) {
601
+ if (typeof window === 'undefined') return;
602
+ const mode = persistMode();
603
+ if (mode === 'none') return;
604
+ if (mode === 'localStorage') {
605
+ try {
606
+ if (path) localStorage.setItem(PATH_LS_KEY, path);
607
+ else localStorage.removeItem(PATH_LS_KEY);
608
+ } catch {
609
+ /* private mode / quota */
610
+ }
611
+ return;
612
+ }
613
+ const target = path ? `#${path}` : '';
614
+ if (window.location.hash === target) return;
615
+ hashSyncSuppressed = true;
616
+ history.replaceState(
617
+ null,
618
+ '',
619
+ target || window.location.pathname + window.location.search,
620
+ );
621
+ }
622
+
623
+ function onHashChange() {
624
+ if (persistMode() !== 'hash') return;
625
+ if (hashSyncSuppressed) {
626
+ hashSyncSuppressed = false;
627
+ return;
628
+ }
629
+ const p = readPersistedPath();
630
+ if (p && p !== currentPath.value) {
631
+ void load(p);
632
+ }
633
+ }
634
+
635
+ watch(currentPath, (p) => writePersistedPath(p));
636
+
637
+ onMounted(async () => {
638
+ // Eagerly start fetching Monaco — the user doesn't pay for it
639
+ // perceptually; click-to-edit hits an in-memory cache.
640
+ preloadEditor();
641
+
642
+ const fromPersist = readPersistedPath();
643
+ await load(fromPersist || undefined);
644
+ await nextTick();
645
+ rootEl.value?.focus();
646
+ // Best-effort initial fetch — silent if the older backend doesn't
647
+ // expose /api/files/manager/starred. Without this stars never light
648
+ // up on first render even when the row IS starred server-side.
649
+ void loadStarred();
650
+ if (persistMode() === 'hash') {
651
+ window.addEventListener('hashchange', onHashChange);
652
+ }
653
+ if (api.endpoints.opsList) {
654
+ pendingOps.startPolling();
655
+ }
656
+ if (api.endpoints.capabilities) {
657
+ api
658
+ .capabilities()
659
+ .then((cap) => {
660
+ capabilitiesData.value = cap;
661
+ })
662
+ .catch(() => {
663
+ /* swallow — `onlyoffice_url` falls back to null */
664
+ });
665
+ }
666
+ });
667
+
668
+ // --------------------------------------------------------------------
669
+ // Keyboard
670
+ // --------------------------------------------------------------------
671
+
672
+ useKeyboardShortcuts(rootEl, {
673
+ onDelete: () => {
674
+ if (!selection.isEmpty.value) showDelete.value = true;
675
+ },
676
+ onRename: () => {
677
+ if (selection.nodes.value.length === 1) {
678
+ renameTarget.value = selection.nodes.value[0];
679
+ showRename.value = true;
680
+ }
681
+ },
682
+ onSelectAll: () => selection.selectAll(),
683
+ onOpen: () => {
684
+ const n = selection.nodes.value[0];
685
+ if (n) openNode(n);
686
+ },
687
+ onClose: () => {
688
+ showNewFolder.value = false;
689
+ showRename.value = false;
690
+ showDelete.value = false;
691
+ showShare.value = false;
692
+ showPreview.value = false;
693
+ ctxRef.value?.hide();
694
+ },
695
+ onFocusSearch: () => toolbarRef.value?.focusSearch(),
696
+ onCut: () => cut(),
697
+ onCopy: () => copyToClipboard(),
698
+ onPaste: () => paste(),
699
+ onGoUp: () => goUp(),
700
+ hasSelection: () => !selection.isEmpty.value,
701
+ });
702
+
703
+ // --------------------------------------------------------------------
704
+ // Actions
705
+ // --------------------------------------------------------------------
706
+
707
+ const OFFICE_EXTS = new Set([
708
+ 'docx', 'xlsx', 'pptx',
709
+ 'doc', 'xls', 'ppt',
710
+ 'odt', 'ods', 'odp',
711
+ ]);
712
+ const TEXT_CODE_EXTS = new Set([
713
+ 'txt', 'md', 'markdown', 'log', 'csv', 'tsv', 'conf', 'ini',
714
+ 'env', 'toml', 'cfg',
715
+ 'json', 'jsonc', 'yaml', 'yml', 'xml', 'svg',
716
+ 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
717
+ 'css', 'scss', 'sass', 'less',
718
+ 'html', 'htm', 'vue', 'svelte',
719
+ 'php', 'py', 'rb', 'rs', 'go', 'java', 'kt', 'swift',
720
+ 'cpp', 'c', 'h', 'hpp', 'cs', 'dart',
721
+ 'sh', 'bash', 'zsh', 'sql', 'lua', 'pl', 'r',
722
+ 'dockerfile', 'gradle', 'gitignore',
723
+ ]);
724
+
725
+ function openNode(n: FileNode) {
726
+ // The virtual `.trash` row opens the backend trash listing, not a real dir.
727
+ if (n.basename === '.trash') {
728
+ void loadTrash();
729
+ return;
730
+ }
731
+ if (n.type === 'dir') {
732
+ // Multi-storage virtual rows have a bare path (`s3-test`); pass
733
+ // them straight to load() which will treat them as the wire form
734
+ // for that storage's root. Real backend rows still come back as
735
+ // `<adapter>://<rel>` and stripAdapter turns them into the user
736
+ // path semantics load() expects.
737
+ const target = multiStorageRoot.value
738
+ ? wireToVirtual(n.path)
739
+ : stripAdapter(n.path);
740
+ void load(target);
741
+ return;
742
+ }
743
+ // "Aç" / double-click contract: open in a new tab against the
744
+ // standalone editor route, regardless of file type. The editor page
745
+ // picks the right viewer (OnlyOffice for office, Monaco for code/
746
+ // text, drawio iframe for .drawio, image/PDF/3D viewers otherwise)
747
+ // and wires save-on-change. This is the shape the origin app ships and
748
+ // what users expect from a Files-style file manager.
749
+ //
750
+ // Capability gate: if we already know the required backend is offline
751
+ // (OnlyOffice for office docs, drawio for diagrams), don't launch a
752
+ // new tab that we'd just render a "service not configured" fallback
753
+ // inside — drop into the in-page preview instead, which is the same
754
+ // dead-end UI but without the tab-switching whiplash.
755
+ // Double-click contract: in-page modal preview. Office docs and
756
+ // other read-only kinds open in view mode so a quick peek doesn't
757
+ // mount an editing surface on top of the content. Code/markdown
758
+ // open in edit so the user gets the fast "open, tweak, Ctrl+S"
759
+ // loop. Modal's "Yeni sekmede aç" button still launches the
760
+ // standalone fullscreen editor route when richer editing is wanted.
761
+ const ext = (n.extension || '').toLowerCase();
762
+ // RBAC: viewers (no edit on this item) always get the read-only preview
763
+ // modal — never the editable surface. This is the "view vs edit" split.
764
+ previewMode.value = permCanEdit((n.perm as string) ?? dirPerm.value)
765
+ ? previewModeForExt(ext)
766
+ : 'view';
767
+ previewTarget.value = n;
768
+ showPreview.value = true;
769
+ emit('file-opened', { path: n.path, basename: n.basename });
770
+ void markRecent(n);
771
+ }
772
+
773
+ const VIEW_DEFAULT_EXTS = new Set<string>([
774
+ ...OFFICE_EXTS,
775
+ 'drawio', 'dio',
776
+ 'pdf', 'epub', 'ipynb', 'tiff', 'tif', 'psd',
777
+ 'mmd', 'mermaid',
778
+ 'glb', 'gltf', 'obj', 'stl', 'fbx', '3ds',
779
+ 'zip', 'rar', '7z', 'tar', 'gz', 'tgz',
780
+ 'jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'svg', 'heic',
781
+ 'mp4', 'webm', 'mov', 'mkv', 'm4v', 'ogv',
782
+ 'mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus',
783
+ ]);
784
+
785
+ function previewModeForExt(ext: string): 'view' | 'edit' {
786
+ if (VIEW_DEFAULT_EXTS.has(ext)) return 'view';
787
+ return 'edit';
788
+ }
789
+
790
+ async function restoreSelection(targets?: FileNode[]) {
791
+ const nodes = targets ?? selection.nodes.value;
792
+ if (nodes.length === 0) return;
793
+ try {
794
+ // filex trash: restore by node id, then refresh the trash listing.
795
+ if (trashMode.value) {
796
+ const ids = nodes
797
+ .map((n) => (n as { id?: number }).id)
798
+ .filter((x): x is number => typeof x === 'number');
799
+ const { restored } = await api.restoreIds(ids);
800
+ flashToast(`${restored} öğe geri getirildi`);
801
+ selection.clear();
802
+ await loadTrash();
803
+ return;
804
+ }
805
+ // Legacy path-based restore (the origin app `.trash/` convention).
806
+ if (!api.endpoints.restore) return;
807
+ const items = nodes.map((n) => n.path); // qualified
808
+ const { restored } = await api.restore(items);
809
+ flashToast(`${restored} öğe geri getirildi`);
810
+ selection.clear();
811
+ await load();
812
+ } catch (err) {
813
+ emit('error', { message: (err as Error).message, context: { op: 'restore' } });
814
+ }
815
+ }
816
+
817
+ function previewNode(n: FileNode) {
818
+ previewMode.value = 'view';
819
+ previewTarget.value = n;
820
+ showPreview.value = true;
821
+ void markRecent(n);
822
+ }
823
+
824
+ /**
825
+ * openNodeInNewTab — launches the standalone /files/edit route in a
826
+ * fresh tab. Used by the context-menu "Aç" action; double-click stays
827
+ * on the in-page modal path. Dirs still navigate inline (no editor for
828
+ * directories). Falls back to the modal if no `openPageBase` is wired
829
+ * by the embedder.
830
+ */
831
+ function openNodeInNewTab(n: FileNode) {
832
+ if (n.type === 'dir') {
833
+ const target = multiStorageRoot.value
834
+ ? wireToVirtual(n.path)
835
+ : stripAdapter(n.path);
836
+ void load(target);
837
+ return;
838
+ }
839
+ // RBAC: a viewer (no edit on this item) can't use the editable "Aç"
840
+ // surface — drop to the read-only in-page preview instead.
841
+ if (!permCanEdit((n.perm as string) ?? dirPerm.value)) {
842
+ previewNode(n);
843
+ return;
844
+ }
845
+ const ext = (n.extension || '').toLowerCase();
846
+ const base = props.config.openPageBase;
847
+ if (!base) {
848
+ // Embedder didn't supply a standalone editor route — keep the
849
+ // in-page modal as the only available affordance.
850
+ openNode(n);
851
+ return;
852
+ }
853
+ // Context-menu "Aç" is the intent-to-edit action — request edit
854
+ // mode so OnlyOffice / Monaco mount with write permissions.
855
+ // Read-only inspection lives on the "Önizle" entry + the dbl-click
856
+ // in-page modal.
857
+ const sep = base.includes('?') ? '&' : '?';
858
+ const url =
859
+ `${base}${sep}path=${encodeURIComponent(n.path)}` +
860
+ `&type=${encodeURIComponent(ext)}` +
861
+ `&mode=edit`;
862
+ window.open(url, '_blank', 'noopener');
863
+ emit('file-opened', { path: n.path, basename: n.basename });
864
+ void markRecent(n);
865
+ }
866
+
867
+ type ContextMode = 'selection' | 'breadcrumb';
868
+ const ctxMode = ref<ContextMode>('selection');
869
+ const breadcrumbCtxPath = ref<string>('');
870
+ const breadcrumbCtxLabel = ref<string>('');
871
+
872
+ const selectionMode = computed<SelectionMode>(() => {
873
+ const sel = selection.nodes.value;
874
+ if (sel.length === 0) return 'none';
875
+ if (sel.length === 1) return sel[0].type === 'dir' ? 'single-dir' : 'single-file';
876
+ return 'multi';
877
+ });
878
+
879
+ async function onToolbarAction(key: string) {
880
+ const sel = selection.nodes.value;
881
+ // The toolbar's "Aç" opens the in-page preview/editor modal (quick peek);
882
+ // everything else shares dispatchItemAction with the context menu so the two
883
+ // identical menus also behave identically.
884
+ if (key === 'open') {
885
+ if (sel[0]) openNode(sel[0]);
886
+ return;
887
+ }
888
+ await dispatchItemAction(key, sel);
889
+ }
890
+
891
+ async function onContextTarget(node: FileNode, ev: MouseEvent) {
892
+ ctxMode.value = 'selection';
893
+ if (!selection.has(node.path)) {
894
+ selection.click(node.path);
895
+ await nextTick();
896
+ }
897
+ ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, selection.nodes.value);
898
+ }
899
+
900
+ function onContextCanvas(ev: MouseEvent) {
901
+ ev.preventDefault();
902
+ ctxMode.value = 'selection';
903
+ selection.clear();
904
+ ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, []);
905
+ }
906
+
907
+ function onCrumbContext(payload: { x: number; y: number; adapterPath: string; label: string }) {
908
+ ctxMode.value = 'breadcrumb';
909
+ breadcrumbCtxPath.value = payload.adapterPath;
910
+ breadcrumbCtxLabel.value = payload.label;
911
+ ctxRef.value?.show({ clientX: payload.x, clientY: payload.y }, []);
912
+ }
913
+
914
+ const contextActions = computed<ContextAction[]>(() => {
915
+ if (ctxMode.value === 'breadcrumb') {
916
+ return [
917
+ { key: 'open', label: t('ctx.open'), icon: '↗' },
918
+ { key: 'copy-path', label: t('breadcrumb.copy_path'), icon: '📋' },
919
+ ];
920
+ }
921
+
922
+ const sel = selection.nodes.value;
923
+ const any = sel.length > 0;
924
+ const single = sel.length === 1;
925
+
926
+ if (trashActive.value) {
927
+ if (!any) return [];
928
+ return [
929
+ { key: 'restore', label: t('ctx.restore'), icon: '↩' },
930
+ { divider: true, key: 'sep1', label: '' },
931
+ { key: 'delete', label: t('ctx.delete_perm'), icon: '🗑', danger: true },
932
+ ];
933
+ }
934
+
935
+ // Storage roots (the virtual rows shown at the multi-storage "/"
936
+ // overview) aren't real filesystem entries — they're mount points.
937
+ // Hide every mutation entry (rename/delete/share/cut/copy/new-folder/
938
+ // paste) and only offer "Aç" so the menu doesn't surface actions
939
+ // that would 4xx on the backend.
940
+ //
941
+ // PRIOR BUG: this used `currentPath === '/'` but the load() branch
942
+ // for the virtual root sets currentPath to EMPTY string, not '/'.
943
+ // So the guard never fired and every mutation action leaked into
944
+ // the menu at the depo listing — including new-folder + paste,
945
+ // which Burak called out in the most direct possible terms. Use
946
+ // the same empty-after-trim test as `atVirtualRoot` above.
947
+ const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
948
+ const inStorageRoot = multiStorageRoot.value && trimmedPath === '';
949
+ if (inStorageRoot) {
950
+ if (!any) return [];
951
+ if (!single) return [];
952
+ return [
953
+ { key: 'open', label: t('ctx.open'), icon: '↗' },
954
+ ];
955
+ }
956
+
957
+ // Empty background right-click: folder-level actions only. Viewers (no edit
958
+ // on this dir) get nothing here.
959
+ if (!any) {
960
+ if (!permCanEdit(dirPerm.value)) return [];
961
+ return [
962
+ { key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' },
963
+ { key: 'paste', label: t('ctx.paste'), icon: '📋', disabled: !clipboard.value.mode },
964
+ ];
965
+ }
966
+
967
+ return selectionActionList(sel);
968
+ });
969
+
970
+ // selectionActionList — the SINGLE source of truth for the actions offered on a
971
+ // selection. BOTH the right-click context menu AND the top toolbar render this
972
+ // exact list so they can never drift apart (Burak: "sağ klik menüyle üst menü
973
+ // tutmuyor"). The toolbar filters out dividers/hidden; the context menu shows
974
+ // them. Action handling is unified in dispatchItemAction().
975
+ function selectionActionList(sel: FileNode[]): ContextAction[] {
976
+ const any = sel.length > 0;
977
+ const single = sel.length === 1;
978
+ const isFile = single && sel[0]?.type === 'file';
979
+ const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
980
+ const singleHasId = single && typeof sel[0]?.id === 'number';
981
+ const copyIdLabel = locale.value === 'en' ? 'Copy node id' : "Node id'yi kopyala";
982
+ // RBAC: gate mutating actions when the caller lacks edit on the target. The
983
+ // "İzinler" (permissions) action shows only for owners on RBAC-on storages.
984
+ const p = selPerm(sel);
985
+ const w = permCanEdit(p); // may write here
986
+ // Unified "Paylaş / İzinler" popup: public share link (editor+) + per-user
987
+ // permissions (owner-only, decided inside the modal).
988
+ // Unified "Paylaş / İzinler" popup carries the public share link, per-user
989
+ // permissions AND the folder-only "Dosya İste" (file-drop) tab — the user
990
+ // picks the action from inside the modal, so there's no separate button.
991
+ const accessLabel = locale.value === 'en' ? 'Share / Permissions' : 'Paylaş / İzinler';
992
+ return [
993
+ { key: 'open', label: t('ctx.open'), icon: '↗', hidden: !single },
994
+ { key: 'preview', label: t('ctx.preview'), icon: '👁', hidden: !single, disabled: !isFile },
995
+ { key: 'download', label: t('ctx.download'), icon: '⬇', hidden: !single, disabled: !isFile },
996
+ { key: 'convert', label: t('ctx.convert'), icon: '🔄', hidden: !single || !effectiveConvertUrl.value || !w, disabled: !isFile },
997
+ { key: 'access', label: accessLabel, icon: '🔗', hidden: !single || !w },
998
+ { key: 'copy-id', label: copyIdLabel, icon: '🆔', hidden: !singleHasId, disabled: !singleHasId },
999
+ { divider: true, key: 'sep1', label: '', hidden: !w },
1000
+ { key: 'rename', label: t('ctx.rename'), icon: '✎', hidden: !single || !w, disabled: !single },
1001
+ { key: 'cut', label: t('ctx.cut'), icon: '✂', hidden: !any || !w, disabled: !any },
1002
+ { key: 'copy', label: t('ctx.copy'), icon: '❐', hidden: !any, disabled: !any },
1003
+ { key: 'paste', label: t('ctx.paste'), icon: '📋', hidden: !w, disabled: !clipboard.value.mode },
1004
+ { divider: true, key: 'sep-meta', label: '', hidden: !singleHasId },
1005
+ { key: 'tags', label: tagsLabel, icon: '🏷', hidden: !singleHasId, disabled: !singleHasId },
1006
+ { divider: true, key: 'sep2', label: '', hidden: !w },
1007
+ { key: 'delete', label: t('ctx.delete'), icon: '🗑', danger: true, hidden: !any || !w, disabled: !any },
1008
+ ];
1009
+ }
1010
+
1011
+ // toolbarActions — what the top toolbar shows. Mirrors the context menu so the
1012
+ // two stay identical for a selection; the empty/trash/virtual-root cases match
1013
+ // the context menu's special branches.
1014
+ const toolbarActions = computed<ContextAction[]>(() => {
1015
+ const sel = selection.nodes.value;
1016
+ if (trashActive.value) {
1017
+ if (sel.length === 0) return [];
1018
+ return [
1019
+ { key: 'restore', label: t('ctx.restore'), icon: '↩' },
1020
+ { key: 'delete', label: t('ctx.delete_perm'), icon: '🗑', danger: true },
1021
+ ];
1022
+ }
1023
+ const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
1024
+ if (multiStorageRoot.value && trimmedPath === '') {
1025
+ return sel.length === 1 ? [{ key: 'open', label: t('ctx.open'), icon: '↗' }] : [];
1026
+ }
1027
+ if (sel.length === 0) return [];
1028
+ return selectionActionList(sel);
1029
+ });
1030
+
1031
+ async function onContextAction(action: ContextAction, targets: FileNode[]) {
1032
+ if (ctxMode.value === 'breadcrumb') {
1033
+ if (action.key === 'open') {
1034
+ void load(stripAdapter(breadcrumbCtxPath.value));
1035
+ } else if (action.key === 'copy-path') {
1036
+ await onCopyPath(breadcrumbCtxPath.value);
1037
+ }
1038
+ return;
1039
+ }
1040
+ await dispatchItemAction(action.key, targets);
1041
+ }
1042
+
1043
+ // dispatchItemAction — unified handler for an action key on a target set. Both
1044
+ // the right-click menu (onContextAction) and the toolbar (onToolbarAction)
1045
+ // route here, so the two menus that now render the SAME list also behave the
1046
+ // same. (Toolbar "Aç" is the one deliberate exception — see onToolbarAction.)
1047
+ async function dispatchItemAction(key: string, targets: FileNode[]) {
1048
+ switch (key) {
1049
+ case 'open':
1050
+ // Context-menu "Aç" launches the standalone fullscreen route
1051
+ // in a new tab. Double-click (openNode) opens the in-page
1052
+ // modal — two distinct affordances on purpose: quick peek vs
1053
+ // dedicated editing surface.
1054
+ if (targets[0]) openNodeInNewTab(targets[0]);
1055
+ break;
1056
+ case 'preview':
1057
+ if (targets[0]) previewNode(targets[0]);
1058
+ break;
1059
+ case 'download':
1060
+ if (targets[0]) downloadFile(targets[0]);
1061
+ break;
1062
+ case 'convert':
1063
+ if (targets[0]) openConvert(targets[0]);
1064
+ break;
1065
+ case 'share':
1066
+ if (targets[0]) openShare(targets[0]);
1067
+ break;
1068
+ case 'access':
1069
+ if (targets[0]) {
1070
+ permTarget.value = targets[0];
1071
+ showPerm.value = true;
1072
+ }
1073
+ break;
1074
+ case 'copy-id':
1075
+ if (targets[0] && typeof targets[0].id === 'number') {
1076
+ const id = targets[0].id;
1077
+ navigator.clipboard?.writeText(String(id)).then(
1078
+ () => flashToast(locale.value === 'en' ? `Node id ${id} copied` : `Node id ${id} kopyalandı`),
1079
+ () => flashToast(`#${id}`),
1080
+ );
1081
+ }
1082
+ break;
1083
+ case 'tags':
1084
+ if (targets[0]) openTagPickerFor(targets[0]);
1085
+ break;
1086
+ case 'rename':
1087
+ if (targets[0]) {
1088
+ renameTarget.value = targets[0];
1089
+ showRename.value = true;
1090
+ }
1091
+ break;
1092
+ case 'cut':
1093
+ clipboard.value = { mode: 'cut', items: targets, sourcePath: currentPath.value };
1094
+ flashToast('Kes → Yapıştır hazır');
1095
+ break;
1096
+ case 'copy':
1097
+ clipboard.value = { mode: 'copy', items: targets, sourcePath: currentPath.value };
1098
+ flashToast('Kopyala → Yapıştır hazır');
1099
+ break;
1100
+ case 'paste':
1101
+ await paste();
1102
+ break;
1103
+ case 'delete':
1104
+ showDelete.value = true;
1105
+ break;
1106
+ case 'restore':
1107
+ if (targets.length > 0) await restoreSelection(targets);
1108
+ break;
1109
+ case 'new-folder':
1110
+ showNewFolder.value = true;
1111
+ break;
1112
+ case 'duplicate':
1113
+ if (targets[0]) await duplicate(targets[0]);
1114
+ break;
1115
+ }
1116
+ }
1117
+
1118
+ function cut() {
1119
+ if (selection.isEmpty.value) return;
1120
+ clipboard.value = { mode: 'cut', items: selection.nodes.value, sourcePath: currentPath.value };
1121
+ flashToast('Kesildi');
1122
+ }
1123
+
1124
+ function copyToClipboard() {
1125
+ if (selection.isEmpty.value) return;
1126
+ clipboard.value = { mode: 'copy', items: selection.nodes.value, sourcePath: currentPath.value };
1127
+ flashToast('Kopyalandı');
1128
+ }
1129
+
1130
+ async function paste() {
1131
+ const cb = clipboard.value;
1132
+ if (!cb.mode || cb.items.length === 0) return;
1133
+ try {
1134
+ const items = cb.items.map((n) => n.path); // already qualified (adapter://rel)
1135
+ const sourceDir = cb.sourcePath || '';
1136
+ const sameDir = cb.mode === 'cut' && sourceDir === currentPath.value;
1137
+ if (sameDir) {
1138
+ flashToast('Aynı klasöre kesilemez');
1139
+ return;
1140
+ }
1141
+
1142
+ if (cb.mode === 'cut') {
1143
+ const { op } = await api.moveAsync(items, qualify(currentPath.value), qualify(sourceDir) || undefined);
1144
+ pendingOps.register(op);
1145
+ flashToast('Taşıma kuyruğa alındı');
1146
+ } else {
1147
+ const { op } = await api.copy(items, qualify(currentPath.value));
1148
+ pendingOps.register(op);
1149
+ flashToast('Kopyalama kuyruğa alındı');
1150
+ }
1151
+ clipboard.value = { mode: null, items: [], sourcePath: null };
1152
+ } catch (err) {
1153
+ emit('error', { message: (err as Error).message, context: { op: 'paste' } });
1154
+ }
1155
+ }
1156
+
1157
+ async function duplicate(n: FileNode) {
1158
+ try {
1159
+ const { op } = await api.copy([n.path], qualify(currentPath.value));
1160
+ pendingOps.register(op);
1161
+ } catch (err) {
1162
+ emit('error', { message: (err as Error).message, context: { op: 'duplicate' } });
1163
+ }
1164
+ }
1165
+
1166
+ function downloadFile(n: FileNode) {
1167
+ // Keep `<adapter>://<rel>` so backend resolves the right storage
1168
+ // (stripping it would default to the first storage, which 404s for
1169
+ // any non-default storage like S3/SFTP/WebDAV).
1170
+ const url = api.downloadUrl(n.path);
1171
+ window.open(url, '_blank');
1172
+ }
1173
+
1174
+ // ------- Modals -------
1175
+
1176
+ async function submitNewFolder(name: string) {
1177
+ try {
1178
+ await api.newFolder(qualify(currentPath.value), name);
1179
+ showNewFolder.value = false;
1180
+ await load();
1181
+ } catch (err) {
1182
+ emit('error', { message: (err as Error).message, context: { op: 'newfolder' } });
1183
+ }
1184
+ }
1185
+
1186
+ async function submitRename(name: string) {
1187
+ const target = renameTarget.value;
1188
+ if (!target) return;
1189
+ try {
1190
+ await api.rename(qualify(currentPath.value), target.path, name);
1191
+ showRename.value = false;
1192
+ renameTarget.value = null;
1193
+ await load();
1194
+ } catch (err) {
1195
+ emit('error', { message: (err as Error).message, context: { op: 'rename' } });
1196
+ }
1197
+ }
1198
+
1199
+ async function confirmDelete() {
1200
+ // In the trash view, items are already soft-deleted. Permanent removal is
1201
+ // admin-only (and the backend auto-purges after the retention window), so
1202
+ // offer Restore here rather than a delete that would just re-trash a path.
1203
+ if (trashMode.value) {
1204
+ showDelete.value = false;
1205
+ flashToast('Çöpteki öğeler saklama süresi sonunda otomatik silinir. Kalıcı silme yönetici panelinden yapılır.');
1206
+ return;
1207
+ }
1208
+ const items = selection.nodes.value.map((n) => n.path);
1209
+ if (items.length === 0) {
1210
+ showDelete.value = false;
1211
+ return;
1212
+ }
1213
+ try {
1214
+ if (api.endpoints.deleteAsync) {
1215
+ const { op } = await api.deleteAsync(items, qualify(currentPath.value));
1216
+ pendingOps.register(op);
1217
+ flashToast('Silme kuyruğa alındı');
1218
+ } else {
1219
+ await api.deleteItems(qualify(currentPath.value), items);
1220
+ await load();
1221
+ }
1222
+ showDelete.value = false;
1223
+ selection.clear();
1224
+ } catch (err) {
1225
+ emit('error', { message: (err as Error).message, context: { op: 'delete' } });
1226
+ }
1227
+ }
1228
+
1229
+ function openShare(n: FileNode) {
1230
+ shareTarget.value = n;
1231
+ activeShare.value = null;
1232
+ showShare.value = true;
1233
+ }
1234
+
1235
+ function openConvert(n: FileNode) {
1236
+ convertTarget.value = n;
1237
+ showConvert.value = true;
1238
+ }
1239
+
1240
+ function onConvertDone(name: string) {
1241
+ flashToast(locale.value === 'en' ? `Converted → ${name}` : `Dönüştürüldü → ${name}`);
1242
+ void load();
1243
+ }
1244
+
1245
+ async function submitShare(payload: {
1246
+ password: boolean;
1247
+ expires_at: string | null;
1248
+ max_downloads: number | null;
1249
+ }) {
1250
+ const target = shareTarget.value;
1251
+ if (!target) return;
1252
+ try {
1253
+ const { share } = await api.createShare({
1254
+ path: target.path, // qualified `<adapter>://<rel>`
1255
+ password: payload.password,
1256
+ expires_at: payload.expires_at,
1257
+ max_downloads: payload.max_downloads,
1258
+ });
1259
+ activeShare.value = share;
1260
+ emit('share-created', { path: target.path, url: share.url, pin: share.password_pin ?? null });
1261
+ } catch (err) {
1262
+ emit('error', { message: (err as Error).message, context: { op: 'share' } });
1263
+ }
1264
+ }
1265
+
1266
+ function closeShare() {
1267
+ showShare.value = false;
1268
+ shareTarget.value = null;
1269
+ activeShare.value = null;
1270
+ }
1271
+
1272
+ // ------- Upload -------
1273
+
1274
+ function triggerUpload() {
1275
+ if (!canWriteHere.value) {
1276
+ flashToast(locale.value === 'en' ? 'Read-only here' : 'Burada yazma yetkiniz yok');
1277
+ return;
1278
+ }
1279
+ fileInputEl.value?.click();
1280
+ }
1281
+
1282
+ function onFilePicked(ev: Event) {
1283
+ const input = ev.target as HTMLInputElement;
1284
+ const list = input.files ? Array.from(input.files) : [];
1285
+ input.value = '';
1286
+ void uploadFiles(list);
1287
+ }
1288
+
1289
+ async function uploadFiles(list: File[]) {
1290
+ if (list.length === 0) return;
1291
+ const canChunk = !!(api.endpoints.uploadInit && api.endpoints.uploadFinalize);
1292
+ for (const f of list) {
1293
+ // Chunked (S3 multipart) only when the endpoints exist AND the file is
1294
+ // large. If chunked isn't viable (storage has no multipart support —
1295
+ // e.g. the local driver — or init errors out) fall back to the legacy
1296
+ // single-POST upload, which works for any storage / file size.
1297
+ if (canChunk && f.size >= 10 * 1024 * 1024) {
1298
+ if (await chunkedUpload(f)) continue;
1299
+ }
1300
+ await legacyUpload(f);
1301
+ }
1302
+ await load();
1303
+ }
1304
+
1305
+ async function legacyUpload(file: File) {
1306
+ // Register a progress row so the corner badge tracks the upload — large files
1307
+ // fall back here from the chunked path, and previously showed no progress at
1308
+ // all (the chunked placeholder was removed on init failure and the legacy
1309
+ // POST tracked nothing, so the badge vanished mid-upload).
1310
+ const id = crypto.randomUUID();
1311
+ const target = qualify(currentPath.value);
1312
+ uploadJobs.value = [
1313
+ ...uploadJobs.value,
1314
+ { id, file, path: target, totalBytes: file.size, uploadedBytes: 0, percent: 0, status: 'uploading', cancel() {} },
1315
+ ];
1316
+ const patch = (p: Partial<UploadJob>) => {
1317
+ const idx = uploadJobs.value.findIndex((j) => j.id === id);
1318
+ if (idx === -1) return;
1319
+ const next = [...uploadJobs.value];
1320
+ next[idx] = { ...next[idx], ...p };
1321
+ uploadJobs.value = next;
1322
+ };
1323
+ try {
1324
+ await api.uploadMultipart(target, [file], (percent) => {
1325
+ patch({ percent, uploadedBytes: Math.round((percent / 100) * file.size) });
1326
+ emit('upload-progress', { uploadId: id, percent, done: percent >= 100 });
1327
+ });
1328
+ patch({ percent: 100, uploadedBytes: file.size, status: 'done' });
1329
+ emit('upload-progress', { uploadId: id, percent: 100, done: true });
1330
+ } catch (err) {
1331
+ patch({ status: 'error' });
1332
+ emit('error', {
1333
+ message: (err as Error).message,
1334
+ context: { op: 'upload', file: file.name },
1335
+ });
1336
+ }
1337
+ }
1338
+
1339
+ /**
1340
+ * Attempt an S3 multipart (chunked) upload. Returns `true` on success,
1341
+ * `false` when the storage can't do multipart (local driver, init 4xx/5xx)
1342
+ * so the caller can transparently fall back to the legacy single-POST
1343
+ * upload. On failure the progress placeholder is removed — no stuck error
1344
+ * row, no error toast, because the fallback path will report any real error.
1345
+ */
1346
+ async function chunkedUpload(file: File): Promise<boolean> {
1347
+ // Register the progress row LAZILY — only once init succeeded and bytes are
1348
+ // actually moving. A doomed init (local driver / 4xx) then shows no badge at
1349
+ // all, so the legacy fallback's own badge is the only one the user sees (no
1350
+ // appear-then-vanish flicker).
1351
+ const id = crypto.randomUUID();
1352
+ let registered = false;
1353
+ try {
1354
+ await chunked.uploadFile({
1355
+ path: qualify(currentPath.value),
1356
+ file,
1357
+ onProgress: (job) => {
1358
+ if (!registered) {
1359
+ if (job.status !== 'uploading' && job.uploadedBytes <= 0) return;
1360
+ uploadJobs.value = [...uploadJobs.value, { ...job, id } as UploadJob];
1361
+ registered = true;
1362
+ } else {
1363
+ const idx = uploadJobs.value.findIndex((j) => j.id === id);
1364
+ if (idx !== -1) {
1365
+ const next = [...uploadJobs.value];
1366
+ next[idx] = { ...job, id } as UploadJob;
1367
+ uploadJobs.value = next;
1368
+ }
1369
+ }
1370
+ emit('upload-progress', {
1371
+ uploadId: job.uploadId ?? id,
1372
+ percent: job.percent,
1373
+ done: job.status === 'done',
1374
+ });
1375
+ },
1376
+ });
1377
+ return true;
1378
+ } catch {
1379
+ if (registered) uploadJobs.value = uploadJobs.value.filter((j) => j.id !== id);
1380
+ return false;
1381
+ }
1382
+ }
1383
+
1384
+ const dragCounter = ref(0);
1385
+ const dragOver = ref(false);
1386
+
1387
+ /**
1388
+ * isExternalFileDrag — `true` only when the user is dragging files
1389
+ * INTO the page from the OS (file picker, finder, etc.). Filters out:
1390
+ * - internal row drags (FE_DND_MIME present)
1391
+ * - browser image drags (`<img draggable=true>` on this page or
1392
+ * across pages). HTML5 `Files` type is leaky — it appears when
1393
+ * dragging any image element even though no real file is moving;
1394
+ * `dataTransfer.items[*].kind === 'file'` is the canonical signal
1395
+ * for an actual OS file.
1396
+ */
1397
+ function isExternalFileDrag(ev: DragEvent): boolean {
1398
+ const dt = ev.dataTransfer;
1399
+ if (!dt) return false;
1400
+ if (dt.types && dt.types.includes(FE_DND_MIME)) return false;
1401
+ // Some browsers expose `items` early in the drag, others only on
1402
+ // drop. When `items` is available we use it as the authoritative
1403
+ // signal — `kind === 'file'` means a real OS file. When unavailable
1404
+ // (Firefox during dragover sometimes returns 0 items), fall back to
1405
+ // the legacy `Files` type check.
1406
+ if (dt.items && dt.items.length > 0) {
1407
+ let hasFile = false;
1408
+ for (const it of Array.from(dt.items)) {
1409
+ if (it.kind === 'file') {
1410
+ hasFile = true;
1411
+ break;
1412
+ }
1413
+ }
1414
+ return hasFile;
1415
+ }
1416
+ return dt.types ? dt.types.includes('Files') : false;
1417
+ }
1418
+
1419
+ function onDragEnter(ev: DragEvent) {
1420
+ if (!isExternalFileDrag(ev)) return;
1421
+ ev.preventDefault();
1422
+ dragCounter.value++;
1423
+ dragOver.value = true;
1424
+ }
1425
+ function onDragLeave() {
1426
+ dragCounter.value = Math.max(0, dragCounter.value - 1);
1427
+ if (dragCounter.value === 0) dragOver.value = false;
1428
+ }
1429
+ function onDragOver(ev: DragEvent) {
1430
+ if (isExternalFileDrag(ev)) {
1431
+ ev.preventDefault();
1432
+ }
1433
+ }
1434
+ function onDropUpload(ev: DragEvent) {
1435
+ // Internal row drag — nothing to do here, the row drop handler
1436
+ // in GridView/ListView already resolved the move.
1437
+ if (ev.dataTransfer?.types.includes(FE_DND_MIME)) {
1438
+ dragCounter.value = 0;
1439
+ dragOver.value = false;
1440
+ return;
1441
+ }
1442
+ // Browser-internal image drag without real files — bail before
1443
+ // we accidentally synthesise an upload from a 0-length file list
1444
+ // (some browsers populate `files` with zero-byte placeholders).
1445
+ if (!isExternalFileDrag(ev)) {
1446
+ dragCounter.value = 0;
1447
+ dragOver.value = false;
1448
+ return;
1449
+ }
1450
+ ev.preventDefault();
1451
+ dragCounter.value = 0;
1452
+ dragOver.value = false;
1453
+ // RBAC: block drag-drop upload where the user can't write.
1454
+ if (!canWriteHere.value) {
1455
+ flashToast(locale.value === 'en' ? 'Read-only here' : 'Burada yazma yetkiniz yok');
1456
+ return;
1457
+ }
1458
+ const list = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : [];
1459
+ if (list.length === 0) return;
1460
+ void uploadFiles(list);
1461
+ }
1462
+
1463
+ function onWindowDragOver(ev: DragEvent) {
1464
+ if (ev.dataTransfer?.types.includes('Files')) ev.preventDefault();
1465
+ }
1466
+ function onWindowDrop(ev: DragEvent) {
1467
+ const root = rootEl.value;
1468
+ const target = ev.target as Node | null;
1469
+ if (root && target && !root.contains(target)) {
1470
+ ev.preventDefault();
1471
+ }
1472
+ }
1473
+ onMounted(() => {
1474
+ window.addEventListener('dragover', onWindowDragOver);
1475
+ window.addEventListener('drop', onWindowDrop);
1476
+ });
1477
+ onBeforeUnmount(() => {
1478
+ window.removeEventListener('dragover', onWindowDragOver);
1479
+ window.removeEventListener('drop', onWindowDrop);
1480
+ window.removeEventListener('hashchange', onHashChange);
1481
+ });
1482
+
1483
+ const clippedPaths = computed<Set<string>>(() => {
1484
+ if (clipboard.value.mode !== 'cut') return new Set();
1485
+ return new Set(clipboard.value.items.map((n) => n.path));
1486
+ });
1487
+
1488
+ // --------------------------------------------------------------------
1489
+ // Item drag&drop move
1490
+ // --------------------------------------------------------------------
1491
+
1492
+ const FE_DND_MIME = 'application/x-brf-files';
1493
+
1494
+ function onItemDragStart(node: FileNode, ev: DragEvent) {
1495
+ if (!ev.dataTransfer) return;
1496
+ if (node.basename === '.trash') {
1497
+ ev.preventDefault();
1498
+ return;
1499
+ }
1500
+ if (!selection.has(node.path)) {
1501
+ selection.click(node.path);
1502
+ }
1503
+ const items = selection.nodes.value
1504
+ .filter((n) => !clippedPaths.value.has(n.path))
1505
+ .filter((n) => n.basename !== '.trash')
1506
+ .map((n) => ({ path: n.path, basename: n.basename, type: n.type })); // qualified
1507
+ ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
1508
+ ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
1509
+ ev.dataTransfer.effectAllowed = 'move';
1510
+ }
1511
+
1512
+ async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string): Promise<void> {
1513
+ try {
1514
+ if (api.endpoints.moveAsync) {
1515
+ const { op } = await api.moveAsync(sources, targetDir, qualify(currentPath.value));
1516
+ pendingOps.register(op);
1517
+ flashToast('Taşıma kuyruğa alındı');
1518
+ } else {
1519
+ await api.move(qualify(currentPath.value), sources, targetDir);
1520
+ await load();
1521
+ }
1522
+ selection.clear();
1523
+ } catch (err) {
1524
+ emit('error', { message: (err as Error).message, context: { op: opLabel, targetDir } });
1525
+ }
1526
+ }
1527
+
1528
+ async function onItemDropInto(target: FileNode, ev: DragEvent) {
1529
+ if (target.type !== 'dir') return;
1530
+ const raw = ev.dataTransfer?.getData(FE_DND_MIME);
1531
+ if (!raw) return;
1532
+ let items: Array<{ path: string }> = [];
1533
+ try {
1534
+ items = JSON.parse(raw);
1535
+ } catch {
1536
+ return;
1537
+ }
1538
+ if (items.length === 0) return;
1539
+
1540
+ const targetDir = target.path; // qualified
1541
+ const sources = items
1542
+ .map((i) => i.path)
1543
+ .filter((p) => p && p !== targetDir && !targetDir.startsWith(p + '/'));
1544
+ if (sources.length === 0) {
1545
+ flashToast('Aynı klasöre taşınamaz');
1546
+ return;
1547
+ }
1548
+ await moveSourcesAsync(sources, targetDir, 'move-dnd');
1549
+ }
1550
+
1551
+ async function onCrumbDropInto(adapterPath: string, ev: DragEvent) {
1552
+ const raw = ev.dataTransfer?.getData(FE_DND_MIME);
1553
+ if (!raw) return;
1554
+ let items: Array<{ path: string }> = [];
1555
+ try {
1556
+ items = JSON.parse(raw);
1557
+ } catch {
1558
+ return;
1559
+ }
1560
+ if (items.length === 0) return;
1561
+
1562
+ const targetDir = adapterPath; // already qualified by breadcrumb
1563
+ const sources = items
1564
+ .map((i) => i.path)
1565
+ .filter((p) => p && p !== targetDir && !targetDir.startsWith(p + '/'));
1566
+ if (sources.length === 0) return;
1567
+ await moveSourcesAsync(sources, targetDir, 'move-dnd-crumb');
1568
+ }
1569
+
1570
+ function onCancelUpload(job: UploadJob) {
1571
+ job.cancel();
1572
+ }
1573
+
1574
+ function onDismissUpload(job: UploadJob) {
1575
+ uploadJobs.value = uploadJobs.value.filter((j) => j.id !== job.id);
1576
+ }
1577
+
1578
+ // ------- Breadcrumb -------
1579
+
1580
+ function onNavigate(adapterPath: string) {
1581
+ // Multi-storage emits empty string for the global "/" crumb. The
1582
+ // load() function recognises that as the storage-list virtual root.
1583
+ if (multiStorageRoot.value && !adapterPath) {
1584
+ void load('');
1585
+ return;
1586
+ }
1587
+ if (multiStorageRoot.value) {
1588
+ void load(wireToVirtual(adapterPath));
1589
+ return;
1590
+ }
1591
+ void load(stripAdapter(adapterPath));
1592
+ }
1593
+
1594
+ async function onCopyPath(adapterPath: string) {
1595
+ try {
1596
+ await navigator.clipboard.writeText(adapterPath);
1597
+ flashToast(t('breadcrumb.copy_path'));
1598
+ } catch {
1599
+ /* no-op */
1600
+ }
1601
+ }
1602
+
1603
+ // Sync auth-headers builder for PreviewModal — fetches against the
1604
+ // OnlyOffice config endpoint and the saveText endpoint need real
1605
+ // headers, not promises. Function-token bearers will use the cached
1606
+ // token; first-call resolution happens via the async path elsewhere.
1607
+ function buildAuthHeaders(extra: Record<string, string> = {}) {
1608
+ return api.authHeadersSync({ ...extra });
1609
+ }
1610
+ </script>
1611
+
1612
+ <template>
1613
+ <div
1614
+ ref="rootEl"
1615
+ class="fe"
1616
+ :class="{
1617
+ 'fe--theme-light': config.theme === 'light',
1618
+ 'fe--theme-dark': config.theme === 'dark',
1619
+ 'fe--is-dragover': dragOver,
1620
+ }"
1621
+ tabindex="-1"
1622
+ @dragenter="onDragEnter"
1623
+ @dragover="onDragOver"
1624
+ @dragleave="onDragLeave"
1625
+ @drop="onDropUpload"
1626
+ @contextmenu="onContextCanvas"
1627
+ >
1628
+ <Toolbar
1629
+ ref="toolbarRef"
1630
+ :view-mode="viewMode"
1631
+ :search-query="searchQuery"
1632
+ :trash-active="trashActive"
1633
+ :actions="toolbarActions"
1634
+ :selection-mode="selectionMode"
1635
+ :paste-enabled="!!clipboard.mode"
1636
+ :convert-enabled="!!effectiveConvertUrl"
1637
+ :can-go-up="canGoUp"
1638
+ :at-virtual-root="atVirtualRoot"
1639
+ :can-write="canWriteHere"
1640
+ :locale="locale"
1641
+ @update:view-mode="viewMode = $event"
1642
+ @update:search-query="searchQuery = $event"
1643
+ @new-folder="showNewFolder = true"
1644
+ @upload="triggerUpload"
1645
+ @refresh="() => load()"
1646
+ @go-up="goUp"
1647
+ @action="onToolbarAction"
1648
+ @open-recents="showRecents = true"
1649
+ />
1650
+
1651
+ <Breadcrumb
1652
+ :dirname="dirname"
1653
+ :adapter="adapter"
1654
+ :root-label="adapter"
1655
+ :locale="locale"
1656
+ :multi-storage-root="multiStorageRoot"
1657
+ :root-path="rootPathProp"
1658
+ @navigate="onNavigate"
1659
+ @copy-path="onCopyPath"
1660
+ @crumb-context="onCrumbContext"
1661
+ @crumb-drop="onCrumbDropInto"
1662
+ />
1663
+
1664
+ <div class="fe__body" @click.self="selection.clear()">
1665
+ <!-- Initial load: show a spinner rather than an empty/"no files" flash.
1666
+ Only when there's nothing yet — navigation keeps the current list. -->
1667
+ <div v-if="loading && files.length === 0" class="fe__loading">
1668
+ <span class="fe__spinner" aria-hidden="true"></span>
1669
+ <p class="fe__loading-text">{{ t('loading') }}</p>
1670
+ </div>
1671
+ <ListView
1672
+ v-else-if="viewMode === 'list'"
1673
+ :files="files"
1674
+ :selected="selection.selected.value"
1675
+ :clipped="clippedPaths"
1676
+ :show-parent-path="!!searchQuery"
1677
+ :locale="locale"
1678
+ :loading="loading"
1679
+ :starred-ids="starredIds"
1680
+ :api-base="props.config.apiBase ?? ''"
1681
+ :auth-headers="() => buildAuthHeaders()"
1682
+ @click-row="(n, m) => selection.click(n.path, m)"
1683
+ @dbl-row="openNode"
1684
+ @context-row="onContextTarget"
1685
+ @item-drag-start="onItemDragStart"
1686
+ @item-drop-into="onItemDropInto"
1687
+ @star-change="onStarChange"
1688
+ />
1689
+ <GridView
1690
+ v-else
1691
+ :files="files"
1692
+ :selected="selection.selected.value"
1693
+ :clipped="clippedPaths"
1694
+ :show-parent-path="!!searchQuery"
1695
+ :locale="locale"
1696
+ :loading="loading"
1697
+ @click-card="(n, m) => selection.click(n.path, m)"
1698
+ @dbl-card="openNode"
1699
+ @context-card="onContextTarget"
1700
+ @item-drag-start="onItemDragStart"
1701
+ @item-drop-into="onItemDropInto"
1702
+ />
1703
+ </div>
1704
+
1705
+ <div v-if="dragOver" class="fe__dragover">
1706
+ <div class="fe__dragover-card">
1707
+ <span class="fe-icon">⬆</span>
1708
+ <p>Dosyaları buraya bırak</p>
1709
+ </div>
1710
+ </div>
1711
+
1712
+ <UploadProgress
1713
+ :jobs="uploadJobs"
1714
+ :locale="locale"
1715
+ @cancel="onCancelUpload"
1716
+ @dismiss="onDismissUpload"
1717
+ />
1718
+
1719
+ <PendingOpsTray
1720
+ :ops="pendingOps.ops.value"
1721
+ :locale="locale"
1722
+ @dismiss="(id) => pendingOps.dismiss(id)"
1723
+ />
1724
+
1725
+ <ContextMenu
1726
+ ref="ctxRef"
1727
+ :locale="locale"
1728
+ :theme="config.theme || 'auto'"
1729
+ :actions="contextActions"
1730
+ @select="onContextAction"
1731
+ />
1732
+
1733
+ <NewFolderModal
1734
+ :open="showNewFolder"
1735
+ :locale="locale"
1736
+ @close="showNewFolder = false"
1737
+ @submit="submitNewFolder"
1738
+ />
1739
+ <RenameModal
1740
+ :open="showRename"
1741
+ :locale="locale"
1742
+ :current-name="renameTarget?.basename || ''"
1743
+ @close="showRename = false"
1744
+ @submit="submitRename"
1745
+ />
1746
+ <DeleteConfirmModal
1747
+ :open="showDelete"
1748
+ :locale="locale"
1749
+ :count="selection.size.value"
1750
+ @close="showDelete = false"
1751
+ @confirm="confirmDelete"
1752
+ />
1753
+ <ShareModal
1754
+ :open="showShare"
1755
+ :locale="locale"
1756
+ :share="activeShare"
1757
+ @close="closeShare"
1758
+ @submit="submitShare"
1759
+ @toast="flashToast"
1760
+ />
1761
+ <PreviewModal
1762
+ :open="showPreview"
1763
+ :locale="locale"
1764
+ :file="previewTarget"
1765
+ :theme="config.theme || 'auto'"
1766
+ :preview-url="(p) => api.previewUrl(p)"
1767
+ :download-url="(p) => api.downloadUrl(p)"
1768
+ :only-office-base="effectiveOnlyOfficeBase"
1769
+ :only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
1770
+ :save-text-endpoint="api.endpoints.saveText || null"
1771
+ :open-mode="previewMode"
1772
+ :auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
1773
+ :auth-credentials="api.credentialsMode()"
1774
+ :drawio-url="effectiveDrawioUrl"
1775
+ :pdf-worker-url="props.config.pdfWorkerUrl || null"
1776
+ :pdf-save-url="props.config.pdfSaveUrl || null"
1777
+ :viewer-base-url="props.config.viewerBaseUrl || null"
1778
+ @close="showPreview = false"
1779
+ />
1780
+ <ConvertModal
1781
+ v-if="showConvert && convertTarget && effectiveConvertUrl"
1782
+ :convert-url="effectiveConvertUrl"
1783
+ :file-name="convertTarget?.basename || convertTarget?.path || ''"
1784
+ :fetch-bytes="() => api.fetchArrayBuffer(convertTarget?.path ?? '')"
1785
+ :upload="(f) => api.uploadMultipart(qualify(currentPath), [f]).then(() => {})"
1786
+ @close="showConvert = false"
1787
+ @done="onConvertDone"
1788
+ />
1789
+ <PermissionsModal
1790
+ v-if="showPerm && permTarget"
1791
+ :api="api"
1792
+ :path="permTarget.path"
1793
+ :is-dir="permTarget.type === 'dir'"
1794
+ :size="typeof permTarget.size === 'number' ? permTarget.size : undefined"
1795
+ :locale="locale === 'en' ? 'en' : 'tr'"
1796
+ @close="showPerm = false"
1797
+ />
1798
+
1799
+ <!-- Recently-opened tray. Anchored to the toolbar trigger via fixed
1800
+ position; click the backdrop or any entry to dismiss.
1801
+ `.fe` + theme class keeps the dark/light cascade matching the
1802
+ host shell — without them the popup floats outside the
1803
+ FileExplorer root and falls back to :root light defaults. -->
1804
+ <transition name="fe-modal">
1805
+ <div
1806
+ v-if="showRecents"
1807
+ class="fe fe-modal__backdrop fe-recents__backdrop"
1808
+ :class="{
1809
+ 'fe--theme-light': config.theme === 'light',
1810
+ 'fe--theme-dark': config.theme === 'dark',
1811
+ }"
1812
+ @click="showRecents = false"
1813
+ >
1814
+ <div class="fe-recents__panel" @click.stop>
1815
+ <div class="fe-recents__header">
1816
+ <strong>{{ locale === 'en' ? 'Recently opened' : 'Son açılanlar' }}</strong>
1817
+ <button class="fe-recents__close" aria-label="Close" @click="showRecents = false">×</button>
1818
+ </div>
1819
+ <RecentlyOpened
1820
+ :api-base="props.config.apiBase ?? ''"
1821
+ :auth-headers="() => buildAuthHeaders()"
1822
+ :limit="20"
1823
+ :refresh-key="recentRefreshKey"
1824
+ @open="onRecentOpen"
1825
+ @error="(msg: string) => emit('error', { message: msg, context: { op: 'recents' } })"
1826
+ />
1827
+ </div>
1828
+ </div>
1829
+ </transition>
1830
+
1831
+ <!-- Tag editor — opened from the context menu via Etiketler. -->
1832
+ <transition name="fe-modal">
1833
+ <div
1834
+ v-if="showTagPicker && tagPickerNode && typeof tagPickerNode.id === 'number'"
1835
+ class="fe-modal__backdrop"
1836
+ @click="showTagPicker = false"
1837
+ >
1838
+ <div class="fe-modal__card fe-modal__card--md" @click.stop>
1839
+ <header class="fe-modal__head">
1840
+ <h2 class="fe-modal__title">
1841
+ {{ locale === 'en' ? 'Tags' : 'Etiketler' }} — {{ tagPickerNode.basename }}
1842
+ </h2>
1843
+ <button class="fe-modal__close" aria-label="Close" @click="showTagPicker = false">×</button>
1844
+ </header>
1845
+ <div class="fe-modal__body">
1846
+ <TagPicker
1847
+ :node-id="tagPickerNode.id"
1848
+ :api-base="props.config.apiBase ?? ''"
1849
+ :auth-headers="() => buildAuthHeaders()"
1850
+ @error="(msg: string) => emit('error', { message: msg, context: { op: 'tags' } })"
1851
+ />
1852
+ </div>
1853
+ </div>
1854
+ </div>
1855
+ </transition>
1856
+
1857
+ <input
1858
+ ref="fileInputEl"
1859
+ type="file"
1860
+ multiple
1861
+ class="fe__file-input"
1862
+ @change="onFilePicked"
1863
+ />
1864
+
1865
+ <transition name="fe-toast">
1866
+ <div v-if="toast" class="fe-toast">{{ toast }}</div>
1867
+ </transition>
1868
+ </div>
1869
+ </template>
1870
+
1871
+ <style src="./styles/variables.css"></style>
1872
+ <style src="./styles/base.css"></style>