Skip to content

Commit 360f06b

Browse files
Drive: version diff viewer, Escape-as-back, opaque command palette
- Version history is now a dedicated modal with a raw line-by-line diff between any past version and the current file (in-browser LCS walk, text files only, capped at 1200 lines), plus per-version restore. Replaces the old pick-to- restore prompt. - Escape now steps back: it clears the selection if any, otherwise goes up one folder (and leaves the space at the root) — works even on a locked vault. - Command palette and shortcuts sheet get a darker, blurred backdrop and a solid panel so page content no longer bleeds through behind them.
1 parent e6a8758 commit 360f06b

5 files changed

Lines changed: 307 additions & 36 deletions

File tree

apps/web/src/app/(app)/drive/page.tsx

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { confirm, prompt, choose, toast } from '@/components/ui/overlays';
6969
import { DropOverlay } from '@/components/drive/DropOverlay';
7070
import { CommandPalette, type PaletteItem } from '@/components/drive/CommandPalette';
7171
import { ShortcutsHelp } from '@/components/drive/ShortcutsHelp';
72+
import { VersionHistory } from '@/components/drive/VersionHistory';
7273

7374
interface ZkFile {
7475
id: string;
@@ -125,6 +126,7 @@ export default function EspacesPage() {
125126
const [dragging, setDragging] = useState(false);
126127
const [viewing, setViewing] = useState<ViewerSource | null>(null);
127128
const [shareLink, setShareLink] = useState<string | null>(null);
129+
const [versionsFor, setVersionsFor] = useState<PublicFile | null>(null);
128130
const fileInput = useRef<HTMLInputElement>(null);
129131

130132
// selection / clipboard / overlays
@@ -751,31 +753,6 @@ export default function EspacesPage() {
751753
setError(err instanceof ApiError ? err.message : t('drive.shareFailed'));
752754
}
753755
}
754-
async function versions(f: PublicFile) {
755-
try {
756-
const res = await api.get<{ versions: { id: string; sizeBytes: number; createdAt: string }[] }>(`/files/${f.id}/versions`);
757-
if (res.versions.length === 0) {
758-
toast(t('drive.noVersions'), 'info');
759-
return;
760-
}
761-
const pick = await choose<string>({
762-
title: t('drive.versionsTitle', { name: f.name }),
763-
message: t('drive.versionsMsg'),
764-
options: res.versions.map((v) => ({
765-
value: v.id,
766-
label: new Date(v.createdAt).toLocaleString(),
767-
description: formatBytes(v.sizeBytes),
768-
})),
769-
});
770-
if (!pick) return;
771-
await api.post(`/files/${f.id}/versions/${pick}/restore`);
772-
await reloadCurrent();
773-
toast(t('drive.versionRestored'), 'success');
774-
} catch (err) {
775-
setError(err instanceof ApiError ? err.message : t('drive.versionsFailed'));
776-
}
777-
}
778-
779756
// ── Bulk actions ─────────────────────────────────────────────────────────────
780757
async function bulkDelete() {
781758
const items = entriesByKeys([...selected]);
@@ -897,7 +874,7 @@ export default function EspacesPage() {
897874
// ── Keyboard shortcuts ───────────────────────────────────────────────────────
898875
// A ref keeps the latest handler so we register the window listener only once.
899876
const keyHandler = (e: KeyboardEvent) => {
900-
if (paletteOpen || helpOpen || viewing || askPass || shareLink) return;
877+
if (paletteOpen || helpOpen || viewing || askPass || shareLink || versionsFor) return;
901878
if (document.querySelector('[class*="z-[100]"]')) return; // an in-app dialog is open
902879
const mod = e.metaKey || e.ctrlKey;
903880
if (mod && e.key.toLowerCase() === 'k') {
@@ -938,6 +915,13 @@ export default function EspacesPage() {
938915
void duplicateSelection();
939916
return;
940917
}
918+
// Escape = step back: drop the selection first, otherwise go up one level (or leave the space).
919+
if (e.key === 'Escape') {
920+
e.preventDefault();
921+
if (selected.size > 0) setSelected(new Set());
922+
else goParent();
923+
return;
924+
}
941925
if (needPass) return;
942926
switch (e.key) {
943927
case 'ArrowDown':
@@ -975,9 +959,6 @@ export default function EspacesPage() {
975959
renameByKey([...selected][0]!);
976960
}
977961
break;
978-
case 'Escape':
979-
setSelected(new Set());
980-
break;
981962
default:
982963
break;
983964
}
@@ -1230,7 +1211,7 @@ export default function EspacesPage() {
12301211
{ label: t('drive.actionMove'), icon: FolderInput, onClick: () => move('file', e.file.id) },
12311212
{ label: t('drive.actionDuplicate'), icon: Files, onClick: () => void duplicateFile(e.file.id, e.file.name, e.file.mimeType, currentFolderId!, true).then(reloadCurrent) },
12321213
{ label: t('drive.actionShare'), icon: Share2, onClick: () => share('file', e.file.id) },
1233-
{ label: t('drive.actionVersions'), icon: History, onClick: () => versions(e.file) },
1214+
{ label: t('drive.actionVersions'), icon: History, onClick: () => setVersionsFor(e.file) },
12341215
{ label: t('drive.actionDelete'), icon: Trash2, danger: true, onClick: () => deleteFile(e.file.id) },
12351216
]}
12361217
/>
@@ -1295,6 +1276,13 @@ export default function EspacesPage() {
12951276
)}
12961277

12971278
{shareLink && <ShareLinkModal link={shareLink} onClose={() => setShareLink(null)} />}
1279+
{versionsFor && (
1280+
<VersionHistory
1281+
file={{ id: versionsFor.id, name: versionsFor.name, mimeType: versionsFor.mimeType }}
1282+
onClose={() => setVersionsFor(null)}
1283+
onRestored={reloadCurrent}
1284+
/>
1285+
)}
12981286
{paletteOpen && <CommandPalette items={paletteItems} onClose={() => setPaletteOpen(false)} />}
12991287
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
13001288
{viewing && <FileViewer source={viewing} onClose={() => setViewing(null)} />}

apps/web/src/components/drive/CommandPalette.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,12 @@ export function CommandPalette({ items, onClose }: { items: PaletteItem[]; onClo
7272
}, [active]);
7373

7474
return (
75-
<div className="fixed inset-0 z-[70] grid place-items-start justify-center bg-black/60 p-4 pt-[12vh]" onClick={onClose}>
75+
<div
76+
className="fixed inset-0 z-[70] grid place-items-start justify-center bg-ink-950/80 p-4 pt-[12vh] backdrop-blur-sm"
77+
onClick={onClose}
78+
>
7679
<div
77-
className="w-full max-w-xl overflow-hidden rounded-xl border border-white/[0.08] bg-[#111118] shadow-xl"
80+
className="w-full max-w-xl overflow-hidden rounded-xl border border-white/10 bg-[#15151d] shadow-2xl ring-1 ring-black/40"
7881
onClick={(e) => e.stopPropagation()}
7982
>
8083
<div className="flex items-center gap-2 border-b border-white/10 px-3.5">

apps/web/src/components/drive/ShortcutsHelp.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ export function ShortcutsHelp({ onClose }: { onClose: () => void }) {
3939
{ keys: ['?'], label: t('drive.scHelp') },
4040
];
4141
return (
42-
<div className="fixed inset-0 z-[70] grid place-items-center bg-black/60 p-4" onClick={onClose}>
42+
<div className="fixed inset-0 z-[70] grid place-items-center bg-ink-950/80 p-4 backdrop-blur-sm" onClick={onClose}>
4343
<div
44-
className="w-full max-w-lg rounded-xl border border-white/[0.08] bg-[#111118] p-5 shadow-xl"
44+
className="w-full max-w-lg rounded-xl border border-white/10 bg-[#15151d] p-5 shadow-2xl ring-1 ring-black/40"
4545
onClick={(e) => e.stopPropagation()}
4646
>
4747
<div className="mb-4 flex items-center justify-between">
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
'use client';
2+
3+
/**
4+
* Version history for a normal (server-encrypted) file, with a raw line-by-line diff between any
5+
* past version and the current content. Diff is computed in the browser via an LCS walk; it is
6+
* only offered for textual files and is skipped above a line cap to keep the table cheap.
7+
*/
8+
import { useEffect, useState } from 'react';
9+
import { History, RotateCcw, GitCompare, Loader2, X } from 'lucide-react';
10+
import { formatBytes } from '@opencoperlock/shared/client';
11+
import { api } from '@/lib/api';
12+
import { previewKind } from '@/lib/fileType';
13+
import { useT } from '@/lib/i18n';
14+
import { confirm, toast } from '@/components/ui/overlays';
15+
16+
interface Version {
17+
id: string;
18+
sizeBytes: number;
19+
createdAt: string;
20+
}
21+
interface DiffLine {
22+
type: 'eq' | 'add' | 'del';
23+
text: string;
24+
}
25+
26+
const MAX_DIFF_LINES = 1200;
27+
28+
// Classic LCS backtrack → a flat add/del/eq line list. O(n·m); the caller caps n and m.
29+
function diffLines(a: string[], b: string[]): DiffLine[] {
30+
const n = a.length;
31+
const m = b.length;
32+
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
33+
for (let i = n - 1; i >= 0; i -= 1) {
34+
for (let j = m - 1; j >= 0; j -= 1) {
35+
dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
36+
}
37+
}
38+
const out: DiffLine[] = [];
39+
let i = 0;
40+
let j = 0;
41+
while (i < n && j < m) {
42+
if (a[i] === b[j]) {
43+
out.push({ type: 'eq', text: a[i]! });
44+
i += 1;
45+
j += 1;
46+
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
47+
out.push({ type: 'del', text: a[i]! });
48+
i += 1;
49+
} else {
50+
out.push({ type: 'add', text: b[j]! });
51+
j += 1;
52+
}
53+
}
54+
while (i < n) out.push({ type: 'del', text: a[i++]! });
55+
while (j < m) out.push({ type: 'add', text: b[j++]! });
56+
return out;
57+
}
58+
59+
async function fetchText(url: string): Promise<string> {
60+
const res = await fetch(url, { credentials: 'include' });
61+
if (!res.ok) throw new Error('fetch failed');
62+
return (await res.blob()).slice(0, 1_000_000).text();
63+
}
64+
65+
export function VersionHistory({
66+
file,
67+
onClose,
68+
onRestored,
69+
}: {
70+
file: { id: string; name: string; mimeType: string };
71+
onClose: () => void;
72+
onRestored: () => Promise<void> | void;
73+
}) {
74+
const { t } = useT();
75+
const [versions, setVersions] = useState<Version[] | null>(null);
76+
const [error, setError] = useState<string | null>(null);
77+
const [openId, setOpenId] = useState<string | null>(null);
78+
const [diff, setDiff] = useState<DiffLine[] | null>(null);
79+
const [diffNote, setDiffNote] = useState<string | null>(null);
80+
const [loadingDiff, setLoadingDiff] = useState(false);
81+
const [restoring, setRestoring] = useState<string | null>(null);
82+
const isText = previewKind(file.name, file.mimeType) === 'text';
83+
84+
useEffect(() => {
85+
void (async () => {
86+
try {
87+
const res = await api.get<{ versions: Version[] }>(`/files/${file.id}/versions`);
88+
res.versions.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
89+
setVersions(res.versions);
90+
} catch {
91+
setError(t('drive.versionsFailed'));
92+
}
93+
})();
94+
}, [file.id, t]);
95+
96+
// Esc closes — the page's global shortcuts are suspended while this modal is open.
97+
useEffect(() => {
98+
const onKey = (e: KeyboardEvent) => {
99+
if (e.key === 'Escape') {
100+
e.preventDefault();
101+
onClose();
102+
}
103+
};
104+
window.addEventListener('keydown', onKey);
105+
return () => window.removeEventListener('keydown', onKey);
106+
}, [onClose]);
107+
108+
async function toggleDiff(v: Version) {
109+
if (openId === v.id) {
110+
setOpenId(null);
111+
setDiff(null);
112+
setDiffNote(null);
113+
return;
114+
}
115+
setOpenId(v.id);
116+
setDiff(null);
117+
setDiffNote(null);
118+
if (!isText) {
119+
setDiffNote(t('drive.versionNotText'));
120+
return;
121+
}
122+
setLoadingDiff(true);
123+
try {
124+
const [curText, oldText] = await Promise.all([
125+
fetchText(api.url(`/files/${file.id}/download`)),
126+
fetchText(api.url(`/files/${file.id}/versions/${v.id}/download`)),
127+
]);
128+
const a = oldText.split('\n');
129+
const b = curText.split('\n');
130+
if (a.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES) {
131+
setDiffNote(t('drive.versionTooLarge'));
132+
return;
133+
}
134+
const d = diffLines(a, b);
135+
if (!d.some((x) => x.type !== 'eq')) setDiffNote(t('drive.versionNoChanges'));
136+
else setDiff(d);
137+
} catch {
138+
setDiffNote(t('drive.versionLoadFailed'));
139+
} finally {
140+
setLoadingDiff(false);
141+
}
142+
}
143+
144+
async function restore(v: Version) {
145+
if (!(await confirm({ title: t('drive.versionRestore'), message: t('drive.versionRestoreConfirm') }))) return;
146+
setRestoring(v.id);
147+
try {
148+
await api.post(`/files/${file.id}/versions/${v.id}/restore`);
149+
await onRestored();
150+
toast(t('drive.versionRestored'), 'success');
151+
onClose();
152+
} catch {
153+
setError(t('drive.versionsFailed'));
154+
} finally {
155+
setRestoring(null);
156+
}
157+
}
158+
159+
const added = diff ? diff.filter((d) => d.type === 'add').length : 0;
160+
const removed = diff ? diff.filter((d) => d.type === 'del').length : 0;
161+
162+
return (
163+
<div className="fixed inset-0 z-[80] grid place-items-center bg-ink-950/80 p-4 backdrop-blur-sm" onClick={onClose}>
164+
<div
165+
className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-white/[0.08] bg-[#111118] shadow-2xl"
166+
onClick={(e) => e.stopPropagation()}
167+
>
168+
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3.5">
169+
<div className="flex min-w-0 items-center gap-2">
170+
<History size={18} className="shrink-0 text-violet-300" />
171+
<h3 className="truncate font-semibold text-zinc-100">{t('drive.versionHistoryTitle', { name: file.name })}</h3>
172+
</div>
173+
<button className="rounded-lg p-1 text-zinc-400 hover:bg-white/5 hover:text-zinc-100" onClick={onClose}>
174+
<X size={16} />
175+
</button>
176+
</div>
177+
178+
<div className="min-h-0 flex-1 overflow-auto p-3">
179+
{error && <p className="px-2 py-3 text-sm text-red-300">{error}</p>}
180+
{!error && versions === null && (
181+
<div className="grid place-items-center py-10">
182+
<Loader2 className="animate-spin text-zinc-500" size={24} />
183+
</div>
184+
)}
185+
{versions && versions.length === 0 && <p className="px-2 py-8 text-center text-sm text-zinc-500">{t('drive.noVersions')}</p>}
186+
187+
{versions && versions.length > 0 && (
188+
<ul className="space-y-1.5">
189+
{versions.map((v, idx) => (
190+
<li key={v.id} className="rounded-lg border border-white/[0.06] bg-white/[0.02]">
191+
<div className="flex items-center gap-3 px-3 py-2.5">
192+
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-white/[0.04] text-xs font-medium text-zinc-400">
193+
v{versions.length - idx}
194+
</span>
195+
<div className="min-w-0 flex-1">
196+
<p className="text-sm text-zinc-200">{new Date(v.createdAt).toLocaleString()}</p>
197+
<p className="text-xs text-zinc-500">{formatBytes(v.sizeBytes)}</p>
198+
</div>
199+
<button
200+
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-zinc-300 transition hover:bg-white/10"
201+
onClick={() => void toggleDiff(v)}
202+
>
203+
<GitCompare size={14} /> {openId === v.id ? t('drive.versionHideDiff') : t('drive.versionDiff')}
204+
</button>
205+
<button
206+
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-zinc-300 transition hover:bg-white/10 disabled:opacity-50"
207+
disabled={restoring === v.id}
208+
onClick={() => void restore(v)}
209+
>
210+
{restoring === v.id ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />} {t('drive.versionRestore')}
211+
</button>
212+
</div>
213+
214+
{openId === v.id && (
215+
<div className="border-t border-white/[0.06] px-3 py-2">
216+
{loadingDiff ? (
217+
<div className="flex items-center gap-2 py-3 text-sm text-zinc-500">
218+
<Loader2 size={14} className="animate-spin" />
219+
</div>
220+
) : diffNote ? (
221+
<p className="py-2 text-sm text-zinc-500">{diffNote}</p>
222+
) : diff ? (
223+
<>
224+
<div className="mb-1.5 flex items-center gap-3 text-xs font-medium">
225+
<span className="text-emerald-400">+{added}</span>
226+
<span className="text-red-400">{removed}</span>
227+
<span className="text-zinc-600">{t('drive.versionDiff')}</span>
228+
</div>
229+
<div className="max-h-[40vh] overflow-auto rounded-lg border border-white/[0.06] bg-ink-950/60 font-mono text-xs leading-relaxed">
230+
{diff.map((d, i) => (
231+
<div
232+
key={i}
233+
className={`flex gap-2 whitespace-pre-wrap break-all px-2 ${
234+
d.type === 'add'
235+
? 'bg-emerald-500/10 text-emerald-200'
236+
: d.type === 'del'
237+
? 'bg-red-500/10 text-red-200'
238+
: 'text-zinc-500'
239+
}`}
240+
>
241+
<span className="select-none text-zinc-600">{d.type === 'add' ? '+' : d.type === 'del' ? '−' : ' '}</span>
242+
<span>{d.text || ' '}</span>
243+
</div>
244+
))}
245+
</div>
246+
</>
247+
) : null}
248+
</div>
249+
)}
250+
</li>
251+
))}
252+
</ul>
253+
)}
254+
</div>
255+
</div>
256+
</div>
257+
);
258+
}

0 commit comments

Comments
 (0)