Edit a text file where you are already reading it

v2 of the viewer: Edit, on text and Markdown, in the dialog and on the
row menu. Save is explicit -- every save mints a new blob, so autosave
would burn quota and multiply the conflicts it cannot see.

Two people editing one file is the case worth getting right. `saveText`
re-reads the node and compares the blob the editor started from: if
somebody else saved in the meantime it refuses, says so, and leaves the
work in the box to copy out. `ifInState` is the obvious tool and the
wrong one -- it is the state of every FileNode in the account, so an
unrelated upload in another folder would fail the save, and a warning
that cries wolf is a warning people click through.

Editing is not offered where saving would lose something: a file
truncated for display would have its tail written away, and one that did
not decode as UTF-8 would have mojibake written over whatever encoding it
really is. Both open read-only and say which. Nor is it offered without
mayModifyContent -- a read-only share just has no Edit.

Closing or cancelling with unsaved changes asks first, Ctrl+S saves, and
mail attachments are unaffected: they pass no onSave, because a message
part is not a thing that can be written back.
This commit is contained in:
2026-09-01 20:51:51 -07:00
parent 8d562628ff
commit a4c0e04ab9
4 changed files with 320 additions and 54 deletions
+241 -50
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Code2, Download, Eye, Printer } from "lucide-react";
import { Dialog } from "./dialog";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Code2, Download, Eye, Pencil, Printer, Save, X } from "lucide-react";
import { confirmDialog, Dialog } from "./dialog";
import { formatSize } from "@/lib/format";
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
@@ -21,21 +21,128 @@ export interface PreviewFile {
inlineUrl: string;
}
type Mode = "rendered" | "source" | "edit";
/**
* Shows a file without downloading it: pictures, PDFs, and anything text.
*
* Grown out of the attachment preview in MessageView, which is where it still
* has one of its two callers -- the other is Files, which until now could only
* hand you the bytes.
* Grown out of the attachment preview in MessageView, which is still one of its
* two callers -- the other is Files, which could only hand you the bytes.
*
* `onSave` is what makes it an editor. Files passes one; mail does not, because
* a message part is not a thing that can be written back.
*/
export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFile | null; onClose: () => void; caption?: ReactNode }) {
export function FilePreviewDialog({
file,
onClose,
caption,
onSave,
startInEdit,
}: {
file: PreviewFile | null;
onClose: () => void;
caption?: ReactNode;
/** Write the text back. Rejecting with a message is how a conflict is reported. */
onSave?: (text: string) => Promise<void>;
/** Open straight into the editor -- what the row menu's Edit asks for. */
startInEdit?: boolean;
}) {
const kind = file ? previewKind(file.type, file.name) : null;
const tooBig = kind === "text" && typeof file?.size === "number" && file.size > TEXT_PREVIEW_MAX;
const pdfRef = useRef<HTMLIFrameElement>(null);
const markdown = Boolean(file) && kind === "text" && isMarkdown(file!.type, file!.name);
/* Markdown opens as the document it is meant to be; the source is a click
away for anyone who wants to see what it actually says. */
const [rendered, setRendered] = useState(true);
const pdfRef = useRef<HTMLIFrameElement>(null);
const loaded = useTextFile(kind === "text" && !tooBig ? file?.url ?? null : null);
const [mode, setMode] = useState<Mode>("source");
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [pendingEdit, setPendingEdit] = useState(false);
/* A new file starts over: Markdown as the document it is, everything else as
what it says, and never in the editor. */
useEffect(() => {
setMode(markdown ? "rendered" : "source");
setDraft("");
setSaveError(null);
setPendingEdit(Boolean(startInEdit));
}, [file?.url, markdown, startInEdit]);
/*
* Three reasons not to offer editing, and each of them would lose data:
*
* - the file was truncated for display, so saving would write the tail away;
* - it did not decode as UTF-8 (the replacement character is the giveaway),
* so saving would write mojibake over whatever encoding it really is;
* - the caller has no way to save it, or the reader has no right to.
*/
const lossy = loaded.text?.includes("") ?? false;
const editable = Boolean(onSave) && kind === "text" && !tooBig && !loaded.truncated && !lossy && loaded.text !== null && !loaded.failed;
const editing = mode === "edit";
const dirty = editing && draft !== (loaded.text ?? "");
/* Opening straight into the editor has to wait for the text to arrive, and
may still land in the read-only view: whether a file can be edited is not
known until it has been read (truncated? not UTF-8?), and the row menu
could only guess from its name. The note under the pane says why. */
useEffect(() => {
if (!pendingEdit || loaded.text === null) return;
setPendingEdit(false);
if (!editable) return;
setDraft(loaded.text);
setMode("edit");
}, [pendingEdit, editable, loaded.text]);
const startEditing = () => {
setDraft(loaded.text ?? "");
setSaveError(null);
setMode("edit");
};
const stopEditing = async () => {
if (dirty && !(await confirmDialog({ title: t("Throw away your changes?"), confirmLabel: t("Discard"), danger: true }))) return;
setMode(markdown ? "rendered" : "source");
setSaveError(null);
};
const save = useCallback(async () => {
if (!onSave || saving) return;
setSaving(true);
setSaveError(null);
try {
await onSave(draft);
loaded.replace(draft);
setMode(markdown ? "rendered" : "source");
} catch (err) {
setSaveError((err as Error).message);
} finally {
setSaving(false);
}
}, [onSave, saving, draft, loaded, markdown]);
/* The shortcut everyone's hands already know. Only while the editor is open,
so it does not shadow the browser's own Save anywhere else. */
useEffect(() => {
if (!editing) return;
const onKey = (ev: KeyboardEvent) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "s") {
ev.preventDefault();
void save();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [editing, save]);
/* Closing with unsaved work asks first -- Escape and the backdrop both come
through here. */
const requestClose = () => {
if (!dirty) {
onClose();
return;
}
void confirmDialog({ title: t("Close without saving?"), confirmLabel: t("Discard"), danger: true }).then((yes) => yes && onClose());
};
/*
* Print what is on screen, not the mail or the file list behind it.
@@ -76,27 +183,43 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
clear();
}
};
return (
<Dialog
open={Boolean(file)}
onClose={onClose}
onClose={requestClose}
title={file?.name ?? t("Preview")}
size="xl"
footer={file && (
<>
{markdown && !tooBig && (
<div className="segmented left" role="group" aria-label={t("View as")}>
<button className={rendered ? "active" : ""} aria-pressed={rendered} onClick={() => setRendered(true)}><Eye size={14} /> {t("Rendered")}</button>
<button className={rendered ? "" : "active"} aria-pressed={!rendered} onClick={() => setRendered(false)}><Code2 size={14} /> {t("Source")}</button>
</div>
)}
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>}
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
</>
)}
closeOnBackdrop={!editing}
footer={
file && (
<>
{editing ? (
<>
{dirty && <span className="hint left">{t("Unsaved changes")}</span>}
<button className="btn" onClick={() => void stopEditing()} disabled={saving}><X size={16} /> {t("Cancel")}</button>
<button className="btn btn-primary" onClick={() => void save()} disabled={saving || !dirty}><Save size={16} /> {saving ? t("Saving…") : t("Save")}</button>
</>
) : (
<>
{markdown && !tooBig && (
<div className="segmented left" role="group" aria-label={t("View as")}>
<button className={mode === "rendered" ? "active" : ""} aria-pressed={mode === "rendered"} onClick={() => setMode("rendered")}><Eye size={14} /> {t("Rendered")}</button>
<button className={mode === "source" ? "active" : ""} aria-pressed={mode === "source"} onClick={() => setMode("source")}><Code2 size={14} /> {t("Source")}</button>
</div>
)}
{editable && <button className="btn" onClick={startEditing}><Pencil size={16} /> {t("Edit")}</button>}
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>}
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
</>
)}
</>
)
}
>
{file && (
<>
{saveError && <div className="error-box mb-8">{saveError}</div>}
{tooBig ? (
<p className="hint">{t("This file is too big to show here ({size}) — download it to read it.", { size: formatSize(file.size ?? 0) })}</p>
) : kind === "image" ? (
@@ -104,10 +227,14 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
) : kind === "pdf" ? (
<iframe ref={pdfRef} title={file.name} src={file.inlineUrl} style={{ width: "100%", height: "70vh", border: 0 }} />
) : kind === "text" ? (
/* `url`, not `inlineUrl`: fetch pays no attention to
Content-Disposition, so this works for the text types the server
will not serve inline -- Markdown among them. */
<TextPreview url={file.url} markdown={markdown && rendered} />
<TextPane
loaded={loaded}
mode={mode}
markdown={markdown}
draft={draft}
onDraft={setDraft}
note={editing ? null : readOnlyNote(loaded, lossy)}
/>
) : (
<p className="hint">{t("There is no preview for this kind of file.")}</p>
)}
@@ -118,27 +245,45 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
);
}
function TextPreview({ url, markdown }: { url: string; markdown: boolean }) {
const [text, setText] = useState<string | null>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
let live = true;
setText(null);
setTruncated(false);
fetch(url, { credentials: "same-origin" })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((body) => {
if (!live) return;
setTruncated(body.length > TEXT_PREVIEW_CHARS);
setText(body.slice(0, TEXT_PREVIEW_CHARS));
})
.catch(() => live && setText(t("Could not load this file.")));
return () => {
live = false;
};
}, [url]);
/** Why the file is being shown but not offered for editing, when there is a reason worth saying. */
function readOnlyNote(loaded: LoadedText, lossy: boolean): string | null {
if (loaded.text === null || loaded.failed) return null;
if (loaded.truncated) return t("Only the beginning is shown — download the file for the rest.");
if (lossy) return t("This file is not UTF-8 text, so editing it here would corrupt it — download it instead.");
return null;
}
function TextPane({
loaded,
mode,
markdown,
draft,
onDraft,
note,
}: {
loaded: LoadedText;
mode: Mode;
markdown: boolean;
draft: string;
onDraft: (v: string) => void;
note: string | null;
}) {
/* Rendering is not free on a long file, and the toggle flips back and forth. */
const html = useMemo(() => (markdown && text ? renderMarkdown(text) : null), [markdown, text]);
const html = useMemo(() => (mode === "rendered" && markdown && loaded.text ? renderMarkdown(loaded.text) : null), [mode, markdown, loaded.text]);
if (mode === "edit") {
return (
<textarea
className="code notranslate"
translate="no"
autoFocus
spellCheck={false}
value={draft}
onChange={(e) => onDraft(e.target.value)}
style={{ height: "60vh", whiteSpace: "pre", display: "block" }}
aria-label={t("File contents")}
/>
);
}
return (
<>
{/* Someone else's file: not ours to translate, and not ours to reflow. */}
@@ -146,10 +291,56 @@ function TextPreview({ url, markdown }: { url: string; markdown: boolean }) {
<div className="md-body notranslate" translate="no" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<pre className="code notranslate" translate="no" style={{ maxHeight: "65vh", whiteSpace: "pre-wrap" }}>
{text ?? t("Loading…")}
{loaded.text ?? t("Loading…")}
</pre>
)}
{truncated && <p className="hint">{t("Only the beginning is shown — download the file for the rest.")}</p>}
{note && <p className="hint">{note}</p>}
</>
);
}
interface LoadedText {
text: string | null;
truncated: boolean;
failed: boolean;
/** Adopt what was just saved as the new baseline, without re-fetching. */
replace(next: string): void;
}
/**
* The file's text, held here rather than in the pane that shows it: the editor,
* the source view and the rendered view are all looking at the same bytes, and
* saving has to know what they were to tell whether anything changed.
*/
function useTextFile(url: string | null): LoadedText {
const [text, setText] = useState<string | null>(null);
const [truncated, setTruncated] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
setText(null);
setTruncated(false);
setFailed(false);
if (!url) return;
let live = true;
fetch(url, { credentials: "same-origin" })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((body) => {
if (!live) return;
setTruncated(body.length > TEXT_PREVIEW_CHARS);
setText(body.slice(0, TEXT_PREVIEW_CHARS));
})
.catch(() => {
if (!live) return;
setFailed(true);
setText(t("Could not load this file."));
});
return () => {
live = false;
};
}, [url]);
const replace = useCallback((next: string) => {
setText(next);
setTruncated(false);
}, []);
return { text, truncated, failed, replace };
}