Merge branch 'hooks/contacts' into feat/plugin-system-audit

This commit is contained in:
Linus Rath
2026-10-14 03:40:05 +02:00
10 changed files with 1366 additions and 176 deletions
@@ -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(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
// Same contact with changed data: still the same open contact.
rerender(<ContactDetail contact={{ ...contact, notes: {} }} onEdit={vi.fn()} onDelete={vi.fn()} />);
rerender(<ContactDetail contact={{ ...contact, id: '2' }} onEdit={vi.fn()} onDelete={vi.fn()} />);
rerender(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
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' });
});
});
+12
View File
@@ -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 (
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
+23 -31
View File
@@ -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<ContactCard> = 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<ContactCard>) => {
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<ContactCard>) => {
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]);
+3 -3
View File
@@ -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'); });
}
}}
/>
+7 -7
View File
@@ -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);
@@ -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> = {}): 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<string, unknown> = {}) => ({
createContact: vi.fn(async (c: Partial<ContactCard>) => ({ ...c, id: 'srv-1' } as ContactCard)),
updateContact: vi.fn(async () => undefined),
deleteContact: vi.fn(async () => undefined),
...overrides,
}) as unknown as IJMAPClient & Record<string, ReturnType<typeof vi.fn>>;
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<string>(), 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<string>(), lastSelectedContactId: null,
});
});
it('reports created, renamed and deleted groups', async () => {
const client = makeClient({
createContact: vi.fn(async (c: Partial<ContactCard>) => ({ ...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<ContactCard>) => ({ ...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: [] },
]);
});
});
+27 -27
View File
@@ -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;
+483
View File
@@ -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<FileNode>) => {
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();
});
});
+105 -26
View File
@@ -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<ContactCard>) {
return contactToView({ addressBookIds: {}, ...contact, id: contact.id ?? '' } as ContactCard);
}
function sameIdSet(a: Set<string>, b: Set<string>): 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<void>;
fetchAllAccountsContacts: (accounts: ContactAccountClient[]) => Promise<void>;
fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[]) => Promise<void>;
createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<void>;
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
// The contact actions resolve to false when a plugin stopped them
// (onBeforeContactCreate / Update / Delete); nothing changed then.
createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<boolean>;
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<boolean>;
deleteContact: (client: IJMAPClient, id: string) => Promise<boolean>;
addLocalContact: (contact: ContactCard) => void;
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
deleteLocalContact: (id: string) => void;
addLocalContact: (contact: ContactCard) => Promise<boolean>;
updateLocalContact: (id: string, updates: Partial<ContactCard>) => Promise<boolean>;
deleteLocalContact: (id: string) => Promise<boolean>;
setSelectedContact: (id: string | null) => void;
setSearchQuery: (query: string) => void;
@@ -537,6 +552,7 @@ export const useContactStore = create<ContactStore>()(
},
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<ContactStore>()(
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<ContactStore>()(
},
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<ContactStore>()(
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<ContactStore>()(
},
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<ContactStore>()(
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<ContactStore>()(
}
},
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<ContactStore>()(
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<ContactStore>()(
...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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
clearSelection: () => set({ selectedContactIds: new Set<string>(), 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<ContactStore>()(
selectedContactIds: new Set<string>(),
};
});
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<ContactStore>()(
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<ContactStore>()(
};
}),
}));
void contactHooks.onContactMove.emit({ contactId: id, toAddressBookId: addressBook.id });
}
}
},
@@ -1309,26 +1373,30 @@ export const useContactStore = create<ContactStore>()(
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<ContactStore>()(
)
);
// 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<string>();
if (sameIdSet(next, prev.selectedContactIds ?? new Set<string>())) return;
void contactHooks.onContactSelectionChange.emit({ contactIds: [...next] });
});
export type { ContactName };
+290 -82
View File
@@ -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<UploadResult>;
uploadFolder: (files: File[]) => Promise<UploadResult>;
cancelUpload: () => void;
deleteResource: (name: string) => Promise<void>;
deleteResources: (names: string[]) => Promise<void>;
renameResource: (oldName: string, newName: string) => Promise<void>;
// Deletes, renames and moves resolve to false when a plugin stopped them
// (onBeforeFileDelete / Rename / Move); nothing changed then.
deleteResource: (name: string) => Promise<boolean>;
deleteResources: (names: string[]) => Promise<boolean>;
renameResource: (oldName: string, newName: string) => Promise<boolean>;
downloadResource: (name: string) => Promise<void>;
downloadResources: (names: string[]) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
createTextFile: (name: string) => Promise<void>;
duplicateResource: (name: string) => Promise<void>;
moveToFolder: (names: string[], targetFolder: string) => Promise<void>;
moveToParent: (names: string[]) => Promise<void>;
moveToFolder: (names: string[], targetFolder: string) => Promise<boolean>;
moveToParent: (names: string[]) => Promise<boolean>;
/** 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<void>;
moveToFolderId: (names: string[], targetId: string | null) => Promise<boolean>;
cutResources: (names: string[]) => void;
copyResources: (names: string[]) => void;
pasteResources: () => Promise<void>;
pasteResources: () => Promise<boolean>;
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<string | null>;
/** Rename any node by id, e.g. a folder in the tree that is not listed. */
renameNode: (id: string, newName: string) => Promise<void>;
renameNode: (id: string, newName: string) => Promise<boolean>;
/** Delete any node by id; leaves the folder on screen if it was inside it. */
deleteNode: (id: string) => Promise<void>;
deleteNode: (id: string) => Promise<boolean>;
/** Download a file by id, wherever it lives. */
downloadNode: (id: string) => Promise<void>;
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<string>, b: Set<string>): 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<boolean> {
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<void>): Promise<void> {
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<FileState>((set, get) => ({
currentParentId: null,
currentPath: '/',
@@ -540,8 +622,10 @@ export const useFileStore = create<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((set, get) => ({
});
}
await refresh();
return true;
},
selectResource: (name: string | null) => {
@@ -1089,12 +1264,14 @@ export const useFileStore = create<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<string>();
if (sameNameSet(next, prev.selectedResources ?? new Set<string>())) return;
const files = state.resources.filter(r => next.has(r.name)).map(r => resourceToView(r, state.currentPath));
void fileHooks.onFileSelectionChange.emit({ files });
});