diff --git a/stores/__tests__/file-store-hooks.test.ts b/stores/__tests__/file-store-hooks.test.ts index 39798efaa..aa31ec20d 100644 --- a/stores/__tests__/file-store-hooks.test.ts +++ b/stores/__tests__/file-store-hooks.test.ts @@ -385,3 +385,99 @@ describe('file-store plugin hooks: changing files', () => { }); }); }); + +describe('file-store plugin hooks: navigation, selection, favourites, undo', () => { + beforeEach(() => clearAllHooks()); + + it('reports each newly opened folder, but not a refresh', async () => { + const navigated = vi.fn(); + fileHooks.onFileNavigate.register('test', navigated); + + await openDocs(); + await useFileStore.getState().refresh(); + await useFileStore.getState().navigate('target', 'Target'); + await useFileStore.getState().navigateUp(); + await useFileStore.getState().navigateByPath('/'); + await flush(); + + expect(navigated.mock.calls.map(c => c[0])).toEqual([ + { path: '/Docs' }, + { path: '/Docs/Target' }, + { path: '/Docs' }, + { path: '/' }, + ]); + }); + + it('does not report a folder that failed to load', async () => { + const client = await openDocs(); + client.listAllFileNodes.mockRejectedValueOnce(new Error('offline')); + const navigated = vi.fn(); + fileHooks.onFileNavigate.register('test', navigated); + + await useFileStore.getState().navigate('target', 'Target'); + await flush(); + expect(navigated).not.toHaveBeenCalled(); + }); + + it('reports selection changes with the selected files, and only real ones', async () => { + await openDocs(); + const selection = vi.fn(); + fileHooks.onFileSelectionChange.register('test', selection); + const store = useFileStore.getState(); + + store.selectResource('a.txt'); + store.selectResource('a.txt'); // same: no change + store.toggleSelect('b.txt'); + store.setSelection(new Set(['a.txt', 'b.txt'])); // same: no change + store.clearSelection(); + store.selectAll(); + await flush(); + + const calls = selection.mock.calls.map(c => c[0].files.map((f: { id: string }) => f.id).sort()); + expect(calls).toEqual([['a'], ['a', 'b'], [], ['a', 'b', 'target']]); + expect(selection.mock.calls[0][0].files[0]).toMatchObject({ name: 'a.txt', path: '/Docs' }); + }); + + it('reports starring and unstarring a folder', async () => { + await openDocs(); + const toggled = vi.fn(); + fileHooks.onFileFavoriteToggle.register('test', toggled); + useFileStore.setState({ favorites: [] }); + + useFileStore.getState().toggleFavorite('/Docs/Target'); + useFileStore.getState().toggleFavorite('/Docs/Target'); + await flush(); + + expect(toggled.mock.calls.map(c => c[0])).toEqual([ + { file: expect.objectContaining({ id: 'target', name: 'Target', type: 'directory', path: '/Docs' }), favorite: true }, + { file: expect.objectContaining({ id: 'target' }), favorite: false }, + ]); + }); + + it('reports an undone rename', async () => { + await openDocs(); + const undone = vi.fn(); + fileHooks.onFileUndo.register('test', undone); + + await useFileStore.getState().renameResource('a.txt', 'c.txt'); + await useFileStore.getState().undoLastAction(); + await flush(); + + expect(undone).toHaveBeenCalledOnce(); + expect(undone).toHaveBeenCalledWith({ + action: { type: 'rename', entries: [{ id: 'a', from: { name: 'a.txt' }, to: { name: 'c.txt' } }] }, + }); + }); + + it('does not report an undo the server refused', async () => { + const client = await openDocs(); + await useFileStore.getState().renameResource('a.txt', 'c.txt'); + client.updateFileNode.mockRejectedValueOnce(new Error('nope')); + const undone = vi.fn(); + fileHooks.onFileUndo.register('test', undone); + + await expect(useFileStore.getState().undoLastAction()).rejects.toThrow(); + await flush(); + expect(undone).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 212b5e02e..952565f92 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -319,6 +319,21 @@ function destroyedViews(views: FileResourceView[], result: { destroyed?: string[ return views.filter(v => gone.has(v.id)); } +/** A starred folder as plugins see it; favourites are kept by path. */ +function favoriteFolderView(folders: FileResource[], path: string): FileResourceView { + const slash = path.lastIndexOf('/'); + const parent = slash > 0 ? path.slice(0, slash) : '/'; + const folder = folders.find(f => folderPath(folders, f.id) === path); + if (folder) return resourceToView(folder, parent); + return { id: '', name: path.slice(slash + 1), type: 'directory', size: 0, mimeType: '', path: parent, modified: '' }; +} + +function sameNameSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const n of a) if (!b.has(n)) return false; + return true; +} + /** A node that is not in any listing; plugins get at least its id. */ function unknownNodeView(id: string): FileResourceView { return { id, name: '', type: 'file', size: 0, mimeType: '', path: '', modified: '' }; @@ -607,8 +622,10 @@ export const useFileStore = create((set, get) => ({ }, navigate: async (parentId: string | null, name?: string) => { - const { client, pathStack } = get(); + const { client, pathStack, currentParentId: previousParentId, hasListed } = get(); if (!client) return; + // A refresh lists the same folder again; plugins only hear about a new one. + const opensFolder = !hasListed || parentId !== previousParentId; set({ isLoading: true, error: null, currentParentId: parentId, selectedResources: new Set() }); @@ -654,6 +671,7 @@ export const useFileStore = create((set, get) => ({ } else { set({ resources, allFolders, isLoading: false, hasListed: true }); } + if (opensFolder) void fileHooks.onFileNavigate.emit({ path: newPath }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to list directory', @@ -1246,12 +1264,14 @@ export const useFileStore = create((set, get) => ({ }, toggleFavorite: (path: string) => { - const { favorites } = get(); - const next = favorites.includes(path) - ? favorites.filter(f => f !== path) - : [...favorites, path]; + const { favorites, allFolders } = get(); + const favorite = !favorites.includes(path); + const next = favorite ? [...favorites, path] : favorites.filter(f => f !== path); set({ favorites: next }); try { localStorage.setItem('files-favorites', JSON.stringify(next)); } catch { /* ignore */ } + if (fileHooks.onFileFavoriteToggle.size > 0) { + void fileHooks.onFileFavoriteToggle.emit({ file: favoriteFolderView(allFolders, path), favorite }); + } }, addRecentFile: (name: string, id: string) => { @@ -1387,6 +1407,12 @@ export const useFileStore = create((set, get) => ({ await client.updateFileNode(entry.id, entry.from); } set({ lastAction: null }); + void fileHooks.onFileUndo.emit({ + action: { + type: lastAction.type, + entries: lastAction.entries.map(e => ({ id: e.id, from: { ...e.from }, to: { ...e.to } })), + }, + }); await refresh(); }, @@ -1424,3 +1450,15 @@ export const useFileStore = create((set, get) => ({ } }, })); + +// Tell plugins when the selected files change, whichever action changed it +// (click, toggle, select all, clear, navigating away, after a delete or move). +// Selecting the same files again is not a change. +useFileStore.subscribe((state, prev) => { + if (state.selectedResources === prev.selectedResources) return; + if (fileHooks.onFileSelectionChange.size === 0) return; + const next = state.selectedResources ?? new Set(); + if (sameNameSet(next, prev.selectedResources ?? new Set())) return; + const files = state.resources.filter(r => next.has(r.name)).map(r => resourceToView(r, state.currentPath)); + void fileHooks.onFileSelectionChange.emit({ files }); +});