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:
@@ -5,6 +5,7 @@ import { foldersNeeded, type PlannedUpload } from "@/lib/dropUpload";
|
|||||||
import { isAppFolder } from "@/lib/appFolder";
|
import { isAppFolder } from "@/lib/appFolder";
|
||||||
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
interface SharedAccount {
|
interface SharedAccount {
|
||||||
id: Id;
|
id: Id;
|
||||||
@@ -56,6 +57,12 @@ interface FilesState {
|
|||||||
mkdir(parentId: Id | null, name: string): Promise<Id>;
|
mkdir(parentId: Id | null, name: string): Promise<Id>;
|
||||||
upload(parentId: Id | null, files: File[]): Promise<void>;
|
upload(parentId: Id | null, files: File[]): Promise<void>;
|
||||||
rename(id: Id, name: string): Promise<void>;
|
rename(id: Id, name: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Write text back over a file. `seenBlobId` is what the editor started from:
|
||||||
|
* if the node has moved on since, somebody else saved and this throws rather
|
||||||
|
* than quietly winning.
|
||||||
|
*/
|
||||||
|
saveText(id: Id, text: string, seenBlobId: Id | null): Promise<Id>;
|
||||||
move(id: Id, parentId: Id | null): Promise<void>;
|
move(id: Id, parentId: Id | null): Promise<void>;
|
||||||
destroy(ids: Id[]): Promise<void>;
|
destroy(ids: Id[]): Promise<void>;
|
||||||
refresh(ids: Id[]): Promise<void>;
|
refresh(ids: Id[]): Promise<void>;
|
||||||
@@ -307,6 +314,35 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
void get().loadTree();
|
void get().loadTree();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async saveText(id, text, seenBlobId) {
|
||||||
|
const accountId = get().accountId!;
|
||||||
|
/*
|
||||||
|
* Look before writing.
|
||||||
|
*
|
||||||
|
* `ifInState` is the obvious tool and the wrong one here: it is the state
|
||||||
|
* of every FileNode in the account, so an unrelated upload in another
|
||||||
|
* folder would fail this save, and a reader who is told "someone changed
|
||||||
|
* it" when nobody did learns to click through the warning. The node's own
|
||||||
|
* blobId is the thing that actually answers the question.
|
||||||
|
*/
|
||||||
|
const fresh = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: [id], properties: fileNodeProps() });
|
||||||
|
const now = fresh.list[0];
|
||||||
|
if (!now) throw new Error(translate("That file is no longer there."));
|
||||||
|
if (now.blobId !== seenBlobId) throw new Error(translate("Somebody else saved this file while it was open. Copy your changes, close it, and start again."));
|
||||||
|
|
||||||
|
const type = now.type || "text/plain";
|
||||||
|
const blob = new Blob([text], { type });
|
||||||
|
const up = await client.upload(accountId, blob, { type });
|
||||||
|
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
|
||||||
|
accountId,
|
||||||
|
update: { [id]: { blobId: up.blobId, type, size: blob.size } },
|
||||||
|
});
|
||||||
|
const err = res.notUpdated?.[id];
|
||||||
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
|
await get().refresh([id]);
|
||||||
|
return up.blobId;
|
||||||
|
},
|
||||||
|
|
||||||
async rename(id, name) {
|
async rename(id, name) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
|
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
|
||||||
|
|||||||
@@ -320,6 +320,8 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
.segmented button.active { background: var(--accent-soft); color: var(--accent-soft-fg); font-weight: 600; }
|
.segmented button.active { background: var(--accent-soft); color: var(--accent-soft-fg); font-weight: 600; }
|
||||||
.segmented button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.segmented button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
.segmented.left { margin-right: auto; }
|
.segmented.left { margin-right: auto; }
|
||||||
|
.dialog-foot .hint.left { margin-right: auto; }
|
||||||
|
.dialog-foot textarea.code, .dialog-body > textarea.code { font-family: var(--font-mono); }
|
||||||
|
|
||||||
/* Rendered Markdown in the file viewer. Deliberately plain: this is somebody's
|
/* Rendered Markdown in the file viewer. Deliberately plain: this is somebody's
|
||||||
notes, not a web page, and the point is to read it. */
|
notes, not a web page, and the point is to read it. */
|
||||||
|
|||||||
+232
-41
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { Code2, Download, Eye, Printer } from "lucide-react";
|
import { Code2, Download, Eye, Pencil, Printer, Save, X } from "lucide-react";
|
||||||
import { Dialog } from "./dialog";
|
import { confirmDialog, Dialog } from "./dialog";
|
||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
|
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
|
||||||
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
||||||
@@ -21,21 +21,128 @@ export interface PreviewFile {
|
|||||||
inlineUrl: string;
|
inlineUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Mode = "rendered" | "source" | "edit";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a file without downloading it: pictures, PDFs, and anything text.
|
* Shows a file without downloading it: pictures, PDFs, and anything text.
|
||||||
*
|
*
|
||||||
* Grown out of the attachment preview in MessageView, which is where it still
|
* Grown out of the attachment preview in MessageView, which is still one of its
|
||||||
* has one of its two callers -- the other is Files, which until now could only
|
* two callers -- the other is Files, which could only hand you the bytes.
|
||||||
* 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 kind = file ? previewKind(file.type, file.name) : null;
|
||||||
const tooBig = kind === "text" && typeof file?.size === "number" && file.size > TEXT_PREVIEW_MAX;
|
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);
|
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
|
const pdfRef = useRef<HTMLIFrameElement>(null);
|
||||||
away for anyone who wants to see what it actually says. */
|
|
||||||
const [rendered, setRendered] = useState(true);
|
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.
|
* 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();
|
clear();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(file)}
|
open={Boolean(file)}
|
||||||
onClose={onClose}
|
onClose={requestClose}
|
||||||
title={file?.name ?? t("Preview")}
|
title={file?.name ?? t("Preview")}
|
||||||
size="xl"
|
size="xl"
|
||||||
footer={file && (
|
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 && (
|
{markdown && !tooBig && (
|
||||||
<div className="segmented left" role="group" aria-label={t("View as")}>
|
<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={mode === "rendered" ? "active" : ""} aria-pressed={mode === "rendered"} onClick={() => setMode("rendered")}><Eye size={14} /> {t("Rendered")}</button>
|
||||||
<button className={rendered ? "" : "active"} aria-pressed={!rendered} onClick={() => setRendered(false)}><Code2 size={14} /> {t("Source")}</button>
|
<button className={mode === "source" ? "active" : ""} aria-pressed={mode === "source"} onClick={() => setMode("source")}><Code2 size={14} /> {t("Source")}</button>
|
||||||
</div>
|
</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>}
|
{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>
|
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{file && (
|
{file && (
|
||||||
<>
|
<>
|
||||||
|
{saveError && <div className="error-box mb-8">{saveError}</div>}
|
||||||
{tooBig ? (
|
{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>
|
<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" ? (
|
) : kind === "image" ? (
|
||||||
@@ -104,10 +227,14 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
|
|||||||
) : kind === "pdf" ? (
|
) : kind === "pdf" ? (
|
||||||
<iframe ref={pdfRef} title={file.name} src={file.inlineUrl} style={{ width: "100%", height: "70vh", border: 0 }} />
|
<iframe ref={pdfRef} title={file.name} src={file.inlineUrl} style={{ width: "100%", height: "70vh", border: 0 }} />
|
||||||
) : kind === "text" ? (
|
) : kind === "text" ? (
|
||||||
/* `url`, not `inlineUrl`: fetch pays no attention to
|
<TextPane
|
||||||
Content-Disposition, so this works for the text types the server
|
loaded={loaded}
|
||||||
will not serve inline -- Markdown among them. */
|
mode={mode}
|
||||||
<TextPreview url={file.url} markdown={markdown && rendered} />
|
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>
|
<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 }) {
|
/** Why the file is being shown but not offered for editing, when there is a reason worth saying. */
|
||||||
const [text, setText] = useState<string | null>(null);
|
function readOnlyNote(loaded: LoadedText, lossy: boolean): string | null {
|
||||||
const [truncated, setTruncated] = useState(false);
|
if (loaded.text === null || loaded.failed) return null;
|
||||||
useEffect(() => {
|
if (loaded.truncated) return t("Only the beginning is shown — download the file for the rest.");
|
||||||
let live = true;
|
if (lossy) return t("This file is not UTF-8 text, so editing it here would corrupt it — download it instead.");
|
||||||
setText(null);
|
return null;
|
||||||
setTruncated(false);
|
}
|
||||||
fetch(url, { credentials: "same-origin" })
|
|
||||||
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
function TextPane({
|
||||||
.then((body) => {
|
loaded,
|
||||||
if (!live) return;
|
mode,
|
||||||
setTruncated(body.length > TEXT_PREVIEW_CHARS);
|
markdown,
|
||||||
setText(body.slice(0, TEXT_PREVIEW_CHARS));
|
draft,
|
||||||
})
|
onDraft,
|
||||||
.catch(() => live && setText(t("Could not load this file.")));
|
note,
|
||||||
return () => {
|
}: {
|
||||||
live = false;
|
loaded: LoadedText;
|
||||||
};
|
mode: Mode;
|
||||||
}, [url]);
|
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. */
|
/* 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Someone else's file: not ours to translate, and not ours to reflow. */}
|
{/* 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 }} />
|
<div className="md-body notranslate" translate="no" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
) : (
|
) : (
|
||||||
<pre className="code notranslate" translate="no" style={{ maxHeight: "65vh", whiteSpace: "pre-wrap" }}>
|
<pre className="code notranslate" translate="no" style={{ maxHeight: "65vh", whiteSpace: "pre-wrap" }}>
|
||||||
{text ?? t("Loading…")}
|
{loaded.text ?? t("Loading…")}
|
||||||
</pre>
|
</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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { ChevronRight, Download, Eye, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react";
|
import { ChevronRight, Download, Eye, File, FilePen, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react";
|
||||||
import { useFiles } from "@/store/files";
|
import { useFiles } from "@/store/files";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import type { FileNode } from "@/jmap/types";
|
import type { FileNode, Id } from "@/jmap/types";
|
||||||
import { formatSize, formatListDate } from "@/lib/format";
|
import { formatSize, formatListDate } from "@/lib/format";
|
||||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||||
import { previewKind } from "@/lib/preview";
|
import { previewKind } from "@/lib/preview";
|
||||||
@@ -28,6 +28,11 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
||||||
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
||||||
const [preview, setPreview] = useState<PreviewFile | null>(null);
|
const [preview, setPreview] = useState<PreviewFile | null>(null);
|
||||||
|
/* What the open editor is editing, and the blob its text came from -- the
|
||||||
|
baseline a save is checked against. Kept beside `preview` rather than in
|
||||||
|
it, because the dialog is presentational and knows nothing about nodes. */
|
||||||
|
const [editTarget, setEditTarget] = useState<{ id: Id; blobId: Id | null } | null>(null);
|
||||||
|
const [startInEdit, setStartInEdit] = useState(false);
|
||||||
/* Shared with the sidebar tree, so a row dragged onto a folder there is
|
/* Shared with the sidebar tree, so a row dragged onto a folder there is
|
||||||
recognised. See the note on `draggingId` in the store. */
|
recognised. See the note on `draggingId` in the store. */
|
||||||
const draggingId = files.draggingId;
|
const draggingId = files.draggingId;
|
||||||
@@ -117,7 +122,33 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
/* A file with nothing to show still does what it always did. */
|
/* A file with nothing to show still does what it always did. */
|
||||||
const canPreview = (n: FileNode) => Boolean(n.blobId) && n.nodeType !== "directory" && previewKind(n.type, n.name) !== null;
|
const canPreview = (n: FileNode) => Boolean(n.blobId) && n.nodeType !== "directory" && previewKind(n.type, n.name) !== null;
|
||||||
|
|
||||||
const openPreview = (n: FileNode) => setPreview({ name: n.name, type: n.type ?? "application/octet-stream", size: n.size, url: blobUrl(n, false), inlineUrl: blobUrl(n, true) });
|
const openPreview = (n: FileNode, edit = false) => {
|
||||||
|
setPreview({ name: n.name, type: n.type ?? "application/octet-stream", size: n.size, url: blobUrl(n, false), inlineUrl: blobUrl(n, true) });
|
||||||
|
setEditTarget({ id: n.id, blobId: n.blobId });
|
||||||
|
setStartInEdit(edit);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* What the menu can tell from a row: text, and the right to write it. Whether
|
||||||
|
it is *really* editable needs the bytes -- a truncated or non-UTF-8 file
|
||||||
|
opens read-only and says so. */
|
||||||
|
const canEditFile = (n: FileNode) => canPreview(n) && previewKind(n.type, n.name) === "text" && Boolean(n.myRights?.mayModifyContent);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only offered where the reader may actually write: a folder shared read-only
|
||||||
|
* still opens, and the Edit button is simply not there. `saveText` checks the
|
||||||
|
* blob it started from, so two people editing the same file get told rather
|
||||||
|
* than one of them losing the work.
|
||||||
|
*/
|
||||||
|
const canEditNode = (n: FileNode | undefined) => Boolean(n?.myRights?.mayModifyContent);
|
||||||
|
const saveEdited = async (text: string) => {
|
||||||
|
const target = editTarget;
|
||||||
|
if (!target) return;
|
||||||
|
const next = await files.saveText(target.id, text, target.blobId);
|
||||||
|
// The file has a new blob now; the next save in this same session is
|
||||||
|
// checked against that one, not the one we opened.
|
||||||
|
setEditTarget({ id: target.id, blobId: next });
|
||||||
|
toast.success(t("Saved"));
|
||||||
|
};
|
||||||
|
|
||||||
/* Double-clicking a file used to download it, which is a decision made for
|
/* Double-clicking a file used to download it, which is a decision made for
|
||||||
you: to look at a picture you had to put it on disk first. Now it opens
|
you: to look at a picture you had to put it on disk first. Now it opens
|
||||||
@@ -205,6 +236,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : (
|
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : (
|
||||||
<>
|
<>
|
||||||
{canPreview(menuNode) && <MenuItem icon={<Eye size={16} />} label={t("Preview")} onClick={() => openPreview(menuNode)} />}
|
{canPreview(menuNode) && <MenuItem icon={<Eye size={16} />} label={t("Preview")} onClick={() => openPreview(menuNode)} />}
|
||||||
|
{canEditFile(menuNode) && <MenuItem icon={<FilePen size={16} />} label={t("Edit")} onClick={() => openPreview(menuNode, true)} />}
|
||||||
<MenuItem icon={<Download size={16} />} label={t("Download")} onClick={() => download(menuNode)} />
|
<MenuItem icon={<Download size={16} />} label={t("Download")} onClick={() => download(menuNode)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -217,7 +249,12 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
|
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
|
||||||
<FilePreviewDialog file={preview} onClose={() => setPreview(null)} />
|
<FilePreviewDialog
|
||||||
|
file={preview}
|
||||||
|
onClose={() => { setPreview(null); setEditTarget(null); setStartInEdit(false); }}
|
||||||
|
onSave={editTarget && canEditNode(files.nodes[editTarget.id]) ? saveEdited : undefined}
|
||||||
|
startInEdit={startInEdit}
|
||||||
|
/>
|
||||||
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
|
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user