From aa60448e5525c30dd7d8e744a0739a8e46c4edbf Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:17:24 +0300 Subject: [PATCH] commander compare --- src/components/navigator/CompareDrawer.tsx | 245 +++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 src/components/navigator/CompareDrawer.tsx diff --git a/src/components/navigator/CompareDrawer.tsx b/src/components/navigator/CompareDrawer.tsx new file mode 100644 index 0000000..8cc9941 --- /dev/null +++ b/src/components/navigator/CompareDrawer.tsx @@ -0,0 +1,245 @@ +import { + Button, + Drawer, + DrawerBody, + DrawerContent, + DrawerHeader, + Progress, + ScrollShadow, +} from '@heroui/react' +import { useQuery } from '@tanstack/react-query' +import { AlertCircleIcon, XIcon } from 'lucide-react' +import { formatBytes } from '../../../lib/format.ts' +import FileIcon from './FileIcon' +import type { Entry, RemoteString } from './types' +import { listPath, serializeRemotePath } from './utils' + +// A Commander panel's current location, as reported by FilePanel's onNavigate. +export type PanelLocation = { remote: RemoteString; path: string } + +type CompareEntry = Entry & { hashes?: Record } + +// One row across the two columns; in the "only on" buckets exactly one side is present. +type ComparePair = { name: string; left?: CompareEntry; right?: CompareEntry } + +const BUCKETS = [ + { key: 'onlyLeft', title: 'Only on left' }, + { key: 'onlyRight', title: 'Only on right' }, + { key: 'sameByName', title: 'Same by name' }, // on both sides, hashes missing or different + { key: 'sameByHash', title: 'Same by hash' }, // on both sides, a shared hash type matches +] as const + +type CompareResult = Record<(typeof BUCKETS)[number]['key'], ComparePair[]> + +// Shallow listing with hashes, in the Commander's order (folders first, then by name) and without +// the hidden files the Commander hides too. +async function listEntries(loc: PanelLocation, signal: AbortSignal): Promise { + const raw = await listPath( + loc.remote as string, + loc.path, + { showHash: true, noModTime: false, noMimeType: true }, + signal + ) + return raw + .map((it): CompareEntry => { + const rel = String(it.Path || it.Name || '') + const name = rel.split('/').pop() ?? '' + return { + key: name, + name, + isDir: !!(it.IsDir || it.IsBucket), + // rclone reports -1 when a backend doesn't know the size. + size: typeof it.Size === 'number' && it.Size >= 0 ? it.Size : undefined, + modTime: it.ModTime, + hashes: it.Hashes && typeof it.Hashes === 'object' ? it.Hashes : undefined, + fullPath: rel, + } + }) + .filter((e) => e.name && !e.name.startsWith('.')) + .sort((a, b) => (a.isDir !== b.isDir ? (a.isDir ? -1 : 1) : a.name.localeCompare(b.name))) +} + +// True when any hash type both sides report carries an equal, non-empty digest. Backends with no +// hashes (or no algorithm in common) never match, so those pairs land in "same by name" — by design. +function hashesMatch(a?: Record, b?: Record): boolean { + if (!a || !b) return false + return Object.entries(a).some( + ([type, digest]) => !!digest && digest.toLowerCase() === b[type]?.toLowerCase() + ) +} + +// Both inputs are sorted, so every bucket comes out sorted without a second pass. +function diff(left: CompareEntry[], right: CompareEntry[]): CompareResult { + const result: CompareResult = { onlyLeft: [], onlyRight: [], sameByName: [], sameByHash: [] } + const rightByName = new Map(right.map((e) => [e.name, e])) + const leftNames = new Set(left.map((e) => e.name)) + + for (const l of left) { + const r = rightByName.get(l.name) + if (r) { + const bucket = hashesMatch(l.hashes, r.hashes) ? result.sameByHash : result.sameByName + bucket.push({ name: l.name, left: l, right: r }) + } else { + result.onlyLeft.push({ name: l.name, left: l }) + } + } + for (const r of right) { + if (!leftNames.has(r.name)) result.onlyRight.push({ name: r.name, right: r }) + } + return result +} + +function locationLabel(loc: PanelLocation | null): string { + if (!loc) return '' + if (loc.remote === 'UI_LOCAL_FS') return loc.path || '/' + return serializeRemotePath(loc.remote ?? '', loc.path) +} + +// One cell in a section column. A missing entry (the empty side of an "only on" row) renders an +// equal-height spacer so the left and right columns stay aligned row-for-row. +function CompareRow({ entry }: { entry?: CompareEntry }) { + if (!entry) return
+ return ( +
+ + + {entry.name} + + + {entry.isDir ? '' : entry.size === undefined ? '—' : formatBytes(entry.size)} + +
+ ) +} + +function CompareSection({ title, pairs }: { title: string; pairs: ComparePair[] }) { + if (pairs.length === 0) return null + return ( +
+
+

{title}

+ + {pairs.length} + +
+
+
+ {pairs.map((p) => ( + + ))} +
+
+ {pairs.map((p) => ( + + ))} +
+
+
+ ) +} + +// Mounted only while the drawer is open, so the listings run (and are cancelled through the query +// signal) with the drawer, and gcTime 0 makes every open a fresh comparison. +function CompareView({ + left, + right, + onClose, +}: { + left: PanelLocation | null + right: PanelLocation | null + onClose: () => void +}) { + const { data, isLoading, error } = useQuery({ + queryKey: ['compare', left, right], + enabled: !!left && !!right, + gcTime: 0, + refetchOnWindowFocus: false, + queryFn: async ({ signal }) => { + const [l, r] = await Promise.all([ + listEntries(left!, signal), + listEntries(right!, signal), + ]) + return diff(l, r) + }, + }) + const isEmpty = !!data && BUCKETS.every((b) => data[b.key].length === 0) + + return ( + <> + +
+ Compare + +
+
+ {[left, right].map((loc, i) => { + const label = locationLabel(loc) + return ( + + {label} + + ) + })} +
+
+ + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ + {error.message} +
+ ) : isEmpty ? ( +
+ Nothing to compare — both folders are empty. +
+ ) : data ? ( + + {BUCKETS.map((b) => ( + + ))} + + ) : null} +
+ + ) +} + +export default function CompareDrawer({ + isOpen, + onClose, + left, + right, +}: { + isOpen: boolean + onClose: () => void + left: PanelLocation | null + right: PanelLocation | null +}) { + return ( + + + {() => } + + + ) +}