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
+36
View File
@@ -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 } } });
+2
View File
@@ -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. */
+241 -50
View File
@@ -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={
{markdown && !tooBig && ( file && (
<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> {editing ? (
<button className={rendered ? "" : "active"} aria-pressed={!rendered} onClick={() => setRendered(false)}><Code2 size={14} /> {t("Source")}</button> <>
</div> {dirty && <span className="hint left">{t("Unsaved changes")}</span>}
)} <button className="btn" onClick={() => void stopEditing()} disabled={saving}><X size={16} /> {t("Cancel")}</button>
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>} <button className="btn btn-primary" onClick={() => void save()} disabled={saving || !dirty}><Save size={16} /> {saving ? t("Saving…") : t("Save")}</button>
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a> </>
</> ) : (
)} <>
{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 && ( {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 };
}
+41 -4
View File
@@ -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>
); );