diff --git a/electron/controllers/firebaseController.js b/electron/controllers/firebaseController.js index 6df125e..cd10dc4 100644 --- a/electron/controllers/firebaseController.js +++ b/electron/controllers/firebaseController.js @@ -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; } @@ -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); @@ -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) { @@ -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 }); @@ -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); @@ -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); @@ -172,4 +204,5 @@ module.exports = { getDb, getStorageEmulatorHost, setConnectionChangeCallback, + getAdminCompatibilityFacade, }; diff --git a/src/App.tsx b/src/App.tsx index e105eb8..dc10fc0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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, @@ -179,6 +180,7 @@ function FirestudioApp() { label, type: 'collection', collectionPath, + docPath, firestoreDatabaseId, databaseLabel, }), @@ -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) + } /> ); } diff --git a/src/features/collections/components/CollectionTab.tsx b/src/features/collections/components/CollectionTab.tsx index f19ab0f..ad030df 100644 --- a/src/features/collections/components/CollectionTab.tsx +++ b/src/features/collections/components/CollectionTab.tsx @@ -16,6 +16,7 @@ import { DialogContentText, DialogActions, Button, + InputAdornment, } from '@mui/material'; import { Storage as CollectionIcon } from '@mui/icons-material'; @@ -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 = ({ project, collectionPath, firestoreDatabaseId, showMessage }) => { +const CollectionTab: React.FC = ({ + project, + collectionPath, + firestoreDatabaseId, + documentPath, + showMessage, + onOpenCollection, +}) => { // Theme const theme = useTheme(); const dispatch = useDispatch(); @@ -114,7 +125,7 @@ const CollectionTab: React.FC = ({ 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 = [], @@ -165,7 +176,13 @@ const CollectionTab: React.FC = ({ 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) { @@ -188,6 +205,7 @@ const CollectionTab: React.FC = ({ project, collectionPath, showError, collectionData?.lastFetchedAt, firestoreDatabaseId, + documentPath, ]); // Wrapped Setters @@ -242,23 +260,23 @@ const CollectionTab: React.FC = ({ 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( @@ -382,6 +400,11 @@ const CollectionTab: React.FC = ({ project, collectionPath, const [selectedRows, setSelectedRows] = useState([]); 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 } = @@ -445,6 +468,19 @@ const CollectionTab: React.FC = ({ 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({ @@ -713,9 +749,35 @@ const CollectionTab: React.FC = ({ project, collectionPath, gap: 1, }} > - - {collectionPath} - + {queryMode === 'simple' ? ( + 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: ( + + + + ), + }} + /> + ) : ( + <> + + {collectionPath} + + )} = ({ }) => { const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; + const tableRef = useRef(null); + const resizeRef = useRef<{ index: number; startX: number; widths: number[] } | null>(null); + const [columnWidths, setColumnWidths] = useState(null); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + const resize = resizeRef.current; + if (!resize) return; + + const minimumWidths = [180, 180, 100]; + const nextWidths = [...resize.widths]; + const leftIndex = resize.index; + const rightIndex = leftIndex + 1; + const totalWidth = resize.widths[leftIndex] + resize.widths[rightIndex]; + const desiredLeftWidth = resize.widths[leftIndex] + event.clientX - resize.startX; + const leftWidth = Math.max( + minimumWidths[leftIndex], + Math.min(desiredLeftWidth, totalWidth - minimumWidths[rightIndex]), + ); + + nextWidths[leftIndex] = leftWidth; + nextWidths[rightIndex] = totalWidth - leftWidth; + setColumnWidths(nextWidths); + }; + + const handleMouseUp = () => { + if (!resizeRef.current) return; + resizeRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + const handleResizeStart = (event: React.MouseEvent, index: number) => { + event.preventDefault(); + event.stopPropagation(); + const headerCells = tableRef.current?.querySelectorAll('thead th'); + if (!headerCells || headerCells.length !== 3) return; + + resizeRef.current = { + index, + startX: event.clientX, + widths: Array.from(headerCells, (cell) => cell.getBoundingClientRect().width), + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }; const contextValue = useMemo( () => ({ @@ -102,18 +156,77 @@ const TreeView: React.FC = ({ return ( - +
+ + + + + - + Key + handleResizeStart(event, 0)} + onMouseEnter={(event) => { + event.currentTarget.style.background = theme.palette.primary.main + '40'; + }} + onMouseLeave={(event) => { + event.currentTarget.style.background = 'transparent'; + }} + sx={{ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + width: 6, + cursor: 'col-resize', + zIndex: 20, + bgcolor: 'transparent', + }} + /> - + Value + handleResizeStart(event, 1)} + onMouseEnter={(event) => { + event.currentTarget.style.background = theme.palette.primary.main + '40'; + }} + onMouseLeave={(event) => { + event.currentTarget.style.background = 'transparent'; + }} + sx={{ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + width: 6, + cursor: 'col-resize', + zIndex: 20, + bgcolor: 'transparent', + }} + /> - - Type - + Type diff --git a/src/features/collections/components/table/CellRenderer.tsx b/src/features/collections/components/table/CellRenderer.tsx index 2a26a96..e8a7083 100644 --- a/src/features/collections/components/table/CellRenderer.tsx +++ b/src/features/collections/components/table/CellRenderer.tsx @@ -10,6 +10,7 @@ import { import { MONOSPACE_FONT_FAMILY } from '../../../../shared/utils/constants'; import { FirestoreValue } from '../../../../shared/utils/firestoreUtils'; import { TableThemeColors } from '../../../../app/theme'; +import { singleLineTruncation } from '../../../../shared/ui/textStyles'; interface CellRendererProps { docId: string; @@ -127,9 +128,7 @@ const CellRenderer: React.FC = ({ padding: '6px 8px', borderBottom: cellBorder, borderRight: cellBorder, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', + ...singleLineTruncation, color: value === undefined ? (isDark ? '#6b6b6b' : '#a0a0a0') : getTypeColor(type, isDark), fontStyle: value === undefined ? 'italic' : 'normal', fontFamily: type === 'Array' || type === 'Map' || type === 'String' ? MONOSPACE_FONT_FAMILY : 'inherit', diff --git a/src/features/collections/components/table/TableHeaders.tsx b/src/features/collections/components/table/TableHeaders.tsx index 7950a97..533751c 100644 --- a/src/features/collections/components/table/TableHeaders.tsx +++ b/src/features/collections/components/table/TableHeaders.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useTheme } from '@mui/material'; import { TableThemeColors } from '../../../../app/theme'; +import { singleLineTruncation } from '../../../../shared/ui/textStyles'; interface TableHeadersProps { visibleFields: string[]; @@ -73,9 +74,7 @@ const TableHeaders: React.FC = ({ fontStyle: 'italic', borderBottom: cellBorder, borderRight: cellBorder, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', + ...singleLineTruncation, zIndex: 10, }} > @@ -115,9 +114,7 @@ const TableHeaders: React.FC = ({ color: tableColors.headerText, borderBottom: cellBorder, borderRight: cellBorder, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', + ...singleLineTruncation, zIndex: 10, }} > diff --git a/src/features/collections/components/table/TableRow.tsx b/src/features/collections/components/table/TableRow.tsx index 8ed87a7..4a075ef 100644 --- a/src/features/collections/components/table/TableRow.tsx +++ b/src/features/collections/components/table/TableRow.tsx @@ -5,6 +5,7 @@ import CellRenderer from './CellRenderer'; import { FirestoreValue } from '../../../../shared/utils/firestoreUtils'; import { Document } from '../../store/collectionSlice'; import { TableThemeColors } from '../../../../app/theme'; +import { singleLineTruncation } from '../../../../shared/ui/textStyles'; interface TableRowProps { doc: Document; // Firestore document with id and data @@ -116,9 +117,7 @@ const TableRow: React.FC = ({ fontSize: '0.75rem', borderBottom: cellBorder, borderRight: cellBorder, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', + ...singleLineTruncation, backgroundColor: rowBg, transition: 'background-color 0.1s ease', }} diff --git a/src/features/collections/components/tree/TreeNodeRow.tsx b/src/features/collections/components/tree/TreeNodeRow.tsx index 9e3fbc8..f370301 100644 --- a/src/features/collections/components/tree/TreeNodeRow.tsx +++ b/src/features/collections/components/tree/TreeNodeRow.tsx @@ -14,6 +14,7 @@ import { } from '../../../../shared/utils/dateUtils'; import { DocumentData } from '../../store/collectionSlice'; import { TreeContext } from './TreeContext'; +import { singleLineTruncation } from '../../../../shared/ui/textStyles'; interface TreeNodeRowProps { nodeKey: string; @@ -105,10 +106,10 @@ const TreeNodeRow: React.FC = ({ py: 0.25, pl: depth * 2 + 1, borderBottom: 1, + borderRight: 1, borderColor: 'divider', cursor: isExpandable ? 'pointer' : 'default', color: 'text.primary', - width: '40%', }} onClick={() => isExpandable && toggleNode(path)} > @@ -123,8 +124,10 @@ const TreeNodeRow: React.FC = ({ {isCollection && } {isDoc && } = ({ - + {!isCollection && !isDoc && !isExpandable ? ( isEditing ? ( isDateLike ? ( @@ -177,8 +180,10 @@ const TreeNodeRow: React.FC = ({ ) ) : ( docId && onCellEdit(docId, nodeKey, value, docData, docCollectionPath)} sx={{ + ...singleLineTruncation, fontSize: '0.8rem', color: getTypeColor(nodeType, isDark), cursor: 'pointer', @@ -190,11 +195,21 @@ const TreeNodeRow: React.FC = ({ ) ) : ( - {displayValue} + + {displayValue} + )} - - {nodeType} + + + {nodeType} + diff --git a/src/features/collections/store/collectionSlice.ts b/src/features/collections/store/collectionSlice.ts index 8ce8274..3b3f6cb 100644 --- a/src/features/collections/store/collectionSlice.ts +++ b/src/features/collections/store/collectionSlice.ts @@ -195,10 +195,10 @@ function parseSimpleFilterValue(value: FirestoreValue): FirestoreValue { export const fetchDocuments = createAppAsyncThunk< { documents: Document[] }, - { project: Project; collection: string; key: string; firestoreDatabaseId?: string } + { project: Project; collection: string; key: string; firestoreDatabaseId?: string; documentPath?: string } >( 'collection/fetchDocuments', - async ({ project, collection, key, firestoreDatabaseId }, { rejectWithValue, getState, extra }) => { + async ({ project, collection, key, firestoreDatabaseId, documentPath }, { rejectWithValue, getState, extra }) => { const electron = extra.electron.api; try { const state = getState().collection.cache[key]; @@ -209,7 +209,23 @@ export const fetchDocuments = createAppAsyncThunk< let documents: Document[] = []; const parsedLimit = typeof limit === 'number' ? limit : parseInt(String(limit), 10) || 50; - if (queryMode === 'js' && jsQuery) { + if (documentPath) { + if (project.authMethod === 'google') { + const result = await electron.googleGetDocument({ + projectId: project.projectId, + documentPath, + databaseId: getGoogleApiDatabaseId(project, firestoreDatabaseId), + }); + if (!result.success || !result.document) throw new Error(result.error || 'Document not found'); + documents = [result.document as unknown as Document]; + } else { + await connectForProject(electron, project, firestoreDatabaseId); + const result = await electron.getDocument(documentPath); + const document = (result as unknown as { document?: Document }).document; + if (!result.success || !document) throw new Error(result.error || 'Document not found'); + documents = [document]; + } + } else if (queryMode === 'js' && jsQuery) { // JS Query Mode if (project.authMethod === 'google') { // Google JS Query logic diff --git a/src/shared/ui/textStyles.ts b/src/shared/ui/textStyles.ts new file mode 100644 index 0000000..1e300c9 --- /dev/null +++ b/src/shared/ui/textStyles.ts @@ -0,0 +1,9 @@ +/** Keeps long cell content inside a resizable column while preserving the full value in a native tooltip. */ +export const singleLineTruncation = { + display: 'block', + minWidth: 0, + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} as const;