@brftech/filex-core 0.21.6 → 0.23.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/filex-core.js +6817 -6634
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +44 -44
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +72 -24
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +209 -0
- package/src/components/GridView.vue +17 -0
- package/src/components/ListView.vue +18 -0
- package/src/components/SecondaryPane.vue +4 -0
- package/src/locales/en.ts +13 -0
- package/src/locales/tr.ts +13 -0
- package/src/styles/base.css +80 -0
- package/src/styles/variables.css +3 -0
- package/src/types/ExplorerConfig.ts +42 -0
package/src/FileExplorer.vue
CHANGED
|
@@ -1419,8 +1419,167 @@ async function onToolbarAction(key: string) {
|
|
|
1419
1419
|
await dispatchItemAction(key, sel);
|
|
1420
1420
|
}
|
|
1421
1421
|
|
|
1422
|
+
// ─── desktop selective sync — "keep on this computer" ──────────────────
|
|
1423
|
+
// Present only when the desktop shell passes config.desktopSync; the web
|
|
1424
|
+
// admin and the embeds never see these entries. State is PULLED, not pushed:
|
|
1425
|
+
// the kept list is re-read as a menu opens, so the component needs no event
|
|
1426
|
+
// channel back to the shell and cannot go stale in a way that outlives one
|
|
1427
|
+
// right-click.
|
|
1428
|
+
const desktopSync = computed(() => props.config.desktopSync ?? null);
|
|
1429
|
+
const keptPairs = ref<Array<{ remote: string; local: string }>>([]);
|
|
1430
|
+
|
|
1431
|
+
async function refreshKept(): Promise<void> {
|
|
1432
|
+
if (!desktopSync.value) return;
|
|
1433
|
+
try {
|
|
1434
|
+
keptPairs.value = await desktopSync.value.kept();
|
|
1435
|
+
} catch {
|
|
1436
|
+
// Shell went away mid-call; a stale entry only mislabels a menu item.
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** The folder the engine is working on RIGHT NOW (null between runs). Drives
|
|
1441
|
+
* the ⟳ row badges and the bottom progress strip. */
|
|
1442
|
+
const keepActive = ref<{
|
|
1443
|
+
remote: string;
|
|
1444
|
+
phase: 'inventory' | 'plan' | 'transfer' | 'settling';
|
|
1445
|
+
done: number;
|
|
1446
|
+
total: number;
|
|
1447
|
+
} | null>(null);
|
|
1448
|
+
|
|
1449
|
+
async function refreshKeepStatus(): Promise<void> {
|
|
1450
|
+
const ds = desktopSync.value;
|
|
1451
|
+
if (!ds?.status) return;
|
|
1452
|
+
try {
|
|
1453
|
+
keepActive.value = (await ds.status()).active ?? null;
|
|
1454
|
+
} catch {
|
|
1455
|
+
keepActive.value = null;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// The shell pokes on every engine output line — during a transfer that can be
|
|
1460
|
+
// several a second, and each refresh is an IPC round-trip. Trailing-edge
|
|
1461
|
+
// throttle: at most one refresh per 300ms, and the FINAL poke always lands,
|
|
1462
|
+
// so the strip cannot get stuck showing a finished transfer.
|
|
1463
|
+
let keepPokeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1464
|
+
// The shell holds the callback and only drops it on the NEXT mount, so a poke
|
|
1465
|
+
// can arrive after this instance is gone — pokes then set refs nothing reads.
|
|
1466
|
+
// Cheap to make explicit rather than rely on that being harmless.
|
|
1467
|
+
let keepAlive = true;
|
|
1468
|
+
function onKeepPoke(): void {
|
|
1469
|
+
if (keepPokeTimer || !keepAlive) return;
|
|
1470
|
+
keepPokeTimer = setTimeout(() => {
|
|
1471
|
+
keepPokeTimer = null;
|
|
1472
|
+
if (!keepAlive) return;
|
|
1473
|
+
void refreshKeepStatus();
|
|
1474
|
+
void refreshKept();
|
|
1475
|
+
}, 300);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
onMounted(() => {
|
|
1479
|
+
void refreshKept();
|
|
1480
|
+
void refreshKeepStatus();
|
|
1481
|
+
desktopSync.value?.onChange?.(onKeepPoke);
|
|
1482
|
+
});
|
|
1483
|
+
onBeforeUnmount(() => {
|
|
1484
|
+
keepAlive = false;
|
|
1485
|
+
if (keepPokeTimer) {
|
|
1486
|
+
clearTimeout(keepPokeTimer);
|
|
1487
|
+
keepPokeTimer = null;
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
/** Adapter-qualified remote for a row. Virtual storage rows carry a bare
|
|
1492
|
+
* name (`docs`), real rows a wire path (`docs://reports`). */
|
|
1493
|
+
function keepRemoteOf(node: FileNode): string {
|
|
1494
|
+
const p = String(node.path ?? '');
|
|
1495
|
+
return p.includes('://') ? p.replace(/\/+$/, '') : `${p}://`;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
type KeepState = 'none' | 'kept' | 'inherited' | 'partial';
|
|
1499
|
+
|
|
1500
|
+
/** True when `child` lives strictly inside `parent` (both wire-form). */
|
|
1501
|
+
function remoteInside(child: string, parent: string): boolean {
|
|
1502
|
+
if (parent.endsWith('://')) return child.startsWith(parent) && child !== parent;
|
|
1503
|
+
return child === parent ? false : child.startsWith(parent + '/');
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/** How `remote` relates to the kept set: exactly a pair, inside one
|
|
1507
|
+
* (inherited), an ancestor of some (partial), or unrelated. */
|
|
1508
|
+
function keepStateOf(remote: string): KeepState {
|
|
1509
|
+
if (keptPairs.value.some((p) => p.remote === remote)) return 'kept';
|
|
1510
|
+
if (keptPairs.value.some((p) => remoteInside(remote, p.remote))) return 'inherited';
|
|
1511
|
+
if (keptPairs.value.some((p) => remoteInside(p.remote, remote))) return 'partial';
|
|
1512
|
+
return 'none';
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
type KeepBadge = 'kept' | 'syncing' | 'cloud' | 'partial';
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* The availability badge for one row: on this computer, being synced right
|
|
1519
|
+
* now, holding kept items somewhere below (partial), or online-only. Every
|
|
1520
|
+
* row gets one — that is the OneDrive/Drive grammar people already read —
|
|
1521
|
+
* except the rows where it would be a lie or noise: trash, and the `.trash`
|
|
1522
|
+
* row itself. `partial` is what saves the user from drilling into every
|
|
1523
|
+
* folder to find out whether anything inside is on this computer.
|
|
1524
|
+
*/
|
|
1525
|
+
function keepBadgeFor(n: FileNode): KeepBadge | null {
|
|
1526
|
+
if (!desktopSync.value || trashActive.value) return null;
|
|
1527
|
+
if (n.basename === '.trash') return null;
|
|
1528
|
+
const r = keepRemoteOf(n);
|
|
1529
|
+
const act = keepActive.value;
|
|
1530
|
+
if (act && (r === act.remote || remoteInside(r, act.remote) || remoteInside(act.remote, r))) {
|
|
1531
|
+
return 'syncing';
|
|
1532
|
+
}
|
|
1533
|
+
const st = keepStateOf(r);
|
|
1534
|
+
if (st === 'kept' || st === 'inherited') return 'kept';
|
|
1535
|
+
if (st === 'partial') return 'partial';
|
|
1536
|
+
return 'cloud';
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/** Bottom strip: what to say while the engine works. */
|
|
1540
|
+
const keepStripLabel = computed<string>(() => {
|
|
1541
|
+
const act = keepActive.value;
|
|
1542
|
+
if (!act) return '';
|
|
1543
|
+
const name = act.remote.endsWith('://')
|
|
1544
|
+
? act.remote.slice(0, -'://'.length)
|
|
1545
|
+
: act.remote.slice(act.remote.lastIndexOf('/') + 1);
|
|
1546
|
+
if (act.phase === 'transfer' && act.total > 0) {
|
|
1547
|
+
return t('keep.strip_transfer', {
|
|
1548
|
+
name,
|
|
1549
|
+
done: String(act.done),
|
|
1550
|
+
total: String(act.total),
|
|
1551
|
+
pct: String(Math.min(100, Math.round((act.done * 100) / act.total))),
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
if (act.phase === 'settling') return t('keep.strip_settling', { name });
|
|
1555
|
+
return t('keep.strip_inventory', { name });
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
const keepStripPercent = computed<number | null>(() => {
|
|
1559
|
+
const act = keepActive.value;
|
|
1560
|
+
if (!act || act.phase !== 'transfer' || act.total <= 0) return null;
|
|
1561
|
+
return Math.min(100, Math.round((act.done * 100) / act.total));
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
/** Menu entries for one selected folder OR file, by its keep state. Empty
|
|
1565
|
+
* for multi-selections, trash, encrypted folders, or a web mount. */
|
|
1566
|
+
function keepActionsFor(sel: FileNode[]): ContextAction[] {
|
|
1567
|
+
const ds = desktopSync.value;
|
|
1568
|
+
const single = sel.length === 1 && (sel[0]?.type === 'dir' || sel[0]?.type === 'file');
|
|
1569
|
+
if (!ds || !single || trashActive.value || e2eActive.value || sel[0]?.e2e === true) return [];
|
|
1570
|
+
const st = keepStateOf(keepRemoteOf(sel[0]!));
|
|
1571
|
+
return [
|
|
1572
|
+
{ divider: true, key: 'sep-keep', label: '' },
|
|
1573
|
+
{ key: 'keep-local', label: t('ctx.keep_local'), icon: '📌', hidden: st === 'kept' || st === 'inherited' },
|
|
1574
|
+
{ key: 'keep-online', label: t('ctx.keep_online'), icon: '☁', hidden: st !== 'kept' },
|
|
1575
|
+
{ key: 'keep-inherited', label: t('ctx.keep_inherited'), icon: '📌', disabled: true, hidden: st !== 'inherited' },
|
|
1576
|
+
{ key: 'keep-reveal', label: t('ctx.keep_reveal'), icon: '📂', hidden: st !== 'kept' && st !== 'inherited' },
|
|
1577
|
+
];
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1422
1580
|
async function onContextTarget(node: FileNode, ev: MouseEvent) {
|
|
1423
1581
|
ctxMode.value = 'selection';
|
|
1582
|
+
void refreshKept(); // menu labels react if the kept set changed since last look
|
|
1424
1583
|
if (!selection.has(node.path)) {
|
|
1425
1584
|
selection.click(node.path);
|
|
1426
1585
|
await nextTick();
|
|
@@ -1447,6 +1606,7 @@ function onCrumbContext(payload: { x: number; y: number; adapterPath: string; la
|
|
|
1447
1606
|
* dispatchItemAction'a gider ve ctxMode==='pane' iken pane-route'lanır. */
|
|
1448
1607
|
function onPaneContext(node: FileNode | null, ev: MouseEvent) {
|
|
1449
1608
|
activePane.value = 'split';
|
|
1609
|
+
void refreshKept();
|
|
1450
1610
|
const sel = splitPaneRef.value?.selectedNodes() ?? [];
|
|
1451
1611
|
// node=null (boş alana sağ-tık): seçimsiz menü (Yeni Klasör + Yapıştır).
|
|
1452
1612
|
// Aksi halde pane seçimi (yoksa tıklanan node) hedeftir.
|
|
@@ -1507,6 +1667,9 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
1507
1667
|
return [
|
|
1508
1668
|
{ key: 'open', label: t('ctx.open'), icon: '↗' },
|
|
1509
1669
|
{ key: 'open-tab', label: t('ctx.open_new_tab'), icon: '⧉' } /* wiring:d1 */,
|
|
1670
|
+
// A whole storage can be kept too — that IS the "sync everything"
|
|
1671
|
+
// shape, and it is one pair, not one per subfolder.
|
|
1672
|
+
...keepActionsFor(sel),
|
|
1510
1673
|
];
|
|
1511
1674
|
}
|
|
1512
1675
|
|
|
@@ -1571,6 +1734,7 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
1571
1734
|
{ key: 'paste', label: t('ctx.paste'), icon: '📋', hidden: !w, disabled: !clipboard.value.mode },
|
|
1572
1735
|
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId },
|
|
1573
1736
|
{ key: 'tags', label: tagsLabel, icon: '🏷', hidden: !singleHasId, disabled: !singleHasId },
|
|
1737
|
+
...keepActionsFor(sel),
|
|
1574
1738
|
{ divider: true, key: 'sep2', label: '', hidden: !w },
|
|
1575
1739
|
{ key: 'delete', label: t('ctx.delete'), icon: '🗑', danger: true, hidden: !any || !w, disabled: !any },
|
|
1576
1740
|
];
|
|
@@ -1631,6 +1795,38 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
1631
1795
|
case 'download':
|
|
1632
1796
|
if (targets[0]) downloadFile(targets[0]);
|
|
1633
1797
|
break;
|
|
1798
|
+
case 'keep-local': {
|
|
1799
|
+
const ds = desktopSync.value;
|
|
1800
|
+
if (!ds || !targets[0]) break;
|
|
1801
|
+
const remote = keepRemoteOf(targets[0]);
|
|
1802
|
+
try {
|
|
1803
|
+
await ds.keep(remote, targets[0].type === 'file' ? 'file' : 'dir');
|
|
1804
|
+
await refreshKept();
|
|
1805
|
+
// The shell may have shown its root-folder prompt and been cancelled —
|
|
1806
|
+
// only claim success when the pair is really there now.
|
|
1807
|
+
if (keepStateOf(remote) !== 'none') flashToast(t('keep.started'));
|
|
1808
|
+
} catch (e) {
|
|
1809
|
+
await refreshKept();
|
|
1810
|
+
flashToast(`${t('keep.failed')}: ${String((e as Error)?.message ?? e)}`);
|
|
1811
|
+
}
|
|
1812
|
+
break;
|
|
1813
|
+
}
|
|
1814
|
+
case 'keep-online': {
|
|
1815
|
+
const ds = desktopSync.value;
|
|
1816
|
+
if (!ds || !targets[0]) break;
|
|
1817
|
+
try {
|
|
1818
|
+
await ds.unkeep(keepRemoteOf(targets[0]));
|
|
1819
|
+
} catch {
|
|
1820
|
+
// The shell owns the confirm dialog and reports its own failures.
|
|
1821
|
+
}
|
|
1822
|
+
await refreshKept();
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
case 'keep-reveal': {
|
|
1826
|
+
const ds = desktopSync.value;
|
|
1827
|
+
if (ds && targets[0]) void ds.reveal(keepRemoteOf(targets[0]));
|
|
1828
|
+
break;
|
|
1829
|
+
}
|
|
1634
1830
|
case 'convert':
|
|
1635
1831
|
if (targets[0]) openConvert(targets[0]);
|
|
1636
1832
|
break;
|
|
@@ -3417,6 +3613,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3417
3613
|
:show-parent-path="!!searchQuery"
|
|
3418
3614
|
:locale="locale"
|
|
3419
3615
|
:loading="loading"
|
|
3616
|
+
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
3420
3617
|
:starred-ids="starredIds"
|
|
3421
3618
|
:api-base="props.config.apiBase ?? ''"
|
|
3422
3619
|
:auth-headers="() => buildAuthHeaders()"
|
|
@@ -3436,6 +3633,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3436
3633
|
:show-parent-path="!!searchQuery"
|
|
3437
3634
|
:locale="locale"
|
|
3438
3635
|
:loading="loading"
|
|
3636
|
+
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
3439
3637
|
:thumb-src="thumbs.src"
|
|
3440
3638
|
@click-card="(n, m) => selection.click(n.path, m)"
|
|
3441
3639
|
@dbl-card="openNode"
|
|
@@ -3467,6 +3665,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3467
3665
|
:key tab kimliğine bağlı — tab geçişinde pane kendi konumuyla temiz
|
|
3468
3666
|
remount olur. -->
|
|
3469
3667
|
<SecondaryPane
|
|
3668
|
+
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
3470
3669
|
v-if="splitVisible && activeSplit"
|
|
3471
3670
|
ref="splitPaneRef"
|
|
3472
3671
|
:key="'split-' + tabsActiveId"
|
|
@@ -3572,6 +3771,16 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3572
3771
|
</svg>
|
|
3573
3772
|
</button>
|
|
3574
3773
|
|
|
3774
|
+
<!-- selective-sync progress: the folder the engine is moving RIGHT NOW.
|
|
3775
|
+
Overlay, pointer-events none — status is never in the way of work. -->
|
|
3776
|
+
<div v-if="keepActive" class="fe-keep-strip" role="status" aria-live="polite">
|
|
3777
|
+
<span class="fe-keep-strip__icon" aria-hidden="true">⟳</span>
|
|
3778
|
+
<span class="fe-keep-strip__label">{{ keepStripLabel }}</span>
|
|
3779
|
+
<div v-if="keepStripPercent !== null" class="fe-keep-strip__bar" aria-hidden="true">
|
|
3780
|
+
<div class="fe-keep-strip__fill" :style="{ width: keepStripPercent + '%' }"></div>
|
|
3781
|
+
</div>
|
|
3782
|
+
</div>
|
|
3783
|
+
|
|
3575
3784
|
<ContextMenu
|
|
3576
3785
|
ref="ctxRef"
|
|
3577
3786
|
:locale="locale"
|
|
@@ -21,6 +21,9 @@ const props = defineProps<{
|
|
|
21
21
|
* root-relative and unauthenticated — a bare <img src> only works for the
|
|
22
22
|
* native same-origin SPA, so embedded hosts NEED this. null = icon. */
|
|
23
23
|
thumbSrc?: (n: FileNode) => string | null;
|
|
24
|
+
/** Desktop selective sync: availability badge per tile. Absent on the
|
|
25
|
+
* web — no badge renders at all. */
|
|
26
|
+
keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
|
|
24
27
|
}>();
|
|
25
28
|
|
|
26
29
|
const emit = defineEmits<{
|
|
@@ -123,6 +126,13 @@ function parentDir(path: string): string {
|
|
|
123
126
|
|
|
124
127
|
// Special rows keep their emoji (trash/storage are not file-TYPE icons);
|
|
125
128
|
// everything else renders the SVG icon set from lib/fileIcons.
|
|
129
|
+
function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
|
|
130
|
+
if (b === 'kept') return '\u2713';
|
|
131
|
+
if (b === 'syncing') return '\u27f3';
|
|
132
|
+
if (b === 'partial') return '\u25d0';
|
|
133
|
+
return '\u2601';
|
|
134
|
+
}
|
|
135
|
+
|
|
126
136
|
function specialEmojiFor(n: FileNode): string | null {
|
|
127
137
|
if (n.basename === '.trash') return '🗑';
|
|
128
138
|
if (n.mime_type === 'inode/storage') return '💾';
|
|
@@ -205,6 +215,13 @@ function snippetTitle(snippet: string): string {
|
|
|
205
215
|
</div>
|
|
206
216
|
<div class="fe-grid__label" :title="n.basename">
|
|
207
217
|
{{ nodeDisplayName(n) }}
|
|
218
|
+
<span
|
|
219
|
+
v-if="keepBadgeFor && keepBadgeFor(n)"
|
|
220
|
+
:class="['fe-keepbadge', 'fe-keepbadge--' + keepBadgeFor(n)]"
|
|
221
|
+
:title="t('keep.badge_' + keepBadgeFor(n))"
|
|
222
|
+
role="img"
|
|
223
|
+
:aria-label="t('keep.badge_' + keepBadgeFor(n))"
|
|
224
|
+
>{{ keepGlyph(keepBadgeFor(n)!) }}</span>
|
|
208
225
|
</div>
|
|
209
226
|
<div
|
|
210
227
|
v-if="showParentPath"
|
|
@@ -39,6 +39,10 @@ const props = defineProps<{
|
|
|
39
39
|
apiBase?: string;
|
|
40
40
|
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
41
41
|
authCredentials?: RequestCredentials;
|
|
42
|
+
/** Desktop selective sync: availability badge per row (kept on this
|
|
43
|
+
* computer / syncing right now / online only). Absent on the web —
|
|
44
|
+
* no badge renders at all. */
|
|
45
|
+
keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
|
|
42
46
|
}>();
|
|
43
47
|
|
|
44
48
|
const emit = defineEmits<{
|
|
@@ -144,6 +148,13 @@ function specialEmojiFor(n: FileNode): string | null {
|
|
|
144
148
|
return null;
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
|
|
152
|
+
if (b === 'kept') return '\u2713';
|
|
153
|
+
if (b === 'syncing') return '\u27f3';
|
|
154
|
+
if (b === 'partial') return '\u25d0';
|
|
155
|
+
return '\u2601';
|
|
156
|
+
}
|
|
157
|
+
|
|
147
158
|
function isPinnedSpecial(n: FileNode): boolean {
|
|
148
159
|
return n.basename === '.trash' || n.mime_type === 'inode/storage';
|
|
149
160
|
}
|
|
@@ -397,6 +408,13 @@ const segments = computed<Segment[]>(() => {
|
|
|
397
408
|
{{ nodeDisplayName(n) }}
|
|
398
409
|
<!-- bul:s3 — content-match badge -->
|
|
399
410
|
<span v-if="rowInContent(n)" class="fe-list__badge">{{ t('search.in_content') }}</span>
|
|
411
|
+
<span
|
|
412
|
+
v-if="keepBadgeFor && keepBadgeFor(n)"
|
|
413
|
+
:class="['fe-keepbadge', 'fe-keepbadge--' + keepBadgeFor(n)]"
|
|
414
|
+
:title="t('keep.badge_' + keepBadgeFor(n))"
|
|
415
|
+
role="img"
|
|
416
|
+
:aria-label="t('keep.badge_' + keepBadgeFor(n))"
|
|
417
|
+
>{{ keepGlyph(keepBadgeFor(n)!) }}</span>
|
|
400
418
|
</span>
|
|
401
419
|
<span
|
|
402
420
|
v-if="showParentPath"
|
|
@@ -58,6 +58,8 @@ const props = defineProps<{
|
|
|
58
58
|
viewMode?: ViewMode;
|
|
59
59
|
/** ui-fix — authenticated thumb resolver, forwarded to grid/gallery. */
|
|
60
60
|
thumbSrc?: (n: FileNode) => string | null;
|
|
61
|
+
/** Desktop selective sync badge resolver, forwarded to the views. */
|
|
62
|
+
keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
|
|
61
63
|
/** ui-fix — mirror the main panel's virtual `.trash` row at storage root
|
|
62
64
|
* so both split panes list identical rows (no row-offset). Defaults on. */
|
|
63
65
|
trashVisible?: boolean;
|
|
@@ -396,6 +398,7 @@ defineExpose({ reload, goUp, selectAll, openSelected, selectedNodes, getPath });
|
|
|
396
398
|
:selected="selected"
|
|
397
399
|
:locale="locale"
|
|
398
400
|
:loading="loading"
|
|
401
|
+
:keep-badge-for="keepBadgeFor"
|
|
399
402
|
@click-row="onViewClick"
|
|
400
403
|
@dbl-row="onRowDbl"
|
|
401
404
|
@context-row="onViewContext"
|
|
@@ -409,6 +412,7 @@ defineExpose({ reload, goUp, selectAll, openSelected, selectedNodes, getPath });
|
|
|
409
412
|
:locale="locale"
|
|
410
413
|
:loading="loading"
|
|
411
414
|
:thumb-src="thumbSrc"
|
|
415
|
+
:keep-badge-for="keepBadgeFor"
|
|
412
416
|
@click-card="onViewClick"
|
|
413
417
|
@dbl-card="onRowDbl"
|
|
414
418
|
@context-card="onViewContext"
|
package/src/locales/en.ts
CHANGED
|
@@ -23,6 +23,19 @@ export const en: Record<string, string> = {
|
|
|
23
23
|
'ctx.cut': 'Cut',
|
|
24
24
|
'ctx.show_hidden': 'Show hidden files',
|
|
25
25
|
'ctx.hide_hidden': 'Hide hidden files',
|
|
26
|
+
'ctx.keep_local': 'Keep on this computer',
|
|
27
|
+
'ctx.keep_online': 'Keep online only',
|
|
28
|
+
'ctx.keep_reveal': 'Open local folder',
|
|
29
|
+
'ctx.keep_inherited': 'Kept on this computer with its parent',
|
|
30
|
+
'keep.started': 'Will be kept on this computer — sync started',
|
|
31
|
+
'keep.failed': 'Could not keep on this computer',
|
|
32
|
+
'keep.badge_kept': 'On this computer',
|
|
33
|
+
'keep.badge_syncing': 'Syncing…',
|
|
34
|
+
'keep.badge_cloud': 'Online only',
|
|
35
|
+
'keep.badge_partial': 'Contains items kept on this computer',
|
|
36
|
+
'keep.strip_transfer': 'Syncing {name} — {done}/{total} ({pct}%)',
|
|
37
|
+
'keep.strip_inventory': 'Syncing {name} — scanning the server…',
|
|
38
|
+
'keep.strip_settling': 'Syncing {name} — settling…',
|
|
26
39
|
'ctx.paste': 'Paste',
|
|
27
40
|
'ctx.info': 'Info',
|
|
28
41
|
'ctx.duplicate': 'Duplicate',
|
package/src/locales/tr.ts
CHANGED
|
@@ -23,6 +23,19 @@ export const tr: Record<string, string> = {
|
|
|
23
23
|
'ctx.cut': 'Kes',
|
|
24
24
|
'ctx.show_hidden': 'Gizli dosyaları göster',
|
|
25
25
|
'ctx.hide_hidden': 'Gizli dosyaları gizle',
|
|
26
|
+
'ctx.keep_local': 'Bilgisayarda tut',
|
|
27
|
+
'ctx.keep_online': 'Yalnızca çevrimiçi tut',
|
|
28
|
+
'ctx.keep_reveal': 'Yerel klasörü aç',
|
|
29
|
+
'ctx.keep_inherited': 'Üst klasörle bilgisayarda tutuluyor',
|
|
30
|
+
'keep.started': 'Bilgisayarda tutulacak — eşitleme başladı',
|
|
31
|
+
'keep.failed': 'Bilgisayarda tutulamadı',
|
|
32
|
+
'keep.badge_kept': 'Bilgisayarda',
|
|
33
|
+
'keep.badge_syncing': 'Eşitleniyor…',
|
|
34
|
+
'keep.badge_cloud': 'Yalnızca çevrimiçi',
|
|
35
|
+
'keep.badge_partial': 'İçinde bilgisayarda tutulanlar var',
|
|
36
|
+
'keep.strip_transfer': '{name} eşitleniyor — {done}/{total} (%{pct})',
|
|
37
|
+
'keep.strip_inventory': '{name} eşitleniyor — sunucu taranıyor…',
|
|
38
|
+
'keep.strip_settling': '{name} eşitleniyor — son durum kaydediliyor…',
|
|
26
39
|
'ctx.paste': 'Yapıştır',
|
|
27
40
|
'ctx.info': 'Bilgi',
|
|
28
41
|
'ctx.duplicate': 'Kopyasını Oluştur',
|
package/src/styles/base.css
CHANGED
|
@@ -3928,3 +3928,83 @@ filex-explorer {
|
|
|
3928
3928
|
}
|
|
3929
3929
|
}
|
|
3930
3930
|
/* === /ui-fix ========================================================= */
|
|
3931
|
+
|
|
3932
|
+
/* ── selective sync (desktop shell only): availability badges + strip ──
|
|
3933
|
+
The glyphs read in the OneDrive/Drive grammar: ✓ on this computer,
|
|
3934
|
+
⟳ being synced right now, ☁ online-only. Rendered only when the host
|
|
3935
|
+
passes keepBadgeFor — the web admin and the embeds never show them. */
|
|
3936
|
+
.fe-keepbadge {
|
|
3937
|
+
flex: 0 0 auto;
|
|
3938
|
+
display: inline-block;
|
|
3939
|
+
margin-left: 4px;
|
|
3940
|
+
font-size: 11px;
|
|
3941
|
+
line-height: 1;
|
|
3942
|
+
vertical-align: middle;
|
|
3943
|
+
}
|
|
3944
|
+
.fe-keepbadge--kept {
|
|
3945
|
+
color: var(--fe-keep-ok, #16a34a);
|
|
3946
|
+
}
|
|
3947
|
+
.fe-keepbadge--cloud {
|
|
3948
|
+
color: var(--fe-text-muted);
|
|
3949
|
+
opacity: 0.7;
|
|
3950
|
+
}
|
|
3951
|
+
.fe-keepbadge--partial {
|
|
3952
|
+
color: var(--fe-keep-ok, #16a34a);
|
|
3953
|
+
opacity: 0.85;
|
|
3954
|
+
}
|
|
3955
|
+
.fe-keepbadge--syncing {
|
|
3956
|
+
color: var(--fe-primary);
|
|
3957
|
+
animation: fe-keep-spin 1.2s linear infinite;
|
|
3958
|
+
}
|
|
3959
|
+
@keyframes fe-keep-spin {
|
|
3960
|
+
to {
|
|
3961
|
+
transform: rotate(360deg);
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
/* Bottom overlay strip: progress of the folder being synced right now.
|
|
3966
|
+
pointer-events none — it reports, it never blocks a click. */
|
|
3967
|
+
.fe-keep-strip {
|
|
3968
|
+
position: absolute;
|
|
3969
|
+
left: 12px;
|
|
3970
|
+
right: 12px;
|
|
3971
|
+
bottom: 10px;
|
|
3972
|
+
z-index: 30;
|
|
3973
|
+
display: flex;
|
|
3974
|
+
align-items: center;
|
|
3975
|
+
gap: 8px;
|
|
3976
|
+
padding: 6px 12px;
|
|
3977
|
+
border: 1px solid var(--fe-border);
|
|
3978
|
+
border-radius: 10px;
|
|
3979
|
+
background: var(--fe-bg-elev);
|
|
3980
|
+
box-shadow: var(--fe-shadow-sm, 0 2px 6px rgba(0, 0, 0, 0.18));
|
|
3981
|
+
font-size: 12.5px;
|
|
3982
|
+
color: var(--fe-text-muted);
|
|
3983
|
+
pointer-events: none;
|
|
3984
|
+
}
|
|
3985
|
+
.fe-keep-strip__icon {
|
|
3986
|
+
font-size: 13px;
|
|
3987
|
+
line-height: 1;
|
|
3988
|
+
color: var(--fe-primary);
|
|
3989
|
+
animation: fe-keep-spin 1.2s linear infinite;
|
|
3990
|
+
}
|
|
3991
|
+
.fe-keep-strip__label {
|
|
3992
|
+
flex: 0 1 auto;
|
|
3993
|
+
overflow: hidden;
|
|
3994
|
+
text-overflow: ellipsis;
|
|
3995
|
+
white-space: nowrap;
|
|
3996
|
+
}
|
|
3997
|
+
.fe-keep-strip__bar {
|
|
3998
|
+
flex: 1 1 80px;
|
|
3999
|
+
min-width: 60px;
|
|
4000
|
+
height: 4px;
|
|
4001
|
+
border-radius: 999px;
|
|
4002
|
+
background: color-mix(in srgb, var(--fe-primary) 18%, transparent);
|
|
4003
|
+
overflow: hidden;
|
|
4004
|
+
}
|
|
4005
|
+
.fe-keep-strip__fill {
|
|
4006
|
+
height: 100%;
|
|
4007
|
+
border-radius: 999px;
|
|
4008
|
+
background: var(--fe-primary);
|
|
4009
|
+
transition: width 0.3s ease;
|
|
4010
|
+
}
|
package/src/styles/variables.css
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
--fe-primary-hover: #2563eb;
|
|
18
18
|
--fe-danger: #dc2626;
|
|
19
19
|
--fe-danger-hover: #b91c1c;
|
|
20
|
+
--fe-keep-ok: #16a34a; /* selective-sync "on this computer" check */
|
|
20
21
|
--fe-shadow: 0 10px 32px rgba(15, 23, 42, 0.14);
|
|
21
22
|
--fe-shadow-sm: 0 2px 6px rgba(15, 23, 42, 0.08);
|
|
22
23
|
--fe-radius: 8px;
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
--fe-primary-hover: #3b82f6;
|
|
66
67
|
--fe-danger: #f87171;
|
|
67
68
|
--fe-danger-hover: #ef4444;
|
|
69
|
+
--fe-keep-ok: #4ade80;
|
|
68
70
|
--fe-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
|
69
71
|
--fe-shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.25);
|
|
70
72
|
}
|
|
@@ -85,6 +87,7 @@
|
|
|
85
87
|
--fe-primary-hover: #3b82f6;
|
|
86
88
|
--fe-danger: #f87171;
|
|
87
89
|
--fe-danger-hover: #ef4444;
|
|
90
|
+
--fe-keep-ok: #4ade80;
|
|
88
91
|
--fe-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
|
89
92
|
--fe-shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.25);
|
|
90
93
|
}
|
|
@@ -308,6 +308,48 @@ export interface ExplorerConfig {
|
|
|
308
308
|
driver?: string;
|
|
309
309
|
readOnly?: boolean;
|
|
310
310
|
}>;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Desktop-shell hook — selective sync ("keep on this computer").
|
|
314
|
+
*
|
|
315
|
+
* Present only when the explorer runs inside the filex desktop app; the
|
|
316
|
+
* shell passes functions that talk to its sync engine, and the explorer
|
|
317
|
+
* grows "Keep on this computer" / "Online only" entries on folder menus.
|
|
318
|
+
* Absent (web admin, embeds): nothing about it renders.
|
|
319
|
+
*
|
|
320
|
+
* Folders only, by design: the sync engine pairs directories, and a file
|
|
321
|
+
* rides along with the folder that holds it.
|
|
322
|
+
*/
|
|
323
|
+
desktopSync?: {
|
|
324
|
+
/** Kept folders for the mounted account, adapter-qualified remotes. */
|
|
325
|
+
kept: () => Promise<Array<{ remote: string; local: string }>>;
|
|
326
|
+
/** Start keeping a folder — or, with kind 'file', a single file.
|
|
327
|
+
* Resolves once the pair is registered (or the user cancelled the
|
|
328
|
+
* shell's root-folder prompt — re-read `kept`). */
|
|
329
|
+
keep: (remote: string, kind: 'dir' | 'file') => Promise<void>;
|
|
330
|
+
/** Stop keeping. The SHELL owns the "what happens to the local copy"
|
|
331
|
+
* question — it asks natively and may cancel; re-read `kept` after. */
|
|
332
|
+
unkeep: (remote: string) => Promise<void>;
|
|
333
|
+
/** Open the folder's local mirror in the OS file manager. */
|
|
334
|
+
reveal: (remote: string) => Promise<void>;
|
|
335
|
+
/** Live engine state, for the row badges and the bottom progress strip.
|
|
336
|
+
* `active` is the folder being worked on right now — the engine walks
|
|
337
|
+
* its pairs one at a time — or null between runs. */
|
|
338
|
+
status?: () => Promise<{
|
|
339
|
+
running: boolean;
|
|
340
|
+
lastError?: string | null;
|
|
341
|
+
active: {
|
|
342
|
+
remote: string;
|
|
343
|
+
phase: 'inventory' | 'plan' | 'transfer' | 'settling';
|
|
344
|
+
done: number;
|
|
345
|
+
total: number;
|
|
346
|
+
} | null;
|
|
347
|
+
}>;
|
|
348
|
+
/** Subscribe to "something about sync changed" pokes from the shell —
|
|
349
|
+
* the explorer re-reads `kept` and `status` when poked. The shell keeps
|
|
350
|
+
* ONE subscriber (the mounted explorer) and overwrites it on remount. */
|
|
351
|
+
onChange?: (cb: () => void) => void;
|
|
352
|
+
};
|
|
311
353
|
}
|
|
312
354
|
|
|
313
355
|
/** Component emits — the parent listens for these events. */
|