@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,144 @@
1
+ /**
2
+ * useKeyboardShortcuts — binds FileExplorer's keyboard affordances.
3
+ *
4
+ * Activated whenever the explorer is mounted (only one instance per
5
+ * page is expected). Skips events that originate inside form controls
6
+ * so the user can type filenames in the search box, modals, etc.
7
+ */
8
+
9
+ import { onMounted, onBeforeUnmount, type Ref } from 'vue';
10
+
11
+ export interface ShortcutHandlers {
12
+ onDelete?: () => void;
13
+ onRename?: () => void; // F2
14
+ onSelectAll?: () => void; // Ctrl+A
15
+ onCut?: () => void; // Ctrl+X
16
+ onCopy?: () => void; // Ctrl+C
17
+ onPaste?: () => void; // Ctrl+V
18
+ onOpen?: () => void; // Enter
19
+ onClose?: () => void; // Escape
20
+ onFocusSearch?: () => void; // /
21
+ onDuplicate?: () => void; // Ctrl+D
22
+ onPathJump?: () => void; // Cmd+K / Ctrl+K
23
+ onGoUp?: () => void; // Alt+Up / Backspace (when nothing selected)
24
+ hasSelection?: () => boolean; // disambiguates Backspace
25
+ }
26
+
27
+ export function useKeyboardShortcuts(rootEl: Ref<HTMLElement | null>, handlers: ShortcutHandlers) {
28
+ function onKey(e: KeyboardEvent) {
29
+ const root = rootEl.value;
30
+ if (!root) return;
31
+
32
+ // Skip when the event originates inside a form control — don't
33
+ // want `Delete` while editing a filename, `/` while typing in the
34
+ // search box, etc. Escape always goes through so modals can close.
35
+ const target = e.target as HTMLElement | null;
36
+ if (
37
+ target &&
38
+ (target.tagName === 'INPUT' ||
39
+ target.tagName === 'TEXTAREA' ||
40
+ target.isContentEditable)
41
+ ) {
42
+ if (e.key !== 'Escape') return;
43
+ }
44
+
45
+ const ctrl = e.ctrlKey || e.metaKey;
46
+
47
+ switch (e.key) {
48
+ case 'Delete':
49
+ if (handlers.onDelete) {
50
+ e.preventDefault();
51
+ handlers.onDelete();
52
+ }
53
+ break;
54
+ case 'Backspace':
55
+ // Backspace = delete when something is selected (file-manager
56
+ // convention), parent-dir navigation otherwise. Without this
57
+ // disambiguation Backspace was firing onDelete with an empty
58
+ // selection and nothing happened, leaving users wondering why
59
+ // the obvious 'go back' key did nothing.
60
+ if (handlers.hasSelection?.() && handlers.onDelete) {
61
+ e.preventDefault();
62
+ handlers.onDelete();
63
+ } else if (handlers.onGoUp) {
64
+ e.preventDefault();
65
+ handlers.onGoUp();
66
+ }
67
+ break;
68
+ case 'F2':
69
+ if (handlers.onRename) {
70
+ e.preventDefault();
71
+ handlers.onRename();
72
+ }
73
+ break;
74
+ case 'Enter':
75
+ if (handlers.onOpen) {
76
+ handlers.onOpen();
77
+ }
78
+ break;
79
+ case 'Escape':
80
+ if (handlers.onClose) {
81
+ handlers.onClose();
82
+ }
83
+ break;
84
+ case '/':
85
+ if (handlers.onFocusSearch && !ctrl) {
86
+ e.preventDefault();
87
+ handlers.onFocusSearch();
88
+ }
89
+ break;
90
+ case 'a':
91
+ case 'A':
92
+ if (ctrl && handlers.onSelectAll) {
93
+ e.preventDefault();
94
+ handlers.onSelectAll();
95
+ }
96
+ break;
97
+ case 'x':
98
+ case 'X':
99
+ if (ctrl && handlers.onCut) {
100
+ e.preventDefault();
101
+ handlers.onCut();
102
+ }
103
+ break;
104
+ case 'c':
105
+ case 'C':
106
+ if (ctrl && handlers.onCopy) {
107
+ e.preventDefault();
108
+ handlers.onCopy();
109
+ }
110
+ break;
111
+ case 'v':
112
+ case 'V':
113
+ if (ctrl && handlers.onPaste) {
114
+ e.preventDefault();
115
+ handlers.onPaste();
116
+ }
117
+ break;
118
+ case 'd':
119
+ case 'D':
120
+ if (ctrl && handlers.onDuplicate) {
121
+ e.preventDefault();
122
+ handlers.onDuplicate();
123
+ }
124
+ break;
125
+ case 'k':
126
+ case 'K':
127
+ if (ctrl && handlers.onPathJump) {
128
+ e.preventDefault();
129
+ handlers.onPathJump();
130
+ }
131
+ break;
132
+ case 'ArrowUp':
133
+ // Alt+Up = parent dir (matches Finder / Files / Explorer).
134
+ if (e.altKey && handlers.onGoUp) {
135
+ e.preventDefault();
136
+ handlers.onGoUp();
137
+ }
138
+ break;
139
+ }
140
+ }
141
+
142
+ onMounted(() => window.addEventListener('keydown', onKey));
143
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
144
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * useLocale — tr/en string table with a tiny `t()` helper.
3
+ *
4
+ * No i18n library — the catalogue is small enough to ship inline (see
5
+ * src/locales).
6
+ */
7
+
8
+ import { computed, type Ref } from 'vue';
9
+ import type { LocaleCode } from '../types/ExplorerConfig';
10
+ import { messages } from '../locales';
11
+
12
+ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
13
+ const lookup = computed(() => {
14
+ const code = typeof localeRef === 'function' ? localeRef() : localeRef.value;
15
+ return messages[code] ?? messages.tr;
16
+ });
17
+
18
+ function t(key: string, vars: Record<string, string | number> = {}): string {
19
+ const raw = lookup.value[key] ?? key;
20
+ return Object.entries(vars).reduce(
21
+ (acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)),
22
+ raw,
23
+ );
24
+ }
25
+
26
+ function formatSize(bytes: number | undefined | null): string {
27
+ if (bytes == null || bytes <= 0) return '—';
28
+ const units: Array<[number, string]> = [
29
+ [1024 ** 4, 'unit.tb'],
30
+ [1024 ** 3, 'unit.gb'],
31
+ [1024 ** 2, 'unit.mb'],
32
+ [1024, 'unit.kb'],
33
+ [1, 'unit.bytes'],
34
+ ];
35
+ for (const [div, key] of units) {
36
+ if (bytes >= div) {
37
+ const val = bytes / div;
38
+ const rounded = val >= 100 ? Math.round(val) : val.toFixed(val >= 10 ? 1 : 2);
39
+ return `${rounded} ${t(key)}`;
40
+ }
41
+ }
42
+ return `${bytes} ${t('unit.bytes')}`;
43
+ }
44
+
45
+ /**
46
+ * Render a FileNode's basename with locale-aware overrides:
47
+ * - `.trash` directory → "Çöp Kutusu" / "Trash"
48
+ * - Trash entries are stored as `<Ymd-His>-<rand>__<original>` so
49
+ * listings inside .trash/ would otherwise show timestamps. Strip
50
+ * that prefix so the user sees the original basename.
51
+ */
52
+ function nodeDisplayName(node: { basename: string }): string {
53
+ if (node.basename === '.trash') return t('node.trash');
54
+ const m = node.basename.match(/^\d{8}-\d{6}-[A-Za-z0-9]+__(.+)$/);
55
+ if (m) return m[1];
56
+ return node.basename;
57
+ }
58
+
59
+ return { t, formatSize, nodeDisplayName };
60
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * useMonacoLoader — fire-and-forget Monaco preloader.
3
+ *
4
+ * The preview/edit flow wants Monaco when available (best-in-class
5
+ * editor) and highlight.js for read-only syntax colour as a graceful
6
+ * fallback. Monaco is heavy (~5 MB), so we kick off the dynamic import
7
+ * at module-init time — by the time the user clicks "edit" on a code
8
+ * file (typically several seconds in), the chunk is already cached.
9
+ *
10
+ * - `monacoPromise` resolves to the Monaco namespace, or `null`
11
+ * if the package is not installed (peer
12
+ * missing) / load fails.
13
+ * - `highlightPromise` resolves to the highlight.js export, or
14
+ * `null` similarly.
15
+ * - `getMonaco()` returns the cached module synchronously
16
+ * (after `await monacoPromise`) so a click
17
+ * handler doesn't re-trigger the import.
18
+ *
19
+ * Both modules are externalized in the bundler config — the consumer
20
+ * either declares them as direct deps (Monaco UX) or doesn't (the
21
+ * highlight.js read-only path takes over). No build-time hard dep here.
22
+ */
23
+
24
+ let monacoPromise: Promise<unknown | null> | null = null;
25
+ let highlightPromise: Promise<unknown | null> | null = null;
26
+ let monacoCached: unknown | null = null;
27
+ let highlightCached: unknown | null = null;
28
+
29
+ /**
30
+ * Kick off the Monaco import the first time someone calls
31
+ * `preloadEditor()` (typically the FileExplorer onMounted hook). Cheap
32
+ * to call repeatedly — only the first call actually triggers the
33
+ * dynamic import.
34
+ */
35
+ export function preloadEditor(): void {
36
+ if (!monacoPromise) {
37
+ monacoPromise = import(/* @vite-ignore */ 'monaco-editor')
38
+ .then((mod) => {
39
+ monacoCached = (mod as { default?: unknown }).default ?? mod;
40
+ return monacoCached;
41
+ })
42
+ .catch(() => {
43
+ monacoCached = null;
44
+ return null;
45
+ });
46
+ }
47
+ if (!highlightPromise) {
48
+ highlightPromise = import(/* @vite-ignore */ 'highlight.js')
49
+ .then((mod) => {
50
+ highlightCached = (mod as { default?: unknown }).default ?? mod;
51
+ return highlightCached;
52
+ })
53
+ .catch(() => {
54
+ highlightCached = null;
55
+ return null;
56
+ });
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Resolve Monaco. If preload hasn't been kicked off yet, this triggers
62
+ * it (and pays the load cost on the spot). Returns `null` when the
63
+ * package isn't installed.
64
+ */
65
+ export async function ensureMonaco(): Promise<unknown | null> {
66
+ if (!monacoPromise) preloadEditor();
67
+ return monacoPromise!;
68
+ }
69
+
70
+ export async function ensureHighlight(): Promise<unknown | null> {
71
+ if (!highlightPromise) preloadEditor();
72
+ return highlightPromise!;
73
+ }
74
+
75
+ /** Synchronous cached lookup — null if Monaco not yet ready or absent. */
76
+ export function getMonaco(): unknown | null {
77
+ return monacoCached;
78
+ }
79
+
80
+ export function getHighlight(): unknown | null {
81
+ return highlightCached;
82
+ }
83
+
84
+ /**
85
+ * Composable wrapper — components that want reactive "is Monaco ready
86
+ * yet?" semantics can read the resolved promise. The implementation is
87
+ * intentionally tiny: components don't need anything fancier than
88
+ * `await ensureMonaco()` inside the click handler.
89
+ */
90
+ export function useMonacoLoader() {
91
+ preloadEditor();
92
+ return {
93
+ ensureMonaco,
94
+ ensureHighlight,
95
+ getMonaco,
96
+ getHighlight,
97
+ };
98
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * usePendingOps — track async copy/move/delete operations.
3
+ *
4
+ * Backend POST /files/{copy,move,delete} returns `{op}` immediately
5
+ * (status='pending') and the actual S3 work runs in a worker. The
6
+ * client polls `/files/ops` every 2s while there's anything still
7
+ * pending or running so the user gets live feedback ("3/5 kopyalandı").
8
+ *
9
+ * Polling is reference-counted: callers `register(op)` after starting a
10
+ * new op; an interval ticks while the registered set has any non-
11
+ * terminal entries. Terminal ops (done/error) stay in the local cache
12
+ * for `RETAIN_MS` so the UI can flash a final state before they fade
13
+ * out, then a sweep removes them.
14
+ */
15
+
16
+ import { computed, ref, onBeforeUnmount } from 'vue';
17
+ import type { ExplorerConfig } from '../types/ExplorerConfig';
18
+ import type { FileApi } from './useFileApi';
19
+
20
+ export interface PendingOp {
21
+ id: number;
22
+ op_type: 'copy' | 'move' | 'delete';
23
+ status: 'pending' | 'running' | 'done' | 'error';
24
+ progress_total: number;
25
+ progress_done: number;
26
+ target_path: string | null;
27
+ source_dir: string | null;
28
+ source_count: number;
29
+ error_message: string | null;
30
+ started_at: string | null;
31
+ finished_at: string | null;
32
+ created_at: string | null;
33
+ }
34
+
35
+ const POLL_MS = 2000;
36
+ const RETAIN_MS = 8000;
37
+
38
+ /**
39
+ * normalizeOp maps the backend ops row (`{kind,total,done,failed,error,dest,
40
+ * status:'ok'|'failed'|'partial'|…}`) onto the PendingOp contract the tray
41
+ * renders (`{op_type,progress_total,progress_done,error_message,status:'done'|
42
+ * 'error'|…}`). Without this the tray showed "undefined/undefined" for a
43
+ * running op (progress_* were undefined) and never treated an 'ok'/'failed'
44
+ * row as terminal. Tolerant of an already-normalized shape (idempotent).
45
+ */
46
+ function normalizeOp(raw: Record<string, unknown>): PendingOp {
47
+ const num = (...vals: unknown[]): number => {
48
+ for (const v of vals) if (typeof v === 'number') return v;
49
+ return 0;
50
+ };
51
+ const str = (...vals: unknown[]): string | null => {
52
+ for (const v of vals) if (typeof v === 'string' && v !== '') return v;
53
+ return null;
54
+ };
55
+ const rawStatus = String(raw.status ?? 'pending');
56
+ const status: PendingOp['status'] =
57
+ rawStatus === 'ok' || rawStatus === 'done'
58
+ ? 'done'
59
+ : rawStatus === 'failed' || rawStatus === 'partial' || rawStatus === 'error'
60
+ ? 'error'
61
+ : rawStatus === 'running'
62
+ ? 'running'
63
+ : 'pending';
64
+ const sources = Array.isArray(raw.sources) ? raw.sources : [];
65
+ const opType = str(raw.op_type, raw.kind) ?? 'delete';
66
+ return {
67
+ id: num(raw.id),
68
+ op_type: (opType === 'copy' || opType === 'move' ? opType : 'delete') as PendingOp['op_type'],
69
+ status,
70
+ progress_total: num(raw.progress_total, raw.total, sources.length),
71
+ progress_done: num(raw.progress_done, raw.done),
72
+ target_path: str(raw.target_path, raw.dest),
73
+ source_dir: str(raw.source_dir),
74
+ source_count: num(raw.source_count, sources.length),
75
+ error_message: str(raw.error_message, raw.error),
76
+ started_at: str(raw.started_at),
77
+ finished_at: str(raw.finished_at),
78
+ created_at: str(raw.created_at),
79
+ };
80
+ }
81
+
82
+ export interface UsePendingOpsOptions {
83
+ /** Called once an op flips into a terminal state (done|error). */
84
+ onSettled?: (op: PendingOp) => void;
85
+ }
86
+
87
+ export function usePendingOps(
88
+ config: ExplorerConfig,
89
+ api: FileApi,
90
+ opts: UsePendingOpsOptions = {},
91
+ ) {
92
+ void config; // kept for future per-instance config knobs
93
+ const ops = ref<PendingOp[]>([]);
94
+ const announced = new Set<number>();
95
+ const settledAt = new Map<number, number>();
96
+ // First poll after mount returns the server's recent-history window
97
+ // (last 5 min of done/error rows). Mark them announced silently and
98
+ // skip the callback so we don't double-fire toasts on F5.
99
+ let firstPollDone = false;
100
+
101
+ const hasActive = computed(() =>
102
+ ops.value.some((o) => o.status === 'pending' || o.status === 'running'),
103
+ );
104
+
105
+ let timer: ReturnType<typeof setInterval> | undefined;
106
+
107
+ function startPolling() {
108
+ if (timer) return;
109
+ if (!api.endpoints.opsList) return;
110
+ timer = setInterval(() => {
111
+ void poll();
112
+ }, POLL_MS);
113
+ // Kick a poll right away.
114
+ void poll();
115
+ }
116
+
117
+ function stopPolling() {
118
+ if (timer) {
119
+ clearInterval(timer);
120
+ timer = undefined;
121
+ }
122
+ }
123
+
124
+ async function poll(): Promise<void> {
125
+ if (!api.endpoints.opsList) return;
126
+ try {
127
+ const res = await api.jsonFetch<{ ops: Record<string, unknown>[] }>(api.endpoints.opsList);
128
+ const incoming = (res.ops || []).map(normalizeOp);
129
+
130
+ for (const op of incoming) {
131
+ if ((op.status === 'done' || op.status === 'error') && !announced.has(op.id)) {
132
+ announced.add(op.id);
133
+ settledAt.set(op.id, Date.now());
134
+ if (firstPollDone) {
135
+ opts.onSettled?.(op);
136
+ }
137
+ }
138
+ }
139
+
140
+ // Filter incoming: pending/running always; settled only if user
141
+ // is currently seeing them in the local tray.
142
+ const localIds = new Set(ops.value.map((o) => o.id));
143
+ const visibleIncoming = incoming.filter((op) => {
144
+ if (op.status === 'pending' || op.status === 'running') return true;
145
+ if (!firstPollDone) return false;
146
+ return localIds.has(op.id);
147
+ });
148
+
149
+ const visibleIds = new Set(visibleIncoming.map((o) => o.id));
150
+ const local = ops.value.filter((o) => !visibleIds.has(o.id));
151
+ const merged = [...visibleIncoming, ...local];
152
+
153
+ firstPollDone = true;
154
+
155
+ // Sweep: drop terminal ops past RETAIN_MS.
156
+ const now = Date.now();
157
+ const after = merged.filter((o) => {
158
+ if (o.status === 'pending' || o.status === 'running') return true;
159
+ const at = settledAt.get(o.id);
160
+ return at !== undefined && now - at < RETAIN_MS;
161
+ });
162
+ ops.value = after;
163
+
164
+ if (!hasActive.value && after.length === 0) {
165
+ stopPolling();
166
+ }
167
+ } catch (err) {
168
+ // eslint-disable-next-line no-console
169
+ console.warn('[usePendingOps] poll failed:', err);
170
+ }
171
+ }
172
+
173
+ function register(raw: PendingOp | Record<string, unknown>): void {
174
+ const op = normalizeOp(raw as Record<string, unknown>);
175
+ const exists = ops.value.some((o) => o.id === op.id);
176
+ if (!exists) {
177
+ ops.value = [op, ...ops.value];
178
+ }
179
+ startPolling();
180
+ }
181
+
182
+ function dismiss(id: number): void {
183
+ ops.value = ops.value.filter((o) => o.id !== id);
184
+ settledAt.delete(id);
185
+ }
186
+
187
+ onBeforeUnmount(() => {
188
+ stopPolling();
189
+ });
190
+
191
+ return {
192
+ ops,
193
+ hasActive,
194
+ register,
195
+ dismiss,
196
+ poll,
197
+ startPolling,
198
+ stopPolling,
199
+ };
200
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * useSelection — multi-select state with Ctrl/Shift semantics.
3
+ *
4
+ * click(path, {ctrl, shift})
5
+ * no-mod → replace selection with [path]
6
+ * ctrl → toggle path in selection
7
+ * shift → range select from anchor to path
8
+ */
9
+
10
+ import { ref, computed } from 'vue';
11
+ import type { FileNode } from '../types/FileNode';
12
+
13
+ export function useSelection(items: () => FileNode[]) {
14
+ const selected = ref<Set<string>>(new Set());
15
+ const anchor = ref<string | null>(null);
16
+
17
+ function click(path: string, mod: { ctrl?: boolean; shift?: boolean } = {}) {
18
+ if (mod.shift && anchor.value) {
19
+ const list = items();
20
+ const aIdx = list.findIndex((n) => n.path === anchor.value);
21
+ const bIdx = list.findIndex((n) => n.path === path);
22
+ if (aIdx === -1 || bIdx === -1) {
23
+ selected.value = new Set([path]);
24
+ anchor.value = path;
25
+ return;
26
+ }
27
+ const [lo, hi] = aIdx < bIdx ? [aIdx, bIdx] : [bIdx, aIdx];
28
+ const next = new Set<string>();
29
+ for (let i = lo; i <= hi; i++) next.add(list[i].path);
30
+ selected.value = next;
31
+ return;
32
+ }
33
+
34
+ if (mod.ctrl) {
35
+ const next = new Set(selected.value);
36
+ if (next.has(path)) next.delete(path);
37
+ else next.add(path);
38
+ selected.value = next;
39
+ anchor.value = path;
40
+ return;
41
+ }
42
+
43
+ selected.value = new Set([path]);
44
+ anchor.value = path;
45
+ }
46
+
47
+ function clear() {
48
+ selected.value = new Set();
49
+ anchor.value = null;
50
+ }
51
+
52
+ function selectAll() {
53
+ selected.value = new Set(items().map((n) => n.path));
54
+ }
55
+
56
+ function has(path: string): boolean {
57
+ return selected.value.has(path);
58
+ }
59
+
60
+ function add(path: string) {
61
+ const next = new Set(selected.value);
62
+ next.add(path);
63
+ selected.value = next;
64
+ }
65
+
66
+ function remove(path: string) {
67
+ const next = new Set(selected.value);
68
+ next.delete(path);
69
+ selected.value = next;
70
+ }
71
+
72
+ const size = computed(() => selected.value.size);
73
+ const isEmpty = computed(() => selected.value.size === 0);
74
+
75
+ const nodes = computed(() => {
76
+ const list = items();
77
+ return list.filter((n) => selected.value.has(n.path));
78
+ });
79
+
80
+ return { selected, anchor, click, clear, selectAll, has, add, remove, size, isEmpty, nodes };
81
+ }