mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Merge branch 'hooks/contacts' into feat/plugin-system-audit
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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'); });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user