From 2fc6c65c6cd211239981f47ef0261d7a1ecb1ae5 Mon Sep 17 00:00:00 2001 From: ChornyiDev Date: Sat, 29 Aug 2026 14:01:50 +0300 Subject: [PATCH 1/5] feat: open collection paths from simple query --- src/App.tsx | 3 + .../collections/components/CollectionTab.tsx | 71 ++++++++++++++++++- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e105eb8..e2181de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -660,6 +660,9 @@ function FirestudioApp() { collectionPath={activeTab.collectionPath} firestoreDatabaseId={activeTab.firestoreDatabaseId} 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..0c7135a 100644 --- a/src/features/collections/components/CollectionTab.tsx +++ b/src/features/collections/components/CollectionTab.tsx @@ -16,8 +16,11 @@ import { DialogContentText, DialogActions, Button, + IconButton, + InputAdornment, + Tooltip, } from '@mui/material'; -import { Storage as CollectionIcon } from '@mui/icons-material'; +import { ArrowForward as OpenCollectionIcon, Storage as CollectionIcon } from '@mui/icons-material'; // Context import { useSelector, useDispatch } from 'react-redux'; @@ -93,12 +96,19 @@ interface CollectionTabProps { /** Service account: FirestoreDatabase.id for this tab */ firestoreDatabaseId?: 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, + showMessage, + onOpenCollection, +}) => { // Theme const theme = useTheme(); const dispatch = useDispatch(); @@ -382,6 +392,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(collectionPath); + + useEffect(() => { + setCollectionPathInput(collectionPath); + }, [collectionPath]); // Nested subcollection data (shared with the tree view so saves can refresh it) const { subcollectionsByDocPath, documentsByPath, ensureSubcollections, ensureDocuments, refreshDocuments } = @@ -445,6 +460,22 @@ 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) || segments.length % 2 === 0) { + showMessage?.( + 'Enter a collection path with an odd number of segments, for example users or users/user-id/posts.', + 'error', + ); + return; + } + + if (nextPath === collectionPath) return; + onOpenCollection?.(nextPath, firestoreDatabaseId); + }, [collectionPathInput, collectionPath, firestoreDatabaseId, onOpenCollection, showMessage]); + const handleToggleFavorite = useCallback(() => { dispatch( toggleFavorite({ @@ -714,7 +745,41 @@ const CollectionTab: React.FC = ({ project, collectionPath, }} > - {collectionPath} + {queryMode === 'simple' ? ( + setCollectionPathInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleOpenCollectionPath(); + } + }} + placeholder="Collection path" + aria-label="Collection path" + sx={{ minWidth: 280 }} + InputProps={{ + sx: { fontSize: '0.8rem', height: 30 }, + endAdornment: ( + + + + + + + + ), + }} + /> + ) : ( + {collectionPath} + )} Date: Sat, 29 Aug 2026 14:24:11 +0300 Subject: [PATCH 2/5] feat: improve collection navigation and tree view --- electron/controllers/firebaseController.js | 46 ++++++- .../collections/components/CollectionTab.tsx | 28 ++--- .../collections/components/TreeView.tsx | 115 ++++++++++++++++-- .../components/tree/TreeNodeRow.tsx | 6 +- 4 files changed, 159 insertions(+), 36 deletions(-) diff --git a/electron/controllers/firebaseController.js b/electron/controllers/firebaseController.js index e16d732..dc7e667 100644 --- a/electron/controllers/firebaseController.js +++ b/electron/controllers/firebaseController.js @@ -12,6 +12,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; } @@ -61,11 +93,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". - const existingApps = [...adminSdk.apps]; + const existingApps = [...adminFacade.apps]; for (const appInstance of existingApps) { try { await appInstance.delete(); @@ -80,7 +113,7 @@ function registerHandlers() { const serviceAccount = JSON.parse(fs.readFileSync(serviceAccountPath, 'utf8')); projectId = serviceAccount.project_id; adminSdk.initializeApp({ - credential: adminSdk.credential.cert(serviceAccount), + credential: adminFacade.credential.cert(serviceAccount), projectId, }); } else if (emulatorHost && explicitProjectId) { @@ -90,8 +123,8 @@ function registerHandlers() { throw new Error('Must provide either serviceAccountPath or emulatorHost with projectId'); } - admin = adminSdk; - db = adminSdk.firestore(); + admin = adminFacade; + db = adminFacade.firestore(); if (databaseId) { db.settings({ databaseId }); @@ -110,7 +143,7 @@ function registerHandlers() { currentStorageEmulatorHost = null; try { const adminSdk = require('firebase-admin'); - const leftover = [...adminSdk.apps]; + const leftover = [...getAdminCompatibilityFacade(adminSdk).apps]; for (const appInstance of leftover) { try { await appInstance.delete(); @@ -131,7 +164,7 @@ function registerHandlers() { // Disconnect from Firebase ipcMain.handle('firebase:disconnect', async () => { try { - const adminSdk = admin || require('firebase-admin'); + const adminSdk = admin || getAdminCompatibilityFacade(require('firebase-admin')); const existingApps = [...adminSdk.apps]; for (const appInstance of existingApps) { try { @@ -170,4 +203,5 @@ module.exports = { getDb, getStorageEmulatorHost, setConnectionChangeCallback, + getAdminCompatibilityFacade, }; diff --git a/src/features/collections/components/CollectionTab.tsx b/src/features/collections/components/CollectionTab.tsx index 0c7135a..afddd6a 100644 --- a/src/features/collections/components/CollectionTab.tsx +++ b/src/features/collections/components/CollectionTab.tsx @@ -16,11 +16,9 @@ import { DialogContentText, DialogActions, Button, - IconButton, InputAdornment, - Tooltip, } from '@mui/material'; -import { ArrowForward as OpenCollectionIcon, Storage as CollectionIcon } from '@mui/icons-material'; +import { Storage as CollectionIcon } from '@mui/icons-material'; // Context import { useSelector, useDispatch } from 'react-redux'; @@ -744,7 +742,6 @@ const CollectionTab: React.FC = ({ gap: 1, }} > - {queryMode === 'simple' ? ( = ({ }} placeholder="Collection path" aria-label="Collection path" - sx={{ minWidth: 280 }} + sx={{ flexGrow: 1 }} InputProps={{ sx: { fontSize: '0.8rem', height: 30 }, - endAdornment: ( - - - - - - + startAdornment: ( + + ), }} /> ) : ( - {collectionPath} + <> + + {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,63 @@ const TreeView: React.FC = ({ return ( - +
+ + + + + - + Key + handleResizeStart(event, 0)} + sx={{ + position: 'absolute', + top: 0, + right: -5, + width: 10, + height: '100%', + cursor: 'col-resize', + zIndex: 1, + }} + /> - + Value + handleResizeStart(event, 1)} + sx={{ + position: 'absolute', + top: 0, + right: -5, + width: 10, + height: '100%', + cursor: 'col-resize', + zIndex: 1, + }} + /> - - Type - + Type diff --git a/src/features/collections/components/tree/TreeNodeRow.tsx b/src/features/collections/components/tree/TreeNodeRow.tsx index 9e3fbc8..739c12a 100644 --- a/src/features/collections/components/tree/TreeNodeRow.tsx +++ b/src/features/collections/components/tree/TreeNodeRow.tsx @@ -105,10 +105,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)} > @@ -134,7 +134,7 @@ const TreeNodeRow: React.FC = ({ - + {!isCollection && !isDoc && !isExpandable ? ( isEditing ? ( isDateLike ? ( @@ -193,7 +193,7 @@ const TreeNodeRow: React.FC = ({ {displayValue} )} - + {nodeType} From d9f9a0610ea54909ec281ae1eaa8a5dd3c62432c Mon Sep 17 00:00:00 2001 From: ChornyiDev Date: Sat, 29 Aug 2026 15:11:02 +0300 Subject: [PATCH 3/5] feat: open individual documents from query path --- src/App.tsx | 15 ++++--- .../collections/components/CollectionTab.tsx | 39 +++++++++++-------- .../collections/store/collectionSlice.ts | 27 +++++++++---- 3 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e2181de..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,6 +661,7 @@ 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 afddd6a..ad030df 100644 --- a/src/features/collections/components/CollectionTab.tsx +++ b/src/features/collections/components/CollectionTab.tsx @@ -93,6 +93,8 @@ 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; } @@ -104,6 +106,7 @@ const CollectionTab: React.FC = ({ project, collectionPath, firestoreDatabaseId, + documentPath, showMessage, onOpenCollection, }) => { @@ -122,7 +125,7 @@ const CollectionTab: React.FC = ({ ); // 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 = [], @@ -173,7 +176,13 @@ const CollectionTab: React.FC = ({ 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) { @@ -196,6 +205,7 @@ const CollectionTab: React.FC = ({ showError, collectionData?.lastFetchedAt, firestoreDatabaseId, + documentPath, ]); // Wrapped Setters @@ -250,23 +260,23 @@ const CollectionTab: React.FC = ({ 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( @@ -390,11 +400,11 @@ const CollectionTab: React.FC = ({ const [selectedRows, setSelectedRows] = useState([]); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); - const [collectionPathInput, setCollectionPathInput] = useState(collectionPath); + const [collectionPathInput, setCollectionPathInput] = useState(documentPath || collectionPath); useEffect(() => { - setCollectionPathInput(collectionPath); - }, [collectionPath]); + setCollectionPathInput(documentPath || collectionPath); + }, [collectionPath, documentPath]); // Nested subcollection data (shared with the tree view so saves can refresh it) const { subcollectionsByDocPath, documentsByPath, ensureSubcollections, ensureDocuments, refreshDocuments } = @@ -462,17 +472,14 @@ const CollectionTab: React.FC = ({ const nextPath = collectionPathInput.trim().replace(/^\/+|\/+$/g, ''); const segments = nextPath.split('/'); - if (!nextPath || segments.some((segment) => !segment) || segments.length % 2 === 0) { - showMessage?.( - 'Enter a collection path with an odd number of segments, for example users or users/user-id/posts.', - 'error', - ); + if (!nextPath || segments.some((segment) => !segment)) { + showMessage?.('Enter a valid Firestore collection or document path.', 'error'); return; } - if (nextPath === collectionPath) return; + if (nextPath === (documentPath || collectionPath)) return; onOpenCollection?.(nextPath, firestoreDatabaseId); - }, [collectionPathInput, collectionPath, firestoreDatabaseId, onOpenCollection, showMessage]); + }, [collectionPathInput, collectionPath, documentPath, firestoreDatabaseId, onOpenCollection, showMessage]); const handleToggleFavorite = useCallback(() => { dispatch( diff --git a/src/features/collections/store/collectionSlice.ts b/src/features/collections/store/collectionSlice.ts index abee32f..3b3f6cb 100644 --- a/src/features/collections/store/collectionSlice.ts +++ b/src/features/collections/store/collectionSlice.ts @@ -182,10 +182,7 @@ function parseSimpleFilterValue(value: FirestoreValue): FirestoreValue { if (trimmed === 'null') return null; if (trimmed !== '' && !Number.isNaN(Number(trimmed))) return Number(trimmed); - if ( - (trimmed.startsWith('[') && trimmed.endsWith(']')) || - (trimmed.startsWith('{') && trimmed.endsWith('}')) - ) { + if ((trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('{') && trimmed.endsWith('}'))) { try { return JSON.parse(trimmed) as FirestoreValue; } catch { @@ -198,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]; @@ -212,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 From 811bc361d6a9080c2c1239cab7ffdc9526d84c2a Mon Sep 17 00:00:00 2001 From: ChornyiDev Date: Sat, 29 Aug 2026 15:42:17 +0300 Subject: [PATCH 4/5] fix: constrain content in resizable columns --- .../components/FilterSortToolbar.tsx | 11 +------ .../collections/components/TreeView.tsx | 30 ++++++++++++++----- .../components/table/CellRenderer.tsx | 5 ++-- .../components/table/TableHeaders.tsx | 9 ++---- .../collections/components/table/TableRow.tsx | 5 ++-- .../components/tree/TreeNodeRow.tsx | 21 +++++++++++-- .../projects/components/ProjectSidebar.tsx | 4 +-- .../sidebar/SidebarProjectsList.tsx | 4 +-- src/features/projects/store/projectsSlice.ts | 3 +- src/shared/ui/textStyles.ts | 9 ++++++ 10 files changed, 60 insertions(+), 41 deletions(-) create mode 100644 src/shared/ui/textStyles.ts diff --git a/src/features/collections/components/FilterSortToolbar.tsx b/src/features/collections/components/FilterSortToolbar.tsx index 2beb0c1..c11e407 100644 --- a/src/features/collections/components/FilterSortToolbar.tsx +++ b/src/features/collections/components/FilterSortToolbar.tsx @@ -1,14 +1,5 @@ import React, { useEffect, useMemo, useRef } from 'react'; -import { - Autocomplete, - Box, - Button, - IconButton, - MenuItem, - Select, - TextField, - Typography, -} from '@mui/material'; +import { Autocomplete, Box, Button, IconButton, MenuItem, Select, TextField, Typography } from '@mui/material'; import { FilterList as FilterIcon, Sort as SortIcon } from '@mui/icons-material'; import { Filter, SortConfig } from '../store/collectionSlice'; diff --git a/src/features/collections/components/TreeView.tsx b/src/features/collections/components/TreeView.tsx index 807c2e9..23bb5b4 100644 --- a/src/features/collections/components/TreeView.tsx +++ b/src/features/collections/components/TreeView.tsx @@ -177,14 +177,21 @@ const TreeView: React.FC = ({ 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: -5, - width: 10, - height: '100%', + right: 0, + bottom: 0, + width: 6, cursor: 'col-resize', - zIndex: 1, + zIndex: 20, + bgcolor: 'transparent', }} /> @@ -201,14 +208,21 @@ const TreeView: React.FC = ({ 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: -5, - width: 10, - height: '100%', + right: 0, + bottom: 0, + width: 6, cursor: 'col-resize', - zIndex: 1, + zIndex: 20, + bgcolor: 'transparent', }} /> 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 739c12a..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; @@ -123,8 +124,10 @@ const TreeNodeRow: React.FC = ({ {isCollection && } {isDoc && } = ({ ) ) : ( 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/projects/components/ProjectSidebar.tsx b/src/features/projects/components/ProjectSidebar.tsx index 5b3c854..41b7cc5 100644 --- a/src/features/projects/components/ProjectSidebar.tsx +++ b/src/features/projects/components/ProjectSidebar.tsx @@ -148,9 +148,7 @@ function ProjectSidebar({ const handleContextMenu = ( e: React.MouseEvent, target: - | MenuTarget - | Project - | { project: Project | GoogleAccount; collection: string; firestoreDatabaseId?: string }, + MenuTarget | Project | { project: Project | GoogleAccount; collection: string; firestoreDatabaseId?: string }, type: Exclude, ) => { e.preventDefault(); diff --git a/src/features/projects/components/sidebar/SidebarProjectsList.tsx b/src/features/projects/components/sidebar/SidebarProjectsList.tsx index 1321bd0..6f297e8 100644 --- a/src/features/projects/components/sidebar/SidebarProjectsList.tsx +++ b/src/features/projects/components/sidebar/SidebarProjectsList.tsx @@ -55,9 +55,7 @@ interface SidebarProjectsListProps { handleContextMenu: ( e: React.MouseEvent, target: - | Project - | { project: Project | GoogleAccount; collection: string; firestoreDatabaseId?: string } - | MenuTarget, + Project | { project: Project | GoogleAccount; collection: string; firestoreDatabaseId?: string } | MenuTarget, type: Exclude, ) => void; isMenuOpen: boolean; diff --git a/src/features/projects/store/projectsSlice.ts b/src/features/projects/store/projectsSlice.ts index 2e02f68..d98fdb7 100644 --- a/src/features/projects/store/projectsSlice.ts +++ b/src/features/projects/store/projectsSlice.ts @@ -527,8 +527,7 @@ export const refreshGoogleAccountProjects = createAppAsyncThunk( try { const state = getState(); const existingAccount = state.projects.items.find((item) => isGoogleAccount(item) && item.id === accountId) as - | GoogleAccount - | undefined; + GoogleAccount | undefined; const effectiveRefreshToken = refreshToken ?? existingAccount?.refreshToken; let accessToken = existingAccount?.accessToken; 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; From 4d4cac84f7ccfa0243d81e707590e128dfd19173 Mon Sep 17 00:00:00 2001 From: ChornyiDev Date: Wed, 2 Sep 2026 17:33:58 +0300 Subject: [PATCH 5/5] test: mock Firebase Admin modular app API --- .../controllers/firebaseController.test.js | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/electron/controllers/firebaseController.test.js b/electron/controllers/firebaseController.test.js index 93d4373..561943b 100644 --- a/electron/controllers/firebaseController.test.js +++ b/electron/controllers/firebaseController.test.js @@ -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')] = { @@ -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] = { @@ -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, { @@ -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, { @@ -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'); @@ -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',