@brftech/filex-core 0.1.83 → 0.2.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.
Files changed (45) hide show
  1. package/dist/{ArchiveViewer-BRASXNIS.js → ArchiveViewer-DCZ1FZYV.js} +2 -2
  2. package/dist/{ArchiveViewer-BRASXNIS.js.map → ArchiveViewer-DCZ1FZYV.js.map} +1 -1
  3. package/dist/{CsvViewer-CgiLiHWw.js → CsvViewer-DMvnp82x.js} +2 -2
  4. package/dist/{CsvViewer-CgiLiHWw.js.map → CsvViewer-DMvnp82x.js.map} +1 -1
  5. package/dist/{DrawioViewer-D2I_uEng.js → DrawioViewer-B5Io0yTO.js} +2 -2
  6. package/dist/{DrawioViewer-D2I_uEng.js.map → DrawioViewer-B5Io0yTO.js.map} +1 -1
  7. package/dist/{EpubViewer-6nVPRR6R.js → EpubViewer-z6RZd3K8.js} +2 -2
  8. package/dist/{EpubViewer-6nVPRR6R.js.map → EpubViewer-z6RZd3K8.js.map} +1 -1
  9. package/dist/{IpynbViewer-B7p9hxFx.js → IpynbViewer-DfL4EauH.js} +2 -2
  10. package/dist/{IpynbViewer-B7p9hxFx.js.map → IpynbViewer-DfL4EauH.js.map} +1 -1
  11. package/dist/{MermaidViewer-O9vGvp4y.js → MermaidViewer-QNl_o7V-.js} +2 -2
  12. package/dist/{MermaidViewer-O9vGvp4y.js.map → MermaidViewer-QNl_o7V-.js.map} +1 -1
  13. package/dist/{PsdViewer-C6EaEAxF.js → PsdViewer-Btmpr0Fz.js} +2 -2
  14. package/dist/{PsdViewer-C6EaEAxF.js.map → PsdViewer-Btmpr0Fz.js.map} +1 -1
  15. package/dist/{TiffViewer-Dod4uIXI.js → TiffViewer-CMLpwds5.js} +2 -2
  16. package/dist/{TiffViewer-Dod4uIXI.js.map → TiffViewer-CMLpwds5.js.map} +1 -1
  17. package/dist/{Viewer3D-B3kLZrwO.js → Viewer3D-gf3cb2gv.js} +2 -2
  18. package/dist/{Viewer3D-B3kLZrwO.js.map → Viewer3D-gf3cb2gv.js.map} +1 -1
  19. package/dist/filex-core.js +17 -15
  20. package/dist/filex-core.umd.cjs +38 -38
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-D49iw00E.js +7119 -0
  23. package/dist/index-D49iw00E.js.map +1 -0
  24. package/dist/index.d.ts +63 -0
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +440 -32
  28. package/src/components/Breadcrumb.vue +100 -5
  29. package/src/components/CommandPalette.vue +533 -0
  30. package/src/components/GridView.vue +8 -13
  31. package/src/components/ListView.vue +199 -21
  32. package/src/components/ShortcutsHelp.vue +59 -0
  33. package/src/components/Toolbar.vue +65 -1
  34. package/src/composables/useFileApi.ts +45 -0
  35. package/src/composables/useKeyboardShortcuts.ts +38 -0
  36. package/src/composables/useRealtime.ts +9 -1
  37. package/src/index.ts +4 -0
  38. package/src/lib/fileIcons.ts +127 -0
  39. package/src/lib/snippet.ts +43 -0
  40. package/src/locales/en.ts +62 -0
  41. package/src/locales/tr.ts +62 -0
  42. package/src/styles/base.css +650 -0
  43. package/src/styles/variables.css +70 -0
  44. package/dist/index-DL6_eaM3.js +0 -5888
  45. package/dist/index-DL6_eaM3.js.map +0 -1
@@ -24,7 +24,7 @@ import type {
24
24
  Capabilities,
25
25
  } from './types/FileNode';
26
26
  import { isExternalUsable } from './types/FileNode';
27
- import { useFileApi } from './composables/useFileApi';
27
+ import { useFileApi, type GlobalSearchHit } from './composables/useFileApi';
28
28
  import { useUploadChunked, type UploadJob } from './composables/useUploadChunked';
29
29
  import { useSelection } from './composables/useSelection';
30
30
  import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
@@ -45,6 +45,10 @@ import GridView from './components/GridView.vue';
45
45
  import ContextMenu, { type ContextAction } from './components/ContextMenu.vue';
46
46
  import UploadProgress from './components/UploadProgress.vue';
47
47
  import PendingOpsTray from './components/PendingOpsTray.vue';
48
+ /* cila:c wiring */
49
+ import CommandPalette from './components/CommandPalette.vue';
50
+ import ShortcutsHelp from './components/ShortcutsHelp.vue';
51
+ /* /cila:c wiring */
48
52
 
49
53
  import NewFolderModal from './modals/NewFolderModal.vue';
50
54
  import RenameModal from './modals/RenameModal.vue';
@@ -79,6 +83,12 @@ const emit = defineEmits<{
79
83
 
80
84
  const api = useFileApi(props.config);
81
85
 
86
+ // Locale up-front: the pendingOps onSettled callback below (and the undo-toast
87
+ // helpers) need `t()` at runtime, so the catalogue must be constructed before
88
+ // they are wired. Depends only on props — safe this early.
89
+ const locale = computed(() => props.config.locale || 'tr');
90
+ const { t } = useLocale(locale);
91
+
82
92
  // Live collaboration (WebSocket file-change events + presence), bundled into the
83
93
  // core so every consumer — the native panel AND the embedded webcomponent —
84
94
  // gets it. Auth is a short-lived ticket fetched through the same API (works
@@ -86,6 +96,10 @@ const api = useFileApi(props.config);
86
96
  // socket is available.
87
97
  const realtime = useRealtime(api, { reload: () => load() });
88
98
  const presenceUsers = realtime.presenceUsers;
99
+ // True while the live socket is unavailable and the explorer runs on the
100
+ // polling fallback — drives the small "no live connection" badge. Healthy
101
+ // connections show nothing.
102
+ const realtimeDegraded = realtime.degraded;
89
103
  function realtimeRoom(vp: string): string | null {
90
104
  const p = (vp || '').replace(/^\/+|\/+$/g, '');
91
105
  if (p === '.trash' || p.startsWith('.trash/')) return null;
@@ -112,10 +126,21 @@ onBeforeUnmount(() => realtime.stop());
112
126
  const thumbs = useThumbs(props.config.apiBase, api);
113
127
 
114
128
  const chunked = useUploadChunked(props.config, api);
129
+
130
+ // Undo registry for async pending ops: when a cleanly-invertible operation
131
+ // (move → reverse move, trash-delete → restore) is queued, its inverse is
132
+ // registered under the op id; once the op settles OK the toast grows a
133
+ // "Geri Al" action. Ops without an entry keep the plain settled toast.
134
+ const opUndo = new Map<number, { message: string; fn: () => Promise<void> }>();
135
+
115
136
  const pendingOps = usePendingOps(props.config, api, {
116
137
  onSettled: (op: PendingOp) => {
138
+ const undo = opUndo.get(op.id);
139
+ opUndo.delete(op.id);
117
140
  if (op.status === 'error') {
118
141
  flashToast(op.error_message || 'İşlem başarısız');
142
+ } else if (undo) {
143
+ undoToast(`${undo.message} (${op.progress_total})`, undo.fn);
119
144
  } else {
120
145
  const verb =
121
146
  op.op_type === 'copy'
@@ -149,6 +174,16 @@ const dirPerm = ref<string>('');
149
174
  // back 404 (folder doesn't exist) or 403 (RBAC-hidden — rendered identically
150
175
  // on purpose so a denied folder doesn't reveal that it exists). '' = none.
151
176
  const notFoundPath = ref<string>('');
177
+ // Listing failure that is NOT a dead link (network error, 5xx): remembered so
178
+ // the body can render a retryable error state instead of a misleading "this
179
+ // folder is empty". Only shown when no listing is visible — a failed
180
+ // navigation away from a healthy listing keeps the current list + toast,
181
+ // exactly as before.
182
+ const loadError = ref<string>('');
183
+ let loadErrorPath: string | undefined;
184
+ function retryLoad() {
185
+ void load(loadErrorPath);
186
+ }
152
187
 
153
188
  const VIEW_MODE_KEY = 'brf-file-explorer:view-mode';
154
189
  const viewMode = customRef<ViewMode>((track, trigger) => {
@@ -178,6 +213,9 @@ const viewMode = customRef<ViewMode>((track, trigger) => {
178
213
  },
179
214
  };
180
215
  });
216
+ /* cila:a density — Toolbar owns the persisted preference (filex.density);
217
+ mirrored here only so the root `.fe` can carry fe--density-compact. */
218
+ const density = ref<'comfortable' | 'compact'>('comfortable');
181
219
  const searchQuery = ref('');
182
220
  // trashMode — true while viewing the filex trash (soft-deleted nodes from the
183
221
  // backend trash endpoint), entered by opening the virtual `.trash` row and
@@ -189,7 +227,6 @@ const trashMode = ref(false);
189
227
  // global root). Set in loadTrash().
190
228
  const trashOrigin = ref<string>('');
191
229
  const trashActive = computed(() => trashMode.value);
192
- const locale = computed(() => props.config.locale || 'tr');
193
230
 
194
231
  // canGoUp/goUp — toolbar's "↑ Up one level" button. In single-storage
195
232
  // mode "" means the storage root; in multi-storage mode "" means
@@ -224,8 +261,6 @@ function goUp() {
224
261
  void load(parent);
225
262
  }
226
263
 
227
- const { t } = useLocale(locale);
228
-
229
264
  const selection = useSelection(() => files.value);
230
265
  watch(
231
266
  () => [...selection.selected.value],
@@ -396,19 +431,57 @@ function selPerm(sel: FileNode[]): string {
396
431
  // Can the current user write into the directory being viewed? Gates the
397
432
  // toolbar New Folder / Upload / Paste + drag-drop upload.
398
433
  const canWriteHere = computed(() => permCanEdit(dirPerm.value));
434
+ // Empty-state affordances: the "drop files here" hint + upload button only
435
+ // make sense in a real writable folder (not the virtual drives root, not the
436
+ // trash view).
437
+ const emptyCanUpload = computed(
438
+ () => canWriteHere.value && !atVirtualRoot.value && !trashMode.value,
439
+ );
399
440
 
400
441
  // Context menu
401
442
  const ctxRef = ref<InstanceType<typeof ContextMenu> | null>(null);
402
443
  const rootEl = ref<HTMLElement | null>(null);
403
444
  const toolbarRef = ref<InstanceType<typeof Toolbar> | null>(null);
404
445
 
405
- // Toast (tiny, no lib)
406
- const toast = ref<string | null>(null);
446
+ // Toast (tiny, no lib). Evolved into a snackbar: plain messages keep the old
447
+ // 2.5s auto-hide; messages carrying an action ("Geri Al") stay 8s and can be
448
+ // dismissed by click or Esc.
449
+ interface ToastState {
450
+ message: string;
451
+ actionLabel?: string;
452
+ action?: () => void | Promise<void>;
453
+ }
454
+ const toast = ref<ToastState | null>(null);
407
455
  let toastTimer: ReturnType<typeof setTimeout> | undefined;
408
- function flashToast(msg: string) {
409
- toast.value = msg;
456
+ function showToast(state: ToastState, ms: number) {
457
+ toast.value = state;
410
458
  if (toastTimer) clearTimeout(toastTimer);
411
- toastTimer = setTimeout(() => (toast.value = null), 2500);
459
+ toastTimer = setTimeout(() => (toast.value = null), ms);
460
+ }
461
+ function flashToast(msg: string) {
462
+ showToast({ message: msg }, 2500);
463
+ }
464
+ function undoToast(message: string, undo: () => Promise<void>) {
465
+ showToast({ message, actionLabel: t('toast.undo'), action: undo }, 8000);
466
+ }
467
+ function dismissToast() {
468
+ if (toastTimer) {
469
+ clearTimeout(toastTimer);
470
+ toastTimer = undefined;
471
+ }
472
+ toast.value = null;
473
+ }
474
+ async function runToastAction() {
475
+ const act = toast.value?.action;
476
+ dismissToast();
477
+ if (!act) return;
478
+ try {
479
+ await act();
480
+ flashToast(t('toast.undone'));
481
+ await load();
482
+ } catch {
483
+ flashToast(t('toast.undo_failed'));
484
+ }
412
485
  }
413
486
 
414
487
  // --------------------------------------------------------------------
@@ -477,6 +550,7 @@ async function load(path?: string) {
477
550
  let requested = path ?? currentPath.value ?? '';
478
551
  try {
479
552
  notFoundPath.value = '';
553
+ loadError.value = '';
480
554
  // Clamp to the confined floor: an empty/above-floor request (incl. a stale
481
555
  // persisted path or the drives root) snaps back to rootPath. This both
482
556
  // suppresses the multi-storage drives list and blocks up-navigation.
@@ -549,8 +623,13 @@ async function load(path?: string) {
549
623
  emit('error', { message: e, context: { path } });
550
624
  return;
551
625
  }
626
+ // Real failure (network, 5xx). Never swallowed: the error still emits, and
627
+ // it surfaces either as the retryable error state (nothing else on screen)
628
+ // or as the classic toast over the still-visible previous listing.
629
+ loadError.value = e;
630
+ loadErrorPath = typeof requested === 'string' ? requested : undefined;
552
631
  emit('error', { message: e, context: { path } });
553
- flashToast(e);
632
+ if (files.value.length > 0) flashToast(e);
554
633
  } finally {
555
634
  loading.value = false;
556
635
  }
@@ -656,6 +735,53 @@ function qualify(p: string): string {
656
735
  return `${adapter.value}://${p.replace(/^\/+/, '')}`;
657
736
  }
658
737
 
738
+ // ----------------------------------------------------------------
739
+ // Undo helpers — compute the inverse of cleanly-invertible operations
740
+ // (move → reverse move, rename → rename back, trash → restore). All
741
+ // paths here are wire form (`<adapter>://<rel>`).
742
+ // ----------------------------------------------------------------
743
+
744
+ function wireBasename(p: string): string {
745
+ const idx = p.indexOf('://');
746
+ const rel = (idx === -1 ? p : p.slice(idx + 3)).replace(/\/+$/, '');
747
+ const slash = rel.lastIndexOf('/');
748
+ return slash === -1 ? rel : rel.slice(slash + 1);
749
+ }
750
+
751
+ function wireParent(p: string): string {
752
+ const idx = p.indexOf('://');
753
+ const prefix = idx === -1 ? '' : p.slice(0, idx + 3);
754
+ const rel = (idx === -1 ? p : p.slice(idx + 3)).replace(/\/+$/, '');
755
+ const slash = rel.lastIndexOf('/');
756
+ return slash === -1 ? prefix : prefix + rel.slice(0, slash);
757
+ }
758
+
759
+ function wireJoin(dir: string, name: string): string {
760
+ if (!dir) return name;
761
+ return dir.endsWith('://') || dir.endsWith('/') ? dir + name : `${dir}/${name}`;
762
+ }
763
+
764
+ // Register the inverse of a queued async move under its op id: once the op
765
+ // settles OK, the toast offers "Geri Al" which queues the reverse move. The
766
+ // inverse op deliberately gets NO undo entry of its own (no redo ping-pong).
767
+ function registerMoveUndo(
768
+ opId: number,
769
+ sources: string[],
770
+ targetWire: string,
771
+ originWire: string | undefined,
772
+ ) {
773
+ if (!originWire || !targetWire) return;
774
+ const movedPaths = sources.map((p) => wireJoin(targetWire, wireBasename(p)));
775
+ if (movedPaths.length === 0) return;
776
+ opUndo.set(opId, {
777
+ message: t('toast.moved'),
778
+ fn: async () => {
779
+ const { op } = await api.moveAsync(movedPaths, originWire, targetWire);
780
+ pendingOps.register(op);
781
+ },
782
+ });
783
+ }
784
+
659
785
  watch(
660
786
  () => searchQuery.value,
661
787
  () => void load(),
@@ -793,6 +919,55 @@ onMounted(async () => {
793
919
  // Keyboard
794
920
  // --------------------------------------------------------------------
795
921
 
922
+ /* cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) state */
923
+ const showPalette = ref(false);
924
+ const showShortcutsHelp = ref(false);
925
+ /* /cila:c wiring */
926
+
927
+ /* bul:s3 — palette "everywhere" search + open-hit navigation */
928
+
929
+ // Debounce/min-chars live in the palette; this is just the API call.
930
+ function paletteGlobalSearch(q: string): Promise<GlobalSearchHit[]> {
931
+ return api.globalSearch(q, { limit: 8, scope: 'all' });
932
+ }
933
+
934
+ /**
935
+ * Open a global-search hit: navigate to the file's folder, then select +
936
+ * preview it through the existing openNode mechanics. Hits come back as raw
937
+ * node rows (in-storage relative `path`, numeric `storage_id`), so the
938
+ * storage segment for multi-storage mode is resolved best-effort: an
939
+ * explicit name on the hit (future backends) > the only configured storage
940
+ * > the storage currently open. A wrong guess lands on the existing
941
+ * "folder not found" state, which is already a graceful dead-end.
942
+ */
943
+ async function openSearchHit(hit: GlobalSearchHit) {
944
+ const rel = String(hit.path ?? '').replace(/^\/+|\/+$/g, '');
945
+ if (!rel) return;
946
+ const isDir = hit.type === 'dir';
947
+ const slash = rel.lastIndexOf('/');
948
+ const targetRel = isDir ? rel : slash === -1 ? '' : rel.slice(0, slash);
949
+ let target = targetRel;
950
+ if (multiStorageRoot.value) {
951
+ const configured = props.config.storages ?? [];
952
+ const storageName =
953
+ (typeof hit.storage === 'string' && hit.storage) ||
954
+ (typeof hit.storage_name === 'string' && hit.storage_name) ||
955
+ (configured.length === 1 ? configured[0].name : '') ||
956
+ adapter.value;
957
+ if (!storageName) return;
958
+ target = targetRel ? `${storageName}/${targetRel}` : storageName;
959
+ }
960
+ await load(target);
961
+ if (isDir) return;
962
+ const name = String(hit.name ?? rel.slice(slash + 1));
963
+ const node = files.value.find((f) => f.type === 'file' && f.basename === name);
964
+ if (node) {
965
+ selection.click(node.path);
966
+ openNode(node);
967
+ }
968
+ }
969
+ /* /bul:s3 */
970
+
796
971
  useKeyboardShortcuts(rootEl, {
797
972
  onDelete: () => {
798
973
  if (!selection.isEmpty.value) showDelete.value = true;
@@ -815,12 +990,21 @@ useKeyboardShortcuts(rootEl, {
815
990
  showShare.value = false;
816
991
  showPreview.value = false;
817
992
  ctxRef.value?.hide();
993
+ dismissToast();
818
994
  },
819
995
  onFocusSearch: () => toolbarRef.value?.focusSearch(),
820
996
  onCut: () => cut(),
821
997
  onCopy: () => copyToClipboard(),
822
998
  onPaste: () => paste(),
823
999
  onGoUp: () => goUp(),
1000
+ /* cila:c wiring */
1001
+ onPathJump: () => {
1002
+ showPalette.value = true;
1003
+ },
1004
+ onShowHelp: () => {
1005
+ showShortcutsHelp.value = true;
1006
+ },
1007
+ /* /cila:c wiring */
824
1008
  hasSelection: () => !selection.isEmpty.value,
825
1009
  });
826
1010
 
@@ -1264,7 +1448,10 @@ async function paste() {
1264
1448
  }
1265
1449
 
1266
1450
  if (cb.mode === 'cut') {
1267
- const { op } = await api.moveAsync(items, qualify(currentPath.value), qualify(sourceDir) || undefined);
1451
+ const targetWire = qualify(currentPath.value);
1452
+ const originWire = qualify(sourceDir) || undefined;
1453
+ const { op } = await api.moveAsync(items, targetWire, originWire);
1454
+ registerMoveUndo(op.id, items, targetWire, originWire);
1268
1455
  pendingOps.register(op);
1269
1456
  flashToast('Taşıma kuyruğa alındı');
1270
1457
  } else {
@@ -1311,10 +1498,20 @@ async function submitRename(name: string) {
1311
1498
  const target = renameTarget.value;
1312
1499
  if (!target) return;
1313
1500
  try {
1314
- await api.rename(qualify(currentPath.value), target.path, name);
1501
+ const dirWire = qualify(currentPath.value);
1502
+ const oldPath = target.path; // qualified
1503
+ const oldName = target.basename;
1504
+ await api.rename(dirWire, oldPath, name);
1315
1505
  showRename.value = false;
1316
1506
  renameTarget.value = null;
1317
1507
  await load();
1508
+ // Clean inverse: rename the new path back to the old basename.
1509
+ if (name && name !== oldName) {
1510
+ const newPath = wireJoin(wireParent(oldPath), name);
1511
+ undoToast(t('toast.renamed'), async () => {
1512
+ await api.rename(dirWire, newPath, oldName);
1513
+ });
1514
+ }
1318
1515
  } catch (err) {
1319
1516
  emit('error', { message: (err as Error).message, context: { op: 'rename' } });
1320
1517
  }
@@ -1329,19 +1526,37 @@ async function confirmDelete() {
1329
1526
  flashToast('Çöpteki öğeler saklama süresi sonunda otomatik silinir. Kalıcı silme yönetici panelinden yapılır.');
1330
1527
  return;
1331
1528
  }
1332
- const items = selection.nodes.value.map((n) => n.path);
1529
+ const targets = selection.nodes.value;
1530
+ const items = targets.map((n) => n.path);
1333
1531
  if (items.length === 0) {
1334
1532
  showDelete.value = false;
1335
1533
  return;
1336
1534
  }
1535
+ // Trash-delete is invertible via node-id restore — but only when EVERY
1536
+ // selected node carries a backend id and the restore endpoint exists.
1537
+ // A partial-undo offer would be a lie, so all-or-nothing.
1538
+ const nodeIds = targets
1539
+ .map((n) => (n as { id?: number }).id)
1540
+ .filter((x): x is number => typeof x === 'number');
1541
+ const restoreUndo =
1542
+ api.endpoints.trashRestore && nodeIds.length === targets.length
1543
+ ? async () => {
1544
+ const { restored } = await api.restoreIds(nodeIds);
1545
+ if (restored === 0) throw new Error('restore failed');
1546
+ }
1547
+ : null;
1337
1548
  try {
1338
1549
  if (api.endpoints.deleteAsync) {
1339
1550
  const { op } = await api.deleteAsync(items, qualify(currentPath.value));
1551
+ if (restoreUndo) {
1552
+ opUndo.set(op.id, { message: t('toast.trashed'), fn: restoreUndo });
1553
+ }
1340
1554
  pendingOps.register(op);
1341
1555
  flashToast('Silme kuyruğa alındı');
1342
1556
  } else {
1343
1557
  await api.deleteItems(qualify(currentPath.value), items);
1344
1558
  await load();
1559
+ if (restoreUndo) undoToast(t('toast.trashed'), restoreUndo);
1345
1560
  }
1346
1561
  showDelete.value = false;
1347
1562
  selection.clear();
@@ -1635,13 +1850,20 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
1635
1850
 
1636
1851
  async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string): Promise<void> {
1637
1852
  try {
1853
+ const originWire = qualify(currentPath.value);
1638
1854
  if (api.endpoints.moveAsync) {
1639
- const { op } = await api.moveAsync(sources, targetDir, qualify(currentPath.value));
1855
+ const { op } = await api.moveAsync(sources, targetDir, originWire);
1856
+ registerMoveUndo(op.id, sources, targetDir, originWire);
1640
1857
  pendingOps.register(op);
1641
1858
  flashToast('Taşıma kuyruğa alındı');
1642
1859
  } else {
1643
- await api.move(qualify(currentPath.value), sources, targetDir);
1860
+ await api.move(originWire, sources, targetDir);
1644
1861
  await load();
1862
+ // Sync move (no async endpoint): offer the reverse move right away.
1863
+ const movedPaths = sources.map((p) => wireJoin(targetDir, wireBasename(p)));
1864
+ undoToast(t('toast.moved'), async () => {
1865
+ await api.move(targetDir, movedPaths, originWire);
1866
+ });
1645
1867
  }
1646
1868
  selection.clear();
1647
1869
  } catch (err) {
@@ -1741,6 +1963,7 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1741
1963
  'fe--theme-light': config.theme === 'light',
1742
1964
  'fe--theme-dark': config.theme === 'dark',
1743
1965
  'fe--is-dragover': dragOver,
1966
+ 'fe--density-compact': density === 'compact' /* cila:a density */,
1744
1967
  }"
1745
1968
  tabindex="-1"
1746
1969
  @dragenter="onDragEnter"
@@ -1764,6 +1987,7 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1764
1987
  :locale="locale"
1765
1988
  @update:view-mode="viewMode = $event"
1766
1989
  @update:search-query="searchQuery = $event"
1990
+ @update:density="density = $event"
1767
1991
  @new-folder="showNewFolder = true"
1768
1992
  @upload="triggerUpload"
1769
1993
  @refresh="() => load()"
@@ -1785,28 +2009,171 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1785
2009
  @crumb-drop="onCrumbDropInto"
1786
2010
  />
1787
2011
 
1788
- <!-- Live presence: who else is viewing this folder (empty → nothing shown). -->
1789
- <div v-if="presenceUsers.length" class="fe__presence">
1790
- <PresenceBar :users="presenceUsers" :locale="locale" />
2012
+ <!-- Live presence: who else is viewing this folder (empty → nothing shown).
2013
+ When the live socket is unavailable the same strip carries a small
2014
+ degraded-connection badge instead (presence is empty in fallback);
2015
+ a healthy connection shows nothing extra. -->
2016
+ <div v-if="presenceUsers.length || realtimeDegraded" class="fe__presence">
2017
+ <PresenceBar v-if="presenceUsers.length" :users="presenceUsers" :locale="locale" />
2018
+ <span
2019
+ v-if="realtimeDegraded"
2020
+ class="fe-connbadge"
2021
+ role="status"
2022
+ :title="t('conn.tooltip')"
2023
+ >
2024
+ <span class="fe-connbadge__dot" aria-hidden="true"></span>
2025
+ {{ t('conn.offline') }}
2026
+ </span>
1791
2027
  </div>
1792
2028
 
1793
2029
  <div class="fe__body" @click.self="selection.clear()">
1794
- <!-- Initial load: show a spinner rather than an empty/"no files" flash.
1795
- Only when there's nothing yet — navigation keeps the current list. -->
1796
- <div v-if="loading && files.length === 0" class="fe__loading">
1797
- <span class="fe__spinner" aria-hidden="true"></span>
1798
- <p class="fe__loading-text">{{ t('loading') }}</p>
2030
+ <!-- Initial load: skeleton ghosts (view-mode aware) instead of an
2031
+ empty/"no files" flash. Only when there's nothing yet — navigation
2032
+ keeps the current list, exactly as before. -->
2033
+ <div v-if="loading && files.length === 0" class="fe__skeleton" role="status">
2034
+ <span class="fe-sr-only">{{ t('loading') }}</span>
2035
+ <div v-if="viewMode === 'grid'" class="fe-skel-grid" aria-hidden="true">
2036
+ <div v-for="i in 8" :key="i" class="fe-skel-card">
2037
+ <div class="fe-skel fe-skel--thumb"></div>
2038
+ <div class="fe-skel fe-skel--label"></div>
2039
+ </div>
2040
+ </div>
2041
+ <div v-else class="fe-skel-list" aria-hidden="true">
2042
+ <div v-for="i in 8" :key="i" class="fe-skel-row">
2043
+ <div class="fe-skel fe-skel--icon"></div>
2044
+ <div class="fe-skel fe-skel--name"></div>
2045
+ <div class="fe-skel fe-skel--size"></div>
2046
+ <div class="fe-skel fe-skel--date"></div>
2047
+ </div>
2048
+ </div>
1799
2049
  </div>
1800
2050
  <!-- Dead deep link (404) or RBAC-hidden dir (403, shown identically):
1801
2051
  a dedicated state instead of a misleading "this folder is empty". -->
1802
- <div v-else-if="notFoundPath" class="fe__notfound">
1803
- <span class="fe__notfound-icon" aria-hidden="true">📁</span>
1804
- <p class="fe__notfound-title">{{ t('notFound.title') }}</p>
1805
- <p class="fe__notfound-path">{{ notFoundPath }}</p>
1806
- <p class="fe__notfound-desc">{{ t('notFound.desc') }}</p>
1807
- <button type="button" class="fe-btn" @click="leaveNotFound">
1808
- {{ t('notFound.goRoot') }}
1809
- </button>
2052
+ <div v-else-if="notFoundPath" class="fe-state">
2053
+ <svg
2054
+ class="fe-state__art"
2055
+ viewBox="0 0 120 100"
2056
+ width="110"
2057
+ height="92"
2058
+ fill="none"
2059
+ stroke="currentColor"
2060
+ stroke-width="2"
2061
+ stroke-linecap="round"
2062
+ stroke-linejoin="round"
2063
+ aria-hidden="true"
2064
+ >
2065
+ <path d="M18 36v42a6 6 0 0 0 6 6h72a6 6 0 0 0 6-6V44a6 6 0 0 0-6-6H62l-9-10H24a6 6 0 0 0-6 6z" />
2066
+ <path d="M52 55c0-4.6 3.6-8 8-8s8 3.4 8 8c0 5.5-8 4.8-8 11" />
2067
+ <circle cx="60" cy="73" r="1.6" fill="currentColor" stroke="none" />
2068
+ </svg>
2069
+ <p class="fe-state__title">{{ t('notFound.title') }}</p>
2070
+ <p class="fe-state__path">{{ notFoundPath }}</p>
2071
+ <p class="fe-state__hint">{{ t('notFound.desc') }}</p>
2072
+ <div class="fe-state__actions">
2073
+ <button type="button" class="fe-btn" @click="leaveNotFound">
2074
+ {{ t('notFound.goRoot') }}
2075
+ </button>
2076
+ </div>
2077
+ </div>
2078
+ <!-- Listing failed (network / 5xx) with nothing else to show: retryable
2079
+ error state in the same visual language. -->
2080
+ <div v-else-if="loadError && files.length === 0" class="fe-state">
2081
+ <svg
2082
+ class="fe-state__art"
2083
+ viewBox="0 0 120 100"
2084
+ width="110"
2085
+ height="92"
2086
+ fill="none"
2087
+ stroke="currentColor"
2088
+ stroke-width="2"
2089
+ stroke-linecap="round"
2090
+ stroke-linejoin="round"
2091
+ aria-hidden="true"
2092
+ >
2093
+ <circle cx="60" cy="50" r="28" />
2094
+ <path d="M60 36v18" />
2095
+ <circle cx="60" cy="63" r="1.8" fill="currentColor" stroke="none" />
2096
+ <path d="M24 88h72" stroke-dasharray="3 5" />
2097
+ </svg>
2098
+ <p class="fe-state__title">{{ t('error.title') }}</p>
2099
+ <p class="fe-state__hint">{{ loadError }}</p>
2100
+ <div class="fe-state__actions">
2101
+ <button type="button" class="fe-btn fe-btn--primary" @click="retryLoad">
2102
+ {{ t('error.retry') }}
2103
+ </button>
2104
+ </div>
2105
+ </div>
2106
+ <!-- Search with zero hits — its own message, not "folder is empty". -->
2107
+ <div v-else-if="!loading && files.length === 0 && searchQuery" class="fe-state">
2108
+ <svg
2109
+ class="fe-state__art"
2110
+ viewBox="0 0 120 100"
2111
+ width="110"
2112
+ height="92"
2113
+ fill="none"
2114
+ stroke="currentColor"
2115
+ stroke-width="2"
2116
+ stroke-linecap="round"
2117
+ stroke-linejoin="round"
2118
+ aria-hidden="true"
2119
+ >
2120
+ <circle cx="52" cy="44" r="22" />
2121
+ <path d="M68 61l20 20" />
2122
+ <path d="M46 38l12 12M58 38l-12 12" />
2123
+ </svg>
2124
+ <p class="fe-state__title">{{ t('empty.search.title') }}</p>
2125
+ <p class="fe-state__hint">{{ t('empty.search.hint') }}</p>
2126
+ </div>
2127
+ <!-- Empty trash view. -->
2128
+ <div v-else-if="!loading && files.length === 0 && trashMode" class="fe-state">
2129
+ <svg
2130
+ class="fe-state__art"
2131
+ viewBox="0 0 120 100"
2132
+ width="110"
2133
+ height="92"
2134
+ fill="none"
2135
+ stroke="currentColor"
2136
+ stroke-width="2"
2137
+ stroke-linecap="round"
2138
+ stroke-linejoin="round"
2139
+ aria-hidden="true"
2140
+ >
2141
+ <path d="M38 34l4 48a6 6 0 0 0 6 5.6h24a6 6 0 0 0 6-5.6l4-48" />
2142
+ <path d="M32 34h56" />
2143
+ <path d="M50 34v-6a6 6 0 0 1 6-6h8a6 6 0 0 1 6 6v6" />
2144
+ <path d="M52 44v32M60 44v32M68 44v32" opacity="0.5" />
2145
+ </svg>
2146
+ <p class="fe-state__title">{{ t('empty.trash.title') }}</p>
2147
+ </div>
2148
+ <!-- Loaded, zero files, no search: the real empty-folder state. The
2149
+ upload affordances follow write permission (RBAC viewers only get
2150
+ the title). -->
2151
+ <div v-else-if="!loading && files.length === 0" class="fe-state">
2152
+ <svg
2153
+ class="fe-state__art"
2154
+ viewBox="0 0 120 100"
2155
+ width="110"
2156
+ height="92"
2157
+ fill="none"
2158
+ stroke="currentColor"
2159
+ stroke-width="2"
2160
+ stroke-linecap="round"
2161
+ stroke-linejoin="round"
2162
+ aria-hidden="true"
2163
+ >
2164
+ <path d="M18 36v42a6 6 0 0 0 6 6h72a6 6 0 0 0 6-6V44a6 6 0 0 0-6-6H62l-9-10H24a6 6 0 0 0-6 6z" />
2165
+ <g v-if="emptyCanUpload">
2166
+ <path d="M60 50v14" stroke-dasharray="3 4" />
2167
+ <path d="M53 59l7 8 7-8" />
2168
+ </g>
2169
+ </svg>
2170
+ <p class="fe-state__title">{{ t('empty.folder') }}</p>
2171
+ <p v-if="emptyCanUpload" class="fe-state__hint">{{ t('empty.hint') }}</p>
2172
+ <div v-if="emptyCanUpload" class="fe-state__actions">
2173
+ <button type="button" class="fe-btn fe-btn--primary" @click="triggerUpload">
2174
+ {{ t('empty.upload') }}
2175
+ </button>
2176
+ </div>
1810
2177
  </div>
1811
2178
  <ListView
1812
2179
  v-else-if="viewMode === 'list'"
@@ -1995,6 +2362,33 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1995
2362
  </div>
1996
2363
  </transition>
1997
2364
 
2365
+ <!-- cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) -->
2366
+ <CommandPalette
2367
+ :open="showPalette"
2368
+ :locale="locale"
2369
+ :files="files"
2370
+ :view-mode="viewMode"
2371
+ :can-write="canWriteHere && !atVirtualRoot && !trashActive"
2372
+ :can-go-up="canGoUp"
2373
+ :global-search="paletteGlobalSearch"
2374
+ @close="showPalette = false"
2375
+ @open-hit="openSearchHit"
2376
+ @open-node="openNode"
2377
+ @navigate="(p: string) => load(p)"
2378
+ @new-folder="showNewFolder = true"
2379
+ @upload="triggerUpload"
2380
+ @toggle-view="viewMode = viewMode === 'list' ? 'grid' : 'list'"
2381
+ @open-trash="loadTrash"
2382
+ @refresh="() => load()"
2383
+ @go-up="goUp"
2384
+ />
2385
+ <ShortcutsHelp
2386
+ :open="showShortcutsHelp"
2387
+ :locale="locale"
2388
+ @close="showShortcutsHelp = false"
2389
+ />
2390
+ <!-- /cila:c wiring -->
2391
+
1998
2392
  <input
1999
2393
  ref="fileInputEl"
2000
2394
  type="file"
@@ -2004,7 +2398,21 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2004
2398
  />
2005
2399
 
2006
2400
  <transition name="fe-toast">
2007
- <div v-if="toast" class="fe-toast">{{ toast }}</div>
2401
+ <div
2402
+ v-if="toast"
2403
+ class="fe-toast"
2404
+ :class="{ 'fe-toast--action': !!toast.actionLabel }"
2405
+ role="status"
2406
+ @click="dismissToast"
2407
+ >
2408
+ <span class="fe-toast__msg">{{ toast.message }}</span>
2409
+ <button
2410
+ v-if="toast.actionLabel && toast.action"
2411
+ type="button"
2412
+ class="fe-toast__action"
2413
+ @click.stop="runToastAction"
2414
+ >{{ toast.actionLabel }}</button>
2415
+ </div>
2008
2416
  </transition>
2009
2417
  </div>
2010
2418
  </template>