feat: save a document edited in its program back into Files, and ask Keep both or Replace when it changed there meanwhile

This commit is contained in:
Linus Rath
2026-11-04 13:56:15 +01:00
parent b70ab8a50c
commit 81f8bf6d60
5 changed files with 308 additions and 2 deletions
@@ -0,0 +1,154 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// pim-p2-1: a document opened from Files in its program goes back into Files
// when the program saves, first choice on the card. If the file changed on
// the server meanwhile, the card asks Keep both or Replace.
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
const files = vi.hoisted(() => ({ client: null as unknown, refresh: vi.fn() }));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock('@/stores/toast-store', () => ({ toast }));
vi.mock('@/lib/device-policy', () => ({ canDownload: () => true, canExport: () => true }));
vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => true }));
vi.mock('@/lib/desktop/mail-links', () => ({ openMailLink: vi.fn() }));
vi.mock('@/lib/desktop/mail-notifications', () => ({ contentHiddenFor: () => false }));
vi.mock('@/lib/launch-handoff', () => ({ handOffDraft: vi.fn() }));
vi.mock('@/lib/popout/open', () => ({ openDraftWindow: vi.fn() }));
vi.mock('@/lib/download-guard', () => ({ saveBlobAs: vi.fn(() => true) }));
vi.mock('@/lib/org/account-subjects', () => ({ useAccountSubjects: { subscribe: () => () => {} } }));
vi.mock('@/stores/policy-store', () => ({ usePolicyStore: { subscribe: () => () => {} } }));
vi.mock('@/lib/sign-out-cleanup', () => ({ addSignOutGate: () => () => {}, onBeforeSignOut: () => () => {}, onFullSignOut: () => {} }));
vi.mock('@/stores/account-store', () => {
const list = [{ id: 'acct', cookieSlot: 2 }];
const useAccountStore = (select: (s: { accounts: typeof list }) => unknown) => select({ accounts: list });
useAccountStore.getState = () => ({ accounts: list });
return { useAccountStore };
});
vi.mock('@/stores/auth-store', () => {
const useAuthStore = (select: (s: { activeAccountId: string | null }) => unknown) => select({ activeAccountId: 'acct' });
useAuthStore.getState = () => ({ activeAccountId: 'acct', client: null, getClientForAccount: () => undefined });
return { useAuthStore };
});
vi.mock('@/stores/file-store', () => ({
useFileStore: { getState: () => ({ client: files.client, currentAccountId: 'acct', refresh: files.refresh }) },
}));
import { EditedFilesHost } from '../edited-files-host';
import { parseEditSource, resetEditSessions, useEditSessions } from '@/lib/desktop/edit-attachments';
const NODE_SOURCE = JSON.stringify({ kind: 'node', nodeId: 'n1', blobId: 'b-old', parentId: 'p1' });
const session = (over: Record<string, unknown> = {}) => ({
id: 's1', name: 'budget-2026.xlsx', type: 'x/y', account: 'acct', source: NODE_SOURCE, changed: true, version: 2, hash: 'h2', claimed: false, held: false, savedAs: false, lastChance: false, ...over,
});
let edit: Record<string, ReturnType<typeof vi.fn>>;
let client: {
getFileNodes: ReturnType<typeof vi.fn>;
uploadBlob: ReturnType<typeof vi.fn>;
replaceFileNodeBlob: ReturnType<typeof vi.fn>;
createFileNode: ReturnType<typeof vi.fn>;
};
function show() {
const sessions = [session()];
edit.list.mockResolvedValue(sessions);
useEditSessions.setState({ sessions });
return render(<EditedFilesHost />);
}
beforeEach(() => {
vi.clearAllMocks();
client = {
getFileNodes: vi.fn(async () => [{ id: 'n1', blobId: 'b-old', name: 'budget-2026.xlsx', parentId: 'p1' }]),
uploadBlob: vi.fn(async () => ({ blobId: 'b-new', size: 1, type: 'x/y' })),
replaceFileNodeBlob: vi.fn(async () => {}),
createFileNode: vi.fn(async () => ({ id: 'n2', name: 'budget-2026 (2).xlsx', parentId: 'p1' })),
};
files.client = client;
edit = {
canEdit: vi.fn(() => true),
list: vi.fn(async () => []),
read: vi.fn(async () => ({ ok: true, name: 'budget-2026.xlsx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h2' })),
taken: vi.fn(async () => {}),
setSource: vi.fn(),
discard: vi.fn(async () => ({ held: [] })),
forceDiscard: vi.fn(async () => ({ held: [] })),
askClear: vi.fn(async () => true),
shown: vi.fn(),
onChange: vi.fn(() => () => {}),
};
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit, os: { notify: vi.fn(async () => ({})) } };
resetEditSessions();
});
afterEach(() => {
cleanup();
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('a file opened from Files remembers its node', () => {
it('reads the node source back', () => {
expect(parseEditSource(JSON.stringify({ kind: 'node', nodeId: 'acct2:n1', blobId: 'b1', parentId: null })))
.toEqual({ kind: 'node', nodeId: 'acct2:n1', blobId: 'b1', parentId: null });
// Older sessions keep working.
expect(parseEditSource('{"kind":"file"}')).toEqual({ kind: 'file' });
expect(parseEditSource(JSON.stringify({ kind: 'node' }))).toEqual({ kind: 'file' });
});
});
describe('Save to Files (pim-p2-1)', () => {
it('is the first choice on the card of a file from Files', () => {
show();
const buttons = screen.getAllByRole('button').filter((b) => b.textContent);
expect(buttons[0].textContent).toBe('save_to_files');
expect(screen.getByText('new_message')).toBeTruthy();
expect(screen.getByText('save_copy')).toBeTruthy();
});
it('puts the new content into the same node when nobody changed it', async () => {
show();
fireEvent.click(screen.getByText('save_to_files'));
await waitFor(() => expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('n1', 'b-new'));
expect(client.createFileNode).not.toHaveBeenCalled();
expect(edit.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'node', nodeId: 'n1', blobId: 'b-new', parentId: 'p1' }));
expect(edit.taken).toHaveBeenCalledWith('s1', 'h2');
expect(toast.success).toHaveBeenCalledWith('saved');
expect(files.refresh).toHaveBeenCalled();
});
it('asks Keep both or Replace when the file changed on the server, and Keep both adds a copy beside it', async () => {
client.getFileNodes.mockResolvedValue([{ id: 'n1', blobId: 'b-someone-else', name: 'budget-2026.xlsx', parentId: 'p1' }]);
show();
fireEvent.click(screen.getByText('save_to_files'));
await waitFor(() => expect(screen.getByTestId('edited-file-conflict').textContent).toBe('changed_on_server'));
expect(client.replaceFileNodeBlob).not.toHaveBeenCalled();
expect(edit.taken).not.toHaveBeenCalled();
expect(screen.getByText('replace')).toBeTruthy();
fireEvent.click(screen.getByText('keep_both'));
await waitFor(() => expect(client.createFileNode).toHaveBeenCalledWith('budget-2026.xlsx', 'b-new', 'x/y', 2, 'p1'));
expect(client.replaceFileNodeBlob).not.toHaveBeenCalled();
expect(edit.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'node', nodeId: 'n2', blobId: 'b-new', parentId: 'p1' }));
expect(edit.taken).toHaveBeenCalledWith('s1', 'h2');
expect(toast.success).toHaveBeenCalledWith('saved_copy');
});
it('Replace writes over the change', async () => {
client.getFileNodes.mockResolvedValue([{ id: 'n1', blobId: 'b-someone-else', name: 'budget-2026.xlsx', parentId: 'p1' }]);
show();
fireEvent.click(screen.getByText('save_to_files'));
fireEvent.click(await screen.findByText('replace'));
await waitFor(() => expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('n1', 'b-new'));
});
it('offers a new file when the original is gone', async () => {
client.getFileNodes.mockResolvedValue([]);
show();
fireEvent.click(screen.getByText('save_to_files'));
await waitFor(() => expect(screen.getByTestId('edited-file-conflict').textContent).toBe('gone_on_server'));
expect(screen.queryByText('replace')).toBeNull();
expect(screen.getByText('save_as_new')).toBeTruthy();
});
});
+63 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { editForbidden, followEditSessions, getEditBridge, parkFilesForReply, parseEditSource, readEditedFile, useEditSessions } from "@/lib/desktop/edit-attachments";
import { filesClientFor, refreshFilesFor, saveBackToNode, saveBesideNode, type NodeEditSource } from "@/lib/desktop/save-to-files";
import { openMailLink } from "@/lib/desktop/mail-links";
import { contentHiddenFor } from "@/lib/desktop/mail-notifications";
import type { DesktopEditSession } from "@/lib/desktop/types";
@@ -35,7 +36,10 @@ function useSignedInAccounts(): Set<string> {
*/
export function EditedFilesHost() {
const t = useTranslations("desktopEdit");
const tFiles = useTranslations("ux_p2_pim_files");
const router = useRouter();
// A file from Files that changed on the server, or went, since it was opened: what to do is asked on its card.
const [conflicts, setConflicts] = useState<Record<string, "changed" | "gone">>({});
const tRef = useRef(t);
tRef.current = t;
const sessions = useEditSessions((s) => s.sessions);
@@ -148,6 +152,42 @@ export function EditedFilesHost() {
if (saveBlobAs(file, file.name, { kind: "attachment", subject: session.account })) void getEditBridge()?.taken(session.id, hash);
});
/**
* Save to Files (pim-p2-1): back into the node it came from, unless that changed on the
* server meanwhile; then the card asks Keep both or Replace. `how`: "check" first,
* "replace" over a change, "beside" as a new file next to it.
*/
const saveToFiles = (session: DesktopEditSession, source: NodeEditSource, how: "check" | "replace" | "beside") => withFile(session, async (file, hash) => {
const client = filesClientFor(session.account);
const bridge = getEditBridge();
if (!client || !bridge) {
toast.error(tFiles("save_failed", { name: session.name }));
return;
}
try {
if (how === "beside") {
const saved = await saveBesideNode(client, source, file);
bridge.setSource(session.id, JSON.stringify(saved.source));
await bridge.taken(session.id, hash);
toast.success(tFiles("saved_copy", { name: saved.name }));
} else {
const result = await saveBackToNode(client, source, file, how === "replace");
if (result.status !== "saved") {
setConflicts((all) => ({ ...all, [session.id]: result.status }));
return;
}
bridge.setSource(session.id, JSON.stringify({ ...source, blobId: result.blobId }));
await bridge.taken(session.id, hash);
toast.success(tFiles("saved", { name: session.name }));
}
setConflicts(({ [session.id]: _done, ...rest }) => rest);
refreshFilesFor(client);
} catch (error) {
console.error("Could not save an edited file to Files:", error);
toast.error(tFiles("save_failed", { name: session.name }));
}
});
const accountLabel = (session: DesktopEditSession) => {
if (accountList.length < 2) return null;
const entry = accountList.find((a) => a.id === session.account);
@@ -172,6 +212,8 @@ export function EditedFilesHost() {
const source = parseEditSource(session.source);
const draftId = source.kind === "draft" && !session.savedAs ? source.draftId : null;
const account = accountLabel(session);
const node = source.kind === "node" ? source : null;
const conflict = node ? conflicts[session.id] : undefined;
return (
<div key={session.id} data-testid="edited-file" className="flex flex-col gap-2 rounded-lg border border-border bg-background p-3 shadow-lg">
<div className="flex items-start gap-2">
@@ -181,6 +223,11 @@ export function EditedFilesHost() {
{account && <p className="truncate text-xs text-muted-foreground" data-testid="edited-file-account">{account}</p>}
<p className="text-xs text-muted-foreground">{t("changed_body")}</p>
{session.lastChance && <p className="text-xs font-medium text-foreground" data-testid="edited-file-last-chance">{t("last_chance")}</p>}
{conflict && (
<p className="text-xs font-medium text-foreground" role="alert" data-testid="edited-file-conflict">
{conflict === "changed" ? tFiles("changed_on_server", { name: session.name }) : tFiles("gone_on_server", { name: session.name })}
</p>
)}
</div>
<button
type="button"
@@ -192,7 +239,21 @@ export function EditedFilesHost() {
<X className="h-4 w-4" />
</button>
</div>
{node && conflict ? (
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "beside")}>
{conflict === "changed" ? tFiles("keep_both") : tFiles("save_as_new")}
</Button>
{conflict === "changed" && (
<Button size="sm" variant="outline" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "replace")}>{tFiles("replace")}</Button>
)}
<Button size="sm" variant="ghost" disabled={busy === session.id} onClick={() => setConflicts(({ [session.id]: _gone, ...rest }) => rest)}>{tFiles("cancel")}</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
{node && (
<Button size="sm" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "check")}>{tFiles("save_to_files")}</Button>
)}
{source.kind === "mail" && (
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToReply(session, source.emailId, source.ownerAccountId)}>{t("reply")}</Button>
)}
@@ -200,10 +261,11 @@ export function EditedFilesHost() {
<Button size="sm" onClick={() => openDraftWindow({ draftId, localAccountId: session.account, ownerAccountId: source.kind === "draft" ? source.ownerAccountId : undefined })}>{t("open_draft")}</Button>
)}
{source.kind !== "mail" && !draftId && (
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToNew(session)}>{t("new_message")}</Button>
<Button size="sm" variant={node ? "outline" : "default"} disabled={busy === session.id} onClick={() => void attachToNew(session)}>{t("new_message")}</Button>
)}
<Button size="sm" variant="outline" disabled={busy === session.id} onClick={() => void saveCopy(session)}>{t("save_copy")}</Button>
</div>
)}
</div>
);
})}
+15 -1
View File
@@ -27,13 +27,27 @@ export type EditSource =
| { kind: 'mail'; emailId: string; ownerAccountId?: string }
/** A draft's attachment: offers the update, in the composer holding it. */
| { kind: 'draft'; draftId: string | null; ownerAccountId?: string }
/**
* A file in Files, opened with "Open in default app": a save goes back into this node.
* `blobId` is the content last known to be there, to tell whether someone changed it
* on the server meanwhile.
*/
| { kind: 'node'; nodeId: string; blobId: string | null; parentId: string | null }
/** Its message went or it was taken off a draft: only the file is left. */
| { kind: 'file' };
export function parseEditSource(raw: string): EditSource {
try {
const parsed = JSON.parse(raw) as Partial<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown };
const parsed = JSON.parse(raw) as Partial<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown; nodeId?: unknown; blobId?: unknown; parentId?: unknown };
const ownerAccountId = typeof parsed.ownerAccountId === 'string' ? parsed.ownerAccountId : undefined;
if (parsed.kind === 'node' && typeof parsed.nodeId === 'string' && parsed.nodeId) {
return {
kind: 'node',
nodeId: parsed.nodeId,
blobId: typeof parsed.blobId === 'string' ? parsed.blobId : null,
parentId: typeof parsed.parentId === 'string' ? parsed.parentId : null,
};
}
if (parsed.kind === 'mail' && typeof parsed.emailId === 'string') return { kind: 'mail', emailId: parsed.emailId, ownerAccountId };
if (parsed.kind === 'draft') return { kind: 'draft', draftId: typeof parsed.draftId === 'string' ? parsed.draftId : null, ownerAccountId };
} catch {
+61
View File
@@ -0,0 +1,61 @@
/**
* "Save to Files" for a document opened from Files in its own program (pim-p2-1): the saved
* bytes go back into the node they came from, as an upload's Replace does (same id, so shares
* and links stay), the way OneDrive or Nextcloud write back an edited file.
*
* Before that the node is read again: when its content is no longer what was opened (someone
* changed it on the server, or it was deleted), nothing is overwritten and the caller asks
* Keep both (a numbered copy beside it) or Replace.
*/
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useAuthStore } from '@/stores/auth-store';
import { useFileStore } from '@/stores/file-store';
import type { EditSource } from './edit-attachments';
export type NodeEditSource = Extract<EditSource, { kind: 'node' }>;
export type SaveBackResult =
| { status: 'saved'; blobId: string }
/** Someone changed the file on the server since it was opened. */
| { status: 'changed' }
/** It isn't there any more. */
| { status: 'gone' };
/** The client for the account the file belongs to, whichever account is on screen. */
export function filesClientFor(account: string): IJMAPClient | null {
const files = useFileStore.getState();
if (files.client && files.currentAccountId === account) return files.client;
const auth = useAuthStore.getState();
return auth.getClientForAccount?.(account) ?? (auth.activeAccountId === account ? auth.client : null) ?? null;
}
/** Puts the file into its node. `force`: also when it changed on the server (Replace). */
export async function saveBackToNode(client: IJMAPClient, source: NodeEditSource, file: File, force = false): Promise<SaveBackResult> {
const [node] = await client.getFileNodes([source.nodeId], ['id', 'blobId', 'name', 'parentId']);
if (!node) return { status: 'gone' };
if (!force && source.blobId && node.blobId !== source.blobId) return { status: 'changed' };
const { blobId } = await client.uploadBlob(file);
await client.replaceFileNodeBlob(source.nodeId, blobId);
return { status: 'saved', blobId };
}
/** Keep both: a new file beside the original, numbered by the server when the name is taken. */
export async function saveBesideNode(client: IJMAPClient, source: NodeEditSource, file: File): Promise<{ source: NodeEditSource; name: string }> {
const { blobId, type } = await client.uploadBlob(file);
let created;
try {
created = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, source.parentId);
} catch (error) {
// The folder went too: the top of Files.
if (source.parentId === null) throw error;
created = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, null);
}
return { source: { kind: 'node', nodeId: created.id, blobId, parentId: created.parentId ?? null }, name: created.name };
}
/** Files on screen shows the new size and date. */
export function refreshFilesFor(client: IJMAPClient): void {
const files = useFileStore.getState();
if (files.client === client) void files.refresh().catch(() => {});
}
+15
View File
@@ -10450,6 +10450,21 @@ export class JMAPClient implements IJMAPClient {
}
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
// A node of a shared drive is known as "accountId:nodeId" (namespaceFileNode):
// read it in its owner's account and hand it back under the same name.
const shared = ids && ids.length > 0 ? this.resolveFileNodeId(ids[0]) : null;
if (shared && shared.accountId !== this.getFilesAccountId()) {
const rawIds = ids!.map((id) => this.resolveFileNodeId(id)).filter((node) => node.accountId === shared.accountId).map((node) => node.id as string);
const response = await this.request(
[["FileNode/get", { accountId: shared.accountId, ids: rawIds, properties: properties || JMAPClient.FILE_NODE_PROPERTIES }, "fn0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/get failed");
}
return ((result[1].list || []) as FileNode[]).map((node) => this.namespaceFileNode(fromWireFileNode(node), shared.accountId));
}
const accountId = this.getFilesAccountId();
const args: Record<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };