Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions electron/controllers/firebaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,38 @@ let onConnectionChange = null;
let currentAuthEmulatorHost = null;
let currentStorageEmulatorHost = null;

/**
* Firebase Admin v14 uses modular service functions instead of the legacy
* namespace methods (`apps`, `firestore()`, `auth()`, and `storage()`). Keep a
* small compatibility facade because the remaining Electron controllers use
* the namespace form.
*/
function getAdminCompatibilityFacade(adminSdk) {
if (typeof adminSdk.getApps !== 'function') return adminSdk;

const app = require('firebase-admin/app');
const firestore = require('firebase-admin/firestore');
const auth = require('firebase-admin/auth');
const storage = require('firebase-admin/storage');

return {
...adminSdk,
get apps() {
return app.getApps();
},
app: app.getApp,
credential: { cert: app.cert },
firestore: Object.assign((firebaseApp) => firestore.getFirestore(firebaseApp), {
FieldValue: firestore.FieldValue,
Filter: firestore.Filter,
GeoPoint: firestore.GeoPoint,
Timestamp: firestore.Timestamp,
}),
auth: (firebaseApp) => auth.getAuth(firebaseApp),
storage: (firebaseApp) => storage.getStorage(firebaseApp),
};
}

function getAdmin() {
return admin;
}
Expand Down Expand Up @@ -62,12 +94,12 @@ function registerHandlers() {
currentStorageEmulatorHost = storageEmulatorHost || null;

const adminSdk = require('firebase-admin');
const adminFacade = getAdminCompatibilityFacade(adminSdk);

// Never call app().delete() unless an app exists — after a failed connect, `admin` may
// still reference the SDK module while no default app was initialized, which throws:
// "The default Firebase app does not exist".
// ponytail: firebase-admin v14 removed the `apps` getter and `app.delete()`; use getApps() + deleteApp()
const existingApps = adminSdk.getApps();
const existingApps = [...adminFacade.apps];
for (const appInstance of existingApps) {
try {
await adminSdk.deleteApp(appInstance);
Expand All @@ -82,7 +114,7 @@ function registerHandlers() {
const serviceAccount = JSON.parse(fs.readFileSync(serviceAccountPath, 'utf8'));
projectId = serviceAccount.project_id;
adminSdk.initializeApp({
credential: adminSdk.cert(serviceAccount),
credential: adminFacade.credential.cert(serviceAccount),
projectId,
});
} else if (emulatorHost && explicitProjectId) {
Expand All @@ -92,8 +124,8 @@ function registerHandlers() {
throw new Error('Must provide either serviceAccountPath or emulatorHost with projectId');
}

admin = adminSdk;
db = getFirestore();
admin = adminFacade;
db = adminFacade.firestore();

if (databaseId) {
db.settings({ databaseId });
Expand All @@ -112,7 +144,7 @@ function registerHandlers() {
currentStorageEmulatorHost = null;
try {
const adminSdk = require('firebase-admin');
const leftover = adminSdk.getApps();
const leftover = [...getAdminCompatibilityFacade(adminSdk).apps];
for (const appInstance of leftover) {
try {
await adminSdk.deleteApp(appInstance);
Expand All @@ -133,8 +165,8 @@ function registerHandlers() {
// Disconnect from Firebase
ipcMain.handle('firebase:disconnect', async () => {
try {
const adminSdk = admin || require('firebase-admin');
const existingApps = adminSdk.getApps();
const adminSdk = admin || getAdminCompatibilityFacade(require('firebase-admin'));
const existingApps = [...adminSdk.apps];
for (const appInstance of existingApps) {
try {
await adminSdk.deleteApp(appInstance);
Expand Down Expand Up @@ -172,4 +204,5 @@ module.exports = {
getDb,
getStorageEmulatorHost,
setConnectionChangeCallback,
getAdminCompatibilityFacade,
};
27 changes: 23 additions & 4 deletions electron/controllers/firebaseController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const mockAppDelete = vi.fn().mockResolvedValue(undefined);
const mockSettings = vi.fn();
/** Simulates firebase-admin apps after initializeApp (v14: getApps() returns an array) */
const mockAppsList = [];
const TEST_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nmock-private-key\n-----END PRIVATE KEY-----\n';
const createServiceAccount = (projectId) => ({
project_id: projectId,
client_email: 'firebase-adminsdk@test-project.iam.gserviceaccount.com',
private_key: TEST_PRIVATE_KEY,
});

// Inject electron mock into require cache
require_.cache[require_.resolve('electron')] = {
Expand Down Expand Up @@ -50,6 +56,19 @@ require_.cache[firebaseAdminPath] = {
},
};

// Inject firebase-admin/app mock because the compatibility facade imports the v14 modular API.
const firebaseAdminAppModulePath = require_.resolve('firebase-admin/app');
require_.cache[firebaseAdminAppModulePath] = {
id: 'firebase-admin/app',
filename: firebaseAdminAppModulePath,
loaded: true,
exports: {
cert: vi.fn().mockReturnValue('mock-credential'),
getApp: vi.fn(),
getApps: () => mockAppsList,
},
};

// Inject firebase-admin/firestore mock (v14 modular API)
const firestoreModulePath = require_.resolve('firebase-admin/firestore');
require_.cache[firestoreModulePath] = {
Expand Down Expand Up @@ -85,7 +104,7 @@ describe('firebaseController', () => {
});

it('connects with a valid service account path', async () => {
const serviceAccount = { project_id: 'test-project' };
const serviceAccount = createServiceAccount('test-project');
readFileSyncMock.mockReturnValue(JSON.stringify(serviceAccount));

const result = await handlers['firebase:connect'](null, {
Expand All @@ -97,7 +116,7 @@ describe('firebaseController', () => {
});

it('connects with databaseId', async () => {
const serviceAccount = { project_id: 'test-project' };
const serviceAccount = createServiceAccount('test-project');
readFileSyncMock.mockReturnValue(JSON.stringify(serviceAccount));

const result = await handlers['firebase:connect'](null, {
Expand All @@ -109,7 +128,7 @@ describe('firebaseController', () => {
});

it('supports backward compat with string param', async () => {
const serviceAccount = { project_id: 'legacy-project' };
const serviceAccount = createServiceAccount('legacy-project');
readFileSyncMock.mockReturnValue(JSON.stringify(serviceAccount));

const result = await handlers['firebase:connect'](null, '/legacy/path.json');
Expand Down Expand Up @@ -141,7 +160,7 @@ describe('firebaseController', () => {
});
expect(failResult.success).toBe(false);

const serviceAccount = { project_id: 'recovery-project' };
const serviceAccount = createServiceAccount('recovery-project');
readFileSyncMock.mockReturnValue(JSON.stringify(serviceAccount));
const okResult = await handlers['firebase:connect'](null, {
serviceAccountPath: '/path/to/sa.json',
Expand Down
18 changes: 12 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,19 @@ function FirestudioApp() {
// Tab Handlers
const onOpenCollection = (
project: Project | GoogleAccount,
collectionPath: string,
path: string,
firestoreDatabaseId?: string,
databaseLabel?: string,
) => {
if (isGoogleAccount(project)) return;
const id = firestoreDatabaseId
? `${project.id}-${firestoreDatabaseId}-${collectionPath}`
: `${project.id}-${collectionPath}`;
const segments = path.split('/');
const docPath = segments.length % 2 === 0 ? path : undefined;
const collectionPath = docPath ? segments.slice(0, -1).join('/') : path;
const id = firestoreDatabaseId ? `${project.id}-${firestoreDatabaseId}-${path}` : `${project.id}-${path}`;
const label =
databaseLabel && !isGoogleAccount(project) && project.authMethod === 'serviceAccount'
? `${databaseLabel} · ${collectionPath}`
: collectionPath;
? `${databaseLabel} · ${path}`
: path;
dispatch(
addTab({
id,
Expand All @@ -179,6 +180,7 @@ function FirestudioApp() {
label,
type: 'collection',
collectionPath,
docPath,
firestoreDatabaseId,
databaseLabel,
}),
Expand Down Expand Up @@ -659,7 +661,11 @@ function FirestudioApp() {
project={project}
collectionPath={activeTab.collectionPath}
firestoreDatabaseId={activeTab.firestoreDatabaseId}
documentPath={activeTab.docPath || undefined}
showMessage={(msg: string, type: MessageType) => dispatch(addLog({ type, message: msg }))}
onOpenCollection={(collectionPath: string, firestoreDatabaseId?: string) =>
onOpenCollection(project, collectionPath, firestoreDatabaseId, activeTab.databaseLabel)
}
/>
);
}
Expand Down
82 changes: 72 additions & 10 deletions src/features/collections/components/CollectionTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DialogContentText,
DialogActions,
Button,
InputAdornment,
} from '@mui/material';
import { Storage as CollectionIcon } from '@mui/icons-material';

Expand Down Expand Up @@ -92,13 +93,23 @@ interface CollectionTabProps {
collectionPath: string;
/** Service account: FirestoreDatabase.id for this tab */
firestoreDatabaseId?: string;
/** When set, renders only this document while retaining the parent collection layout. */
documentPath?: string;
showMessage?: (message: string, type: 'success' | 'error' | 'info' | 'warning') => void;
onOpenCollection?: (collectionPath: string, firestoreDatabaseId?: string) => void;
}

/**
* CollectionTab - Main component for Firestore collection management
*/
const CollectionTab: React.FC<CollectionTabProps> = ({ project, collectionPath, firestoreDatabaseId, showMessage }) => {
const CollectionTab: React.FC<CollectionTabProps> = ({
project,
collectionPath,
firestoreDatabaseId,
documentPath,
showMessage,
onOpenCollection,
}) => {
// Theme
const theme = useTheme();
const dispatch = useDispatch<AppDispatch>();
Expand All @@ -114,7 +125,7 @@ const CollectionTab: React.FC<CollectionTabProps> = ({ project, collectionPath,
);

// Collection Data (Redux)
const collectionKey = buildCollectionStateKey(project, collectionPath, firestoreDatabaseId);
const collectionKey = buildCollectionStateKey(project, documentPath || collectionPath, firestoreDatabaseId);
const collectionData = useSelector((state: RootState) => selectCollectionData(state, collectionKey));
const {
documents = [],
Expand Down Expand Up @@ -165,7 +176,13 @@ const CollectionTab: React.FC<CollectionTabProps> = ({ project, collectionPath,
initialFetchRef.current.inFlight = true;
try {
await dispatch(
fetchDocuments({ project, collection: collectionPath, key: collectionKey, firestoreDatabaseId }),
fetchDocuments({
project,
collection: collectionPath,
key: collectionKey,
firestoreDatabaseId,
documentPath,
}),
).unwrap();
initialFetchRef.current.done = true;
} catch (error: unknown) {
Expand All @@ -188,6 +205,7 @@ const CollectionTab: React.FC<CollectionTabProps> = ({ project, collectionPath,
showError,
collectionData?.lastFetchedAt,
firestoreDatabaseId,
documentPath,
]);

// Wrapped Setters
Expand Down Expand Up @@ -242,23 +260,23 @@ const CollectionTab: React.FC<CollectionTabProps> = ({ project, collectionPath,
const loadDocuments = useCallback(async () => {
try {
await dispatch(
fetchDocuments({ project, collection: collectionPath, key: collectionKey, firestoreDatabaseId }),
fetchDocuments({ project, collection: collectionPath, key: collectionKey, firestoreDatabaseId, documentPath }),
).unwrap();
} catch (error) {
showError(error);
}
}, [dispatch, project, collectionPath, collectionKey, firestoreDatabaseId, showError]);
}, [dispatch, project, collectionPath, collectionKey, firestoreDatabaseId, documentPath, showError]);

// Execute JS Query (Same thunk, just ensures state is ready)
const executeJsQuery = useCallback(async () => {
try {
await dispatch(
fetchDocuments({ project, collection: collectionPath, key: collectionKey, firestoreDatabaseId }),
fetchDocuments({ project, collection: collectionPath, key: collectionKey, firestoreDatabaseId, documentPath }),
).unwrap();
} catch (error) {
showError(error);
}
}, [dispatch, project, collectionPath, collectionKey, firestoreDatabaseId, showError]);
}, [dispatch, project, collectionPath, collectionKey, firestoreDatabaseId, documentPath, showError]);

// Import/Export Wrappers
const saveDocumentsFromJson = useCallback(
Expand Down Expand Up @@ -382,6 +400,11 @@ const CollectionTab: React.FC<CollectionTabProps> = ({ project, collectionPath,
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [collectionPathInput, setCollectionPathInput] = useState(documentPath || collectionPath);

useEffect(() => {
setCollectionPathInput(documentPath || collectionPath);
}, [collectionPath, documentPath]);

// Nested subcollection data (shared with the tree view so saves can refresh it)
const { subcollectionsByDocPath, documentsByPath, ensureSubcollections, ensureDocuments, refreshDocuments } =
Expand Down Expand Up @@ -445,6 +468,19 @@ const CollectionTab: React.FC<CollectionTabProps> = ({ project, collectionPath,
}
}, [queryMode, loadDocuments, executeJsQuery]);

const handleOpenCollectionPath = useCallback(() => {
const nextPath = collectionPathInput.trim().replace(/^\/+|\/+$/g, '');
const segments = nextPath.split('/');

if (!nextPath || segments.some((segment) => !segment)) {
showMessage?.('Enter a valid Firestore collection or document path.', 'error');
return;
}

if (nextPath === (documentPath || collectionPath)) return;
onOpenCollection?.(nextPath, firestoreDatabaseId);
}, [collectionPathInput, collectionPath, documentPath, firestoreDatabaseId, onOpenCollection, showMessage]);

const handleToggleFavorite = useCallback(() => {
dispatch(
toggleFavorite({
Expand Down Expand Up @@ -713,9 +749,35 @@ const CollectionTab: React.FC<CollectionTabProps> = ({ project, collectionPath,
gap: 1,
}}
>
<CollectionIcon sx={{ fontSize: 16, color: 'text.secondary', ml: 1 }} />
<Typography sx={{ fontSize: '0.8rem', color: 'text.primary' }}>{collectionPath}</Typography>
<Box sx={{ flexGrow: 1 }} />
{queryMode === 'simple' ? (
<TextField
size="small"
value={collectionPathInput}
onChange={(event) => setCollectionPathInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleOpenCollectionPath();
}
}}
placeholder="Collection path"
aria-label="Collection path"
sx={{ flexGrow: 1 }}
InputProps={{
sx: { fontSize: '0.8rem', height: 30 },
startAdornment: (
<InputAdornment position="start">
<CollectionIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
</InputAdornment>
),
}}
/>
) : (
<>
<CollectionIcon sx={{ fontSize: 16, color: 'text.secondary', ml: 1 }} />
<Typography sx={{ fontSize: '0.8rem', color: 'text.primary' }}>{collectionPath}</Typography>
</>
)}
<TextField
size="small"
placeholder="Search"
Expand Down
Loading
Loading