Skip to content
Open
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,
};
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