diff --git a/components/desktop/__tests__/edited-files-host-save-to-files.test.tsx b/components/desktop/__tests__/edited-files-host-save-to-files.test.tsx new file mode 100644 index 000000000..a40406995 --- /dev/null +++ b/components/desktop/__tests__/edited-files-host-save-to-files.test.tsx @@ -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 = {}) => ({ + 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>; +let client: { + getFileNodes: ReturnType; + uploadBlob: ReturnType; + replaceFileNodeBlob: ReturnType; + createFileNode: ReturnType; +}; + +function show() { + const sessions = [session()]; + edit.list.mockResolvedValue(sessions); + useEditSessions.setState({ sessions }); + return render(); +} + +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(); + }); +}); diff --git a/components/desktop/edited-files-host.tsx b/components/desktop/edited-files-host.tsx index 5eaf7906f..0ff889da3 100644 --- a/components/desktop/edited-files-host.tsx +++ b/components/desktop/edited-files-host.tsx @@ -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 { */ 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>({}); 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 (
@@ -181,6 +223,11 @@ export function EditedFilesHost() { {account &&

{account}

}

{t("changed_body")}

{session.lastChance &&

{t("last_chance")}

} + {conflict && ( +

+ {conflict === "changed" ? tFiles("changed_on_server", { name: session.name }) : tFiles("gone_on_server", { name: session.name })} +

+ )}
+ {node && conflict ? ( +
+ + {conflict === "changed" && ( + + )} + +
+ ) : (
+ {node && ( + + )} {source.kind === "mail" && ( )} @@ -200,10 +261,11 @@ export function EditedFilesHost() { )} {source.kind !== "mail" && !draftId && ( - + )}
+ )} ); })} diff --git a/lib/desktop/edit-attachments.ts b/lib/desktop/edit-attachments.ts index 0640a108b..c515fadef 100644 --- a/lib/desktop/edit-attachments.ts +++ b/lib/desktop/edit-attachments.ts @@ -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 & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown }; + const parsed = JSON.parse(raw) as Partial & { 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 { diff --git a/lib/desktop/save-to-files.ts b/lib/desktop/save-to-files.ts new file mode 100644 index 000000000..f1f77b98e --- /dev/null +++ b/lib/desktop/save-to-files.ts @@ -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; + +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 { + 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(() => {}); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6b445018b..ed218edcb 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -10450,6 +10450,21 @@ export class JMAPClient implements IJMAPClient { } async getFileNodes(ids: string[] | null, properties?: string[]): Promise { + // 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 = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };