@brftech/filex-core 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-EGgNqLPE.js} +2 -2
- package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-EGgNqLPE.js.map} +1 -1
- package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-BvriJ42M.js} +2 -2
- package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-BvriJ42M.js.map} +1 -1
- package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-B_bSPsWX.js} +2 -2
- package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-B_bSPsWX.js.map} +1 -1
- package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-hl68h4gG.js} +2 -2
- package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-hl68h4gG.js.map} +1 -1
- package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-lhwOjXK7.js} +2 -2
- package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-lhwOjXK7.js.map} +1 -1
- package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-H2Z6AYXg.js} +2 -2
- package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-H2Z6AYXg.js.map} +1 -1
- package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Bsk-11yH.js} +2 -2
- package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Bsk-11yH.js.map} +1 -1
- package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-D-j9vycG.js} +2 -2
- package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-D-j9vycG.js.map} +1 -1
- package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-Bcr70rTe.js} +2 -2
- package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-Bcr70rTe.js.map} +1 -1
- package/dist/filex-core.js +50 -25
- package/dist/filex-core.umd.cjs +59 -58
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-C_ZSnk_I.js +11109 -0
- package/dist/index-C_ZSnk_I.js.map +1 -0
- package/dist/index.d.ts +597 -4
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +636 -15
- package/src/components/CommandPalette.vue +24 -1
- package/src/components/ContextMenu.vue +114 -8
- package/src/components/GalleryView.vue +214 -0
- package/src/components/GridView.vue +34 -1
- package/src/components/InspectorPanel.vue +170 -0
- package/src/components/ListView.vue +40 -6
- package/src/components/OnboardingTour.vue +332 -0
- package/src/components/OperationsCenter.vue +284 -0
- package/src/components/PendingOpsTray.vue +50 -66
- package/src/components/QuickLook.vue +142 -0
- package/src/components/SecondaryPane.vue +411 -0
- package/src/components/ShortcutSettings.vue +270 -0
- package/src/components/ShortcutsHelp.vue +30 -9
- package/src/components/TabBar.vue +174 -0
- package/src/components/ThemeGallery.vue +253 -0
- package/src/components/Toolbar.vue +75 -11
- package/src/components/UploadProgress.vue +49 -57
- package/src/composables/useFileApi.ts +53 -0
- package/src/composables/useKeyboardShortcuts.ts +340 -137
- package/src/composables/useOperations.ts +291 -0
- package/src/composables/useTabs.ts +222 -0
- package/src/index.ts +51 -0
- package/src/lib/dragGhost.ts +41 -0
- package/src/lib/themes.ts +505 -0
- package/src/locales/en.ts +125 -0
- package/src/locales/tr.ts +125 -0
- package/src/modals/Modal.vue +41 -5
- package/src/styles/base.css +1210 -0
- package/src/types/FileNode.ts +1 -1
- package/dist/index-BLocdbmc.js +0 -7993
- package/dist/index-BLocdbmc.js.map +0 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOperations — unified long-running-operation store (wiring:c3).
|
|
3
|
+
*
|
|
4
|
+
* One presentation-level aggregation point for everything that used to be
|
|
5
|
+
* scattered across corner UIs:
|
|
6
|
+
* - uploads (useUploadChunked jobs / legacy multipart rows)
|
|
7
|
+
* - ops-queue jobs (usePendingOps copy/move/delete worker ops)
|
|
8
|
+
* - future kinds (convert / archive — same `sync()` contract, own group)
|
|
9
|
+
*
|
|
10
|
+
* The store does NOT talk to the network. Publishers (renderless
|
|
11
|
+
* UploadProgress / PendingOpsTray adapters) reconcile their reactive source
|
|
12
|
+
* lists into the store via `sync(group, items)`; OperationsCenter renders the
|
|
13
|
+
* merged picture (badge + panel). Action callbacks (cancel/retry/dismiss)
|
|
14
|
+
* are registered per row so the panel can route user intent back to the
|
|
15
|
+
* owning surface without the store knowing any transport details.
|
|
16
|
+
*
|
|
17
|
+
* Lifecycle rules (per the İşlem Merkezi contract):
|
|
18
|
+
* - running rows live in `active`;
|
|
19
|
+
* - done/aborted rows linger in `active` for DONE_LINGER_MS ("bitti"
|
|
20
|
+
* flash) then move to `history` (session-only, capped);
|
|
21
|
+
* - error rows are STICKY: they stay in `active` until the user
|
|
22
|
+
* dismisses or retries them — even after the source list swept them
|
|
23
|
+
* (usePendingOps drops terminal ops after its RETAIN window).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { computed, getCurrentScope, onScopeDispose, ref } from 'vue';
|
|
27
|
+
|
|
28
|
+
export type OperationKind = 'upload' | 'copy' | 'move' | 'delete' | 'convert' | 'archive';
|
|
29
|
+
export type OperationStatus = 'running' | 'done' | 'error' | 'aborted';
|
|
30
|
+
|
|
31
|
+
/** What a publisher hands to `sync()` for one row. */
|
|
32
|
+
export interface OperationInput {
|
|
33
|
+
/** Stable id within the group (upload uuid / ops row id). */
|
|
34
|
+
id: string | number;
|
|
35
|
+
kind: OperationKind;
|
|
36
|
+
/** Primary label — file name or target path. '' → renderer falls back to the kind verb. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** 0..100, or null for indeterminate (renderer shows a spinner). */
|
|
39
|
+
percent: number | null;
|
|
40
|
+
status: OperationStatus;
|
|
41
|
+
error?: string | null;
|
|
42
|
+
/** Queue op accepted but not started yet ("Sırada"). */
|
|
43
|
+
queued?: boolean;
|
|
44
|
+
/** Progress counters for queue ops (3/5 items). */
|
|
45
|
+
doneCount?: number;
|
|
46
|
+
totalCount?: number;
|
|
47
|
+
/** Byte progress for uploads. */
|
|
48
|
+
uploadedBytes?: number;
|
|
49
|
+
totalBytes?: number;
|
|
50
|
+
cancellable?: boolean;
|
|
51
|
+
retryable?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OperationActions {
|
|
55
|
+
cancel?: () => void;
|
|
56
|
+
/** Clean the row out of the OWNING source list (called on retire/dismiss). */
|
|
57
|
+
dismiss?: () => void;
|
|
58
|
+
retry?: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Operation {
|
|
62
|
+
/** `${group}:${id}` — unique across publishers. */
|
|
63
|
+
key: string;
|
|
64
|
+
kind: OperationKind;
|
|
65
|
+
name: string;
|
|
66
|
+
percent: number | null;
|
|
67
|
+
status: OperationStatus;
|
|
68
|
+
error: string | null;
|
|
69
|
+
queued: boolean;
|
|
70
|
+
doneCount: number | null;
|
|
71
|
+
totalCount: number | null;
|
|
72
|
+
uploadedBytes: number | null;
|
|
73
|
+
totalBytes: number | null;
|
|
74
|
+
cancellable: boolean;
|
|
75
|
+
retryable: boolean;
|
|
76
|
+
startedAt: number;
|
|
77
|
+
settledAt: number | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DONE_LINGER_MS = 4000;
|
|
81
|
+
const HISTORY_CAP = 50;
|
|
82
|
+
|
|
83
|
+
function toOperation(key: string, input: OperationInput, prev?: Operation): Operation {
|
|
84
|
+
const terminal = input.status !== 'running';
|
|
85
|
+
return {
|
|
86
|
+
key,
|
|
87
|
+
kind: input.kind,
|
|
88
|
+
name: input.name,
|
|
89
|
+
percent: input.percent,
|
|
90
|
+
status: input.status,
|
|
91
|
+
error: input.error ?? null,
|
|
92
|
+
queued: input.queued ?? false,
|
|
93
|
+
doneCount: input.doneCount ?? null,
|
|
94
|
+
totalCount: input.totalCount ?? null,
|
|
95
|
+
uploadedBytes: input.uploadedBytes ?? null,
|
|
96
|
+
totalBytes: input.totalBytes ?? null,
|
|
97
|
+
cancellable: input.cancellable ?? false,
|
|
98
|
+
retryable: input.retryable ?? false,
|
|
99
|
+
startedAt: prev?.startedAt ?? Date.now(),
|
|
100
|
+
settledAt: prev?.settledAt ?? (terminal ? Date.now() : null),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function useOperations() {
|
|
105
|
+
const active = ref<Operation[]>([]);
|
|
106
|
+
const history = ref<Operation[]>([]);
|
|
107
|
+
|
|
108
|
+
const actions = new Map<string, OperationActions>();
|
|
109
|
+
/** Keys already moved to history — sync must not resurrect them. */
|
|
110
|
+
const retired = new Set<string>();
|
|
111
|
+
const lingerTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
112
|
+
|
|
113
|
+
function clearTimer(key: string) {
|
|
114
|
+
const tm = lingerTimers.get(key);
|
|
115
|
+
if (tm !== undefined) {
|
|
116
|
+
clearTimeout(tm);
|
|
117
|
+
lingerTimers.delete(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Move a row from active to the session history (newest first). */
|
|
122
|
+
function retire(key: string, opts: { toHistory?: boolean } = {}) {
|
|
123
|
+
clearTimer(key);
|
|
124
|
+
const idx = active.value.findIndex((o) => o.key === key);
|
|
125
|
+
if (idx === -1) return;
|
|
126
|
+
const [op] = active.value.splice(idx, 1);
|
|
127
|
+
active.value = [...active.value];
|
|
128
|
+
retired.add(key);
|
|
129
|
+
if (opts.toHistory !== false) {
|
|
130
|
+
history.value = [{ ...op, settledAt: op.settledAt ?? Date.now() }, ...history.value].slice(
|
|
131
|
+
0,
|
|
132
|
+
HISTORY_CAP,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function scheduleLinger(key: string) {
|
|
138
|
+
if (lingerTimers.has(key)) return;
|
|
139
|
+
lingerTimers.set(
|
|
140
|
+
key,
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
lingerTimers.delete(key);
|
|
143
|
+
// Retire + ask the owning source to drop its row so it doesn't
|
|
144
|
+
// resurrect on the next sync (uploads keep done rows forever).
|
|
145
|
+
const acts = actions.get(key);
|
|
146
|
+
retire(key);
|
|
147
|
+
acts?.dismiss?.();
|
|
148
|
+
}, DONE_LINGER_MS),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Full-list reconcile for one publisher group. Upserts every present row,
|
|
154
|
+
* removes vanished ones (sticky errors excepted), and manages the
|
|
155
|
+
* done-linger → history hand-off.
|
|
156
|
+
*/
|
|
157
|
+
function sync(
|
|
158
|
+
group: string,
|
|
159
|
+
items: Array<{ input: OperationInput; actions?: OperationActions }>,
|
|
160
|
+
): void {
|
|
161
|
+
const prefix = `${group}:`;
|
|
162
|
+
const present = new Set<string>();
|
|
163
|
+
let next = [...active.value];
|
|
164
|
+
let changed = false;
|
|
165
|
+
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
const key = `${group}:${item.input.id}`;
|
|
168
|
+
present.add(key);
|
|
169
|
+
if (item.actions) actions.set(key, item.actions);
|
|
170
|
+
if (retired.has(key)) continue; // already history — don't resurrect
|
|
171
|
+
const idx = next.findIndex((o) => o.key === key);
|
|
172
|
+
const op = toOperation(key, item.input, idx === -1 ? undefined : next[idx]);
|
|
173
|
+
if (idx === -1) {
|
|
174
|
+
next = [op, ...next];
|
|
175
|
+
changed = true;
|
|
176
|
+
} else if (JSON.stringify(next[idx]) !== JSON.stringify(op)) {
|
|
177
|
+
next[idx] = op;
|
|
178
|
+
changed = true;
|
|
179
|
+
}
|
|
180
|
+
if (op.status === 'done' || op.status === 'aborted') scheduleLinger(key);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Vanished from source: running → drop silently (superseded elsewhere),
|
|
184
|
+
// done/aborted → straight to history, error → sticky (keep visible).
|
|
185
|
+
for (const o of [...next]) {
|
|
186
|
+
if (!o.key.startsWith(prefix) || present.has(o.key)) continue;
|
|
187
|
+
if (o.status === 'error') continue;
|
|
188
|
+
clearTimer(o.key);
|
|
189
|
+
next = next.filter((x) => x.key !== o.key);
|
|
190
|
+
changed = true;
|
|
191
|
+
if (o.status !== 'running') {
|
|
192
|
+
retired.add(o.key);
|
|
193
|
+
history.value = [{ ...o, settledAt: o.settledAt ?? Date.now() }, ...history.value].slice(
|
|
194
|
+
0,
|
|
195
|
+
HISTORY_CAP,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Bookkeeping: retired keys whose source rows are gone can be forgotten
|
|
201
|
+
// (their ids won't reappear — uuids / monotonic op ids).
|
|
202
|
+
for (const key of [...retired]) {
|
|
203
|
+
if (key.startsWith(prefix) && !present.has(key)) retired.delete(key);
|
|
204
|
+
}
|
|
205
|
+
for (const key of [...actions.keys()]) {
|
|
206
|
+
if (key.startsWith(prefix) && !present.has(key) && !active.value.some((o) => o.key === key)) {
|
|
207
|
+
// keep actions for rows still on screen (sticky errors)
|
|
208
|
+
if (!next.some((o) => o.key === key)) actions.delete(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (changed) active.value = next;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function cancel(key: string) {
|
|
216
|
+
actions.get(key)?.cancel?.();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function retry(key: string) {
|
|
220
|
+
const acts = actions.get(key);
|
|
221
|
+
retire(key); // failed attempt goes to history; retry publishes a fresh row
|
|
222
|
+
acts?.dismiss?.();
|
|
223
|
+
acts?.retry?.();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function dismiss(key: string) {
|
|
227
|
+
const acts = actions.get(key);
|
|
228
|
+
retire(key);
|
|
229
|
+
acts?.dismiss?.();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function clearHistory() {
|
|
233
|
+
history.value = [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const runningCount = computed(
|
|
237
|
+
() => active.value.filter((o) => o.status === 'running').length,
|
|
238
|
+
);
|
|
239
|
+
const errorCount = computed(() => active.value.filter((o) => o.status === 'error').length);
|
|
240
|
+
const hasError = computed(() => errorCount.value > 0);
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Aggregate progress across determinate running rows (uploads weighted by
|
|
244
|
+
* bytes, queue ops by item counts). null → nothing determinate is running
|
|
245
|
+
* (badge ring spins instead of filling).
|
|
246
|
+
*/
|
|
247
|
+
const overallPercent = computed<number | null>(() => {
|
|
248
|
+
const running = active.value.filter((o) => o.status === 'running');
|
|
249
|
+
let sum = 0;
|
|
250
|
+
let n = 0;
|
|
251
|
+
for (const o of running) {
|
|
252
|
+
let frac: number | null = null;
|
|
253
|
+
if (o.totalBytes && o.totalBytes > 0) {
|
|
254
|
+
frac = Math.min(1, (o.uploadedBytes ?? 0) / o.totalBytes);
|
|
255
|
+
} else if (o.totalCount && o.totalCount > 0) {
|
|
256
|
+
frac = Math.min(1, (o.doneCount ?? 0) / o.totalCount);
|
|
257
|
+
} else if (o.percent !== null) {
|
|
258
|
+
frac = Math.min(1, o.percent / 100);
|
|
259
|
+
}
|
|
260
|
+
if (frac !== null) {
|
|
261
|
+
sum += frac;
|
|
262
|
+
n += 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (n === 0) return null;
|
|
266
|
+
return Math.round((sum / n) * 100);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (getCurrentScope()) {
|
|
270
|
+
onScopeDispose(() => {
|
|
271
|
+
for (const tm of lingerTimers.values()) clearTimeout(tm);
|
|
272
|
+
lingerTimers.clear();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
active,
|
|
278
|
+
history,
|
|
279
|
+
runningCount,
|
|
280
|
+
errorCount,
|
|
281
|
+
hasError,
|
|
282
|
+
overallPercent,
|
|
283
|
+
sync,
|
|
284
|
+
cancel,
|
|
285
|
+
retry,
|
|
286
|
+
dismiss,
|
|
287
|
+
clearHistory,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export type OperationsStore = ReturnType<typeof useOperations>;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTabs — wiring:d1 sekmeler (çalışma alanı tab şeridi).
|
|
3
|
+
*
|
|
4
|
+
* A LAYER ABOVE FileExplorer's location state, never a replacement for
|
|
5
|
+
* it: each tab is a location snapshot `{ id, path, viewMode, split }`.
|
|
6
|
+
* The composable performs no fetching and owns no navigation — the host
|
|
7
|
+
* activates a tab by running its EXISTING `load(path)` pipeline and
|
|
8
|
+
* reports navigations back through `syncActive()`, which keeps the
|
|
9
|
+
* active snapshot glued to wherever the user actually is.
|
|
10
|
+
*
|
|
11
|
+
* `path` is stored in the SAME form as FileExplorer's `currentPath`
|
|
12
|
+
* (virtual `<storage>/<rel>` in multi-storage mode, bare relative path
|
|
13
|
+
* in single-storage mode) — i.e. the exact string `load(path)` accepts.
|
|
14
|
+
* Storing the wire form (`adapter://rel`) instead would break the
|
|
15
|
+
* rootPath-floor clamp inside load() (the floor is compared in user-path
|
|
16
|
+
* form), so the user-path form is deliberate.
|
|
17
|
+
*
|
|
18
|
+
* Persistence: localStorage under a host-supplied key (the host derives
|
|
19
|
+
* it from the pathPersist scope: disabled when `pathPersist: 'none'`,
|
|
20
|
+
* suffixed with the rootPath confine so embedded instances with
|
|
21
|
+
* different confines never clobber each other). Schema:
|
|
22
|
+
*
|
|
23
|
+
* { "v": 1, "active": "<id>",
|
|
24
|
+
* "tabs": [ { "id", "path", "viewMode", "split": {"path"}|null } ] }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { computed, ref, watch, type Ref } from 'vue';
|
|
28
|
+
import type { ViewMode } from '../types/FileNode';
|
|
29
|
+
|
|
30
|
+
/** Per-tab split state — the secondary pane's own location. */
|
|
31
|
+
export interface TabSplit {
|
|
32
|
+
path: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TabState {
|
|
36
|
+
id: string;
|
|
37
|
+
/** Location snapshot in `currentPath` form (see module docs). */
|
|
38
|
+
path: string;
|
|
39
|
+
viewMode: ViewMode;
|
|
40
|
+
split: TabSplit | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UseTabsOptions {
|
|
44
|
+
/** localStorage key; null disables persistence entirely. */
|
|
45
|
+
storageKey: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeId(): string {
|
|
49
|
+
try {
|
|
50
|
+
return crypto.randomUUID();
|
|
51
|
+
} catch {
|
|
52
|
+
return `t${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useTabs(opts: UseTabsOptions) {
|
|
57
|
+
const tabs: Ref<TabState[]> = ref([]);
|
|
58
|
+
const activeId = ref<string>('');
|
|
59
|
+
|
|
60
|
+
const activeTab = computed<TabState | null>(
|
|
61
|
+
() => tabs.value.find((t) => t.id === activeId.value) ?? null,
|
|
62
|
+
);
|
|
63
|
+
const activeIndex = computed(() => tabs.value.findIndex((t) => t.id === activeId.value));
|
|
64
|
+
const hasMultiple = computed(() => tabs.value.length > 1);
|
|
65
|
+
|
|
66
|
+
// ------------------------------------------------------------------
|
|
67
|
+
// Persistence
|
|
68
|
+
// ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** Restore from storage. Returns true when at least one tab loaded. */
|
|
71
|
+
function restore(): boolean {
|
|
72
|
+
if (!opts.storageKey) return false;
|
|
73
|
+
try {
|
|
74
|
+
const raw = localStorage.getItem(opts.storageKey);
|
|
75
|
+
if (!raw) return false;
|
|
76
|
+
const parsed = JSON.parse(raw) as { v?: unknown; active?: unknown; tabs?: unknown };
|
|
77
|
+
if (!parsed || !Array.isArray(parsed.tabs) || parsed.tabs.length === 0) return false;
|
|
78
|
+
const clean: TabState[] = [];
|
|
79
|
+
for (const t of parsed.tabs as Array<Record<string, unknown>>) {
|
|
80
|
+
if (!t || typeof t.path !== 'string') continue;
|
|
81
|
+
// ViewMode is validated loosely on purpose: future modes (e.g. the
|
|
82
|
+
// gallery wave) must survive a round-trip through an older schema.
|
|
83
|
+
const vm =
|
|
84
|
+
typeof t.viewMode === 'string' && t.viewMode ? (t.viewMode as ViewMode) : 'list';
|
|
85
|
+
const rawSplit = t.split as { path?: unknown } | null | undefined;
|
|
86
|
+
const split =
|
|
87
|
+
rawSplit && typeof rawSplit === 'object' && typeof rawSplit.path === 'string'
|
|
88
|
+
? { path: rawSplit.path }
|
|
89
|
+
: null;
|
|
90
|
+
clean.push({
|
|
91
|
+
id: typeof t.id === 'string' && t.id ? t.id : makeId(),
|
|
92
|
+
path: t.path,
|
|
93
|
+
viewMode: vm,
|
|
94
|
+
split,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (clean.length === 0) return false;
|
|
98
|
+
tabs.value = clean;
|
|
99
|
+
activeId.value = clean.some((t) => t.id === parsed.active)
|
|
100
|
+
? String(parsed.active)
|
|
101
|
+
: clean[0].id;
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function persist(): void {
|
|
109
|
+
if (!opts.storageKey) return;
|
|
110
|
+
try {
|
|
111
|
+
localStorage.setItem(
|
|
112
|
+
opts.storageKey,
|
|
113
|
+
JSON.stringify({ v: 1, active: activeId.value, tabs: tabs.value }),
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
/* private mode / quota */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
watch([tabs, activeId], persist, { deep: true });
|
|
121
|
+
|
|
122
|
+
// ------------------------------------------------------------------
|
|
123
|
+
// Mutations
|
|
124
|
+
// ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** Create the first tab (no-op once any tab exists). */
|
|
127
|
+
function seed(path: string, viewMode: ViewMode): void {
|
|
128
|
+
if (tabs.value.length > 0) return;
|
|
129
|
+
const t: TabState = { id: makeId(), path, viewMode, split: null };
|
|
130
|
+
tabs.value = [t];
|
|
131
|
+
activeId.value = t.id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Update the ACTIVE tab's snapshot (navigation / view-mode change). */
|
|
135
|
+
function syncActive(patch: Partial<Pick<TabState, 'path' | 'viewMode'>>): void {
|
|
136
|
+
const t = activeTab.value;
|
|
137
|
+
if (!t) return;
|
|
138
|
+
if (patch.path !== undefined) t.path = patch.path;
|
|
139
|
+
if (patch.viewMode !== undefined) t.viewMode = patch.viewMode;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Open a new tab right after the active one (browser convention). */
|
|
143
|
+
function openTab(
|
|
144
|
+
path: string,
|
|
145
|
+
o: { viewMode: ViewMode; background?: boolean; split?: TabSplit | null },
|
|
146
|
+
): TabState {
|
|
147
|
+
const t: TabState = { id: makeId(), path, viewMode: o.viewMode, split: o.split ?? null };
|
|
148
|
+
const idx = activeIndex.value;
|
|
149
|
+
tabs.value.splice(idx === -1 ? tabs.value.length : idx + 1, 0, t);
|
|
150
|
+
if (!o.background) activeId.value = t.id;
|
|
151
|
+
return t;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Close a tab. The last remaining tab never closes. Returns the tab
|
|
156
|
+
* the host must ACTIVATE (right neighbour, else left) when the active
|
|
157
|
+
* one was closed; null when the visible location is unchanged.
|
|
158
|
+
*/
|
|
159
|
+
function closeTab(id: string): TabState | null {
|
|
160
|
+
if (tabs.value.length <= 1) return null;
|
|
161
|
+
const idx = tabs.value.findIndex((t) => t.id === id);
|
|
162
|
+
if (idx === -1) return null;
|
|
163
|
+
const wasActive = tabs.value[idx].id === activeId.value;
|
|
164
|
+
tabs.value.splice(idx, 1);
|
|
165
|
+
if (!wasActive) return null;
|
|
166
|
+
const next = tabs.value[Math.min(idx, tabs.value.length - 1)];
|
|
167
|
+
activeId.value = next.id;
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Make a tab active. Returns it when the active tab actually changed. */
|
|
172
|
+
function activate(id: string): TabState | null {
|
|
173
|
+
const t = tabs.value.find((x) => x.id === id);
|
|
174
|
+
if (!t || t.id === activeId.value) return null;
|
|
175
|
+
activeId.value = t.id;
|
|
176
|
+
return t;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Cycle: +1 = next, -1 = previous (wraps). */
|
|
180
|
+
function step(delta: number): TabState | null {
|
|
181
|
+
if (tabs.value.length < 2) return null;
|
|
182
|
+
const idx = activeIndex.value === -1 ? 0 : activeIndex.value;
|
|
183
|
+
const next = tabs.value[(idx + delta + tabs.value.length) % tabs.value.length];
|
|
184
|
+
return activate(next.id);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Drag-sort support: move the tab at `from` to position `to`. */
|
|
188
|
+
function move(from: number, to: number): void {
|
|
189
|
+
if (from === to) return;
|
|
190
|
+
if (from < 0 || to < 0 || from >= tabs.value.length || to >= tabs.value.length) return;
|
|
191
|
+
const list = [...tabs.value];
|
|
192
|
+
const [t] = list.splice(from, 1);
|
|
193
|
+
list.splice(to, 0, t);
|
|
194
|
+
tabs.value = list;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Set (or clear with null) the ACTIVE tab's split state. */
|
|
198
|
+
function setSplit(split: TabSplit | null): void {
|
|
199
|
+
const t = activeTab.value;
|
|
200
|
+
if (!t) return;
|
|
201
|
+
t.split = split;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
tabs,
|
|
206
|
+
activeId,
|
|
207
|
+
activeTab,
|
|
208
|
+
activeIndex,
|
|
209
|
+
hasMultiple,
|
|
210
|
+
restore,
|
|
211
|
+
seed,
|
|
212
|
+
syncActive,
|
|
213
|
+
openTab,
|
|
214
|
+
closeTab,
|
|
215
|
+
activate,
|
|
216
|
+
step,
|
|
217
|
+
move,
|
|
218
|
+
setSplit,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export type TabsApi = ReturnType<typeof useTabs>;
|
package/src/index.ts
CHANGED
|
@@ -82,3 +82,54 @@ export {
|
|
|
82
82
|
|
|
83
83
|
// ——— Locale catalogue (consumers may merge their own keys) ———
|
|
84
84
|
export { messages, tr, en } from './locales';
|
|
85
|
+
|
|
86
|
+
/* wiring:c1 — theme registry + gallery (hosts can list/apply themes programmatically) */
|
|
87
|
+
export {
|
|
88
|
+
THEMES,
|
|
89
|
+
THEME_LS_KEY,
|
|
90
|
+
DEFAULT_THEME_ID,
|
|
91
|
+
themeById,
|
|
92
|
+
useThemeState,
|
|
93
|
+
setTheme,
|
|
94
|
+
applyThemeToEl,
|
|
95
|
+
syncThemeStyle,
|
|
96
|
+
generateThemeCss,
|
|
97
|
+
} from './lib/themes';
|
|
98
|
+
export type { ThemeDef, ThemeTokenMap } from './lib/themes';
|
|
99
|
+
export { default as ThemeGallery } from './components/ThemeGallery.vue';
|
|
100
|
+
/* wiring:c2 — customizable shortcut registry + settings/quick-look UI */
|
|
101
|
+
export {
|
|
102
|
+
SHORTCUT_ACTIONS,
|
|
103
|
+
useShortcutList,
|
|
104
|
+
effectiveCombo,
|
|
105
|
+
setShortcutOverride,
|
|
106
|
+
resetShortcut,
|
|
107
|
+
resetAllShortcuts,
|
|
108
|
+
findShortcutConflict,
|
|
109
|
+
comboFromEvent,
|
|
110
|
+
} from './composables/useKeyboardShortcuts';
|
|
111
|
+
export type {
|
|
112
|
+
ShortcutActionDef,
|
|
113
|
+
ShortcutView,
|
|
114
|
+
ShortcutConflict,
|
|
115
|
+
} from './composables/useKeyboardShortcuts';
|
|
116
|
+
export { default as ShortcutSettings } from './components/ShortcutSettings.vue';
|
|
117
|
+
export { default as QuickLook } from './components/QuickLook.vue';
|
|
118
|
+
/* /wiring:c2 */
|
|
119
|
+
/* wiring:c3 — unified operations center (badge + panel + store) */
|
|
120
|
+
export { useOperations } from './composables/useOperations';
|
|
121
|
+
export type {
|
|
122
|
+
Operation,
|
|
123
|
+
OperationInput,
|
|
124
|
+
OperationActions,
|
|
125
|
+
OperationKind,
|
|
126
|
+
OperationStatus,
|
|
127
|
+
OperationsStore,
|
|
128
|
+
} from './composables/useOperations';
|
|
129
|
+
export { default as OperationsCenter } from './components/OperationsCenter.vue';
|
|
130
|
+
/* wiring:d1 — sekmeler + split panel */
|
|
131
|
+
export { useTabs } from './composables/useTabs';
|
|
132
|
+
export type { TabState, TabSplit, TabsApi } from './composables/useTabs';
|
|
133
|
+
export { default as TabBar } from './components/TabBar.vue';
|
|
134
|
+
export { default as SecondaryPane } from './components/SecondaryPane.vue';
|
|
135
|
+
/* /wiring:d1 */
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dragGhost — wiring:c4 custom HTML5 drag image.
|
|
3
|
+
*
|
|
4
|
+
* The browser default drag image (a translucent snapshot of the row/card)
|
|
5
|
+
* reads poorly for multi-selection drags. This builds a small pill with the
|
|
6
|
+
* dragged item's name and, for multi-drags, a count badge, parks it
|
|
7
|
+
* off-screen (setDragImage requires a rendered element), and removes it on
|
|
8
|
+
* the next tick. Purely visual — the dataTransfer payload is untouched.
|
|
9
|
+
*
|
|
10
|
+
* Styled by `.fe-dragghost` in base.css via the `--fe-*` tokens published
|
|
11
|
+
* at :root (and re-published at `.dark`/html level for dark hosts), so it
|
|
12
|
+
* matches the active theme even though it's appended to <body>.
|
|
13
|
+
*/
|
|
14
|
+
export function applyDragGhost(ev: DragEvent, name: string, count: number): void {
|
|
15
|
+
const dt = ev.dataTransfer;
|
|
16
|
+
if (!dt || typeof document === 'undefined' || typeof dt.setDragImage !== 'function') return;
|
|
17
|
+
try {
|
|
18
|
+
const el = document.createElement('div');
|
|
19
|
+
el.className = 'fe-dragghost';
|
|
20
|
+
const label = document.createElement('span');
|
|
21
|
+
label.className = 'fe-dragghost__name';
|
|
22
|
+
label.textContent = name;
|
|
23
|
+
el.appendChild(label);
|
|
24
|
+
if (count > 1) {
|
|
25
|
+
const badge = document.createElement('span');
|
|
26
|
+
badge.className = 'fe-dragghost__badge';
|
|
27
|
+
badge.textContent = String(count);
|
|
28
|
+
el.appendChild(badge);
|
|
29
|
+
}
|
|
30
|
+
el.style.position = 'fixed';
|
|
31
|
+
el.style.top = '-1000px';
|
|
32
|
+
el.style.left = '-1000px';
|
|
33
|
+
document.body.appendChild(el);
|
|
34
|
+
dt.setDragImage(el, 14, 14);
|
|
35
|
+
// The engine snapshots the element synchronously during dragstart —
|
|
36
|
+
// safe to drop it right after.
|
|
37
|
+
setTimeout(() => el.remove(), 0);
|
|
38
|
+
} catch {
|
|
39
|
+
/* non-critical visual nicety — native ghost remains */
|
|
40
|
+
}
|
|
41
|
+
}
|