From 96f8aa6a6323484eb5a3658c4047532aa5c690d7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:24:19 +0200 Subject: [PATCH 1/4] feat: tell plugins when contacts are created, changed or deleted, and let them stop it --- components/contacts/contacts-app.tsx | 54 ++-- components/email/email-viewer.tsx | 6 +- stores/__tests__/contact-store-hooks.test.ts | 251 +++++++++++++++++++ stores/__tests__/contact-store.test.ts | 54 ++-- stores/contact-store.ts | 82 ++++-- 5 files changed, 364 insertions(+), 83 deletions(-) create mode 100644 stores/__tests__/contact-store-hooks.test.ts diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index b72efc7c1..07899ff28 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -347,7 +347,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = if (supportsSync && client) { await updateContact(client, contactId, { keywords: updatedKeywords }); } else { - updateLocalContact(contactId, { keywords: updatedKeywords }); + await updateLocalContact(contactId, { keywords: updatedKeywords }); } } const msg = contactIds.length === 1 @@ -427,11 +427,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = if (!confirmed) return; try { - if (supportsSync && client) { - await deleteContact(client, contactId); - } else { - deleteLocalContact(contactId); - } + const deleted = supportsSync && client + ? await deleteContact(client, contactId) + : await deleteLocalContact(contactId); + // A plugin stopped it and has told the user why. + if (!deleted) return; toast.success(t("toast.deleted")); if (selectedContactId === contactId) setView("list"); } catch (error) { @@ -468,33 +468,27 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source; void _id; void _uid; void _created; void _updated; const data: Partial = JSON.parse(JSON.stringify(rest)); - if (supportsSync && client) { - await createContact(client, data); - toast.success(t("toast.created")); - } else { - const localContact: ContactCard = { + const created = supportsSync && client + ? await createContact(client, data) + : await addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: data.addressBookIds || {}, ...data, - }; - addLocalContact(localContact); - toast.success(t("toast.created")); - } + }); + if (created) toast.success(t("toast.created")); }, [supportsSync, client, createContact, addLocalContact, t]); const handleSaveNew = useCallback(async (data: Partial) => { - if (supportsSync && client) { - await createContact(client, data); - toast.success(t("toast.created")); - } else { - const localContact: ContactCard = { + const created = supportsSync && client + ? await createContact(client, data) + : await addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...data, - }; - addLocalContact(localContact); - toast.success(t("toast.created")); - } + }); + // A plugin stopped it: keep the form open with what was typed. + if (!created) return; + toast.success(t("toast.created")); setDefaultBookIdForCreate(undefined); setCreatePrefill(undefined); if (returnToEmail) { @@ -508,13 +502,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const handleSaveEdit = useCallback(async (data: Partial) => { if (!selectedContact) return; - if (supportsSync && client) { - await updateContact(client, selectedContact.id, data); - toast.success(t("toast.updated")); - } else { - updateLocalContact(selectedContact.id, data); - toast.success(t("toast.updated")); - } + const updated = supportsSync && client + ? await updateContact(client, selectedContact.id, data) + : await updateLocalContact(selectedContact.id, data); + if (!updated) return; + toast.success(t("toast.updated")); setView("detail"); }, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8fc8e9118..4ba1e06cf 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -6178,10 +6178,10 @@ export function EmailViewer({ }} : {}), }; if (client && supportsSync) { - createContact(client, contactData).then(() => toast.success('Contact added')); + createContact(client, contactData).then((created) => { if (created) toast.success('Contact added'); }); } else { - addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard); - toast.success('Contact added'); + void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard) + .then((created) => { if (created) toast.success('Contact added'); }); } }} /> diff --git a/stores/__tests__/contact-store-hooks.test.ts b/stores/__tests__/contact-store-hooks.test.ts new file mode 100644 index 000000000..88cfc6ae7 --- /dev/null +++ b/stores/__tests__/contact-store-hooks.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useContactStore } from '../contact-store'; +import { contactHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { ContactCard } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +const card = (overrides: Partial = {}): ContactCard => ({ + id: 'c1', + addressBookIds: { 'ab-1': true }, + name: { full: 'Jane Doe', components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }], isOrdered: true }, + emails: { e0: { address: 'jane@example.com' } }, + ...overrides, +}); + +const makeClient = (overrides: Record = {}) => ({ + createContact: vi.fn(async (c: Partial) => ({ ...c, id: 'srv-1' } as ContactCard)), + updateContact: vi.fn(async () => undefined), + deleteContact: vi.fn(async () => undefined), + ...overrides, +}) as unknown as IJMAPClient & Record>; + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe('contact-store plugin hooks: contact CRUD', () => { + beforeEach(() => { + clearAllHooks(); + useContactStore.setState({ + contacts: [], addressBooks: [], selectedContactId: null, error: null, + supportsSync: true, selectedContactIds: new Set(), lastSelectedContactId: null, + }); + }); + + describe('createContact', () => { + it('asks plugins first and tells them about the created contact', async () => { + const client = makeClient(); + const before = vi.fn(() => { + expect(client.createContact).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + contactHooks.onBeforeContactCreate.register('test', before); + contactHooks.onAfterContactCreate.register('test', after); + + const { id: _id, ...draft } = card(); + void _id; + expect(await useContactStore.getState().createContact(client, draft)).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledOnce(); + expect(before).toHaveBeenCalledWith(expect.objectContaining({ id: '', fullName: 'Jane Doe', emails: ['jane@example.com'] })); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ id: 'srv-1', fullName: 'Jane Doe', addressBookId: 'ab-1' }); + }); + + it('creates nothing when a plugin says no', async () => { + const client = makeClient(); + const after = vi.fn(); + contactHooks.onBeforeContactCreate.register('test', () => false); + contactHooks.onAfterContactCreate.register('test', after); + + expect(await useContactStore.getState().createContact(client, card())).toBe(false); + await flush(); + + expect(client.createContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts).toEqual([]); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a contact the server refused', async () => { + const client = makeClient({ createContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const after = vi.fn(); + contactHooks.onAfterContactCreate.register('test', after); + + await expect(useContactStore.getState().createContact(client, card())).rejects.toThrow('nope'); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('updateContact', () => { + it('passes the id and patch first, then the changed contact', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient(); + const before = vi.fn(() => { + expect(client.updateContact).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + contactHooks.onBeforeContactUpdate.register('test', before); + contactHooks.onAfterContactUpdate.register('test', after); + + const patch = { emails: { e0: { address: 'new@example.com' } } }; + expect(await useContactStore.getState().updateContact(client, 'c1', patch)).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledWith({ contactId: 'c1', patch }); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ id: 'c1', emails: ['new@example.com'] }); + }); + + it('changes nothing when a plugin says no', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient(); + const after = vi.fn(); + contactHooks.onBeforeContactUpdate.register('test', () => false); + contactHooks.onAfterContactUpdate.register('test', after); + + expect(await useContactStore.getState().updateContact(client, 'c1', { kind: 'org' })).toBe(false); + await flush(); + + expect(client.updateContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts[0].kind).toBeUndefined(); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report an update the server refused', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient({ updateContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const after = vi.fn(); + contactHooks.onAfterContactUpdate.register('test', after); + + await expect(useContactStore.getState().updateContact(client, 'c1', { kind: 'org' })).rejects.toThrow(); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('deleteContact', () => { + it('asks plugins first and reports the deleted id', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient(); + const before = vi.fn(() => { + expect(client.deleteContact).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + contactHooks.onBeforeContactDelete.register('test', before); + contactHooks.onAfterContactDelete.register('test', after); + + expect(await useContactStore.getState().deleteContact(client, 'c1')).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledWith({ contactIds: ['c1'] }); + expect(after).toHaveBeenCalledWith({ contactIds: ['c1'] }); + expect(useContactStore.getState().contacts).toEqual([]); + }); + + it('keeps the contact when a plugin says no', async () => { + useContactStore.setState({ contacts: [card()], selectedContactId: 'c1' }); + const client = makeClient(); + const after = vi.fn(); + contactHooks.onBeforeContactDelete.register('test', () => false); + contactHooks.onAfterContactDelete.register('test', after); + + expect(await useContactStore.getState().deleteContact(client, 'c1')).toBe(false); + await flush(); + + expect(client.deleteContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts).toHaveLength(1); + expect(useContactStore.getState().selectedContactId).toBe('c1'); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a delete the server refused', async () => { + useContactStore.setState({ contacts: [card()] }); + const client = makeClient({ deleteContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const after = vi.fn(); + contactHooks.onAfterContactDelete.register('test', after); + + await expect(useContactStore.getState().deleteContact(client, 'c1')).rejects.toThrow(); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('local contacts (no server sync)', () => { + beforeEach(() => useContactStore.setState({ supportsSync: false })); + + it('fires the same hooks for create, update and delete', async () => { + const created = vi.fn(); + const updated = vi.fn(); + const deleted = vi.fn(); + contactHooks.onAfterContactCreate.register('test', created); + contactHooks.onAfterContactUpdate.register('test', updated); + contactHooks.onAfterContactDelete.register('test', deleted); + + await useContactStore.getState().addLocalContact(card({ id: 'local-1' })); + await useContactStore.getState().updateLocalContact('local-1', { kind: 'org' }); + await useContactStore.getState().deleteLocalContact('local-1'); + await flush(); + + expect(created.mock.calls[0][0]).toMatchObject({ id: 'local-1', fullName: 'Jane Doe' }); + expect(updated.mock.calls[0][0]).toMatchObject({ id: 'local-1', kind: 'org' }); + expect(deleted).toHaveBeenCalledWith({ contactIds: ['local-1'] }); + }); + + it('leaves the list alone when plugins say no', async () => { + useContactStore.setState({ contacts: [card({ id: 'local-1' })] }); + contactHooks.onBeforeContactCreate.register('test', () => false); + contactHooks.onBeforeContactUpdate.register('test', () => false); + contactHooks.onBeforeContactDelete.register('test', () => false); + + expect(await useContactStore.getState().addLocalContact(card({ id: 'local-2' }))).toBe(false); + expect(await useContactStore.getState().updateLocalContact('local-1', { kind: 'org' })).toBe(false); + expect(await useContactStore.getState().deleteLocalContact('local-1')).toBe(false); + + const { contacts } = useContactStore.getState(); + expect(contacts.map(c => c.id)).toEqual(['local-1']); + expect(contacts[0].kind).toBeUndefined(); + }); + }); + + describe('bulkDeleteContacts', () => { + it('asks once with every id and reports only the ones that went', async () => { + useContactStore.setState({ contacts: [card({ id: 'a' }), card({ id: 'b' }), card({ id: 'c' })] }); + const client = makeClient({ + deleteContact: vi.fn(async (id: string) => { if (id === 'b') throw new Error('locked'); }), + }); + const before = vi.fn(() => { + expect(client.deleteContact).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + contactHooks.onBeforeContactDelete.register('test', before); + contactHooks.onAfterContactDelete.register('test', after); + + await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b', 'c']); + await flush(); + + expect(before).toHaveBeenCalledOnce(); + expect(before).toHaveBeenCalledWith({ contactIds: ['a', 'b', 'c'] }); + expect(after).toHaveBeenCalledOnce(); + expect(after).toHaveBeenCalledWith({ contactIds: ['a', 'c'] }); + }); + + it('deletes nothing when a plugin says no', async () => { + useContactStore.setState({ + contacts: [card({ id: 'a' }), card({ id: 'b' })], + selectedContactIds: new Set(['a', 'b']), + }); + const client = makeClient(); + contactHooks.onBeforeContactDelete.register('test', () => false); + + await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b']); + + expect(client.deleteContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts).toHaveLength(2); + expect(useContactStore.getState().selectedContactIds.size).toBe(2); + }); + }); +}); diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index 89da2d6d4..ff9b46e3a 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -42,64 +42,64 @@ describe('contact-store', () => { }); describe('addLocalContact', () => { - it('should append contact to array', () => { - useContactStore.getState().addLocalContact(makeContact()); + it('should append contact to array', async () => { + await useContactStore.getState().addLocalContact(makeContact()); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].id).toBe('contact-1'); }); - it('should preserve existing contacts', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); + it('should preserve existing contacts', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); expect(useContactStore.getState().contacts).toHaveLength(2); }); }); describe('updateLocalContact', () => { - it('should update matching contact', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().updateLocalContact('c1', { + it('should update matching contact', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'updated@example.com' } }, }); expect(useContactStore.getState().contacts[0].emails!.e0.address).toBe('updated@example.com'); }); - it('should not modify other contacts', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } })); - useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } }); + it('should not modify other contacts', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } })); + await useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } }); expect(useContactStore.getState().contacts[1].emails!.e0.address).toBe('c2@test.com'); }); - it('should no-op for non-existent id', () => { - useContactStore.getState().addLocalContact(makeContact()); - useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' }); + it('should no-op for non-existent id', async () => { + await useContactStore.getState().addLocalContact(makeContact()); + await useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' }); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].kind).toBeUndefined(); }); }); describe('deleteLocalContact', () => { - it('should remove contact by id', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); - useContactStore.getState().deleteLocalContact('c1'); + it('should remove contact by id', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); + await useContactStore.getState().deleteLocalContact('c1'); expect(useContactStore.getState().contacts).toHaveLength(1); expect(useContactStore.getState().contacts[0].id).toBe('c2'); }); - it('should clear selectedContactId when deleting selected', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + it('should clear selectedContactId when deleting selected', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().setSelectedContact('c1'); - useContactStore.getState().deleteLocalContact('c1'); + await useContactStore.getState().deleteLocalContact('c1'); expect(useContactStore.getState().selectedContactId).toBeNull(); }); - it('should preserve selectedContactId when deleting other', () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); - useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); + it('should preserve selectedContactId when deleting other', async () => { + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c2' })); useContactStore.getState().setSelectedContact('c1'); - useContactStore.getState().deleteLocalContact('c2'); + await useContactStore.getState().deleteLocalContact('c2'); expect(useContactStore.getState().selectedContactId).toBe('c1'); }); }); @@ -574,7 +574,7 @@ describe('contact-store', () => { }); it('should preserve selectedContactId when deleting other group', async () => { - useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); + await useContactStore.getState().addLocalContact(makeContact({ id: 'c1' })); useContactStore.getState().setSelectedContact('c1'); await useContactStore.getState().createGroup(null, 'Team', []); const groupId = useContactStore.getState().contacts[1].id; diff --git a/stores/contact-store.ts b/stores/contact-store.ts index cef716d91..985752935 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -6,6 +6,13 @@ import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; import { getClientByLocalAccountId } from './client-registry'; import { sanitizeDisplayName, splitMailbox } from '@/lib/rfc5322-mailbox'; +import { contactHooks } from '@/lib/plugin-hooks'; +import { contactToView } from '@/lib/plugin-projection'; + +/** A contact that is about to be created, projected for plugins (no id yet). */ +function draftContactView(contact: Partial) { + return contactToView({ addressBookIds: {}, ...contact, id: contact.id ?? '' } as ContactCard); +} /** One compose-autocomplete suggestion: a person, or a contact group. */ type RecipientSuggestion = { name: string; email: string; group?: { id: string; memberCount: number } }; @@ -343,13 +350,15 @@ interface ContactStore { fetchAddressBooks: (client: IJMAPClient) => Promise; fetchAllAccountsContacts: (accounts: ContactAccountClient[]) => Promise; fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[]) => Promise; - createContact: (client: IJMAPClient, contact: Partial) => Promise; - updateContact: (client: IJMAPClient, id: string, updates: Partial) => Promise; - deleteContact: (client: IJMAPClient, id: string) => Promise; + // The contact actions resolve to false when a plugin stopped them + // (onBeforeContactCreate / Update / Delete); nothing changed then. + createContact: (client: IJMAPClient, contact: Partial) => Promise; + updateContact: (client: IJMAPClient, id: string, updates: Partial) => Promise; + deleteContact: (client: IJMAPClient, id: string) => Promise; - addLocalContact: (contact: ContactCard) => void; - updateLocalContact: (id: string, updates: Partial) => void; - deleteLocalContact: (id: string) => void; + addLocalContact: (contact: ContactCard) => Promise; + updateLocalContact: (id: string, updates: Partial) => Promise; + deleteLocalContact: (id: string) => Promise; setSelectedContact: (id: string | null) => void; setSearchQuery: (query: string) => void; @@ -537,6 +546,7 @@ export const useContactStore = create()( }, createContact: async (client, contact) => { + if (!(await contactHooks.onBeforeContactCreate.intercept(draftContactView(contact)))) return false; set({ isLoading: true, error: null }); try { // Determine target account from the selected address book. Also @@ -572,6 +582,8 @@ export const useContactStore = create()( contacts: [...state.contacts, created], isLoading: false, })); + void contactHooks.onAfterContactCreate.emit(contactToView(created)); + return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to create contact'; set({ error: msg, isLoading: false }); @@ -580,6 +592,7 @@ export const useContactStore = create()( }, updateContact: async (client, id, updates) => { + if (!(await contactHooks.onBeforeContactUpdate.intercept({ contactId: id, patch: updates }))) return false; set({ error: null }); try { const contact = get().contacts.find(c => c.id === id); @@ -602,6 +615,9 @@ export const useContactStore = create()( c.id === id ? { ...c, ...updates } : c ), })); + const updated = get().contacts.find(c => c.id === id); + if (updated) void contactHooks.onAfterContactUpdate.emit(contactToView(updated)); + return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to update contact'; set({ error: msg }); @@ -610,6 +626,7 @@ export const useContactStore = create()( }, deleteContact: async (client, id) => { + if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [id] }))) return false; set({ error: null }); try { const contact = get().contacts.find(c => c.id === id); @@ -625,6 +642,8 @@ export const useContactStore = create()( selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, }; }); + void contactHooks.onAfterContactDelete.emit({ contactIds: [id] }); + return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to delete contact'; set({ error: msg }); @@ -632,24 +651,38 @@ export const useContactStore = create()( } }, - addLocalContact: (contact) => set((state) => ({ - contacts: [...state.contacts, contact], - })), + addLocalContact: async (contact) => { + if (!(await contactHooks.onBeforeContactCreate.intercept(draftContactView(contact)))) return false; + set((state) => ({ contacts: [...state.contacts, contact] })); + void contactHooks.onAfterContactCreate.emit(contactToView(contact)); + return true; + }, - updateLocalContact: (id, updates) => set((state) => ({ - contacts: state.contacts.map(c => - c.id === id ? { ...c, ...updates } : c - ), - })), + updateLocalContact: async (id, updates) => { + if (!(await contactHooks.onBeforeContactUpdate.intercept({ contactId: id, patch: updates }))) return false; + set((state) => ({ + contacts: state.contacts.map(c => + c.id === id ? { ...c, ...updates } : c + ), + })); + const updated = get().contacts.find(c => c.id === id); + if (updated) void contactHooks.onAfterContactUpdate.emit(contactToView(updated)); + return true; + }, - deleteLocalContact: (id) => set((state) => { - const removedIds = new Set([id]); - const cleaned = cleanGroupMembers(state.contacts, removedIds); - return { - contacts: cleaned.filter(c => c.id !== id), - selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, - }; - }), + deleteLocalContact: async (id) => { + if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [id] }))) return false; + set((state) => { + const removedIds = new Set([id]); + const cleaned = cleanGroupMembers(state.contacts, removedIds); + return { + contacts: cleaned.filter(c => c.id !== id), + selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, + }; + }); + void contactHooks.onAfterContactDelete.emit({ contactIds: [id] }); + return true; + }, setSelectedContact: (id) => set({ selectedContactId: id }), setSearchQuery: (query) => set({ searchQuery: query }), @@ -939,6 +972,8 @@ export const useContactStore = create()( clearSelection: () => set({ selectedContactIds: new Set(), lastSelectedContactId: null }), bulkDeleteContacts: async (client, ids) => { + if (ids.length === 0) return; + if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [...ids] }))) return; set({ error: null }); const { supportsSync, contacts } = get(); const deletedIds = new Set(ids); @@ -968,6 +1003,9 @@ export const useContactStore = create()( selectedContactIds: new Set(), }; }); + if (deletedIds.size > 0) { + void contactHooks.onAfterContactDelete.emit({ contactIds: ids.filter(id => deletedIds.has(id)) }); + } }, bulkAddToGroup: async (client, groupId, contactIds) => { From 8ec7dc54abf8b3eb87098c081616e46657bb8efb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:27:50 +0200 Subject: [PATCH 2/4] feat: tell plugins when contacts are opened, selected, moved or imported and when groups change --- .../__tests__/contact-detail.test.tsx | 21 +++ components/contacts/contact-detail.tsx | 12 ++ stores/__tests__/contact-store-hooks.test.ts | 144 ++++++++++++++++++ stores/contact-store.ts | 49 +++++- 4 files changed, 222 insertions(+), 4 deletions(-) diff --git a/components/contacts/__tests__/contact-detail.test.tsx b/components/contacts/__tests__/contact-detail.test.tsx index 27f5ce962..a908f69f9 100644 --- a/components/contacts/__tests__/contact-detail.test.tsx +++ b/components/contacts/__tests__/contact-detail.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ContactDetail } from '../contact-detail'; import type { ContactCard } from '@/lib/jmap/types'; +import { contactHooks, clearAllHooks } from '@/lib/plugin-hooks'; beforeEach(() => { Object.assign(navigator, { @@ -65,3 +66,23 @@ describe('ContactDetail', () => { expect(onDelete).toHaveBeenCalledOnce(); }); }); + +describe('ContactDetail plugin hooks', () => { + beforeEach(() => clearAllHooks()); + + it('tells plugins once when a contact is opened, again for another one', async () => { + const opened = vi.fn(); + contactHooks.onContactOpen.register('test', opened); + + const { rerender } = render(); + // Same contact with changed data: still the same open contact. + rerender(); + rerender(); + rerender(); + await Promise.resolve(); + + expect(opened).toHaveBeenCalledTimes(2); + expect(opened.mock.calls[0][0]).toMatchObject({ id: '1', fullName: 'Alice Smith', emails: ['alice@example.com'] }); + expect(opened.mock.calls[1][0]).toMatchObject({ id: '2' }); + }); +}); diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index babdfc65b..4f048fc72 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -16,6 +16,8 @@ import { exportContact } from "./contact-export"; import { printContact } from "./contact-print"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { contactHooks } from "@/lib/plugin-hooks"; +import { contactToView } from "@/lib/plugin-projection"; type MoreItem = | { @@ -124,6 +126,16 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0; + // Tell plugins once per contact shown here, not on every edit of that card. + const latestContact = useRef(contact); + useEffect(() => { latestContact.current = contact; }); + const openedId = contact?.id; + useEffect(() => { + const opened = latestContact.current; + if (!openedId || !opened) return; + void contactHooks.onContactOpen.emit(contactToView(opened)); + }, [openedId]); + if (!contact) { return (
diff --git a/stores/__tests__/contact-store-hooks.test.ts b/stores/__tests__/contact-store-hooks.test.ts index 88cfc6ae7..b4ec555b5 100644 --- a/stores/__tests__/contact-store-hooks.test.ts +++ b/stores/__tests__/contact-store-hooks.test.ts @@ -249,3 +249,147 @@ describe('contact-store plugin hooks: contact CRUD', () => { }); }); }); + +describe('contact-store plugin hooks: groups, import, move, selection', () => { + beforeEach(() => { + clearAllHooks(); + useContactStore.setState({ + contacts: [], addressBooks: [], selectedContactId: null, error: null, + supportsSync: true, selectedContactIds: new Set(), lastSelectedContactId: null, + }); + }); + + it('reports created, renamed and deleted groups', async () => { + const client = makeClient({ + createContact: vi.fn(async (c: Partial) => ({ ...c, id: 'g1' } as ContactCard)), + }); + const changes = vi.fn(); + contactHooks.onContactGroupChange.register('test', changes); + + await useContactStore.getState().createGroup(client, 'Team', []); + await useContactStore.getState().updateGroup(client, 'g1', 'Crew'); + await useContactStore.getState().deleteGroup(client, 'g1'); + await flush(); + + expect(changes.mock.calls.map(c => c[0])).toEqual([ + { groupId: 'g1', change: 'created' }, + { groupId: 'g1', change: 'updated' }, + { groupId: 'g1', change: 'deleted' }, + ]); + }); + + it('does not report a group change the server refused', async () => { + useContactStore.setState({ contacts: [card({ id: 'g1', kind: 'group', members: {} })] }); + const client = makeClient({ deleteContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const changes = vi.fn(); + contactHooks.onContactGroupChange.register('test', changes); + + await expect(useContactStore.getState().deleteGroup(client, 'g1')).rejects.toThrow(); + await flush(); + expect(changes).not.toHaveBeenCalled(); + }); + + it('reports the members that were really added or removed, once per action', async () => { + useContactStore.setState({ + contacts: [ + card({ id: 'a', uid: 'uid-a' }), card({ id: 'b', uid: 'uid-b' }), + card({ id: 'g1', kind: 'group', members: { 'uid-a': true } }), + ], + }); + const client = makeClient(); + const members = vi.fn(); + contactHooks.onContactGroupMemberChange.register('test', members); + + // a is already in the group; only b is new. bulkAddToGroup goes through + // addMembersToGroup and must not fire twice. + await useContactStore.getState().bulkAddToGroup(client, 'g1', ['a', 'b']); + await useContactStore.getState().removeMembersFromGroup(client, 'g1', ['a']); + await flush(); + + expect(members.mock.calls.map(c => c[0])).toEqual([ + { groupId: 'g1', added: ['b'], removed: [] }, + { groupId: 'g1', added: [], removed: ['a'] }, + ]); + }); + + it('does not report members the server refused', async () => { + useContactStore.setState({ contacts: [card({ id: 'a' }), card({ id: 'g1', kind: 'group', members: {} })] }); + const client = makeClient({ updateContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const members = vi.fn(); + contactHooks.onContactGroupMemberChange.register('test', members); + + await expect(useContactStore.getState().addMembersToGroup(client, 'g1', ['a'])).rejects.toThrow(); + await flush(); + expect(members).not.toHaveBeenCalled(); + }); + + it('reports an import with the book and the count', async () => { + let n = 0; + const client = makeClient({ + createContact: vi.fn(async (c: Partial) => ({ ...c, id: `srv-${++n}`, addressBookIds: { 'book-9': true } } as ContactCard)), + }); + const imported = vi.fn(); + contactHooks.onContactsImport.register('test', imported); + + await useContactStore.getState().importContacts(client, [card({ id: 'x' }), card({ id: 'y' })]); + await flush(); + + expect(imported).toHaveBeenCalledOnce(); + expect(imported).toHaveBeenCalledWith({ addressBookId: 'book-9', imported: 2 }); + }); + + it('does not report an import where nothing arrived', async () => { + const client = makeClient({ createContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const imported = vi.fn(); + contactHooks.onContactsImport.register('test', imported); + + await useContactStore.getState().importContacts(client, [card({ id: 'x' })]); + await flush(); + expect(imported).not.toHaveBeenCalled(); + }); + + it('reports each moved contact with the target book', async () => { + useContactStore.setState({ contacts: [card({ id: 'a' }), card({ id: 'b' })] }); + const client = makeClient({ getContactsAccountId: () => 'acc' }); + const moved = vi.fn(); + contactHooks.onContactMove.register('test', moved); + + await useContactStore.getState().moveContactToAddressBook(client, ['a', 'b'], { id: 'book-2', name: 'Work' } as never); + await flush(); + + expect(moved.mock.calls.map(c => c[0])).toEqual([ + { contactId: 'a', toAddressBookId: 'book-2' }, + { contactId: 'b', toAddressBookId: 'book-2' }, + ]); + }); + + it('does not report a move the server refused', async () => { + useContactStore.setState({ contacts: [card({ id: 'a' })] }); + const client = makeClient({ getContactsAccountId: () => 'acc', updateContact: vi.fn().mockRejectedValue(new Error('nope')) }); + const moved = vi.fn(); + contactHooks.onContactMove.register('test', moved); + + await expect(useContactStore.getState().moveContactToAddressBook(client, ['a'], { id: 'book-2', name: 'Work' } as never)).rejects.toThrow(); + await flush(); + expect(moved).not.toHaveBeenCalled(); + }); + + it('reports selection changes, and only real ones', async () => { + const selection = vi.fn(); + contactHooks.onContactSelectionChange.register('test', selection); + const store = useContactStore.getState(); + + store.toggleContactSelection('a'); + store.selectRangeContacts('c', ['a', 'b', 'c']); + store.selectAllContacts(['a', 'b', 'c']); // same ids: no change + store.clearSelection(); + store.clearSelection(); // already empty: no change + await flush(); + + expect(selection.mock.calls.map(c => c[0])).toEqual([ + { contactIds: ['a'] }, + { contactIds: ['a', 'b', 'c'] }, + { contactIds: [] }, + ]); + }); +}); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 985752935..1e6aeecaa 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -14,6 +14,12 @@ function draftContactView(contact: Partial) { return contactToView({ addressBookIds: {}, ...contact, id: contact.id ?? '' } as ContactCard); } +function sameIdSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const id of a) if (!b.has(id)) return false; + return true; +} + /** One compose-autocomplete suggestion: a person, or a contact group. */ type RecipientSuggestion = { name: string; email: string; group?: { id: string; memberCount: number } }; @@ -831,9 +837,11 @@ export const useContactStore = create()( members, }; + let groupId: string; if (client && get().supportsSync) { const created = await client.createContact(groupData); set((state) => ({ contacts: [...state.contacts, created] })); + groupId = created.id; } else { const localGroup: ContactCard = { id: `local-${generateUUID()}`, @@ -841,7 +849,9 @@ export const useContactStore = create()( ...groupData, } as ContactCard; set((state) => ({ contacts: [...state.contacts, localGroup] })); + groupId = localGroup.id; } + void contactHooks.onContactGroupChange.emit({ groupId, change: 'created' }); }, updateGroup: async (client, groupId, name) => { @@ -859,6 +869,7 @@ export const useContactStore = create()( c.id === groupId ? { ...c, ...updates } : c ), })); + void contactHooks.onContactGroupChange.emit({ groupId, change: 'updated' }); }, addMembersToGroup: async (client, groupId, memberIds) => { @@ -867,9 +878,11 @@ export const useContactStore = create()( if (!group) return; const newMembers = { ...group.members }; + const added: string[] = []; memberIds.forEach(id => { const contact = contacts.find(c => c.id === id); const key = contact?.uid || contact?.originalId || id; + if (!newMembers[key]) added.push(id); newMembers[key] = true; }); @@ -884,6 +897,9 @@ export const useContactStore = create()( c.id === groupId ? { ...c, members: newMembers } : c ), })); + if (added.length > 0) { + void contactHooks.onContactGroupMemberChange.emit({ groupId, added, removed: [] }); + } }, removeMembersFromGroup: async (client, groupId, memberIds) => { @@ -892,16 +908,19 @@ export const useContactStore = create()( if (!group?.members) return; const newMembers = { ...group.members }; + const removed: string[] = []; memberIds.forEach(id => { // Try direct id match first if (newMembers[id] !== undefined) { delete newMembers[id]; + removed.push(id); return; } // Try uid-based match const contact = contacts.find(c => c.id === id); if (contact?.uid && newMembers[contact.uid] !== undefined) { delete newMembers[contact.uid]; + removed.push(id); } else { // Try stripping urn:uuid: prefix matching for (const key of Object.keys(newMembers)) { @@ -909,6 +928,7 @@ export const useContactStore = create()( const bareUid = contact?.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact?.uid; if (bareKey === id || bareKey === bareUid) { delete newMembers[key]; + removed.push(id); break; } } @@ -926,6 +946,9 @@ export const useContactStore = create()( c.id === groupId ? { ...c, members: newMembers } : c ), })); + if (removed.length > 0) { + void contactHooks.onContactGroupMemberChange.emit({ groupId, added: [], removed }); + } }, deleteGroup: async (client, groupId) => { @@ -939,6 +962,7 @@ export const useContactStore = create()( contacts: state.contacts.filter(c => c.id !== groupId), selectedContactId: state.selectedContactId === groupId ? null : state.selectedContactId, })); + void contactHooks.onContactGroupChange.emit({ groupId, change: 'deleted' }); }, toggleContactSelection: (id) => set((state) => { @@ -1038,6 +1062,7 @@ export const useContactStore = create()( c.id === id ? { ...c, addressBookIds: { [addressBook.id]: true } } : c ), })); + void contactHooks.onContactMove.emit({ contactId: id, toAddressBookId: addressBook.id }); } else { // Cross-account: create in target, delete from source const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, id: _id, ...contactData } = contact; @@ -1064,6 +1089,7 @@ export const useContactStore = create()( }; }), })); + void contactHooks.onContactMove.emit({ contactId: id, toAddressBookId: addressBook.id }); } } }, @@ -1347,26 +1373,30 @@ export const useContactStore = create()( importContacts: async (client, contacts) => { const { supportsSync } = get(); let imported = 0; + // The book the cards landed in, as the server filed the first one. + let addressBookId = ''; for (const contact of contacts) { try { + let added: ContactCard; if (client && supportsSync) { const { id: _id, ...data } = contact; - const created = await client.createContact(data); - set((state) => ({ contacts: [...state.contacts, created] })); + added = await client.createContact(data); } else { - const localContact: ContactCard = { + added = { ...contact, id: `local-${generateUUID()}`, }; - set((state) => ({ contacts: [...state.contacts, localContact] })); } + set((state) => ({ contacts: [...state.contacts, added] })); + if (!addressBookId) addressBookId = Object.keys(added.addressBookIds || {}).find(k => added.addressBookIds[k]) || ''; imported++; } catch (error) { console.error('Failed to import contact:', error); } } + if (imported > 0) void contactHooks.onContactsImport.emit({ addressBookId, imported }); return imported; }, }); @@ -1381,4 +1411,15 @@ export const useContactStore = create()( ) ); +// Tell plugins when the set of checked contacts changes, whichever action +// changed it (toggle, range, select all, clear, bulk actions). Re-setting the +// same ids is not a change. +useContactStore.subscribe((state, prev) => { + if (state.selectedContactIds === prev.selectedContactIds) return; + if (contactHooks.onContactSelectionChange.size === 0) return; + const next = state.selectedContactIds ?? new Set(); + if (sameIdSet(next, prev.selectedContactIds ?? new Set())) return; + void contactHooks.onContactSelectionChange.emit({ contactIds: [...next] }); +}); + export type { ContactName }; From 96f0681ab5026ee98f32fa1c6ff549c17a60c378 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:36:13 +0200 Subject: [PATCH 3/4] feat: tell plugins when files are uploaded, downloaded, deleted, renamed, moved or copied, and let them stop it --- components/files/files-app.tsx | 14 +- stores/__tests__/file-store-hooks.test.ts | 387 ++++++++++++++++++++++ stores/file-store.ts | 324 +++++++++++++----- 3 files changed, 641 insertions(+), 84 deletions(-) create mode 100644 stores/__tests__/file-store-hooks.test.ts diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index f1d7ca70f..8133f2d1d 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -357,7 +357,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { if (!confirmed) return; try { - await deleteResource(name); + if (!await deleteResource(name)) return; toast.success(t("delete_success")); } catch (err) { console.error("Failed to delete:", err); @@ -375,7 +375,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { if (!confirmed) return; try { - await deleteResources(names); + if (!await deleteResources(names)) return; toast.success(t("batch_delete_success", { count: names.length })); } catch (err) { console.error("Failed to batch delete:", err); @@ -395,7 +395,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const handleRename = useCallback(async (oldName: string, newName: string) => { try { - await renameResource(oldName, newName); + if (!await renameResource(oldName, newName)) return; toast.success(t("rename_success"), { action: { label: t("undo"), onClick: handleUndo }, }); @@ -451,7 +451,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const handleMoveTo = useCallback(async (names: string[], targetId: string | null) => { try { - await moveToFolderId(names, targetId); + if (!await moveToFolderId(names, targetId)) return; toast.success(t("move_success", { count: names.length }), { action: { label: t("undo"), onClick: handleUndo }, }); @@ -463,7 +463,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const handlePaste = useCallback(async () => { try { - await pasteResources(); + if (!await pasteResources()) return; toast.success(t("paste_success"), { action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined, }); @@ -485,7 +485,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const handleRenameNode = useCallback(async (id: string, newName: string) => { try { - await renameNode(id, newName); + if (!await renameNode(id, newName)) return; toast.success(t("rename_success"), { action: { label: t("undo"), onClick: handleUndo }, }); @@ -504,7 +504,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { }); if (!confirmed) return; try { - await deleteNode(id); + if (!await deleteNode(id)) return; toast.success(t("delete_success")); } catch (err) { console.error("Failed to delete:", err); diff --git a/stores/__tests__/file-store-hooks.test.ts b/stores/__tests__/file-store-hooks.test.ts new file mode 100644 index 000000000..39798efaa --- /dev/null +++ b/stores/__tests__/file-store-hooks.test.ts @@ -0,0 +1,387 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useFileStore } from '../file-store'; +import { fileHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileNode } from '@/lib/jmap/types'; + +const dir = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: 'd', blobId: null, size: 0, created: '', modified: '2026-01-01T00:00:00Z', +}); +const file = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', modified: '2026-01-01T00:00:00Z', +}); + +/** In-memory FileNode backend with spies on every server call. */ +function makeClient(initial: FileNode[]) { + let nodes = initial.map(n => ({ ...n })); + let seq = 0; + const create = (name: string, blobId: string | null, type: string, size: number, parentId: string | null) => { + const node: FileNode = { id: `new${++seq}`, parentId, name, type, blobId, size, created: '', modified: '' }; + nodes.push(node); + return { ...node }; + }; + const client = { + listAllFileNodes: vi.fn(async () => nodes.map(n => ({ ...n }))), + createFileDirectory: vi.fn(async (name: string, parentId: string | null) => create(name, null, 'd', 0, parentId)), + createFileNode: vi.fn(async (name: string, blobId: string, type: string, size: number, parentId: string | null) => + create(name, blobId, type, size, parentId)), + updateFileNode: vi.fn(async (id: string, patch: Partial) => { + const node = nodes.find(n => n.id === id); + if (node) Object.assign(node, patch); + }), + destroyFileNodes: vi.fn(async (ids: string[]) => { + nodes = nodes.filter(n => !ids.includes(n.id)); + return { destroyed: ids, notDestroyed: [] }; + }), + copyFileNode: vi.fn(async (id: string, newName: string, parentId: string | null) => { + const original = nodes.find(n => n.id === id)!; + return create(newName, original.blobId, original.type, original.size, parentId); + }), + uploadBlob: vi.fn(async (f: File) => ({ blobId: `blob-${f.name}`, type: f.type })), + downloadBlob: vi.fn(async () => undefined), + }; + return client as unknown as IJMAPClient & typeof client; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +/** A drive with /Docs (a.txt, b.txt) and /Docs/Target, opened at /Docs. */ +async function openDocs() { + const client = makeClient([ + dir('docs', 'Docs', null), + file('a', 'a.txt', 'docs'), + file('b', 'b.txt', 'docs'), + dir('target', 'Target', 'docs'), + ]); + useFileStore.setState({ + client, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }], + resources: [], allFolders: [], selectedResources: new Set(), clipboard: null, lastAction: null, + uploadAbortController: null, uploadProgress: null, hasListed: false, + }); + await useFileStore.getState().navigate('docs', 'Docs'); + return client; +} + +const names = () => useFileStore.getState().resources.map(r => r.name).sort(); + +describe('file-store plugin hooks: changing files', () => { + beforeEach(() => clearAllHooks()); + + describe('uploads', () => { + it('asks about each file before it goes up and skips the refused ones', async () => { + const client = await openDocs(); + const before = vi.fn(({ name }: { name: string }) => { + expect(client.uploadBlob).not.toHaveBeenCalled(); + return name !== 'no.txt'; + }); + const after = vi.fn(); + fileHooks.onBeforeFileUpload.register('test', before); + fileHooks.onAfterFileUpload.register('test', after); + + const yes = new File(['hello'], 'yes.txt', { type: 'text/plain' }); + const no = new File(['nope'], 'no.txt', { type: 'text/plain' }); + const result = await useFileStore.getState().uploadFiles([yes, no]); + await flush(); + + expect(before).toHaveBeenCalledTimes(2); + expect(before).toHaveBeenCalledWith({ name: 'yes.txt', size: 5, type: 'text/plain', path: '/Docs' }); + expect(result.uploaded).toBe(1); + expect(client.uploadBlob).toHaveBeenCalledTimes(1); + expect(names()).not.toContain('no.txt'); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ name: 'yes.txt', type: 'file', mimeType: 'text/plain', path: '/Docs' }); + }); + + it('uploads nothing when every file is refused', async () => { + const client = await openDocs(); + fileHooks.onBeforeFileUpload.register('test', () => false); + + await useFileStore.getState().uploadFile(new File(['x'], 'x.txt')); + const result = await useFileStore.getState().uploadFiles([new File(['y'], 'y.txt')]); + + expect(result).toEqual({ uploaded: 0, canceled: false }); + expect(client.uploadBlob).not.toHaveBeenCalled(); + expect(client.createFileNode).not.toHaveBeenCalled(); + }); + + it('does not create the folder of a refused file in a folder upload', async () => { + const client = await openDocs(); + const after = vi.fn(); + fileHooks.onBeforeFileUpload.register('test', ({ path }: { path: string }) => path !== '/Docs/secret'); + fileHooks.onAfterFileUpload.register('test', after); + + const inSub = new File(['s'], 'key.pem'); + Object.defineProperty(inSub, 'webkitRelativePath', { value: 'secret/key.pem' }); + const top = new File(['t'], 'readme.md'); + await useFileStore.getState().uploadFolder([inSub, top]); + await flush(); + + expect(client.createFileDirectory).not.toHaveBeenCalled(); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ name: 'readme.md', path: '/Docs' }); + }); + + it('does not report an upload the server refused', async () => { + const client = await openDocs(); + client.createFileNode.mockRejectedValueOnce(new Error('quota')); + const after = vi.fn(); + fileHooks.onAfterFileUpload.register('test', after); + + await expect(useFileStore.getState().uploadFiles([new File(['x'], 'x.txt')])).rejects.toThrow('quota'); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports a cancelled upload, but only while one runs', async () => { + await openDocs(); + const cancelled = vi.fn(); + fileHooks.onFileUploadCancel.register('test', cancelled); + + useFileStore.getState().cancelUpload(); + useFileStore.setState({ + uploadAbortController: new AbortController(), + uploadProgress: { name: 'big.iso', loaded: 1, total: 9, current: 1, totalFiles: 1 }, + }); + useFileStore.getState().cancelUpload(); + await flush(); + + expect(cancelled).toHaveBeenCalledOnce(); + expect(cancelled).toHaveBeenCalledWith({ name: 'big.iso', path: '/Docs' }); + }); + }); + + it('reports a created folder', async () => { + await openDocs(); + const created = vi.fn(); + fileHooks.onDirectoryCreate.register('test', created); + + await useFileStore.getState().createDirectory('New'); + await flush(); + + expect(created).toHaveBeenCalledOnce(); + expect(created.mock.calls[0][0]).toMatchObject({ name: 'New', type: 'directory', path: '/Docs' }); + }); + + describe('delete', () => { + it('asks once for all files and reports them', async () => { + const client = await openDocs(); + const before = vi.fn(() => { + expect(client.destroyFileNodes).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + fileHooks.onBeforeFileDelete.register('test', before); + fileHooks.onAfterFileDelete.register('test', after); + + expect(await useFileStore.getState().deleteResources(['a.txt', 'b.txt'])).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledOnce(); + const asked = (before.mock.calls[0] as unknown as [{ files: { id: string; path: string }[] }])[0]; + expect(asked.files.map(f => f.id)).toEqual(['a', 'b']); + expect(asked.files[0].path).toBe('/Docs'); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0].files.map((f: { id: string }) => f.id)).toEqual(['a', 'b']); + }); + + it('keeps everything when a plugin says no', async () => { + const client = await openDocs(); + const after = vi.fn(); + fileHooks.onBeforeFileDelete.register('test', () => false); + fileHooks.onAfterFileDelete.register('test', after); + + expect(await useFileStore.getState().deleteResource('a.txt')).toBe(false); + expect(await useFileStore.getState().deleteResources(['a.txt', 'b.txt'])).toBe(false); + expect(await useFileStore.getState().deleteNode('target')).toBe(false); + await flush(); + + expect(client.destroyFileNodes).not.toHaveBeenCalled(); + expect(names()).toEqual(['Target', 'a.txt', 'b.txt']); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a delete the server refused', async () => { + const client = await openDocs(); + client.destroyFileNodes.mockRejectedValueOnce(new Error('locked')); + const after = vi.fn(); + fileHooks.onAfterFileDelete.register('test', after); + + await expect(useFileStore.getState().deleteResource('a.txt')).rejects.toThrow(); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports a folder deleted from the tree with its path', async () => { + await openDocs(); + const after = vi.fn(); + fileHooks.onAfterFileDelete.register('test', after); + + await useFileStore.getState().deleteNode('target'); + await flush(); + expect(after.mock.calls[0][0].files[0]).toMatchObject({ id: 'target', name: 'Target', type: 'directory', path: '/Docs' }); + }); + }); + + describe('rename', () => { + it('passes the file and new name first, then the previous name', async () => { + const client = await openDocs(); + const before = vi.fn(() => { + expect(client.updateFileNode).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + fileHooks.onBeforeFileRename.register('test', before); + fileHooks.onFileRename.register('test', after); + + expect(await useFileStore.getState().renameResource('a.txt', 'c.txt')).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledWith({ file: expect.objectContaining({ id: 'a', name: 'a.txt', path: '/Docs' }), newName: 'c.txt' }); + expect(after).toHaveBeenCalledWith({ file: expect.objectContaining({ id: 'a', name: 'c.txt' }), previousName: 'a.txt' }); + }); + + it('keeps the name when a plugin says no', async () => { + const client = await openDocs(); + const after = vi.fn(); + fileHooks.onBeforeFileRename.register('test', () => false); + fileHooks.onFileRename.register('test', after); + + expect(await useFileStore.getState().renameResource('a.txt', 'c.txt')).toBe(false); + expect(await useFileStore.getState().renameNode('target', 'Moved')).toBe(false); + await flush(); + + expect(client.updateFileNode).not.toHaveBeenCalled(); + expect(useFileStore.getState().lastAction).toBeNull(); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a rename the server refused', async () => { + const client = await openDocs(); + client.updateFileNode.mockRejectedValueOnce(new Error('taken')); + const after = vi.fn(); + fileHooks.onFileRename.register('test', after); + + await expect(useFileStore.getState().renameNode('target', 'X')).rejects.toThrow(); + await flush(); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('move and copy', () => { + it('asks once for all moved files with the target path', async () => { + const client = await openDocs(); + const before = vi.fn(() => { + expect(client.updateFileNode).not.toHaveBeenCalled(); + return true; + }); + const after = vi.fn(); + fileHooks.onBeforeFileMove.register('test', before); + fileHooks.onFileMove.register('test', after); + + expect(await useFileStore.getState().moveToFolderId(['a.txt', 'b.txt'], 'target')).toBe(true); + await flush(); + + expect(before).toHaveBeenCalledOnce(); + const asked = (before.mock.calls[0] as unknown as [{ files: { id: string }[]; toPath: string }])[0]; + expect(asked.files.map(f => f.id)).toEqual(['a', 'b']); + expect(asked.toPath).toBe('/Docs/Target'); + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0].toPath).toBe('/Docs/Target'); + expect(after.mock.calls[0][0].files.map((f: { id: string }) => f.id)).toEqual(['a', 'b']); + }); + + it('moves nothing when a plugin says no', async () => { + const client = await openDocs(); + const after = vi.fn(); + fileHooks.onBeforeFileMove.register('test', () => false); + fileHooks.onFileMove.register('test', after); + + expect(await useFileStore.getState().moveToFolderId(['a.txt'], 'target')).toBe(false); + expect(await useFileStore.getState().moveToFolder(['a.txt'], 'Target')).toBe(false); + expect(await useFileStore.getState().moveToParent(['a.txt'])).toBe(false); + useFileStore.getState().cutResources(['b.txt']); + await useFileStore.getState().navigate('target', 'Target'); + expect(await useFileStore.getState().pasteResources()).toBe(false); + await flush(); + + expect(client.updateFileNode).not.toHaveBeenCalled(); + expect(useFileStore.getState().clipboard).not.toBeNull(); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports only the files that moved before a failure', async () => { + const client = await openDocs(); + client.updateFileNode.mockImplementationOnce(async () => undefined).mockRejectedValueOnce(new Error('denied')); + const after = vi.fn(); + fileHooks.onFileMove.register('test', after); + + await expect(useFileStore.getState().moveToParent(['a.txt', 'b.txt'])).rejects.toThrow('denied'); + await flush(); + + expect(after).toHaveBeenCalledOnce(); + expect(after.mock.calls[0][0]).toMatchObject({ toPath: '/', files: [expect.objectContaining({ id: 'a', path: '/' })] }); + }); + + it('reports cut and paste as a move and copy and paste as a copy', async () => { + await openDocs(); + const moved = vi.fn(); + const copied = vi.fn(); + fileHooks.onFileMove.register('test', moved); + fileHooks.onFileCopy.register('test', copied); + + useFileStore.getState().copyResources(['a.txt']); + await useFileStore.getState().navigate('target', 'Target'); + await useFileStore.getState().pasteResources(); + await useFileStore.getState().navigate('docs', 'Docs'); + useFileStore.getState().cutResources(['b.txt']); + await useFileStore.getState().navigate('target', 'Target'); + await useFileStore.getState().pasteResources(); + await flush(); + + expect(copied).toHaveBeenCalledOnce(); + const copy = copied.mock.calls[0][0]; + expect(copy.toPath).toBe('/Docs/Target'); + expect(copy.files[0]).toMatchObject({ name: 'a.txt', path: '/Docs/Target' }); + expect(copy.files[0].id).not.toBe('a'); + expect(moved).toHaveBeenCalledOnce(); + expect(moved.mock.calls[0][0]).toMatchObject({ toPath: '/Docs/Target', files: [expect.objectContaining({ id: 'b', path: '/Docs/Target' })] }); + }); + + it('reports a duplicate with the new copy', async () => { + await openDocs(); + const duplicated = vi.fn(); + fileHooks.onFileDuplicate.register('test', duplicated); + + await useFileStore.getState().duplicateResource('a.txt'); + await flush(); + + expect(duplicated).toHaveBeenCalledOnce(); + expect(duplicated.mock.calls[0][0]).toMatchObject({ name: 'a (copy).txt', path: '/Docs', type: 'file' }); + }); + }); + + describe('download', () => { + it('reports each downloaded file', async () => { + await openDocs(); + const downloaded = vi.fn(); + fileHooks.onFileDownload.register('test', downloaded); + + await useFileStore.getState().downloadResources(['a.txt', 'b.txt']); + await useFileStore.getState().downloadNode('a'); + await flush(); + + expect(downloaded.mock.calls.map(c => [c[0].id, c[0].path])).toEqual([['a', '/Docs'], ['b', '/Docs'], ['a', '/Docs']]); + }); + + it('does not report a failed download', async () => { + const client = await openDocs(); + client.downloadBlob.mockRejectedValueOnce(new Error('gone')); + const downloaded = vi.fn(); + fileHooks.onFileDownload.register('test', downloaded); + + await expect(useFileStore.getState().downloadResource('a.txt')).rejects.toThrow(); + await flush(); + expect(downloaded).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 5a0c56c0e..212b5e02e 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -2,6 +2,9 @@ import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode, FileNodeRights } from '@/lib/jmap/types'; import { imageBlobUrl } from '@/lib/file-preview'; +import { fileHooks } from '@/lib/plugin-hooks'; +import { fileToView } from '@/lib/plugin-projection'; +import type { FileResourceView } from '@/lib/plugin-types'; export interface FileResource { id: string; @@ -38,6 +41,8 @@ interface ClipboardState { serverNames: string[]; sourceParentId: string | null; sourcePath: string; + /** The cut or copied items as plugins see them, taken when they were picked. */ + files: FileResourceView[]; } /** How a batch upload ended: files that made it, and whether the user cancelled. */ @@ -104,22 +109,24 @@ interface FileState { uploadFiles: (files: File[]) => Promise; uploadFolder: (files: File[]) => Promise; cancelUpload: () => void; - deleteResource: (name: string) => Promise; - deleteResources: (names: string[]) => Promise; - renameResource: (oldName: string, newName: string) => Promise; + // Deletes, renames and moves resolve to false when a plugin stopped them + // (onBeforeFileDelete / Rename / Move); nothing changed then. + deleteResource: (name: string) => Promise; + deleteResources: (names: string[]) => Promise; + renameResource: (oldName: string, newName: string) => Promise; downloadResource: (name: string) => Promise; downloadResources: (names: string[]) => Promise; getImageUrl: (name: string) => Promise; getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>; createTextFile: (name: string) => Promise; duplicateResource: (name: string) => Promise; - moveToFolder: (names: string[], targetFolder: string) => Promise; - moveToParent: (names: string[]) => Promise; + moveToFolder: (names: string[], targetFolder: string) => Promise; + moveToParent: (names: string[]) => Promise; /** Move listed items into any folder by id (null = the root), e.g. a tree or breadcrumb drop. */ - moveToFolderId: (names: string[], targetId: string | null) => Promise; + moveToFolderId: (names: string[], targetId: string | null) => Promise; cutResources: (names: string[]) => void; copyResources: (names: string[]) => void; - pasteResources: () => Promise; + pasteResources: () => Promise; selectResource: (name: string | null) => void; toggleSelect: (name: string) => void; selectAll: () => void; @@ -132,9 +139,9 @@ interface FileState { /** Open the folder holding a node and return the node's name, or null when it is gone. */ revealNode: (id: string) => Promise; /** Rename any node by id, e.g. a folder in the tree that is not listed. */ - renameNode: (id: string, newName: string) => Promise; + renameNode: (id: string, newName: string) => Promise; /** Delete any node by id; leaves the folder on screen if it was inside it. */ - deleteNode: (id: string) => Promise; + deleteNode: (id: string) => Promise; /** Download a file by id, wherever it lives. */ downloadNode: (id: string) => Promise; removeRecentFile: (id: string) => void; @@ -280,6 +287,66 @@ function buildPathFromStack(stack: { id: string | null; name: string }[]): strin return '/' + stack.slice(1).map(s => s.name).join('/'); } +function joinPath(base: string, name: string): string { + if (!name) return base; + return base === '/' || base === '' ? `/${name}` : `${base}/${name}`; +} + +/** A listed item as plugins see it; `path` is the folder that holds it. */ +function resourceToView(r: FileResource, path: string): FileResourceView { + return fileToView({ + id: r.id, + parentId: r.parentId, + name: r.name, + type: r.isDirectory ? 'd' : r.contentType, + blobId: r.isDirectory ? null : r.blobId, + size: r.contentLength, + created: r.lastModified, + modified: r.lastModified, + }, path); +} + +/** Path of the folder holding a node, from the folder list ('' when unknown). */ +function parentPath(folders: FileResource[], parentId: string | null | undefined): string { + if (parentId == null) return '/'; + return folderPath(folders, parentId) ?? ''; +} + +/** Views of the items whose ids the server reports as destroyed. */ +function destroyedViews(views: FileResourceView[], result: { destroyed?: string[] } | undefined): FileResourceView[] { + if (!result?.destroyed) return views; + const gone = new Set(result.destroyed); + return views.filter(v => gone.has(v.id)); +} + +/** A node that is not in any listing; plugins get at least its id. */ +function unknownNodeView(id: string): FileResourceView { + return { id, name: '', type: 'file', size: 0, mimeType: '', path: '', modified: '' }; +} + +/** Ask plugins once about moving these items from `fromPath` to `toPath`. */ +async function approveMove(items: FileResource[], fromPath: string, toPath: string): Promise { + if (items.length === 0) return true; + const files = items.map(r => resourceToView(r, fromPath)); + return fileHooks.onBeforeFileMove.intercept({ files, toPath }); +} + +/** + * Move each item with `moveOne`, then tell plugins which ones moved. Items + * moved before a failure are still reported; the error is passed on. + */ +async function reportMoves(items: FileResource[], toPath: string, moveOne: (r: FileResource) => Promise): Promise { + const moved: FileResourceView[] = []; + try { + for (const resource of items) { + await moveOne(resource); + moved.push(resourceToView(resource, toPath)); + } + } finally { + if (moved.length > 0) void fileHooks.onFileMove.emit({ files: moved, toPath }); + } +} + export const useFileStore = create((set, get) => ({ currentParentId: null, currentPath: '/', @@ -644,16 +711,18 @@ export const useFileStore = create((set, get) => ({ }, createDirectory: async (name: string) => { - const { client, currentParentId, refresh } = get(); + const { client, currentParentId, currentPath, refresh } = get(); if (!client) return; - await client.createFileDirectory(name, currentParentId); + const created = await client.createFileDirectory(name, currentParentId); + void fileHooks.onDirectoryCreate.emit(fileToView(created, currentPath)); await refresh(); }, uploadFile: async (file: File) => { - const { client, currentParentId } = get(); + const { client, currentParentId, currentPath } = get(); if (!client) return; + if (!(await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path: currentPath }))) return; const abortController = new AbortController(); set({ uploadAbortController: abortController }); @@ -669,17 +738,27 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) return; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } }); - await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); + const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); + void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath)); } finally { set({ uploadProgress: null, uploadAbortController: null }); } }, - uploadFiles: async (files: File[]) => { - const { client, currentParentId, resources } = get(); + uploadFiles: async (allFiles: File[]) => { + const { client, currentParentId, currentPath, resources } = get(); if (!client) return { uploaded: 0, canceled: false }; let uploaded = 0; + // Plugins may hold back single files; the rest go up. + const files: File[] = []; + for (const file of allFiles) { + if (await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path: currentPath })) { + files.push(file); + } + } + if (files.length === 0) return { uploaded: 0, canceled: false }; + const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; @@ -702,8 +781,9 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); - await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); + const node = await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); uploaded++; + void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath)); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') break; set({ uploadProgress: null, uploadAbortController: null }); @@ -716,18 +796,36 @@ export const useFileStore = create((set, get) => ({ }, cancelUpload: () => { - const { uploadAbortController } = get(); + const { uploadAbortController, uploadProgress, currentPath } = get(); if (uploadAbortController) { uploadAbortController.abort(); set({ uploadProgress: null, uploadAbortController: null }); + void fileHooks.onFileUploadCancel.emit({ name: uploadProgress?.name ?? '', path: currentPath }); } }, - uploadFolder: async (files: File[]) => { - const { client, currentParentId } = get(); - if (!client || files.length === 0) return { uploaded: 0, canceled: false }; + uploadFolder: async (allFiles: File[]) => { + const { client, currentParentId, currentPath } = get(); + if (!client || allFiles.length === 0) return { uploaded: 0, canceled: false }; let uploaded = 0; + const relativeOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; + const dirOf = (relativePath: string) => { + const slash = relativePath.lastIndexOf('/'); + return slash >= 0 ? relativePath.slice(0, slash) : ''; + }; + + // Ask plugins about every file before anything is created, so a folder + // that only held refused files is not created either. + const files: File[] = []; + for (const file of allFiles) { + const path = joinPath(currentPath, dirOf(relativeOf(file))); + if (await fileHooks.onBeforeFileUpload.intercept({ name: file.name, size: file.size, type: file.type, path })) { + files.push(file); + } + } + if (files.length === 0) return { uploaded: 0, canceled: false }; + const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; @@ -758,6 +856,7 @@ export const useFileStore = create((set, get) => ({ try { const created = await client.createFileDirectory(dirName, parentId); dirIds.set(dir, created.id); + void fileHooks.onDirectoryCreate.emit(fileToView(created, joinPath(currentPath, parentPath))); } catch { // Directory may already exist - leave it unmapped; files fall back to // the closest known parent below. @@ -768,9 +867,8 @@ export const useFileStore = create((set, get) => ({ for (let i = 0; i < files.length; i++) { if (abortController.signal.aborted) break; const file = files[i]; - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; - const slash = relativePath.lastIndexOf('/'); - const dirPath = slash >= 0 ? relativePath.slice(0, slash) : ''; + const relativePath = relativeOf(file); + const dirPath = dirOf(relativePath); const parentId = dirIds.get(dirPath) ?? currentParentId; set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } }); @@ -785,8 +883,11 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); - await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId); + const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId); uploaded++; + // A file whose folder could not be created lands in the closest known one. + const landedIn = dirIds.has(dirPath) ? joinPath(currentPath, dirPath) : currentPath; + void fileHooks.onAfterFileUpload.emit(fileToView(node, landedIn)); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') break; set({ uploadProgress: null, uploadAbortController: null }); @@ -799,50 +900,68 @@ export const useFileStore = create((set, get) => ({ }, deleteResource: async (name: string) => { - const { client, resources, recentFiles, refresh } = get(); - if (!client) return; + const { client, resources, recentFiles, currentPath, refresh } = get(); + if (!client) return false; const resource = resources.find(r => r.name === name); - if (!resource) return; + if (!resource) return false; + + const files = [resourceToView(resource, currentPath)]; + if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false; // The server removes descendant nodes (onDestroyRemoveChildren). - await client.destroyFileNodes([resource.id]); + const result = await client.destroyFileNodes([resource.id]); + const deleted = destroyedViews(files, result); + if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted }); const nextRecentFiles = recentFiles.filter(r => r.id !== resource.id); set({ recentFiles: nextRecentFiles }); try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ } await refresh(); + return true; }, deleteResources: async (names: string[]) => { - const { client, resources, recentFiles, refresh } = get(); - if (!client) return; + const { client, resources, recentFiles, currentPath, refresh } = get(); + if (!client) return false; const idsToDelete: string[] = []; + const files: FileResourceView[] = []; for (const name of names) { const resource = resources.find(r => r.name === name); - if (resource) idsToDelete.push(resource.id); + if (resource) { + idsToDelete.push(resource.id); + files.push(resourceToView(resource, currentPath)); + } } - if (idsToDelete.length === 0) return; + if (idsToDelete.length === 0) return false; + if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false; // The server removes descendant nodes (onDestroyRemoveChildren). - await client.destroyFileNodes(idsToDelete); + const result = await client.destroyFileNodes(idsToDelete); + const deleted = destroyedViews(files, result); + if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted }); const deletedIdSet = new Set(idsToDelete); const nextRecentFiles = recentFiles.filter(r => !deletedIdSet.has(r.id)); set({ selectedResources: new Set() }); set({ recentFiles: nextRecentFiles }); try { localStorage.setItem('files-recent-files', JSON.stringify(nextRecentFiles)); } catch { /* ignore */ } await refresh(); + return true; }, renameResource: async (oldName: string, newName: string) => { - const { client, resources, refresh } = get(); - if (!client) return; + const { client, resources, currentPath, refresh } = get(); + if (!client) return false; const resource = resources.find(r => r.name === oldName); - if (!resource) return; + if (!resource) return false; + + const file = resourceToView(resource, currentPath); + if (!(await fileHooks.onBeforeFileRename.intercept({ file, newName }))) return false; await client.updateFileNode(resource.id, { name: newName }); + void fileHooks.onFileRename.emit({ file: { ...file, name: newName }, previousName: oldName }); set({ lastAction: { @@ -852,16 +971,18 @@ export const useFileStore = create((set, get) => ({ }, }); await refresh(); + return true; }, downloadResource: async (name: string) => { - const { client, resources } = get(); + const { client, resources, currentPath } = get(); if (!client) return; const resource = resources.find(r => r.name === name); if (!resource?.blobId) return; await client.downloadBlob(resource.blobId, resource.name, resource.contentType); + void fileHooks.onFileDownload.emit(resourceToView(resource, currentPath)); }, downloadResources: async (names: string[]) => { @@ -908,7 +1029,7 @@ export const useFileStore = create((set, get) => ({ }, duplicateResource: async (name: string) => { - const { client, resources, currentParentId, refresh } = get(); + const { client, resources, currentParentId, currentPath, refresh } = get(); if (!client) return; const resource = resources.find(r => r.name === name); @@ -919,56 +1040,67 @@ export const useFileStore = create((set, get) => ({ ? `${name.substring(0, dotIdx)} (copy)${name.substring(dotIdx)}` : `${name} (copy)`; - await client.copyFileNode(resource.id, copyName, currentParentId); + const copy = await client.copyFileNode(resource.id, copyName, currentParentId); + void fileHooks.onFileDuplicate.emit(fileToView(copy, currentPath)); await refresh(); }, moveToFolder: async (names: string[], targetFolder: string) => { - const { client, resources, refresh } = get(); - if (!client) return; + const { client, resources, currentPath, refresh } = get(); + if (!client) return false; const targetResource = resources.find(r => r.name === targetFolder && r.isDirectory); - if (!targetResource) return; + if (!targetResource) return false; + + const toMove = names + .map(name => resources.find(r => r.name === name)) + .filter((r): r is FileResource => !!r && r.id !== targetResource.id); + const toPath = joinPath(currentPath, targetFolder); + if (!(await approveMove(toMove, currentPath, toPath))) return false; const entries: UndoAction['entries'] = []; - for (const name of names) { - const resource = resources.find(r => r.name === name); - if (!resource || resource.id === targetResource.id) continue; + await reportMoves(toMove, toPath, async (resource) => { await client.updateFileNode(resource.id, { parentId: targetResource.id }); entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: targetResource.id } }); - } + }); set({ selectedResources: new Set(), lastAction: { type: 'move', entries, sourceParentId: null }, }); await refresh(); + return true; }, moveToParent: async (names: string[]) => { - const { client, resources, pathStack, refresh } = get(); - if (!client || pathStack.length <= 1) return; + const { client, resources, pathStack, currentPath, refresh } = get(); + if (!client || pathStack.length <= 1) return false; // Move into the grandparent of the current folder's contents, i.e. the // entry one level up in the breadcrumb stack. const newParentId = pathStack[pathStack.length - 2].id; + const toMove = names + .map(name => resources.find(r => r.name === name)) + .filter((r): r is FileResource => !!r); + const toPath = buildPathFromStack(pathStack.slice(0, -1)); + if (!(await approveMove(toMove, currentPath, toPath))) return false; + const entries: UndoAction['entries'] = []; - for (const name of names) { - const resource = resources.find(r => r.name === name); - if (!resource) continue; + await reportMoves(toMove, toPath, async (resource) => { await client.updateFileNode(resource.id, { parentId: newParentId }); entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: newParentId } }); - } + }); set({ selectedResources: new Set(), lastAction: { type: 'move', entries, sourceParentId: null }, }); await refresh(); + return true; }, moveToFolderId: async (names: string[], targetId: string | null) => { - const { client, resources, currentParentId, allFolders, refresh } = get(); - if (!client || targetId === currentParentId) return; + const { client, resources, currentParentId, currentPath, allFolders, refresh } = get(); + if (!client || targetId === currentParentId) return false; // A folder must not move into itself or anything below it. const parentOf = new Map(allFolders.map(f => [f.id, f.parentId ?? null])); @@ -979,50 +1111,74 @@ export const useFileStore = create((set, get) => ({ return false; }; + const toMove = names + .map(name => resources.find(r => r.name === name)) + .filter((r): r is FileResource => !!r && !(r.isDirectory && isInside(targetId, r.id))); + const toPath = parentPath(allFolders, targetId); + if (!(await approveMove(toMove, currentPath, toPath))) return false; + const entries: UndoAction['entries'] = []; - for (const name of names) { - const resource = resources.find(r => r.name === name); - if (!resource) continue; - if (resource.isDirectory && isInside(targetId, resource.id)) continue; + await reportMoves(toMove, toPath, async (resource) => { await client.updateFileNode(resource.id, { parentId: targetId }); entries.push({ id: resource.id, from: { parentId: resource.parentId }, to: { parentId: targetId } }); - } + }); set({ selectedResources: new Set(), lastAction: entries.length > 0 ? { type: 'move', entries, sourceParentId: null } : get().lastAction, }); await refresh(); + return true; }, cutResources: (names: string[]) => { const { currentPath, currentParentId, resources } = get(); const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[]; const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[]; - set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } }); + const files = resources.filter(r => names.includes(r.name)).map(r => resourceToView(r, currentPath)); + set({ clipboard: { mode: 'cut', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath, files } }); }, copyResources: (names: string[]) => { const { currentPath, currentParentId, resources } = get(); const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[]; const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[]; - set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } }); + const files = resources.filter(r => names.includes(r.name)).map(r => resourceToView(r, currentPath)); + set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath, files } }); }, pasteResources: async () => { - const { client, currentParentId, clipboard, refresh } = get(); - if (!client || !clipboard) return; + const { client, currentParentId, currentPath, clipboard, refresh } = get(); + if (!client || !clipboard) return false; + + const viewOf = (id: string) => clipboard.files?.find(f => f.id === id); + if (clipboard.mode === 'cut') { + const files = clipboard.ids.map(viewOf).filter((f): f is FileResourceView => !!f); + if (!(await fileHooks.onBeforeFileMove.intercept({ files, toPath: currentPath }))) return false; + } const entries: UndoAction['entries'] = []; + const done: FileResourceView[] = []; - for (let i = 0; i < clipboard.ids.length; i++) { - const id = clipboard.ids[i]; - const displayName = clipboard.names[i]; + try { + for (let i = 0; i < clipboard.ids.length; i++) { + const id = clipboard.ids[i]; + const displayName = clipboard.names[i]; - if (clipboard.mode === 'cut') { - await client.updateFileNode(id, { parentId: currentParentId }); - entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } }); - } else { - await client.copyFileNode(id, displayName, currentParentId); + if (clipboard.mode === 'cut') { + await client.updateFileNode(id, { parentId: currentParentId }); + entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } }); + const view = viewOf(id); + if (view) done.push({ ...view, path: currentPath }); + } else { + const copy = await client.copyFileNode(id, displayName, currentParentId); + done.push(fileToView(copy, currentPath)); + } + } + } finally { + // Report what really happened, also when a later item failed. + if (done.length > 0) { + if (clipboard.mode === 'cut') void fileHooks.onFileMove.emit({ files: done, toPath: currentPath }); + else void fileHooks.onFileCopy.emit({ files: done, toPath: currentPath }); } } @@ -1033,6 +1189,7 @@ export const useFileStore = create((set, get) => ({ }); } await refresh(); + return true; }, selectResource: (name: string | null) => { @@ -1137,11 +1294,15 @@ export const useFileStore = create((set, get) => ({ }, renameNode: async (id: string, newName: string) => { - const { client, allFolders, favorites, refresh } = get(); - if (!client) return; + const { client, allFolders, resources, favorites, refresh } = get(); + if (!client) return false; const node = allFolders.find(f => f.id === id); const oldPath = folderPath(allFolders, id); + const known = node ?? resources.find(r => r.id === id); + const file = known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id); + if (!(await fileHooks.onBeforeFileRename.intercept({ file, newName }))) return false; await client.updateFileNode(id, { name: newName }); + void fileHooks.onFileRename.emit({ file: { ...file, name: newName }, previousName: file.name }); // Starred folders are kept by path: follow the rename. if (oldPath) { const newPath = oldPath.slice(0, oldPath.lastIndexOf('/') + 1) + newName; @@ -1163,13 +1324,19 @@ export const useFileStore = create((set, get) => ({ set({ currentPath: buildPathFromStack(get().pathStack) }); } await refresh(); + return true; }, deleteNode: async (id: string) => { - const { client, allFolders, favorites, recentFiles, pathStack, navigate, refresh } = get(); - if (!client) return; + const { client, allFolders, resources, favorites, recentFiles, pathStack, navigate, refresh } = get(); + if (!client) return false; const path = folderPath(allFolders, id); - await client.destroyFileNodes([id]); + const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id); + const files = [known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id)]; + if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false; + const result = await client.destroyFileNodes([id]); + const deleted = destroyedViews(files, result); + if (deleted.length > 0) void fileHooks.onAfterFileDelete.emit({ files: deleted }); const nextFavorites = path ? favorites.filter(f => !isUnder(f, path)) : favorites; // Recent files only know their own id; drop the one deleted, the rest are @@ -1187,9 +1354,10 @@ export const useFileStore = create((set, get) => ({ const parent = pathStack[idx - 1]; set({ pathStack: pathStack.slice(0, idx) }); await navigate(parent.id, parent.name); - return; + return true; } await refresh(); + return true; }, downloadNode: async (id: string) => { @@ -1201,6 +1369,8 @@ export const useFileStore = create((set, get) => ({ const node = nodes.find(n => n.id === id); if (!node?.blobId) throw new Error('File not found'); await client.downloadBlob(node.blobId, node.name, node.type); + const folders = nodes.filter(isFolder).map(nodeToResource); + void fileHooks.onFileDownload.emit(fileToView(node, parentPath(folders, node.parentId))); }, removeRecentFile: (id: string) => { From 6b98fccead19fadbe0708b649412ce1a7e23fa90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:39:30 +0200 Subject: [PATCH 4/4] feat: tell plugins when a folder is opened, files are selected or starred, and an action is undone --- stores/__tests__/file-store-hooks.test.ts | 96 +++++++++++++++++++++++ stores/file-store.ts | 48 ++++++++++-- 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/stores/__tests__/file-store-hooks.test.ts b/stores/__tests__/file-store-hooks.test.ts index 39798efaa..aa31ec20d 100644 --- a/stores/__tests__/file-store-hooks.test.ts +++ b/stores/__tests__/file-store-hooks.test.ts @@ -385,3 +385,99 @@ describe('file-store plugin hooks: changing files', () => { }); }); }); + +describe('file-store plugin hooks: navigation, selection, favourites, undo', () => { + beforeEach(() => clearAllHooks()); + + it('reports each newly opened folder, but not a refresh', async () => { + const navigated = vi.fn(); + fileHooks.onFileNavigate.register('test', navigated); + + await openDocs(); + await useFileStore.getState().refresh(); + await useFileStore.getState().navigate('target', 'Target'); + await useFileStore.getState().navigateUp(); + await useFileStore.getState().navigateByPath('/'); + await flush(); + + expect(navigated.mock.calls.map(c => c[0])).toEqual([ + { path: '/Docs' }, + { path: '/Docs/Target' }, + { path: '/Docs' }, + { path: '/' }, + ]); + }); + + it('does not report a folder that failed to load', async () => { + const client = await openDocs(); + client.listAllFileNodes.mockRejectedValueOnce(new Error('offline')); + const navigated = vi.fn(); + fileHooks.onFileNavigate.register('test', navigated); + + await useFileStore.getState().navigate('target', 'Target'); + await flush(); + expect(navigated).not.toHaveBeenCalled(); + }); + + it('reports selection changes with the selected files, and only real ones', async () => { + await openDocs(); + const selection = vi.fn(); + fileHooks.onFileSelectionChange.register('test', selection); + const store = useFileStore.getState(); + + store.selectResource('a.txt'); + store.selectResource('a.txt'); // same: no change + store.toggleSelect('b.txt'); + store.setSelection(new Set(['a.txt', 'b.txt'])); // same: no change + store.clearSelection(); + store.selectAll(); + await flush(); + + const calls = selection.mock.calls.map(c => c[0].files.map((f: { id: string }) => f.id).sort()); + expect(calls).toEqual([['a'], ['a', 'b'], [], ['a', 'b', 'target']]); + expect(selection.mock.calls[0][0].files[0]).toMatchObject({ name: 'a.txt', path: '/Docs' }); + }); + + it('reports starring and unstarring a folder', async () => { + await openDocs(); + const toggled = vi.fn(); + fileHooks.onFileFavoriteToggle.register('test', toggled); + useFileStore.setState({ favorites: [] }); + + useFileStore.getState().toggleFavorite('/Docs/Target'); + useFileStore.getState().toggleFavorite('/Docs/Target'); + await flush(); + + expect(toggled.mock.calls.map(c => c[0])).toEqual([ + { file: expect.objectContaining({ id: 'target', name: 'Target', type: 'directory', path: '/Docs' }), favorite: true }, + { file: expect.objectContaining({ id: 'target' }), favorite: false }, + ]); + }); + + it('reports an undone rename', async () => { + await openDocs(); + const undone = vi.fn(); + fileHooks.onFileUndo.register('test', undone); + + await useFileStore.getState().renameResource('a.txt', 'c.txt'); + await useFileStore.getState().undoLastAction(); + await flush(); + + expect(undone).toHaveBeenCalledOnce(); + expect(undone).toHaveBeenCalledWith({ + action: { type: 'rename', entries: [{ id: 'a', from: { name: 'a.txt' }, to: { name: 'c.txt' } }] }, + }); + }); + + it('does not report an undo the server refused', async () => { + const client = await openDocs(); + await useFileStore.getState().renameResource('a.txt', 'c.txt'); + client.updateFileNode.mockRejectedValueOnce(new Error('nope')); + const undone = vi.fn(); + fileHooks.onFileUndo.register('test', undone); + + await expect(useFileStore.getState().undoLastAction()).rejects.toThrow(); + await flush(); + expect(undone).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 212b5e02e..952565f92 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -319,6 +319,21 @@ function destroyedViews(views: FileResourceView[], result: { destroyed?: string[ return views.filter(v => gone.has(v.id)); } +/** A starred folder as plugins see it; favourites are kept by path. */ +function favoriteFolderView(folders: FileResource[], path: string): FileResourceView { + const slash = path.lastIndexOf('/'); + const parent = slash > 0 ? path.slice(0, slash) : '/'; + const folder = folders.find(f => folderPath(folders, f.id) === path); + if (folder) return resourceToView(folder, parent); + return { id: '', name: path.slice(slash + 1), type: 'directory', size: 0, mimeType: '', path: parent, modified: '' }; +} + +function sameNameSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const n of a) if (!b.has(n)) return false; + return true; +} + /** A node that is not in any listing; plugins get at least its id. */ function unknownNodeView(id: string): FileResourceView { return { id, name: '', type: 'file', size: 0, mimeType: '', path: '', modified: '' }; @@ -607,8 +622,10 @@ export const useFileStore = create((set, get) => ({ }, navigate: async (parentId: string | null, name?: string) => { - const { client, pathStack } = get(); + const { client, pathStack, currentParentId: previousParentId, hasListed } = get(); if (!client) return; + // A refresh lists the same folder again; plugins only hear about a new one. + const opensFolder = !hasListed || parentId !== previousParentId; set({ isLoading: true, error: null, currentParentId: parentId, selectedResources: new Set() }); @@ -654,6 +671,7 @@ export const useFileStore = create((set, get) => ({ } else { set({ resources, allFolders, isLoading: false, hasListed: true }); } + if (opensFolder) void fileHooks.onFileNavigate.emit({ path: newPath }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to list directory', @@ -1246,12 +1264,14 @@ export const useFileStore = create((set, get) => ({ }, toggleFavorite: (path: string) => { - const { favorites } = get(); - const next = favorites.includes(path) - ? favorites.filter(f => f !== path) - : [...favorites, path]; + const { favorites, allFolders } = get(); + const favorite = !favorites.includes(path); + const next = favorite ? [...favorites, path] : favorites.filter(f => f !== path); set({ favorites: next }); try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ } + if (fileHooks.onFileFavoriteToggle.size > 0) { + void fileHooks.onFileFavoriteToggle.emit({ file: favoriteFolderView(allFolders, path), favorite }); + } }, addRecentFile: (name: string, id: string) => { @@ -1387,6 +1407,12 @@ export const useFileStore = create((set, get) => ({ await client.updateFileNode(entry.id, entry.from); } set({ lastAction: null }); + void fileHooks.onFileUndo.emit({ + action: { + type: lastAction.type, + entries: lastAction.entries.map(e => ({ id: e.id, from: { ...e.from }, to: { ...e.to } })), + }, + }); await refresh(); }, @@ -1424,3 +1450,15 @@ export const useFileStore = create((set, get) => ({ } }, })); + +// Tell plugins when the selected files change, whichever action changed it +// (click, toggle, select all, clear, navigating away, after a delete or move). +// Selecting the same files again is not a change. +useFileStore.subscribe((state, prev) => { + if (state.selectedResources === prev.selectedResources) return; + if (fileHooks.onFileSelectionChange.size === 0) return; + const next = state.selectedResources ?? new Set(); + if (sameNameSet(next, prev.selectedResources ?? new Set())) return; + const files = state.resources.filter(r => next.has(r.name)).map(r => resourceToView(r, state.currentPath)); + void fileHooks.onFileSelectionChange.emit({ files }); +});