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/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/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__/contact-store-hooks.test.ts b/stores/__tests__/contact-store-hooks.test.ts
new file mode 100644
index 000000000..b4ec555b5
--- /dev/null
+++ b/stores/__tests__/contact-store-hooks.test.ts
@@ -0,0 +1,395 @@
+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);
+ });
+ });
+});
+
+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/__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/__tests__/file-store-hooks.test.ts b/stores/__tests__/file-store-hooks.test.ts
new file mode 100644
index 000000000..aa31ec20d
--- /dev/null
+++ b/stores/__tests__/file-store-hooks.test.ts
@@ -0,0 +1,483 @@
+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();
+ });
+ });
+});
+
+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/contact-store.ts b/stores/contact-store.ts
index cef716d91..1e6aeecaa 100644
--- a/stores/contact-store.ts
+++ b/stores/contact-store.ts
@@ -6,6 +6,19 @@ 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);
+}
+
+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 } };
@@ -343,13 +356,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 +552,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 +588,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 +598,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 +621,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 +632,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 +648,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 +657,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 }),
@@ -798,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()}`,
@@ -808,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) => {
@@ -826,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) => {
@@ -834,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;
});
@@ -851,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) => {
@@ -859,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)) {
@@ -876,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;
}
}
@@ -893,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) => {
@@ -906,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) => {
@@ -939,6 +996,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 +1027,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) => {
@@ -1000,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;
@@ -1026,6 +1089,7 @@ export const useContactStore = create()(
};
}),
}));
+ void contactHooks.onContactMove.emit({ contactId: id, toAddressBookId: addressBook.id });
}
}
},
@@ -1309,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;
},
});
@@ -1343,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 };
diff --git a/stores/file-store.ts b/stores/file-store.ts
index 5a0c56c0e..952565f92 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,81 @@ 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 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: '' };
+}
+
+/** 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: '/',
@@ -540,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() });
@@ -587,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',
@@ -644,16 +729,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 +756,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 +799,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 +814,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 +874,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 +885,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 +901,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 +918,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 +989,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 +1047,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 +1058,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 +1129,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 +1207,7 @@ export const useFileStore = create((set, get) => ({
});
}
await refresh();
+ return true;
},
selectResource: (name: string | null) => {
@@ -1089,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) => {
@@ -1137,11 +1314,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 +1344,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 +1374,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 +1389,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) => {
@@ -1217,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();
},
@@ -1254,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 });
+});