Skip to content
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ temp/

# Bun lock files for developers using bun instead of pnpm
bun.lock

.atl
.opencode
.codegraph
9 changes: 4 additions & 5 deletions electron/controllers/firestore/recursiveDeleteRest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 52 additions & 1 deletion electron/controllers/firestore/recursiveDeleteRest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
52 changes: 20 additions & 32 deletions electron/controllers/googleController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 };
}
});
Expand Down Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions src/features/projects/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ function ProjectSidebar({
handleContextMenu={handleContextMenu}
isMenuOpen={isMenuOpen}
menuTarget={menuTarget}
onRefreshCollections={onRefreshCollections}
onRefreshFirestoreDatabase={onRefreshFirestoreDatabase}
/>

{/* Bottom Toolbar */}
Expand Down
65 changes: 65 additions & 0 deletions src/features/projects/components/sidebar/SidebarProjectsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand All @@ -86,6 +89,8 @@ function SidebarProjectsList({
handleContextMenu,
isMenuOpen,
menuTarget,
onRefreshCollections,
onRefreshFirestoreDatabase,
}: SidebarProjectsListProps) {
const dispatch = useDispatch();

Expand Down Expand Up @@ -524,6 +529,26 @@ function SidebarProjectsList({
{fd.databaseId}
</Typography>
</Box>
<Tooltip title="Refresh collections">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
if (onRefreshFirestoreDatabase) {
onRefreshFirestoreDatabase(project, fd.id);
} else if (onRefreshCollections) {
onRefreshCollections(project);
}
}}
sx={{
p: 0.2,
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
>
<RefreshIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={(e) => {
Expand Down Expand Up @@ -1020,6 +1045,26 @@ function SidebarProjectsList({
{fd.databaseId}
</Typography>
</Box>
<Tooltip title="Refresh collections">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
if (onRefreshFirestoreDatabase) {
onRefreshFirestoreDatabase(project, fd.id);
} else if (onRefreshCollections) {
onRefreshCollections(project);
}
}}
sx={{
p: 0.2,
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
>
<RefreshIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={(e) => {
Expand Down Expand Up @@ -1489,6 +1534,26 @@ function SidebarProjectsList({
{fd.databaseId}
</Typography>
</Box>
<Tooltip title="Refresh collections">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
if (onRefreshFirestoreDatabase) {
onRefreshFirestoreDatabase(project, fd.id);
} else if (onRefreshCollections) {
onRefreshCollections(project);
}
}}
sx={{
p: 0.2,
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
>
<RefreshIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={(e) => {
Expand Down
25 changes: 25 additions & 0 deletions src/features/projects/store/projectsSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]);
});
});
19 changes: 10 additions & 9 deletions src/features/projects/store/projectsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,17 @@ const isProject = (item: Project | GoogleAccount): item is Project => {
};

const normalizeCollections = (collections?: Array<FirestoreCollection | string>) => {
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<string, FirestoreCollection>();
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<{
Expand Down
Loading