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<{