From ffc4087fdcc211b6085642b80df1ca29976d8f17 Mon Sep 17 00:00:00 2001 From: lookieAU Date: Mon, 20 Jul 2026 23:55:43 +0530 Subject: [PATCH 1/2] added fixes for phantom docs, storage name resolution and subcollections --- .gitignore | 3 + .../controllers/firestore/documentList.js | 53 ++++ .../firestore/documentList.test.js | 111 +++++++++ .../firestore/recursiveDeleteRest.js | 79 ++++++ .../firestore/recursiveDeleteRest.test.js | 73 ++++++ electron/controllers/firestoreController.js | 44 ++-- .../controllers/firestoreController.test.js | 89 +++++++ electron/controllers/googleController.js | 68 ++++-- .../controllers/storage/bucketResolver.js | 68 ++++++ .../storage/bucketResolver.test.js | 118 +++++++++ electron/controllers/storageController.js | 108 ++------- .../controllers/storageController.test.js | 109 +++++++++ electron/preload.js | 2 + package.json | 10 +- .../collections/components/CollectionTab.tsx | 5 +- .../collections/components/TreeView.tsx | 224 +++++------------ .../dialogs/DeleteCollectionDialog.tsx | 3 +- .../collections/components/table/TableRow.tsx | 5 +- .../components/tree/TreeContext.ts | 26 ++ .../components/tree/TreeNodeRow.tsx | 226 ++++++++++++++++++ .../hooks/useTreeSubcollections.ts | 70 ++++++ .../collections/store/collectionSlice.ts | 4 +- src/shared/utils/electronMock.ts | 8 +- src/vite-env.d.ts | 12 +- 24 files changed, 1213 insertions(+), 305 deletions(-) create mode 100644 electron/controllers/firestore/documentList.js create mode 100644 electron/controllers/firestore/documentList.test.js create mode 100644 electron/controllers/firestore/recursiveDeleteRest.js create mode 100644 electron/controllers/firestore/recursiveDeleteRest.test.js create mode 100644 electron/controllers/storage/bucketResolver.js create mode 100644 electron/controllers/storage/bucketResolver.test.js create mode 100644 electron/controllers/storageController.test.js create mode 100644 src/features/collections/components/tree/TreeContext.ts create mode 100644 src/features/collections/components/tree/TreeNodeRow.tsx create mode 100644 src/features/collections/hooks/useTreeSubcollections.ts diff --git a/.gitignore b/.gitignore index fa7725b..b001f45 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ coverage/ # Temporary files tmp/ temp/ + +# Bun lock files for developers using bun instead of pnpm +bun.lock diff --git a/electron/controllers/firestore/documentList.js b/electron/controllers/firestore/documentList.js new file mode 100644 index 0000000..89961fc --- /dev/null +++ b/electron/controllers/firestore/documentList.js @@ -0,0 +1,53 @@ +const PHANTOM_SCAN_MAX_DOCS = 1000; + +function toDocumentEntry(doc) { + return { id: doc.id, data: doc.data(), path: doc.ref.path }; +} + +function toPhantomEntry(ref) { + return { id: ref.id, data: {}, path: ref.path, missing: true }; +} + +function byId(a, b) { + if (a.id === b.id) return 0; + return a.id < b.id ? -1 : 1; +} + +async function shouldScanPhantoms(collection, pageFull) { + if (pageFull) return false; + try { + const aggregate = await collection.count().get(); + return aggregate.data().count <= PHANTOM_SCAN_MAX_DOCS; + } catch { + return true; + } +} + +async function listPhantoms(collection, documents, startAfter) { + try { + const refs = await collection.listDocuments(); + const presentIds = new Set(documents.map((doc) => doc.id)); + return refs.filter((ref) => !presentIds.has(ref.id) && (!startAfter || ref.id > startAfter)).map(toPhantomEntry); + } catch { + return []; + } +} + +async function fetchDocumentsPage(dbRef, { collectionPath, limit, startAfter }) { + const collection = dbRef.collection(collectionPath); + let query = collection.limit(limit); + if (startAfter) { + const cursor = await collection.doc(startAfter).get(); + if (cursor.exists) query = query.startAfter(cursor); + } + + const snapshot = await query.get(); + const documents = snapshot.docs.map(toDocumentEntry); + if (!(await shouldScanPhantoms(collection, snapshot.size >= limit))) return documents; + + const phantoms = await listPhantoms(collection, documents, startAfter); + if (!phantoms.length) return documents; + return [...documents, ...phantoms].sort(byId).slice(0, limit); +} + +module.exports = { fetchDocumentsPage }; diff --git a/electron/controllers/firestore/documentList.test.js b/electron/controllers/firestore/documentList.test.js new file mode 100644 index 0000000..1d299bd --- /dev/null +++ b/electron/controllers/firestore/documentList.test.js @@ -0,0 +1,111 @@ +// @vitest-environment node +import { describe, it, expect, vi } from 'vitest'; +import { createRequire } from 'module'; + +const require_ = createRequire(import.meta.url); +const { fetchDocumentsPage } = require_('./documentList'); + +function snapshotDoc(id, data = { field: 1 }) { + return { id, data: () => data, ref: { path: `col/${id}` } }; +} + +function mockDb({ docs, listedIds = [], listDocumentsError, count = docs.length, countError, cursorExists = true }) { + const snapshot = { docs, size: docs.length }; + const listDocuments = listDocumentsError + ? vi.fn().mockRejectedValue(listDocumentsError) + : vi.fn().mockResolvedValue(listedIds.map((id) => ({ id, path: `col/${id}` }))); + const countGet = countError + ? vi.fn().mockRejectedValue(countError) + : vi.fn().mockResolvedValue({ data: () => ({ count }) }); + const query = { get: vi.fn().mockResolvedValue(snapshot), startAfter: vi.fn() }; + query.startAfter.mockReturnValue(query); + const collection = { + limit: vi.fn().mockReturnValue(query), + doc: vi.fn().mockReturnValue({ get: vi.fn().mockResolvedValue({ exists: cursorExists }) }), + listDocuments, + count: vi.fn().mockReturnValue({ get: countGet }), + }; + return { db: { collection: vi.fn().mockReturnValue(collection) }, query, collection }; +} + +describe('fetchDocumentsPage', () => { + it('merges phantom documents into the page sorted by id', async () => { + const { db } = mockDb({ docs: [snapshotDoc('a'), snapshotDoc('c')], listedIds: ['a', 'b', 'c'] }); + + const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null }); + + expect(documents.map((d) => d.id)).toEqual(['a', 'b', 'c']); + expect(documents[1]).toEqual({ id: 'b', data: {}, path: 'col/b', missing: true }); + expect(documents[0].missing).toBeUndefined(); + }); + + it('returns query results when listDocuments fails', async () => { + const { db } = mockDb({ docs: [snapshotDoc('a')], listDocumentsError: new Error('unavailable') }); + + const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null }); + + expect(documents.map((d) => d.id)).toEqual(['a']); + }); + + it('shows all phantoms in an empty collection', async () => { + const { db } = mockDb({ docs: [], listedIds: ['x', 'y'] }); + + const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null }); + + expect(documents.map((d) => d.id)).toEqual(['x', 'y']); + expect(documents.every((d) => d.missing)).toBe(true); + }); + + it('caps the merged page at the requested limit', async () => { + const { db } = mockDb({ docs: [snapshotDoc('a')], listedIds: ['b', 'c', 'd'] }); + + const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 2, startAfter: null }); + + expect(documents.map((d) => d.id)).toEqual(['a', 'b']); + }); + + it('excludes phantoms at or before the startAfter cursor', async () => { + const { db } = mockDb({ docs: [snapshotDoc('d')], listedIds: ['a', 'c', 'e'] }); + + const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: 'b' }); + + expect(documents.map((d) => d.id)).toEqual(['c', 'd', 'e']); + }); + + it('skips the phantom scan when the page is full', async () => { + const docs = [snapshotDoc('a'), snapshotDoc('b')]; + const { db, collection } = mockDb({ docs, listedIds: ['a', 'b', 'c'] }); + + const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 2, startAfter: null }); + + expect(documents.map((d) => d.id)).toEqual(['a', 'b']); + expect(collection.listDocuments).not.toHaveBeenCalled(); + expect(collection.count).not.toHaveBeenCalled(); + }); + + it('skips the phantom scan for large collections', async () => { + const { db, collection } = mockDb({ docs: [snapshotDoc('a')], listedIds: ['a', 'b'], count: 9875 }); + + const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null }); + + expect(documents.map((d) => d.id)).toEqual(['a']); + expect(collection.listDocuments).not.toHaveBeenCalled(); + }); + + it('scans phantoms when the count aggregate is unavailable', async () => { + const { db } = mockDb({ docs: [snapshotDoc('a')], listedIds: ['a', 'b'], countError: new Error('unsupported') }); + + const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null }); + + expect(documents.map((d) => d.id)).toEqual(['a', 'b']); + }); + + it('applies the startAfter cursor to the query', async () => { + const { db, query, collection } = mockDb({ docs: [snapshotDoc('b')], listedIds: ['b'] }); + + await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: 'a' }); + + expect(collection.doc).toHaveBeenCalledWith('a'); + expect(query.startAfter).toHaveBeenCalled(); + }); +}); diff --git a/electron/controllers/firestore/recursiveDeleteRest.js b/electron/controllers/firestore/recursiveDeleteRest.js new file mode 100644 index 0000000..88a0961 --- /dev/null +++ b/electron/controllers/firestore/recursiveDeleteRest.js @@ -0,0 +1,79 @@ +const BATCH_SIZE = 500; + +function toRestError(result) { + if (!result.ok) { + const error = new Error(result.error?.error || 'Authentication failed'); + error.ipcResult = result.error; + return error; + } + const apiError = result.data.error; + const error = new Error(apiError.message); + error.ipcResult = { success: false, error: apiError.message, requiresReauth: apiError.code === 401 }; + return error; +} + +async function fetchJson(authenticatedFetch, url, options = {}) { + const result = await authenticatedFetch(url, options); + if (!result.ok || result.data?.error) throw toRestError(result); + return result.data || {}; +} + +function postJson(authenticatedFetch, url, body) { + return fetchJson(authenticatedFetch, url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function relativeDocPath(resourceName) { + return resourceName.split('/documents/')[1]; +} + +async function listCollectionIds({ authenticatedFetch, urlRoot }, docPath) { + const ids = []; + let pageToken; + do { + const data = await postJson( + authenticatedFetch, + `${urlRoot}/${docPath}:listCollectionIds`, + pageToken ? { pageToken } : {}, + ); + ids.push(...(data.collectionIds || [])); + pageToken = data.nextPageToken; + } while (pageToken); + return ids; +} + +async function listDocumentPaths({ authenticatedFetch, urlRoot }, collectionPath) { + const paths = []; + let pageToken; + do { + const query = new URLSearchParams({ pageSize: '300', showMissing: 'true' }); + if (pageToken) query.set('pageToken', pageToken); + const data = await fetchJson(authenticatedFetch, `${urlRoot}/${collectionPath}?${query}`); + paths.push(...(data.documents || []).map((doc) => relativeDocPath(doc.name))); + pageToken = data.nextPageToken; + } while (pageToken); + return paths; +} + +async function collectTreePaths(ctx, docPath, acc) { + for (const collectionId of await listCollectionIds(ctx, docPath)) { + for (const childPath of await listDocumentPaths(ctx, `${docPath}/${collectionId}`)) { + await collectTreePaths(ctx, childPath, acc); + } + } + acc.push(docPath); + return acc; +} + +async function deleteDocumentTree({ authenticatedFetch, urlRoot, nameRoot, docPath }) { + const paths = await collectTreePaths({ authenticatedFetch, urlRoot }, docPath, []); + for (let start = 0; start < paths.length; start += BATCH_SIZE) { + const writes = paths.slice(start, start + BATCH_SIZE).map((path) => ({ delete: `${nameRoot}/${path}` })); + await postJson(authenticatedFetch, `${urlRoot}:commit`, { writes }); + } +} + +module.exports = { deleteDocumentTree, listCollectionIds }; diff --git a/electron/controllers/firestore/recursiveDeleteRest.test.js b/electron/controllers/firestore/recursiveDeleteRest.test.js new file mode 100644 index 0000000..2a8b2bc --- /dev/null +++ b/electron/controllers/firestore/recursiveDeleteRest.test.js @@ -0,0 +1,73 @@ +// @vitest-environment node +import { describe, it, expect, vi } from 'vitest'; +import { createRequire } from 'module'; + +const require_ = createRequire(import.meta.url); +const { deleteDocumentTree } = require_('./recursiveDeleteRest'); + +const URL_ROOT = 'https://firestore.googleapis.com/v1/projects/p/databases/(default)/documents'; +const NAME_ROOT = 'projects/p/databases/(default)/documents'; + +function fakeFirestore(tree) { + const commits = []; + const authenticatedFetch = vi.fn(async (url, options = {}) => { + if (url === `${URL_ROOT}:commit`) { + commits.push(JSON.parse(options.body).writes.map((w) => w.delete)); + return { ok: true, data: {} }; + } + const listMatch = url.match(/\/documents\/(.+):listCollectionIds$/); + if (listMatch) { + const node = tree[listMatch[1]] || {}; + return { ok: true, data: { collectionIds: Object.keys(node) } }; + } + const collectionPath = url.replace(`${URL_ROOT}/`, '').split('?')[0]; + const segments = collectionPath.split('/'); + const docPath = segments.slice(0, -1).join('/'); + const collectionId = segments[segments.length - 1]; + const childIds = docPath ? (tree[docPath]?.[collectionId] ?? []) : (tree['']?.[collectionId] ?? []); + return { + ok: true, + data: { documents: childIds.map((id) => ({ name: `${NAME_ROOT}/${collectionPath}/${id}` })) }, + }; + }); + return { authenticatedFetch, commits }; +} + +describe('deleteDocumentTree', () => { + it('deletes descendants before the root document', async () => { + const { authenticatedFetch, commits } = fakeFirestore({ + 'col/doc1': { sub: ['child1', 'child2'] }, + 'col/doc1/sub/child1': {}, + 'col/doc1/sub/child2': {}, + }); + + await deleteDocumentTree({ authenticatedFetch, urlRoot: URL_ROOT, nameRoot: NAME_ROOT, docPath: 'col/doc1' }); + + expect(commits.flat()).toEqual([ + `${NAME_ROOT}/col/doc1/sub/child1`, + `${NAME_ROOT}/col/doc1/sub/child2`, + `${NAME_ROOT}/col/doc1`, + ]); + }); + + it('deletes a leaf document without subcollections', async () => { + const { authenticatedFetch, commits } = fakeFirestore({ 'col/doc1': {} }); + + await deleteDocumentTree({ authenticatedFetch, urlRoot: URL_ROOT, nameRoot: NAME_ROOT, docPath: 'col/doc1' }); + + expect(commits.flat()).toEqual([`${NAME_ROOT}/col/doc1`]); + }); + + it('surfaces API errors with reauth metadata', async () => { + const authenticatedFetch = vi + .fn() + .mockResolvedValue({ ok: true, data: { error: { code: 401, message: 'Unauthenticated' } } }); + + await expect( + deleteDocumentTree({ authenticatedFetch, urlRoot: URL_ROOT, nameRoot: NAME_ROOT, docPath: 'col/doc1' }), + ).rejects.toMatchObject({ + message: 'Unauthenticated', + ipcResult: { success: false, error: 'Unauthenticated', requiresReauth: true }, + }); + }); +}); diff --git a/electron/controllers/firestoreController.js b/electron/controllers/firestoreController.js index dec850b..bde581f 100644 --- a/electron/controllers/firestoreController.js +++ b/electron/controllers/firestoreController.js @@ -6,6 +6,7 @@ const { ipcMain, dialog } = require('electron'); const fs = require('fs'); const vm = require('vm'); +const { fetchDocumentsPage } = require('./firestore/documentList'); let adminRef = null; let dbRef = null; @@ -52,19 +53,7 @@ function registerHandlers() { ipcMain.handle('firestore:getDocuments', async (event, { collectionPath, limit = 50, startAfter = null }) => { try { if (!dbRef) throw new Error('Not connected to Firebase'); - - let query = dbRef.collection(collectionPath).limit(limit); - if (startAfter) { - const startDoc = await dbRef.collection(collectionPath).doc(startAfter).get(); - if (startDoc.exists) query = query.startAfter(startDoc); - } - - const snapshot = await query.get(); - const documents = []; - snapshot.forEach((doc) => { - documents.push({ id: doc.id, data: doc.data(), path: doc.ref.path }); - }); - + const documents = await fetchDocumentsPage(dbRef, { collectionPath, limit, startAfter }); return { success: true, documents }; } catch (error) { return { success: false, error: error.message }; @@ -93,6 +82,16 @@ function registerHandlers() { } }); + ipcMain.handle('firestore:listSubcollections', async (event, documentPath) => { + try { + if (!dbRef) throw new Error('Not connected to Firebase'); + const collections = await dbRef.doc(documentPath).listCollections(); + return { success: true, collections: collections.map((col) => col.id) }; + } catch (error) { + return { success: false, error: normalizeFirestoreError(error) }; + } + }); + // Create document ipcMain.handle('firestore:createDocument', async (event, { collectionPath, documentId, data }) => { try { @@ -130,10 +129,12 @@ function registerHandlers() { }); // Delete document - ipcMain.handle('firestore:deleteDocument', async (event, documentPath) => { + ipcMain.handle('firestore:deleteDocument', async (event, params) => { try { if (!dbRef) throw new Error('Not connected to Firebase'); - await dbRef.doc(documentPath).delete(); + const { documentPath, recursive = true } = typeof params === 'string' ? { documentPath: params } : params; + if (recursive) await dbRef.recursiveDelete(dbRef.doc(documentPath)); + else await dbRef.doc(documentPath).delete(); return { success: true }; } catch (error) { return { success: false, error: error.message }; @@ -281,17 +282,8 @@ function registerHandlers() { ipcMain.handle('firestore:deleteCollection', async (event, collectionPath) => { try { if (!dbRef) throw new Error('Not connected to Firebase'); - const snapshot = await dbRef.collection(collectionPath).get(); - const batch = dbRef.batch(); - let count = 0; - - snapshot.forEach((doc) => { - batch.delete(doc.ref); - count++; - }); - if (count > 0) await batch.commit(); - - return { success: true, count }; + await dbRef.recursiveDelete(dbRef.collection(collectionPath)); + return { success: true }; } catch (error) { return { success: false, error: error.message }; } diff --git a/electron/controllers/firestoreController.test.js b/electron/controllers/firestoreController.test.js index e333064..cce6c27 100644 --- a/electron/controllers/firestoreController.test.js +++ b/electron/controllers/firestoreController.test.js @@ -92,6 +92,95 @@ describe('firestoreController', () => { expect(result.error).toContain('Permission denied'); }); + // ─── getDocuments ──────────────────────────────────────────────────────── + + it('getDocuments includes phantom documents alongside query results', async () => { + const snapshot = { + docs: [ + { id: 'a', data: () => ({ n: 1 }), ref: { path: 'col/a' } }, + { id: 'c', data: () => ({ n: 3 }), ref: { path: 'col/c' } }, + ], + size: 2, + }; + const mockCollection = { + limit: vi.fn().mockReturnValue({ get: vi.fn().mockResolvedValue(snapshot) }), + listDocuments: vi.fn().mockResolvedValue([ + { id: 'a', path: 'col/a' }, + { id: 'b', path: 'col/b' }, + { id: 'c', path: 'col/c' }, + ]), + }; + setRefs(null, { collection: vi.fn().mockReturnValue(mockCollection) }); + + const result = await handlers['firestore:getDocuments'](null, { collectionPath: 'col' }); + + expect(result.success).toBe(true); + expect(result.documents.map((d) => d.id)).toEqual(['a', 'b', 'c']); + expect(result.documents[1]).toEqual({ id: 'b', data: {}, path: 'col/b', missing: true }); + }); + + it('getDocuments falls back to query results when listDocuments fails', async () => { + const snapshot = { docs: [{ id: 'a', data: () => ({}), ref: { path: 'col/a' } }], size: 1 }; + const mockCollection = { + limit: vi.fn().mockReturnValue({ get: vi.fn().mockResolvedValue(snapshot) }), + listDocuments: vi.fn().mockRejectedValue(new Error('unavailable')), + }; + setRefs(null, { collection: vi.fn().mockReturnValue(mockCollection) }); + + const result = await handlers['firestore:getDocuments'](null, { collectionPath: 'col' }); + + expect(result.success).toBe(true); + expect(result.documents.map((d) => d.id)).toEqual(['a']); + }); + + // ─── listSubcollections ────────────────────────────────────────────────── + + it('listSubcollections returns subcollection ids for a document path', async () => { + const listCollections = vi.fn().mockResolvedValue([{ id: 'sub1' }, { id: 'sub2' }]); + setRefs(null, { doc: vi.fn().mockReturnValue({ listCollections }) }); + + const result = await handlers['firestore:listSubcollections'](null, 'col/doc1'); + + expect(result.success).toBe(true); + expect(result.collections).toEqual(['sub1', 'sub2']); + }); + + // ─── recursive deletes ─────────────────────────────────────────────────── + + it('deleteDocument deletes the document tree recursively', async () => { + const docRef = { path: 'col/doc1' }; + const recursiveDelete = vi.fn().mockResolvedValue(undefined); + setRefs(null, { doc: vi.fn().mockReturnValue(docRef), recursiveDelete }); + + const result = await handlers['firestore:deleteDocument'](null, 'col/doc1'); + + expect(result.success).toBe(true); + expect(recursiveDelete).toHaveBeenCalledWith(docRef); + }); + + it('deleteDocument performs a shallow delete when recursive is false', async () => { + const shallowDelete = vi.fn().mockResolvedValue(undefined); + const recursiveDelete = vi.fn(); + setRefs(null, { doc: vi.fn().mockReturnValue({ delete: shallowDelete }), recursiveDelete }); + + const result = await handlers['firestore:deleteDocument'](null, { documentPath: 'col/doc1', recursive: false }); + + expect(result.success).toBe(true); + expect(shallowDelete).toHaveBeenCalled(); + expect(recursiveDelete).not.toHaveBeenCalled(); + }); + + it('deleteCollection deletes the collection tree recursively', async () => { + const collectionRef = { path: 'col' }; + const recursiveDelete = vi.fn().mockResolvedValue(undefined); + setRefs(null, { collection: vi.fn().mockReturnValue(collectionRef), recursiveDelete }); + + const result = await handlers['firestore:deleteCollection'](null, 'col'); + + expect(result.success).toBe(true); + expect(recursiveDelete).toHaveBeenCalledWith(collectionRef); + }); + // ─── executeJsQuery ────────────────────────────────────────────────────── it('executeJsQuery has Firestore types in sandbox', async () => { diff --git a/electron/controllers/googleController.js b/electron/controllers/googleController.js index 76a6df8..af26ad4 100644 --- a/electron/controllers/googleController.js +++ b/electron/controllers/googleController.js @@ -11,6 +11,7 @@ const http = require('http'); const net = require('net'); const fetch = require('node-fetch'); const { convertToFirestoreValue, parseFirestoreDocument } = require('../utils/firestoreHelpers'); +const { deleteDocumentTree, listCollectionIds } = require('./firestore/recursiveDeleteRest'); /** Path segment for Firestore REST: "(default)" literal or percent-encoded custom id */ function firestoreDbPathSegment(databaseId) { @@ -492,7 +493,7 @@ function registerHandlers() { try { const dbSeg = firestoreDbPathSegment(databaseIdFromHandlerParams({ databaseId })); const result = await authenticatedFetch( - `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents/${collectionPath}?pageSize=${limit}`, + `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents/${collectionPath}?pageSize=${limit}&showMissing=true`, ); if (!result.ok) return result.error; @@ -502,10 +503,12 @@ function registerHandlers() { const documents = (data.documents || []).map((doc) => { const pathParts = doc.name.split('/'); const docId = pathParts[pathParts.length - 1]; + const missing = !doc.createTime && !doc.fields; return { id: docId, data: parseFirestoreDocument(doc.fields || {}), path: collectionPath + '/' + docId, + ...(missing ? { missing: true } : {}), }; }); return { success: true, documents }; @@ -514,6 +517,23 @@ function registerHandlers() { } }); + ipcMain.handle('google:listSubcollections', async (event, { projectId, documentPath, databaseId }) => { + try { + const dbSeg = firestoreDbPathSegment(databaseIdFromHandlerParams({ databaseId })); + const collections = await listCollectionIds( + { + authenticatedFetch, + urlRoot: `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents`, + }, + documentPath, + ); + return { success: true, collections }; + } catch (error) { + if (error.ipcResult) return error.ipcResult; + return { success: false, error: error.message }; + } + }); + ipcMain.handle('google:getDocument', async (event, { projectId, documentPath, databaseId }) => { try { const dbSeg = firestoreDbPathSegment(databaseIdFromHandlerParams({ databaseId })); @@ -636,25 +656,37 @@ function registerHandlers() { }); // Delete Document - ipcMain.handle('google:deleteDocument', async (event, { projectId, collectionPath, documentId, databaseId }) => { - try { - const dbSeg = firestoreDbPathSegment(databaseIdFromHandlerParams({ databaseId })); - const result = await authenticatedFetch( - `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents/${collectionPath}/${documentId}`, - { method: 'DELETE' }, - ); - if (!result.ok) return result.error; + ipcMain.handle( + 'google:deleteDocument', + async (event, { projectId, collectionPath, documentId, databaseId, recursive = true }) => { + try { + const resolvedDatabaseId = databaseIdFromHandlerParams({ databaseId }); + const dbSeg = firestoreDbPathSegment(resolvedDatabaseId); + const urlRoot = `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${dbSeg}/documents`; + const docPath = `${collectionPath}/${documentId}`; + + if (recursive) { + await deleteDocumentTree({ + authenticatedFetch, + urlRoot, + nameRoot: `projects/${projectId}/databases/${resolvedDatabaseId}/documents`, + docPath, + }); + return { success: true }; + } - const data = result.data; - // DELETE returns empty response on success - if (data && data.error) { - return { success: false, error: data.error.message, requiresReauth: data.error.code === 401 }; + const result = await authenticatedFetch(`${urlRoot}/${docPath}`, { method: 'DELETE' }); + if (!result.ok) return result.error; + if (result.data && result.data.error) { + return { success: false, error: result.data.error.message, requiresReauth: result.data.error.code === 401 }; + } + return { success: true }; + } catch (error) { + if (error.ipcResult) return error.ipcResult; + return { success: false, error: error.message }; } - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } - }); + }, + ); // ============================================ // Firebase Authentication (Identity Toolkit API) diff --git a/electron/controllers/storage/bucketResolver.js b/electron/controllers/storage/bucketResolver.js new file mode 100644 index 0000000..087bea1 --- /dev/null +++ b/electron/controllers/storage/bucketResolver.js @@ -0,0 +1,68 @@ +const fetch = require('node-fetch'); + +const cache = new Map(); + +function bucketCandidates(projectId) { + return [`${projectId}.firebasestorage.app`, `${projectId}.appspot.com`]; +} + +async function resolveWithProbe(cacheKey, candidates, probe) { + if (cache.has(cacheKey)) return cache.get(cacheKey); + for (const bucketName of candidates) { + if (await probe(bucketName)) { + cache.set(cacheKey, bucketName); + return bucketName; + } + } + return candidates[0]; +} + +function resolveAdminBucket(adminRef, projectId) { + return resolveWithProbe(`admin:${projectId}`, bucketCandidates(projectId), async (bucketName) => { + try { + const [exists] = await adminRef.storage().bucket(bucketName).exists(); + return exists; + } catch { + return false; + } + }); +} + +function resolveOauthBucket(projectId, accessToken) { + return resolveWithProbe(`oauth:${projectId}`, bucketCandidates(projectId), async (bucketName) => { + try { + const response = await fetch(`https://storage.googleapis.com/storage/v1/b/${bucketName}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const data = await response.json(); + return !data.error; + } catch { + return false; + } + }); +} + +function resolveEmulatorBucket(baseUrl, projectId) { + return resolveWithProbe(`emulator:${projectId}`, bucketCandidates(projectId), async (bucketName) => { + try { + const response = await fetch(`${baseUrl}/b/${bucketName}/o?maxResults=1`, { + headers: { Authorization: 'Bearer owner' }, + }); + return response.ok; + } catch { + return false; + } + }); +} + +function clearBucketCache(...prefixes) { + if (!prefixes.length) { + cache.clear(); + return; + } + for (const key of [...cache.keys()]) { + if (prefixes.some((prefix) => key.startsWith(prefix))) cache.delete(key); + } +} + +module.exports = { resolveAdminBucket, resolveOauthBucket, resolveEmulatorBucket, clearBucketCache }; diff --git a/electron/controllers/storage/bucketResolver.test.js b/electron/controllers/storage/bucketResolver.test.js new file mode 100644 index 0000000..ecf7a2b --- /dev/null +++ b/electron/controllers/storage/bucketResolver.test.js @@ -0,0 +1,118 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRequire } from 'module'; + +const require_ = createRequire(import.meta.url); +const fetchMock = vi.fn(); + +require_.cache[require_.resolve('node-fetch')] = { + id: 'node-fetch', + filename: require_.resolve('node-fetch'), + loaded: true, + exports: fetchMock, +}; + +const resolverPath = require_.resolve('./bucketResolver'); +delete require_.cache[resolverPath]; +const { resolveAdminBucket, resolveOauthBucket, resolveEmulatorBucket, clearBucketCache } = require_(resolverPath); + +function adminRefWithBuckets(existingBuckets) { + const existsMock = vi.fn(); + const bucketMock = vi.fn((name) => ({ + exists: () => { + existsMock(name); + return Promise.resolve([existingBuckets.includes(name)]); + }, + })); + return { adminRef: { storage: () => ({ bucket: bucketMock }) }, existsMock }; +} + +describe('bucketResolver', () => { + beforeEach(() => { + clearBucketCache(); + fetchMock.mockReset(); + }); + + it('resolveAdminBucket prefers firebasestorage.app', async () => { + const { adminRef } = adminRefWithBuckets(['p1.firebasestorage.app', 'p1.appspot.com']); + + expect(await resolveAdminBucket(adminRef, 'p1')).toBe('p1.firebasestorage.app'); + }); + + it('resolveAdminBucket falls back to appspot.com for legacy projects', async () => { + const { adminRef } = adminRefWithBuckets(['p1.appspot.com']); + + expect(await resolveAdminBucket(adminRef, 'p1')).toBe('p1.appspot.com'); + }); + + it('resolveAdminBucket caches successful probes', async () => { + const { adminRef, existsMock } = adminRefWithBuckets(['p1.firebasestorage.app']); + + await resolveAdminBucket(adminRef, 'p1'); + await resolveAdminBucket(adminRef, 'p1'); + + expect(existsMock).toHaveBeenCalledTimes(1); + }); + + it('resolveAdminBucket does not cache the fallback when no bucket exists', async () => { + const { adminRef, existsMock } = adminRefWithBuckets([]); + + expect(await resolveAdminBucket(adminRef, 'p1')).toBe('p1.firebasestorage.app'); + await resolveAdminBucket(adminRef, 'p1'); + + expect(existsMock).toHaveBeenCalledTimes(4); + }); + + it('resolveAdminBucket treats probe errors as missing buckets', async () => { + const adminRef = { + storage: () => ({ + bucket: () => ({ exists: () => Promise.reject(new Error('network')) }), + }), + }; + + expect(await resolveAdminBucket(adminRef, 'p1')).toBe('p1.firebasestorage.app'); + }); + + it('clearBucketCache forces a re-probe', async () => { + const { adminRef, existsMock } = adminRefWithBuckets(['p1.firebasestorage.app']); + + await resolveAdminBucket(adminRef, 'p1'); + clearBucketCache('admin:'); + await resolveAdminBucket(adminRef, 'p1'); + + expect(existsMock).toHaveBeenCalledTimes(2); + }); + + it('clearBucketCache with prefixes leaves other scopes cached', async () => { + const { adminRef, existsMock } = adminRefWithBuckets(['p1.firebasestorage.app']); + await resolveAdminBucket(adminRef, 'p1'); + + clearBucketCache('oauth:', 'emulator:'); + await resolveAdminBucket(adminRef, 'p1'); + + expect(existsMock).toHaveBeenCalledTimes(1); + }); + + it('resolveOauthBucket probes firebasestorage.app first', async () => { + fetchMock.mockResolvedValue({ json: () => Promise.resolve({}) }); + + expect(await resolveOauthBucket('p1', 'token')).toBe('p1.firebasestorage.app'); + expect(fetchMock).toHaveBeenCalledWith('https://storage.googleapis.com/storage/v1/b/p1.firebasestorage.app', { + headers: { Authorization: 'Bearer token' }, + }); + }); + + it('resolveOauthBucket falls through to appspot.com when first probe errors', async () => { + fetchMock + .mockResolvedValueOnce({ json: () => Promise.resolve({ error: { code: 404 } }) }) + .mockResolvedValueOnce({ json: () => Promise.resolve({}) }); + + expect(await resolveOauthBucket('p1', 'token')).toBe('p1.appspot.com'); + }); + + it('resolveEmulatorBucket resolves via response.ok', async () => { + fetchMock.mockResolvedValueOnce({ ok: false }).mockResolvedValueOnce({ ok: true }); + + expect(await resolveEmulatorBucket('http://localhost:9199/v0', 'p1')).toBe('p1.appspot.com'); + }); +}); diff --git a/electron/controllers/storageController.js b/electron/controllers/storageController.js index 297f816..2d6df25 100644 --- a/electron/controllers/storageController.js +++ b/electron/controllers/storageController.js @@ -8,14 +8,18 @@ const path = require('path'); const fs = require('fs'); const fetch = require('node-fetch'); const googleController = require('./googleController'); +const { + resolveAdminBucket, + resolveOauthBucket, + resolveEmulatorBucket, + clearBucketCache, +} = require('./storage/bucketResolver'); let adminRef = null; let storageEmulatorHost = null; -// Cache for resolved bucket names per project -const bucketNameCache = {}; - function setAdminRef(admin) { + if (!admin) clearBucketCache('admin:', 'emulator:'); adminRef = admin; } @@ -28,33 +32,9 @@ function getEmulatorBaseUrl() { return `http://${storageEmulatorHost}/v0`; } -/** - * Detect the correct bucket name for a project on the storage emulator. - * Tries both naming conventions and caches the result. - */ -async function getEmulatorBucketName(projectId) { - if (!storageEmulatorHost) return null; - if (bucketNameCache[projectId]) return bucketNameCache[projectId]; - - const baseUrl = getEmulatorBaseUrl(); - const patterns = [`${projectId}.firebasestorage.app`, `${projectId}.appspot.com`]; - - for (const bucketName of patterns) { - try { - const url = `${baseUrl}/b/${bucketName}/o?maxResults=1`; - const response = await fetch(url, { headers: { Authorization: 'Bearer owner' } }); - if (response.ok) { - bucketNameCache[projectId] = bucketName; - return bucketName; - } - } catch (e) { - void e; - } - } - - // Default to newer convention if no bucket exists yet - bucketNameCache[projectId] = patterns[0]; - return patterns[0]; +function getBucketName(projectId) { + if (storageEmulatorHost) return resolveEmulatorBucket(getEmulatorBaseUrl(), projectId); + return resolveAdminBucket(adminRef, projectId); } /** @@ -71,46 +51,6 @@ function normalizeStorageItem(item, prefix) { }; } -/** - * Get the default storage bucket name for a Firebase project. - * Firebase can use different bucket naming conventions: - * - Older projects: {projectId}.appspot.com - * - Newer projects: {projectId}.firebasestorage.app - * - Custom domains: Could be anything - */ -async function getStorageBucketName(projectId, accessToken) { - // Check cache first - if (bucketNameCache[projectId]) { - return bucketNameCache[projectId]; - } - - // Try different bucket name patterns - const bucketPatterns = [`${projectId}.appspot.com`, `${projectId}.firebasestorage.app`]; - - for (const bucketName of bucketPatterns) { - try { - const url = `https://storage.googleapis.com/storage/v1/b/${bucketName}`; - const response = await fetch(url, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - const data = await response.json(); - - // If no error, bucket exists - if (!data.error) { - console.log(`[Storage] Found bucket: ${bucketName}`); - bucketNameCache[projectId] = bucketName; - return bucketName; - } - } catch (error) { - void error; - // Continue trying other patterns - } - } - - // Default to appspot.com if nothing found (will show proper error to user) - return `${projectId}.appspot.com`; -} - function getProjectId() { if (adminRef?.apps?.length > 0) { return adminRef.app().options.credential?.projectId || adminRef.app().options.projectId; @@ -124,7 +64,7 @@ function registerHandlers() { try { const projectId = getProjectId(); if (!projectId) throw new Error('Not connected to Firebase'); - const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + const bucketName = await getBucketName(projectId); const prefix = storagePath ? (storagePath.endsWith('/') ? storagePath : storagePath + '/') : ''; if (storageEmulatorHost) { @@ -188,7 +128,7 @@ function registerHandlers() { const localPath = filePaths[0]; const fileName = path.basename(localPath); - const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + const bucketName = await getBucketName(projectId); const destination = storagePath ? `${storagePath}/${fileName}` : fileName; if (storageEmulatorHost) { @@ -226,10 +166,10 @@ function registerHandlers() { if (!projectId) throw new Error('Not connected'); const { filePath: savePath } = await dialog.showSaveDialog({ defaultPath: filePath.split('/').pop() }); if (!savePath) return { success: false, error: 'No save location' }; + const bucketName = await getBucketName(projectId); if (storageEmulatorHost) { const baseUrl = getEmulatorBaseUrl(); - const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; const url = `${baseUrl}/b/${bucketName}/o/${encodeURIComponent(filePath)}?alt=media`; const response = await fetch(url, { headers: { Authorization: 'Bearer owner' } }); if (!response.ok) { @@ -241,7 +181,7 @@ function registerHandlers() { return { success: true, savedTo: savePath }; } - const bucket = adminRef.storage().bucket(`${projectId}.appspot.com`); + const bucket = adminRef.storage().bucket(bucketName); await bucket.file(filePath).download({ destination: savePath }); return { success: true, savedTo: savePath }; } catch (error) { @@ -254,7 +194,7 @@ function registerHandlers() { try { const projectId = getProjectId(); if (!projectId) throw new Error('Not connected'); - const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + const bucketName = await getBucketName(projectId); if (storageEmulatorHost) { const baseUrl = getEmulatorBaseUrl(); @@ -286,7 +226,7 @@ function registerHandlers() { try { const projectId = getProjectId(); if (!projectId) throw new Error('Not connected'); - const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + const bucketName = await getBucketName(projectId); if (storageEmulatorHost) { const baseUrl = getEmulatorBaseUrl(); @@ -314,7 +254,7 @@ function registerHandlers() { try { const projectId = getProjectId(); if (!projectId) throw new Error('Not connected'); - const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + const bucketName = await getBucketName(projectId); const placeholderPath = folderPath.endsWith('/') ? folderPath + '.placeholder' : folderPath + '/.placeholder'; if (storageEmulatorHost) { @@ -348,7 +288,7 @@ function registerHandlers() { try { const projectId = getProjectId(); if (!projectId) throw new Error('Not connected'); - const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + const bucketName = await getBucketName(projectId); if (storageEmulatorHost) { const baseUrl = getEmulatorBaseUrl(); @@ -396,7 +336,7 @@ function registerHandlers() { try { const accessToken = googleController.getAccessToken(); if (!accessToken) return { success: false, error: 'Not signed in' }; - const bucketName = await getStorageBucketName(projectId, accessToken); + const bucketName = await resolveOauthBucket(projectId, accessToken); const prefix = storagePath ? (storagePath.endsWith('/') ? storagePath : storagePath + '/') : ''; const url = `https://storage.googleapis.com/storage/v1/b/${bucketName}/o?prefix=${encodeURIComponent(prefix)}&delimiter=/`; const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); @@ -445,7 +385,7 @@ function registerHandlers() { const fileName = path.basename(localPath); const fileContent = fs.readFileSync(localPath); const mimeType = require('mime-types').lookup(localPath) || 'application/octet-stream'; - const bucketName = await getStorageBucketName(projectId, accessToken); + const bucketName = await resolveOauthBucket(projectId, accessToken); const destination = storagePath ? `${storagePath}/${fileName}` : fileName; const url = `https://storage.googleapis.com/upload/storage/v1/b/${bucketName}/o?uploadType=media&name=${encodeURIComponent(destination)}`; const response = await fetch(url, { @@ -475,7 +415,7 @@ function registerHandlers() { if (!accessToken) return { success: false, error: 'Not signed in' }; const { filePath: savePath } = await dialog.showSaveDialog({ defaultPath: filePath.split('/').pop() }); if (!savePath) return { success: false, error: 'No save location' }; - const bucketName = await getStorageBucketName(projectId, accessToken); + const bucketName = await resolveOauthBucket(projectId, accessToken); const url = `https://storage.googleapis.com/storage/v1/b/${bucketName}/o/${encodeURIComponent(filePath)}?alt=media`; const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); if (!response.ok) { @@ -494,7 +434,7 @@ function registerHandlers() { try { const accessToken = googleController.getAccessToken(); if (!accessToken) return { success: false, error: 'Not signed in' }; - const bucketName = await getStorageBucketName(projectId, accessToken); + const bucketName = await resolveOauthBucket(projectId, accessToken); const metadataUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodeURIComponent(filePath)}`; const metadataResponse = await fetch(metadataUrl, { headers: { Authorization: `Bearer ${accessToken}` } }); const metadata = await metadataResponse.json(); @@ -515,7 +455,7 @@ function registerHandlers() { try { const accessToken = googleController.getAccessToken(); if (!accessToken) return { success: false, error: 'Not signed in' }; - const bucketName = await getStorageBucketName(projectId, accessToken); + const bucketName = await resolveOauthBucket(projectId, accessToken); const url = `https://storage.googleapis.com/storage/v1/b/${bucketName}/o/${encodeURIComponent(filePath)}`; const response = await fetch(url, { method: 'DELETE', @@ -535,7 +475,7 @@ function registerHandlers() { try { const accessToken = googleController.getAccessToken(); if (!accessToken) return { success: false, error: 'Not signed in' }; - const bucketName = await getStorageBucketName(projectId, accessToken); + const bucketName = await resolveOauthBucket(projectId, accessToken); const placeholderPath = folderPath.endsWith('/') ? folderPath + '.placeholder' : folderPath + '/.placeholder'; const url = `https://storage.googleapis.com/upload/storage/v1/b/${bucketName}/o?uploadType=media&name=${encodeURIComponent(placeholderPath)}`; const response = await fetch(url, { diff --git a/electron/controllers/storageController.test.js b/electron/controllers/storageController.test.js new file mode 100644 index 0000000..6336ea8 --- /dev/null +++ b/electron/controllers/storageController.test.js @@ -0,0 +1,109 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRequire } from 'module'; + +const require_ = createRequire(import.meta.url); +const handleMock = vi.fn(); +const resolveAdminBucketMock = vi.fn(); +const clearBucketCacheMock = vi.fn(); + +require_.cache[require_.resolve('electron')] = { + id: 'electron', + filename: require_.resolve('electron'), + loaded: true, + exports: { + ipcMain: { handle: handleMock }, + dialog: { showSaveDialog: vi.fn(), showOpenDialog: vi.fn() }, + }, +}; + +require_.cache[require_.resolve('node-fetch')] = { + id: 'node-fetch', + filename: require_.resolve('node-fetch'), + loaded: true, + exports: vi.fn(), +}; + +require_.cache[require_.resolve('./googleController')] = { + id: 'googleController', + filename: require_.resolve('./googleController'), + loaded: true, + exports: { getAccessToken: vi.fn() }, +}; + +require_.cache[require_.resolve('./storage/bucketResolver')] = { + id: 'bucketResolver', + filename: require_.resolve('./storage/bucketResolver'), + loaded: true, + exports: { + resolveAdminBucket: resolveAdminBucketMock, + resolveOauthBucket: vi.fn(), + resolveEmulatorBucket: vi.fn(), + clearBucketCache: clearBucketCacheMock, + }, +}; + +const controllerPath = require_.resolve('./storageController'); +delete require_.cache[controllerPath]; +const { registerHandlers, setAdminRef, setStorageEmulatorHost } = require_(controllerPath); +registerHandlers(); + +const handlers = {}; +for (const [channel, handler] of handleMock.mock.calls) { + handlers[channel] = handler; +} + +function adminRefForProject(projectId, bucketImpl) { + return { + apps: [{}], + app: () => ({ options: { projectId } }), + storage: () => ({ bucket: bucketImpl }), + }; +} + +describe('storageController', () => { + beforeEach(() => { + vi.clearAllMocks(); + setStorageEmulatorHost(null); + setAdminRef(null); + }); + + it('listFiles returns error when not connected', async () => { + const result = await handlers['storage:listFiles'](null, { path: '' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Not connected'); + }); + + it('listFiles targets the resolved bucket instead of a hardcoded one', async () => { + resolveAdminBucketMock.mockResolvedValue('p1.firebasestorage.app'); + const getFilesMock = vi.fn().mockResolvedValue([[], null, { prefixes: [] }]); + const bucketMock = vi.fn(() => ({ getFiles: getFilesMock })); + setAdminRef(adminRefForProject('p1', bucketMock)); + + const result = await handlers['storage:listFiles'](null, { path: '' }); + + expect(result.success).toBe(true); + expect(resolveAdminBucketMock).toHaveBeenCalledWith(expect.anything(), 'p1'); + expect(bucketMock).toHaveBeenCalledWith('p1.firebasestorage.app'); + }); + + it('listFiles surfaces bucket-not-found errors', async () => { + resolveAdminBucketMock.mockResolvedValue('p1.firebasestorage.app'); + const bucketMock = vi.fn(() => ({ + getFiles: vi.fn().mockRejectedValue(new Error('The specified bucket does not exist')), + })); + setAdminRef(adminRefForProject('p1', bucketMock)); + + const result = await handlers['storage:listFiles'](null, { path: '' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('does not exist'); + }); + + it('disconnect clears admin and emulator bucket caches', () => { + setAdminRef(null); + + expect(clearBucketCacheMock).toHaveBeenCalledWith('admin:', 'emulator:'); + }); +}); diff --git a/electron/preload.js b/electron/preload.js index e96c87d..49c9a28 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -28,6 +28,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getCollections: () => ipcRenderer.invoke('firestore:getCollections'), getDocuments: (params) => ipcRenderer.invoke('firestore:getDocuments', params), getDocument: (documentPath) => ipcRenderer.invoke('firestore:getDocument', documentPath), + listSubcollections: (documentPath) => ipcRenderer.invoke('firestore:listSubcollections', documentPath), createDocument: (params) => ipcRenderer.invoke('firestore:createDocument', params), updateDocument: (params) => ipcRenderer.invoke('firestore:updateDocument', params), setDocument: (params) => ipcRenderer.invoke('firestore:setDocument', params), @@ -58,6 +59,7 @@ contextBridge.exposeInMainWorld('electronAPI', { googleGetCollections: (params) => ipcRenderer.invoke('google:getCollections', params), googleGetDocuments: (params) => ipcRenderer.invoke('google:getDocuments', params), googleGetDocument: (params) => ipcRenderer.invoke('google:getDocument', params), + googleListSubcollections: (params) => ipcRenderer.invoke('google:listSubcollections', params), googleSetDocument: (params) => ipcRenderer.invoke('google:setDocument', params), // Firebase Storage operations (Service Account) diff --git a/package.json b/package.json index 973ecc0..147bcd9 100644 --- a/package.json +++ b/package.json @@ -232,10 +232,10 @@ "esbuild", "sharp", "protobufjs" - ], - "overrides": { - "axios": ">=1.13.5", - "tar": ">=7.5.7" - } + ] + }, + "overrides": { + "axios": ">=1.13.5", + "tar": ">=7.5.7" } } diff --git a/src/features/collections/components/CollectionTab.tsx b/src/features/collections/components/CollectionTab.tsx index d1f22d6..d671d04 100644 --- a/src/features/collections/components/CollectionTab.tsx +++ b/src/features/collections/components/CollectionTab.tsx @@ -757,6 +757,8 @@ const CollectionTab: React.FC = ({ project, collectionPath, {viewMode === 'tree' && ( = ({ project, collectionPath, Are you sure you want to delete {selectedRows.length} selected document - {selectedRows.length > 1 ? 's' : ''}? This action cannot be undone. + {selectedRows.length > 1 ? 's' : ''}? All nested subcollections will also be permanently deleted. This + action cannot be undone. {selectedRows.length <= 10 && ( diff --git a/src/features/collections/components/TreeView.tsx b/src/features/collections/components/TreeView.tsx index b09335c..434e6c8 100644 --- a/src/features/collections/components/TreeView.tsx +++ b/src/features/collections/components/TreeView.tsx @@ -1,27 +1,15 @@ -import React from 'react'; -import { - Box, - IconButton, - Typography, - TextField, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - useTheme, -} from '@mui/material'; -import { - ExpandMore as ExpandMoreIcon, - ChevronRight as ChevronRightIcon, - Storage as CollectionIcon, - Description as DocumentIcon, -} from '@mui/icons-material'; +import React, { useMemo } from 'react'; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, useTheme } from '@mui/material'; import { FirestoreValue } from '../../../shared/utils/firestoreUtils'; import { Document } from '../store/collectionSlice'; +import { Project } from '../../projects/store/projectsSlice'; +import { useTreeSubcollections } from '../hooks/useTreeSubcollections'; +import TreeNodeRow from './tree/TreeNodeRow'; +import { TreeContext, TreeContextValue } from './tree/TreeContext'; interface TreeViewProps { + project: Project; + firestoreDatabaseId?: string; collectionPath: string; documents: Document[]; expandedNodes: Record; @@ -38,6 +26,8 @@ interface TreeViewProps { } const TreeView: React.FC = ({ + project, + firestoreDatabaseId, collectionPath, documents, expandedNodes, @@ -54,151 +44,53 @@ const TreeView: React.FC = ({ }) => { const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; + const { subcollectionsByDocPath, documentsByPath, ensureSubcollections, ensureDocuments } = useTreeSubcollections( + project, + firestoreDatabaseId, + ); - interface TreeRowProps { - nodeKey: string; - value: FirestoreValue; - path: string; - docId?: string; - depth?: number; - isDoc?: boolean; - isCollection?: boolean; - } - - const TreeRow: React.FC = ({ - nodeKey, - value, - path, - docId, - depth = 0, - isDoc = false, - isCollection = false, - }) => { - const nodeType = isCollection ? 'Collection' : isDoc ? 'Document' : getType(value); - const isExpandable = isCollection || isDoc || nodeType === 'Array' || nodeType === 'Map'; - const isExpanded = expandedNodes[path]; - const displayValue = isExpandable ? '' : formatValue(value, nodeType); - const isEditing = - !isCollection && !isDoc && !isExpandable && editingCell?.docId === docId && editingCell?.field === nodeKey; - - return ( - <> - - isExpandable && toggleNode(path)} - > - - {isExpandable ? ( - - {isExpanded ? : } - - ) : ( - - )} - {isCollection && } - {isDoc && } - {nodeKey} - - - - {!isCollection && !isDoc && !isExpandable ? ( - isEditing ? ( - setEditValue(e.target.value)} - onBlur={onCellSave} - onKeyDown={onCellKeyDown} - autoFocus - sx={{ - '& .MuiInputBase-input': { - fontFamily: 'monospace', - fontSize: '0.8rem', - py: 0.5, - }, - }} - /> - ) : ( - docId && onCellEdit(docId, nodeKey, value)} - sx={{ - fontSize: '0.8rem', - color: getTypeColor(nodeType, isDark), - cursor: 'pointer', - '&:hover': { bgcolor: 'action.hover', borderRadius: 0.5 }, - p: 0.5, - }} - > - {displayValue} - - ) - ) : ( - {displayValue} - )} - - - {nodeType} - - - {/* Recursively render children if expanded */} - {isExpanded && ( - <> - {/* For Collection: render Documents */} - {isCollection && - documents.map((doc) => ( - - ))} - - {/* For Document or Recursive Objects: render keys */} - {!isCollection && !isDoc && isExpandable && value && typeof value === 'object' - ? Object.entries(value as Record).map(([k, v]) => ( - - )) - : null} - - {/* For Document: render fields (handled slightly differently in original but let's align) */} - {isDoc && - value && - typeof value === 'object' && - Object.entries(value as Record).map(([k, v]) => ( - - ))} - - )} - - ); - }; + const contextValue = useMemo( + () => ({ + rootPath: collectionPath, + rootDocuments: documents, + expandedNodes, + toggleNode, + editingCell, + editValue, + setEditValue, + onCellEdit, + onCellSave, + onCellKeyDown, + getType, + getTypeColor, + formatValue, + isDark, + subcollectionsByDocPath, + documentsByPath, + ensureSubcollections, + ensureDocuments, + }), + [ + collectionPath, + documents, + expandedNodes, + toggleNode, + editingCell, + editValue, + setEditValue, + onCellEdit, + onCellSave, + onCellKeyDown, + getType, + getTypeColor, + formatValue, + isDark, + subcollectionsByDocPath, + documentsByPath, + ensureSubcollections, + ensureDocuments, + ], + ); return ( @@ -217,7 +109,9 @@ const TreeView: React.FC = ({ - + + + diff --git a/src/features/collections/components/dialogs/DeleteCollectionDialog.tsx b/src/features/collections/components/dialogs/DeleteCollectionDialog.tsx index 1b45bb0..e697c7f 100644 --- a/src/features/collections/components/dialogs/DeleteCollectionDialog.tsx +++ b/src/features/collections/components/dialogs/DeleteCollectionDialog.tsx @@ -37,7 +37,8 @@ const DeleteCollectionDialog: React.FC = ({ open, o Are you sure you want to delete the collection "{collection}"? - This will permanently delete all documents in this collection. This action cannot be undone. + This will permanently delete all documents in this collection, including their nested subcollections. This + action cannot be undone. diff --git a/src/features/collections/components/table/TableRow.tsx b/src/features/collections/components/table/TableRow.tsx index 3bf2ffc..8ed87a7 100644 --- a/src/features/collections/components/table/TableRow.tsx +++ b/src/features/collections/components/table/TableRow.tsx @@ -106,11 +106,12 @@ const TableRow: React.FC = ({ {/* Document ID */}
; + toggleNode: (path: string) => void; + editingCell: { docId: string; field: string } | null; + editValue: string; + setEditValue: (value: string) => void; + onCellEdit: (docId: string | null, field: string | null, value: FirestoreValue) => void; + onCellSave: () => void; + onCellKeyDown: (e: React.KeyboardEvent) => void; + getType: (value: FirestoreValue) => string; + getTypeColor: (type: string, isDark: boolean) => string; + formatValue: (value: FirestoreValue, type: string) => string; + isDark: boolean; + subcollectionsByDocPath: Record; + documentsByPath: Record; + ensureSubcollections: (docPath: string) => void; + ensureDocuments: (collectionPath: string) => void; +} + +export const TreeContext = React.createContext(null); diff --git a/src/features/collections/components/tree/TreeNodeRow.tsx b/src/features/collections/components/tree/TreeNodeRow.tsx new file mode 100644 index 0000000..79fb82c --- /dev/null +++ b/src/features/collections/components/tree/TreeNodeRow.tsx @@ -0,0 +1,226 @@ +import React, { useContext, useEffect } from 'react'; +import { Box, IconButton, TableCell, TableRow, TextField, Typography } from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + ChevronRight as ChevronRightIcon, + Storage as CollectionIcon, + Description as DocumentIcon, +} from '@mui/icons-material'; +import { FirestoreValue } from '../../../../shared/utils/firestoreUtils'; +import { TreeContext } from './TreeContext'; + +interface TreeNodeRowProps { + nodeKey: string; + value: FirestoreValue; + path: string; + docId?: string; + depth?: number; + isDoc?: boolean; + isCollection?: boolean; + missing?: boolean; +} + +const TreeNodeRow: React.FC = ({ + nodeKey, + value, + path, + docId, + depth = 0, + isDoc = false, + isCollection = false, + missing = false, +}) => { + const ctx = useContext(TreeContext); + if (!ctx) throw new Error('TreeNodeRow must be rendered inside a TreeContext provider'); + const { + rootPath, + rootDocuments, + expandedNodes, + toggleNode, + editingCell, + editValue, + setEditValue, + onCellEdit, + onCellSave, + onCellKeyDown, + getType, + getTypeColor, + formatValue, + isDark, + subcollectionsByDocPath, + documentsByPath, + ensureSubcollections, + ensureDocuments, + } = ctx; + + const nodeType = isCollection ? 'Collection' : isDoc ? 'Document' : getType(value); + const isExpandable = isCollection || isDoc || nodeType === 'Array' || nodeType === 'Map'; + const isExpanded = expandedNodes[path]; + const displayValue = isExpandable ? '' : formatValue(value, nodeType); + const isEditing = + !isCollection && !isDoc && !isExpandable && editingCell?.docId === docId && editingCell?.field === nodeKey; + + const isRoot = isCollection && path === rootPath; + const collectionDocs = isCollection ? (isRoot ? rootDocuments : documentsByPath[path]) : undefined; + const subcollectionIds = isDoc ? subcollectionsByDocPath[path] : undefined; + + useEffect(() => { + if (!isExpanded) return; + if (isDoc) ensureSubcollections(path); + if (isCollection && !isRoot) ensureDocuments(path); + }, [isExpanded, isDoc, isCollection, isRoot, path, ensureSubcollections, ensureDocuments]); + + const isLoadingChildren = + Boolean(isExpanded) && ((isDoc && !subcollectionIds) || (isCollection && !isRoot && !collectionDocs)); + + return ( + <> + + isExpandable && toggleNode(path)} + > + + {isExpandable ? ( + + {isExpanded ? : } + + ) : ( + + )} + {isCollection && } + {isDoc && } + + {nodeKey} + + + + + {!isCollection && !isDoc && !isExpandable ? ( + isEditing ? ( + setEditValue(e.target.value)} + onBlur={onCellSave} + onKeyDown={onCellKeyDown} + autoFocus + sx={{ + '& .MuiInputBase-input': { + fontFamily: 'monospace', + fontSize: '0.8rem', + py: 0.5, + }, + }} + /> + ) : ( + docId && onCellEdit(docId, nodeKey, value)} + sx={{ + fontSize: '0.8rem', + color: getTypeColor(nodeType, isDark), + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover', borderRadius: 0.5 }, + p: 0.5, + }} + > + {displayValue} + + ) + ) : ( + {displayValue} + )} + + + {nodeType} + + + + {isLoadingChildren && ( + + + Loading… + + + )} + + {isExpanded && ( + <> + {isCollection && + (collectionDocs ?? []).map((doc) => ( + + ))} + + {!isCollection && + !isDoc && + isExpandable && + value && + typeof value === 'object' && + Object.entries(value as Record).map(([k, v]) => ( + + ))} + + {isDoc && ( + <> + {value && + typeof value === 'object' && + Object.entries(value as Record).map(([k, v]) => ( + + ))} + {(subcollectionIds ?? []).map((id) => ( + + ))} + + )} + + )} + + ); +}; + +export default TreeNodeRow; diff --git a/src/features/collections/hooks/useTreeSubcollections.ts b/src/features/collections/hooks/useTreeSubcollections.ts new file mode 100644 index 0000000..400079e --- /dev/null +++ b/src/features/collections/hooks/useTreeSubcollections.ts @@ -0,0 +1,70 @@ +import { useCallback, useRef, useState } from 'react'; +import { electronService } from '../../../shared/services/electronService'; +import { Project } from '../../projects/store/projectsSlice'; +import { getGoogleApiDatabaseId } from '../../projects/utils/firestoreDatabaseUtils'; +import { Document } from '../store/collectionSlice'; + +const NESTED_PAGE_LIMIT = 50; + +export interface TreeSubcollections { + subcollectionsByDocPath: Record; + documentsByPath: Record; + ensureSubcollections: (docPath: string) => void; + ensureDocuments: (collectionPath: string) => void; +} + +export function useTreeSubcollections(project: Project, firestoreDatabaseId?: string): TreeSubcollections { + const [subcollectionsByDocPath, setSubcollectionsByDocPath] = useState>({}); + const [documentsByPath, setDocumentsByPath] = useState>({}); + const requestedPaths = useRef(new Set()); + + const isGoogle = project.authMethod === 'google'; + const databaseId = getGoogleApiDatabaseId(project, firestoreDatabaseId); + + const ensureSubcollections = useCallback( + async (docPath: string) => { + const requestKey = `doc:${docPath}`; + if (requestedPaths.current.has(requestKey)) return; + requestedPaths.current.add(requestKey); + + const api = electronService.api; + const result = isGoogle + ? await api.googleListSubcollections({ projectId: project.projectId, documentPath: docPath, databaseId }) + : await api.listSubcollections(docPath); + + if (result.success) { + setSubcollectionsByDocPath((prev) => ({ ...prev, [docPath]: result.collections ?? [] })); + } else { + requestedPaths.current.delete(requestKey); + } + }, + [isGoogle, project.projectId, databaseId], + ); + + const ensureDocuments = useCallback( + async (collectionPath: string) => { + const requestKey = `col:${collectionPath}`; + if (requestedPaths.current.has(requestKey)) return; + requestedPaths.current.add(requestKey); + + const api = electronService.api; + const result = isGoogle + ? await api.googleGetDocuments({ + projectId: project.projectId, + collectionPath, + limit: NESTED_PAGE_LIMIT, + databaseId, + }) + : await api.getDocuments({ collectionPath, limit: NESTED_PAGE_LIMIT }); + + if (result.success) { + setDocumentsByPath((prev) => ({ ...prev, [collectionPath]: (result.documents ?? []) as Document[] })); + } else { + requestedPaths.current.delete(requestKey); + } + }, + [isGoogle, project.projectId, databaseId], + ); + + return { subcollectionsByDocPath, documentsByPath, ensureSubcollections, ensureDocuments }; +} diff --git a/src/features/collections/store/collectionSlice.ts b/src/features/collections/store/collectionSlice.ts index 14f6a02..c4ba12f 100644 --- a/src/features/collections/store/collectionSlice.ts +++ b/src/features/collections/store/collectionSlice.ts @@ -22,6 +22,7 @@ export interface DocumentData { export interface Document { id: string; data: DocumentData; + missing?: boolean; } export interface Filter { @@ -525,9 +526,10 @@ export const renameCollection = createAppAsyncThunk< collectionPath: currentPath, documentId: doc.id, databaseId: getGoogleApiDatabaseId(project, firestoreDatabaseId), + recursive: false, }); } else { - await electron.deleteDocument(`${currentPath}/${doc.id}`); + await electron.deleteDocument({ documentPath: `${currentPath}/${doc.id}`, recursive: false }); } } diff --git a/src/shared/utils/electronMock.ts b/src/shared/utils/electronMock.ts index 7557081..a0825ab 100644 --- a/src/shared/utils/electronMock.ts +++ b/src/shared/utils/electronMock.ts @@ -26,6 +26,9 @@ const mockElectronAPI: ElectronAPI = { getDocument: async (_documentPath: string) => { return { success: false, error: 'Requires Electron mode' }; }, + listSubcollections: async (_documentPath: string) => { + return { success: false, error: 'Requires Electron mode' }; + }, createDocument: async (_params: CreateDocumentParams) => { return { success: false, error: 'Requires Electron mode' }; }, @@ -35,7 +38,7 @@ const mockElectronAPI: ElectronAPI = { setDocument: async (_params: SetDocumentParams) => { return { success: false, error: 'Requires Electron mode' }; }, - deleteDocument: async (_documentPath: string) => { + deleteDocument: async (_params: string | { documentPath: string; recursive?: boolean }) => { return { success: false, error: 'Requires Electron mode' }; }, deleteCollection: async (_collectionPath: string) => { @@ -103,6 +106,9 @@ const mockElectronAPI: ElectronAPI = { console.warn('[Mock] googleGetDocument requires Electron mode'); return { success: false, error: 'Requires Electron mode' }; }, + googleListSubcollections: async (_params: { projectId: string; documentPath: string; databaseId?: string }) => { + return { success: false, error: 'Requires Electron mode' }; + }, googleExecuteStructuredQuery: async (_params: StructuredQueryParams) => { console.warn('[Mock] googleExecuteStructuredQuery'); return { success: false, error: 'Requires Electron mode' }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index cbbef32..4b67436 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -4,6 +4,7 @@ interface FirestoreDocument { id: string; data: Record; + missing?: boolean; } interface FirestoreCollection { @@ -88,6 +89,7 @@ interface GoogleDeleteDocumentParams { collectionPath: string; documentId: string; databaseId?: string; + recursive?: boolean; } interface StructuredQueryParams { @@ -220,10 +222,13 @@ interface ElectronAPI { params: GetDocumentsParams, ) => Promise<{ success: boolean; documents?: FirestoreDocument[]; error?: string }>; getDocument: (documentPath: string) => Promise<{ success: boolean; data?: Record; error?: string }>; + listSubcollections: (documentPath: string) => Promise<{ success: boolean; collections?: string[]; error?: string }>; createDocument: (params: CreateDocumentParams) => Promise<{ success: boolean; error?: string }>; updateDocument: (params: SetDocumentParams) => Promise<{ success: boolean; error?: string }>; setDocument: (params: SetDocumentParams) => Promise<{ success: boolean; error?: string }>; - deleteDocument: (documentPath: string) => Promise<{ success: boolean; error?: string }>; + deleteDocument: ( + params: string | { documentPath: string; recursive?: boolean }, + ) => Promise<{ success: boolean; error?: string }>; deleteCollection: (collectionPath: string) => Promise<{ success: boolean; error?: string }>; // Query @@ -257,6 +262,11 @@ interface ElectronAPI { documentPath: string; databaseId?: string; }) => Promise<{ success: boolean; document?: Record; error?: string }>; + googleListSubcollections: (params: { + projectId: string; + documentPath: string; + databaseId?: string; + }) => Promise<{ success: boolean; collections?: string[]; error?: string }>; googleSetDocument: (params: GoogleSetDocumentParams) => Promise<{ success: boolean; error?: string }>; googleDeleteDocument: (params: GoogleDeleteDocumentParams) => Promise<{ success: boolean; error?: string }>; googleExecuteStructuredQuery: ( From 43c5e3d08368e3c2f8a6273e15a63332a9b12f87 Mon Sep 17 00:00:00 2001 From: lookieAU Date: Wed, 22 Jul 2026 10:14:51 +0530 Subject: [PATCH 2/2] reverted package.json formatting changes for failing build --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 147bcd9..973ecc0 100644 --- a/package.json +++ b/package.json @@ -232,10 +232,10 @@ "esbuild", "sharp", "protobufjs" - ] - }, - "overrides": { - "axios": ">=1.13.5", - "tar": ">=7.5.7" + ], + "overrides": { + "axios": ">=1.13.5", + "tar": ">=7.5.7" + } } }