diff --git a/.gitignore b/.gitignore index b001f45..88efc7f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ temp/ # Bun lock files for developers using bun instead of pnpm bun.lock + +.atl +.opencode +.codegraph \ No newline at end of file diff --git a/electron/controllers/firestore/recursiveDeleteRest.js b/electron/controllers/firestore/recursiveDeleteRest.js index 88a0961..59a6c1c 100644 --- a/electron/controllers/firestore/recursiveDeleteRest.js +++ b/electron/controllers/firestore/recursiveDeleteRest.js @@ -33,12 +33,11 @@ function relativeDocPath(resourceName) { async function listCollectionIds({ authenticatedFetch, urlRoot }, docPath) { const ids = []; let pageToken; + const url = docPath ? `${urlRoot}/${docPath}:listCollectionIds` : `${urlRoot}:listCollectionIds`; do { - const data = await postJson( - authenticatedFetch, - `${urlRoot}/${docPath}:listCollectionIds`, - pageToken ? { pageToken } : {}, - ); + const body = { pageSize: 300 }; + if (pageToken) body.pageToken = pageToken; + const data = await postJson(authenticatedFetch, url, body); ids.push(...(data.collectionIds || [])); pageToken = data.nextPageToken; } while (pageToken); diff --git a/electron/controllers/firestore/recursiveDeleteRest.test.js b/electron/controllers/firestore/recursiveDeleteRest.test.js index 2a8b2bc..df574c2 100644 --- a/electron/controllers/firestore/recursiveDeleteRest.test.js +++ b/electron/controllers/firestore/recursiveDeleteRest.test.js @@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest'; import { createRequire } from 'module'; const require_ = createRequire(import.meta.url); -const { deleteDocumentTree } = require_('./recursiveDeleteRest'); +const { deleteDocumentTree, listCollectionIds } = require_('./recursiveDeleteRest'); const URL_ROOT = 'https://firestore.googleapis.com/v1/projects/p/databases/(default)/documents'; const NAME_ROOT = 'projects/p/databases/(default)/documents'; @@ -71,3 +71,54 @@ describe('deleteDocumentTree', () => { }); }); }); + +describe('listCollectionIds', () => { + it('fetches root collections across multiple pages with pagination tokens', async () => { + const authenticatedFetch = vi.fn(async (url, options) => { + expect(url).toBe(`${URL_ROOT}:listCollectionIds`); + const body = JSON.parse(options.body); + expect(body.pageSize).toBe(300); + + if (!body.pageToken) { + return { + ok: true, + data: { + collectionIds: ['users', 'products'], + nextPageToken: 'page-2-token', + }, + }; + } + if (body.pageToken === 'page-2-token') { + return { + ok: true, + data: { + collectionIds: ['orders', 'settings'], + }, + }; + } + return { ok: true, data: { collectionIds: [] } }; + }); + + const result = await listCollectionIds({ authenticatedFetch, urlRoot: URL_ROOT }); + expect(result).toEqual(['users', 'products', 'orders', 'settings']); + expect(authenticatedFetch).toHaveBeenCalledTimes(2); + }); + + it('fetches subcollections for a specified document path', async () => { + const authenticatedFetch = vi.fn(async (url, options) => { + expect(url).toBe(`${URL_ROOT}/users/u1:listCollectionIds`); + const body = JSON.parse(options.body); + expect(body.pageSize).toBe(300); + return { + ok: true, + data: { + collectionIds: ['messages', 'notifications'], + }, + }; + }); + + const result = await listCollectionIds({ authenticatedFetch, urlRoot: URL_ROOT }, 'users/u1'); + expect(result).toEqual(['messages', 'notifications']); + expect(authenticatedFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/controllers/googleController.js b/electron/controllers/googleController.js index af26ad4..9725292 100644 --- a/electron/controllers/googleController.js +++ b/electron/controllers/googleController.js @@ -433,17 +433,10 @@ function registerHandlers() { let collections = []; try { const dbSeg = firestoreDbPathSegment(databaseId); - const colResult = await authenticatedFetch( - `https://firestore.googleapis.com/v1/projects/${p.projectId}/databases/${dbSeg}/documents:listCollectionIds`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }, - ); - if (colResult.ok && colResult.data.collectionIds) { - collections = colResult.data.collectionIds; - } + collections = await listCollectionIds({ + authenticatedFetch, + urlRoot: `https://firestore.googleapis.com/v1/projects/${p.projectId}/databases/${dbSeg}/documents`, + }); } catch (error) { void error; } @@ -475,16 +468,13 @@ function registerHandlers() { } const databaseId = typeof params === 'object' && params ? databaseIdFromHandlerParams(params) : '(default)'; const dbSeg = firestoreDbPathSegment(databaseId); - const result = await authenticatedFetch( - `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents:listCollectionIds`, - { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }, - ); - if (!result.ok) return result.error; - - const data = result.data; - if (data.error) return { success: false, error: data.error.message, requiresReauth: data.error.code === 401 }; - return { success: true, collections: data.collectionIds || [] }; + const collections = await listCollectionIds({ + authenticatedFetch, + urlRoot: `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents`, + }); + return { success: true, collections }; } catch (error) { + if (error.ipcResult) return error.ipcResult; return { success: false, error: error.message }; } }); @@ -876,18 +866,16 @@ function registerHandlers() { ipcMain.handle('google:exportCollections', async (event, { projectId, databaseId }) => { try { const dbSeg = firestoreDbPathSegment(databaseIdFromHandlerParams({ databaseId })); - // First get all collection IDs - const colResult = await authenticatedFetch( - `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents:listCollectionIds`, - { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }, - ); - if (!colResult.ok) return colResult.error; - - const colData = colResult.data; - if (colData.error) - return { success: false, error: colData.error.message, requiresReauth: colData.error.code === 401 }; - - const collectionIds = colData.collectionIds || []; + let collectionIds = []; + try { + collectionIds = await listCollectionIds({ + authenticatedFetch, + urlRoot: `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents`, + }); + } catch (error) { + if (error.ipcResult) return error.ipcResult; + return { success: false, error: error.message }; + } const allData = {}; const pageSize = 300; diff --git a/src/features/projects/components/ProjectSidebar.tsx b/src/features/projects/components/ProjectSidebar.tsx index 004f8a9..5b3c854 100644 --- a/src/features/projects/components/ProjectSidebar.tsx +++ b/src/features/projects/components/ProjectSidebar.tsx @@ -272,6 +272,8 @@ function ProjectSidebar({ handleContextMenu={handleContextMenu} isMenuOpen={isMenuOpen} menuTarget={menuTarget} + onRefreshCollections={onRefreshCollections} + onRefreshFirestoreDatabase={onRefreshFirestoreDatabase} /> {/* Bottom Toolbar */} diff --git a/src/features/projects/components/sidebar/SidebarProjectsList.tsx b/src/features/projects/components/sidebar/SidebarProjectsList.tsx index c23d8f5..1321bd0 100644 --- a/src/features/projects/components/sidebar/SidebarProjectsList.tsx +++ b/src/features/projects/components/sidebar/SidebarProjectsList.tsx @@ -15,6 +15,7 @@ import { Add as AddIcon, Dns as DatabaseIcon, Computer as ComputerIcon, + Refresh as RefreshIcon, } from '@mui/icons-material'; import { useDispatch } from 'react-redux'; import { setSidebarItemExpanded, Tab } from '../../../../app/store/slices/uiSlice'; @@ -61,6 +62,8 @@ interface SidebarProjectsListProps { ) => void; isMenuOpen: boolean; menuTarget: MenuTarget | null; + onRefreshCollections?: (project: Project | GoogleAccount) => void; + onRefreshFirestoreDatabase?: (project: Project, firestoreDatabaseId: string) => void; } function SidebarProjectsList({ @@ -86,6 +89,8 @@ function SidebarProjectsList({ handleContextMenu, isMenuOpen, menuTarget, + onRefreshCollections, + onRefreshFirestoreDatabase, }: SidebarProjectsListProps) { const dispatch = useDispatch(); @@ -524,6 +529,26 @@ function SidebarProjectsList({ {fd.databaseId} + + { + e.stopPropagation(); + if (onRefreshFirestoreDatabase) { + onRefreshFirestoreDatabase(project, fd.id); + } else if (onRefreshCollections) { + onRefreshCollections(project); + } + }} + sx={{ + p: 0.2, + color: 'text.secondary', + '&:hover': { color: 'primary.main' }, + }} + > + + + { @@ -1020,6 +1045,26 @@ function SidebarProjectsList({ {fd.databaseId} + + { + e.stopPropagation(); + if (onRefreshFirestoreDatabase) { + onRefreshFirestoreDatabase(project, fd.id); + } else if (onRefreshCollections) { + onRefreshCollections(project); + } + }} + sx={{ + p: 0.2, + color: 'text.secondary', + '&:hover': { color: 'primary.main' }, + }} + > + + + { @@ -1489,6 +1534,26 @@ function SidebarProjectsList({ {fd.databaseId} + + { + e.stopPropagation(); + if (onRefreshFirestoreDatabase) { + onRefreshFirestoreDatabase(project, fd.id); + } else if (onRefreshCollections) { + onRefreshCollections(project); + } + }} + sx={{ + p: 0.2, + color: 'text.secondary', + '&:hover': { color: 'primary.main' }, + }} + > + + + { diff --git a/src/features/projects/store/projectsSlice.test.ts b/src/features/projects/store/projectsSlice.test.ts index 37c33ff..f226209 100644 --- a/src/features/projects/store/projectsSlice.test.ts +++ b/src/features/projects/store/projectsSlice.test.ts @@ -197,4 +197,29 @@ describe('projectsSlice thunks', () => { // Collections should be normalized to objects expect(project.collections).toEqual([{ id: 'col1', path: 'col1' }]); }); + + it('loadProjects deduplicates and sorts collections alphabetically', async () => { + const saved = [ + { + id: '2', + projectId: 'sorted-project', + authMethod: 'serviceAccount', + serviceAccountPath: '/sa.json', + collections: ['zebra', 'alpha', 'zebra', 'beta'], + }, + ]; + localStorage.setItem('firefoo-projects', JSON.stringify(saved)); + + const store = createTestStore({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await store.dispatch(loadProjects() as any); + + const state = store.getState().projects; + const project = state.items[0] as Project; + expect(project.collections).toEqual([ + { id: 'alpha', path: 'alpha' }, + { id: 'beta', path: 'beta' }, + { id: 'zebra', path: 'zebra' }, + ]); + }); }); diff --git a/src/features/projects/store/projectsSlice.ts b/src/features/projects/store/projectsSlice.ts index 7ee784f..2e02f68 100644 --- a/src/features/projects/store/projectsSlice.ts +++ b/src/features/projects/store/projectsSlice.ts @@ -74,16 +74,17 @@ const isProject = (item: Project | GoogleAccount): item is Project => { }; const normalizeCollections = (collections?: Array) => { - if (!collections) return []; - return collections.map((collection) => { - if (typeof collection === 'string') { - return { id: collection, path: collection }; + if (!collections || !Array.isArray(collections)) return []; + const map = new Map(); + for (const collection of collections) { + if (!collection) continue; + const id = typeof collection === 'string' ? collection : collection.id; + const path = typeof collection === 'string' ? collection : collection.path || collection.id; + if (id && !map.has(id)) { + map.set(id, { id, path }); } - return { - id: collection.id, - path: collection.path || collection.id, - }; - }); + } + return Array.from(map.values()).sort((a, b) => a.id.localeCompare(b.id)); }; const createAppAsyncThunk = createAsyncThunk.withTypes<{