Skip to content

Commit 1bb6e0b

Browse files
committed
fixup! feat(files): cache files tree on browser
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
1 parent 09a3f6b commit 1bb6e0b

6 files changed

Lines changed: 192 additions & 58 deletions

File tree

apps/files/src/components/FileEntry/FileEntryName.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export default defineComponent({
206206
207207
methods: {
208208
checkIfNodeExists(name: string) {
209-
const sources: string[] = (this.activeFolder as { _children?: string[] })?._children || []
209+
const sources: string[] = this.activeFolder.attributes._children || []
210210
return sources.some((sourceName) => basename(sourceName) === name)
211211
},
212212

apps/files/src/store/files.ts

Lines changed: 162 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,93 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { Folder, Node } from '@nextcloud/files'
6+
import type { IFolder, INode, NodeData } from '@nextcloud/files'
77
import type { FileSource, FilesState, FilesStore, RootOptions, RootsStore, Service } from '../types.ts'
88

99
import { subscribe } from '@nextcloud/event-bus'
10+
import { File, Folder } from '@nextcloud/files'
1011
import { defineStore } from 'pinia'
1112
import Vue from 'vue'
1213
import logger from '../logger.ts'
1314
import { fetchNode } from '../services/WebdavClient.ts'
1415
import { usePathsStore } from './paths.ts'
1516

17+
const DB_NAME = 'nextcloud_files_store'
18+
const DB_VERSION = 1
19+
const STORE_FILES = 'files'
20+
const STORE_ROOTS = 'roots'
21+
22+
/**
23+
* Lazily opened IndexedDB connection, shared across all store instances.
24+
* IndexedDB can store significantly more data than localStorage and supports
25+
* storing millions of individual file records efficiently.
26+
*/
27+
const dbPromise: Promise<IDBDatabase> = new Promise((resolve, reject) => {
28+
if (typeof indexedDB === 'undefined') {
29+
reject(new Error('IndexedDB is not available'))
30+
return
31+
}
32+
const request = indexedDB.open(DB_NAME, DB_VERSION)
33+
request.onupgradeneeded = (event) => {
34+
const db = (event.target as IDBOpenDBRequest).result
35+
if (!db.objectStoreNames.contains(STORE_FILES)) {
36+
db.createObjectStore(STORE_FILES)
37+
}
38+
if (!db.objectStoreNames.contains(STORE_ROOTS)) {
39+
db.createObjectStore(STORE_ROOTS)
40+
}
41+
}
42+
request.onsuccess = () => resolve(request.result)
43+
request.onerror = () => reject(request.error)
44+
})
45+
46+
/**
47+
* Deserialize a stored JSON string back into a File or Folder instance.
48+
*
49+
* @param nodeJson JSON string of a serialized node
50+
*/
51+
function deserializeNode(nodeJson: string): INode | undefined {
52+
try {
53+
const node = JSON.parse(nodeJson) as [NodeData, RegExp]
54+
if (node[0].mime === 'httpd/unix-directory') {
55+
return new Folder(...node)
56+
}
57+
return new File(...node)
58+
} catch {
59+
return undefined
60+
}
61+
}
62+
63+
/**
64+
* Restore all entries from an IndexedDB object store into a keyed map of INode instances.
65+
*
66+
* @param db Open IDBDatabase
67+
* @param storeName Name of the object store to read
68+
*/
69+
function restoreFromStore(db: IDBDatabase, storeName: string): Promise<Record<string, INode>> {
70+
return new Promise((resolve) => {
71+
const result: Record<string, INode> = {}
72+
const tx = db.transaction(storeName, 'readonly')
73+
const objectStore = tx.objectStore(storeName)
74+
const keysRequest = objectStore.getAllKeys()
75+
const valuesRequest = objectStore.getAll()
76+
77+
tx.oncomplete = () => {
78+
const keys = keysRequest.result as string[]
79+
const values = valuesRequest.result as string[]
80+
keys.forEach((key, index) => {
81+
const raw = values[index]
82+
const node = raw !== undefined ? deserializeNode(raw) : undefined
83+
if (node) {
84+
result[key] = node
85+
}
86+
})
87+
resolve(result)
88+
}
89+
tx.onerror = () => resolve(result)
90+
})
91+
}
92+
1693
/**
1794
*
1895
* @param args
@@ -30,17 +107,17 @@ export function useFilesStore(...args) {
30107
*
31108
* @param state
32109
*/
33-
getNode: (state) => (source: FileSource): Node | undefined => state.files[source],
110+
getNode: (state) => (source: FileSource): INode | undefined => state.files[source],
34111

35112
/**
36113
* Get a list of files or folders by their IDs
37114
* Note: does not return undefined values
38115
*
39116
* @param state
40117
*/
41-
getNodes: (state) => (sources: FileSource[]): Node[] => sources
118+
getNodes: (state) => (sources: FileSource[]): INode[] => sources
42119
.map((source) => state.files[source])
43-
.filter(Boolean),
120+
.filter(Boolean) as INode[],
44121

45122
/**
46123
* Get files or folders by their file ID
@@ -49,14 +126,14 @@ export function useFilesStore(...args) {
49126
*
50127
* @param state
51128
*/
52-
getNodesById: (state) => (fileId: number): Node[] => Object.values(state.files).filter((node) => node.fileid === fileId),
129+
getNodesById: (state) => (fileId: string): INode[] => Object.values(state.files).filter((node) => node.id === fileId),
53130

54131
/**
55132
* Get the root folder of a service
56133
*
57134
* @param state
58135
*/
59-
getRoot: (state) => (service: Service): Folder | undefined => state.roots[service],
136+
getRoot: (state) => (service: Service): IFolder | undefined => state.roots[service],
60137
},
61138

62139
actions: {
@@ -67,17 +144,17 @@ export function useFilesStore(...args) {
67144
* @param path - The path relative within the service
68145
* @return The folder if found
69146
*/
70-
getDirectoryByPath(service: string, path?: string): Folder | undefined {
147+
getDirectoryByPath(service: string, path?: string): IFolder | undefined {
71148
const pathsStore = usePathsStore()
72-
let folder: Folder | undefined
149+
let folder: IFolder | undefined
73150

74151
// Get the containing folder from path store
75152
if (!path || path === '/') {
76153
folder = this.getRoot(service)
77154
} else {
78155
const source = pathsStore.getPath(service, path)
79156
if (source) {
80-
folder = this.getNode(source) as Folder | undefined
157+
folder = this.getNode(source) as IFolder | undefined
81158
}
82159
}
83160

@@ -91,20 +168,20 @@ export function useFilesStore(...args) {
91168
* @param path - The path relative within the service
92169
* @return Array of cached nodes within the path
93170
*/
94-
getNodesByPath(service: string, path?: string): Node[] {
171+
getNodesByPath(service: string, path?: string): INode[] {
95172
const folder = this.getDirectoryByPath(service, path)
96173

97174
// If we found a cache entry and the cache entry was already loaded (has children) then use it
98-
return (folder?._children ?? [])
175+
return (folder?.attributes._children ?? [])
99176
.map((source: string) => this.getNode(source))
100177
.filter(Boolean)
101178
},
102179

103-
updateNodes(nodes: Node[]) {
180+
updateNodes(nodes: INode[]) {
104181
// Update the store all at once
105182
const files = nodes.reduce((acc, node) => {
106-
if (!node.fileid) {
107-
logger.error('Trying to update/set a node without fileid', { node })
183+
if (!node.id) {
184+
logger.error('Trying to update/set a node without id', { node })
108185
return acc
109186
}
110187

@@ -113,55 +190,87 @@ export function useFilesStore(...args) {
113190
}, {} as FilesStore)
114191

115192
Vue.set(this, 'files', { ...this.files, ...files })
193+
194+
// Persist new/updated nodes individually to IndexedDB
195+
dbPromise.then((db) => {
196+
const tx = db.transaction(STORE_FILES, 'readwrite')
197+
const objectStore = tx.objectStore(STORE_FILES)
198+
for (const [source, node] of Object.entries(files)) {
199+
objectStore.put(node.toJSON(), source)
200+
}
201+
}).catch((e) => logger.error('Failed to persist nodes to IndexedDB', { error: e }))
116202
},
117203

118-
deleteNodes(nodes: Node[]) {
204+
deleteNodes(nodes: INode[]) {
119205
nodes.forEach((node) => {
120206
if (node.source) {
121207
Vue.delete(this.files, node.source)
122208
}
123209
})
210+
211+
// Remove deleted nodes from IndexedDB
212+
dbPromise.then((db) => {
213+
const tx = db.transaction(STORE_FILES, 'readwrite')
214+
const objectStore = tx.objectStore(STORE_FILES)
215+
for (const node of nodes) {
216+
if (node.source) {
217+
objectStore.delete(node.source)
218+
}
219+
}
220+
}).catch((e) => logger.error('Failed to delete nodes from IndexedDB', { error: e }))
124221
},
125222

126223
setRoot({ service, root }: RootOptions) {
127224
Vue.set(this.roots, service, root)
225+
226+
// Persist the root folder to IndexedDB
227+
dbPromise.then((db) => {
228+
const tx = db.transaction(STORE_ROOTS, 'readwrite')
229+
tx.objectStore(STORE_ROOTS).put(root.toJSON(), service)
230+
}).catch((e) => logger.error('Failed to persist root to IndexedDB', { error: e }))
128231
},
129232

130-
onDeletedNode(node: Node) {
233+
onDeletedNode(node: INode) {
131234
this.deleteNodes([node])
132235
},
133236

134-
onCreatedNode(node: Node) {
237+
onCreatedNode(node: INode) {
135238
this.updateNodes([node])
136239
},
137240

138-
onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) {
139-
if (!node.fileid) {
140-
logger.error('Trying to update/set a node without fileid', { node })
241+
onMovedNode({ node, oldSource }: { node: INode, oldSource: string }) {
242+
if (!node.id) {
243+
logger.error('Trying to update/set a node without id', { node })
141244
return
142245
}
143246

247+
// Remove the old source key from IndexedDB before writing the new one
248+
dbPromise.then((db) => {
249+
const tx = db.transaction(STORE_FILES, 'readwrite')
250+
tx.objectStore(STORE_FILES).delete(oldSource)
251+
}).catch((e) => logger.error('Failed to delete moved node from IndexedDB', { error: e }))
252+
144253
// Update the path of the node
145254
Vue.delete(this.files, oldSource)
146255
this.updateNodes([node])
147256
},
148257

149-
async onUpdatedNode(node: Node) {
150-
if (!node.fileid) {
151-
logger.error('Trying to update/set a node without fileid', { node })
258+
async onUpdatedNode(node: INode) {
259+
if (!node.id) {
260+
logger.error('Trying to update/set a node without id', { node })
152261
return
153262
}
154263

155264
// If we have multiple nodes with the same file ID, we need to update all of them
156-
const nodes = this.getNodesById(node.fileid)
265+
const nodes = this.getNodesById(node.id)
157266
if (nodes.length > 1) {
158267
await Promise.all(nodes.map((node) => fetchNode(node.path))).then(this.updateNodes)
159-
logger.debug(nodes.length + ' nodes updated in store', { fileid: node.fileid })
268+
logger.debug(nodes.length + ' nodes updated in store', { id: node.id })
160269
return
161270
}
162271

163272
// If we have only one node with the file ID, we can update it directly
164-
if (nodes.length === 1 && node.source === nodes[0].source) {
273+
if (nodes.length === 1 && nodes[0] && node.source === nodes[0].source) {
165274
this.updateNodes([node])
166275
return
167276
}
@@ -171,17 +280,27 @@ export function useFilesStore(...args) {
171280
},
172281

173282
// Handlers for legacy sidebar (no real nodes support)
174-
onAddFavorite(node: Node) {
283+
onAddFavorite(node: INode) {
175284
const ourNode = this.getNode(node.source)
176285
if (ourNode) {
177286
Vue.set(ourNode.attributes, 'favorite', 1)
287+
// Persist the updated node to IndexedDB
288+
dbPromise.then((db) => {
289+
const tx = db.transaction(STORE_FILES, 'readwrite')
290+
tx.objectStore(STORE_FILES).put(ourNode.toJSON(), ourNode.source)
291+
}).catch((e) => logger.error('Failed to update favorite node in IndexedDB', { error: e }))
178292
}
179293
},
180294

181-
onRemoveFavorite(node: Node) {
295+
onRemoveFavorite(node: INode) {
182296
const ourNode = this.getNode(node.source)
183297
if (ourNode) {
184298
Vue.set(ourNode.attributes, 'favorite', 0)
299+
// Persist the updated node to IndexedDB
300+
dbPromise.then((db) => {
301+
const tx = db.transaction(STORE_FILES, 'readwrite')
302+
tx.objectStore(STORE_FILES).put(ourNode.toJSON(), ourNode.source)
303+
}).catch((e) => logger.error('Failed to update favorite node in IndexedDB', { error: e }))
185304
}
186305
},
187306
},
@@ -198,6 +317,21 @@ export function useFilesStore(...args) {
198317
subscribe('files:favorites:added', fileStore.onAddFavorite)
199318
subscribe('files:favorites:removed', fileStore.onRemoveFavorite)
200319

320+
// Restore state from IndexedDB asynchronously.
321+
// Each node is stored as an individual record, so this scales to millions of files.
322+
dbPromise.then(async (db) => {
323+
const [files, roots] = await Promise.all([
324+
restoreFromStore(db, STORE_FILES),
325+
restoreFromStore(db, STORE_ROOTS),
326+
])
327+
fileStore.$state.files = files as FilesStore
328+
fileStore.$state.roots = roots as RootsStore
329+
logger.info('Restored files store from IndexedDB', {
330+
files: Object.keys(files).length,
331+
roots: Object.keys(roots).length,
332+
})
333+
}).catch((e) => logger.info('Failed to restore files store from IndexedDB', { error: e }))
334+
201335
fileStore._initialized = true
202336
}
203337

0 commit comments

Comments
 (0)