Line data Source code
1 : import { StorageBackendType } from './storage_backend.js';
2 : import { type SessionLocation, displayPath } from './session_locator.js';
3 : import { join, readDir, stat } from '../platform/electron_fs.js';
4 : import { isDesktopHost } from '../platform/native_globals.js';
5 : import { getOPFSRoot } from '../platform/opfs_root.js';
6 :
7 : /// The kind of a directory entry
8 1 : export enum StorageTreeEntryKind {
9 1 : File = 0,
10 1 : Directory = 1,
11 1 : }
12 :
13 : /// One entry of a listed directory. A listing carries names only, the metadata of files is fetched
14 : /// separately for the rows that are visible.
15 : export interface StorageTreeEntry {
16 : /// The name of the entry in its directory
17 : name: string;
18 : /// Is the entry a file or a directory?
19 : kind: StorageTreeEntryKind;
20 : }
21 :
22 : /// The metadata of a file
23 : export interface StorageTreeFileStat {
24 : /// The size in bytes, null if the file could not be read
25 : sizeBytes: number | null;
26 : /// The last modification time in epoch ms, null if unknown
27 : modifiedMs: number | null;
28 : /// A short note shown instead of the size, e.g. for files that are held open by HyperDB
29 : note: string | null;
30 : }
31 :
32 : /// A folder the storage browser can list, e.g. the OPFS root or a native session folder
33 : export interface StorageTreeRoot {
34 : /// The unique key of the root
35 : key: string;
36 : /// The label of the root
37 : label: string;
38 : /// The path of the root for display, e.g. `opfs://` or `fs:///home/me/session`
39 : displayPath: string;
40 : /// List the entries of a directory, given as path segments relative to the root
41 : list(path: readonly string[]): Promise<StorageTreeEntry[]>;
42 : /// Read the metadata of a file, given as path segments relative to the root
43 : stat(path: readonly string[]): Promise<StorageTreeFileStat>;
44 : }
45 :
46 : /// The key of the OPFS root
47 1 : export const OPFS_TREE_ROOT_KEY = 'opfs';
48 :
49 : /// Directories first, then a natural sort by name (e.g. "page-2" < "page-10")
50 1 : export function sortStorageTreeEntries(entries: StorageTreeEntry[]): StorageTreeEntry[] {
51 1 : return entries.sort((a, b) => (b.kind - a.kind) || a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
52 1 : }
53 :
54 : /// The Origin Private File System as a storage tree root
55 1 : export function opfsTreeRoot(getRoot: () => Promise<FileSystemDirectoryHandle> = getOPFSRoot): StorageTreeRoot {
56 1 : const directory = async (path: readonly string[]): Promise<FileSystemDirectoryHandle> => {
57 1 : let dir = await getRoot();
58 1 : for (const name of path) {
59 1 : dir = await dir.getDirectoryHandle(name, { create: false });
60 : }
61 1 : return dir;
62 1 : };
63 1 : return {
64 1 : key: OPFS_TREE_ROOT_KEY,
65 1 : label: 'Origin Private File System',
66 1 : displayPath: 'opfs://',
67 1 : async list(path) {
68 1 : const dir = await directory(path);
69 1 : const entries: StorageTreeEntry[] = [];
70 1 : for await (const [name, handle] of dir.entries()) {
71 1 : entries.push({ name, kind: handle.kind === 'directory' ? StorageTreeEntryKind.Directory : StorageTreeEntryKind.File });
72 : }
73 1 : return sortStorageTreeEntries(entries);
74 1 : },
75 1 : async stat(path) {
76 1 : const dir = await directory(path.slice(0, -1));
77 1 : const handle = await dir.getFileHandle(path[path.length - 1], { create: false });
78 1 : try {
79 1 : const file = await handle.getFile();
80 1 : return { sizeBytes: file.size, modifiedMs: file.lastModified, note: null };
81 1 : } catch (error) {
82 : // Files with an open sync access handle, e.g. the databases of a live HyperDB instance,
83 : // may refuse to be read.
84 1 : const name = (error as Error)?.name;
85 1 : const inUse = name === 'NoModificationAllowedError' || name === 'InvalidStateError';
86 1 : return { sizeBytes: null, modifiedMs: null, note: inUse ? 'in use' : 'unavailable' };
87 1 : }
88 1 : },
89 1 : };
90 1 : }
91 :
92 : /// A native folder as a storage tree root. Symlinks are listed as files and never followed.
93 1 : export function nativeTreeRoot(dir: string, label: string, shownPath: string): StorageTreeRoot {
94 1 : return {
95 1 : key: `native:${dir}`,
96 1 : label,
97 1 : displayPath: shownPath,
98 1 : async list(path) {
99 1 : const entries = await readDir(await join(dir, ...path));
100 1 : return sortStorageTreeEntries(entries.map(entry => ({
101 1 : name: entry.name,
102 1 : kind: entry.isDirectory && !entry.isSymlink ? StorageTreeEntryKind.Directory : StorageTreeEntryKind.File,
103 1 : })));
104 1 : },
105 1 : async stat(path) {
106 1 : const value = await stat(await join(dir, ...path));
107 1 : return { sizeBytes: value.size, modifiedMs: value.mtime?.getTime() ?? null, note: null };
108 1 : },
109 1 : };
110 1 : }
111 :
112 : /// The session routing the storage browser reads its native roots from
113 : export interface StorageTreeSessions {
114 : /// The user-facing session order, as session UUIDs
115 : getSessionOrder(): string[];
116 : /// The physical location of a session's files
117 : getSessionLocation(sessionId: string): SessionLocation;
118 : }
119 :
120 : /// The roots of the storage browser: the OPFS root, followed by the native session folders on desktop
121 1 : export function listStorageTreeRoots(sessions: StorageTreeSessions, desktop: boolean = isDesktopHost()): StorageTreeRoot[] {
122 1 : const roots = [opfsTreeRoot()];
123 1 : if (!desktop) {
124 1 : return roots;
125 : }
126 1 : const seen = new Set<string>();
127 1 : for (const sessionId of sessions.getSessionOrder()) {
128 1 : const location = sessions.getSessionLocation(sessionId);
129 1 : if (location.type !== StorageBackendType.Native || !location.nativePath || seen.has(location.nativePath)) {
130 1 : continue;
131 : }
132 1 : seen.add(location.nativePath);
133 1 : roots.push(nativeTreeRoot(location.nativePath, sessionId, displayPath(sessionId, location)));
134 1 : }
135 1 : return roots;
136 1 : }
137 :
138 : /// A queued or running stat request
139 : interface StatRequest {
140 : /// Fetch the stat
141 : fetch: () => Promise<StorageTreeFileStat>;
142 : /// The subscribers waiting for the stat
143 : listeners: Set<(stat: StorageTreeFileStat) => void>;
144 : }
145 :
146 : /// The file stats of the storage browser, fetched on demand for the visible rows.
147 : ///
148 : /// At most `concurrency` stats are fetched at once. A queued request whose subscribers all left
149 : /// (e.g. the row was scrolled out of view) is skipped. Fetched stats stay cached, a refresh replaces
150 : /// the whole cache.
151 1 : export class StorageTreeStatCache {
152 : /// The fetched stats by node key
153 1 : private readonly stats = new Map<string, StorageTreeFileStat>();
154 : /// The queued and running requests by node key
155 1 : private readonly requests = new Map<string, StatRequest>();
156 : /// The node keys of the queued requests
157 1 : private readonly queue: string[] = [];
158 : /// The number of running requests
159 1 : private running = 0;
160 :
161 1 : constructor(private readonly concurrency: number = 4) {}
162 :
163 : /// The cached stat of a file
164 1 : get(key: string): StorageTreeFileStat | undefined {
165 1 : return this.stats.get(key);
166 1 : }
167 :
168 : /// Subscribe to the stat of a file, fetching it unless it is cached. Returns the unsubscribe function.
169 1 : subscribe(key: string, fetch: () => Promise<StorageTreeFileStat>, listener: (stat: StorageTreeFileStat) => void): () => void {
170 1 : if (this.stats.has(key)) {
171 1 : return () => {};
172 : }
173 1 : let request = this.requests.get(key);
174 1 : if (request == null) {
175 1 : request = { fetch, listeners: new Set() };
176 1 : this.requests.set(key, request);
177 1 : this.queue.push(key);
178 1 : }
179 1 : const listeners = request.listeners;
180 1 : listeners.add(listener);
181 1 : this.pump();
182 1 : return () => {
183 1 : listeners.delete(listener);
184 1 : };
185 1 : }
186 :
187 : /// Start queued requests up to the concurrency limit
188 1 : private pump(): void {
189 1 : while (this.running < this.concurrency && this.queue.length > 0) {
190 1 : const key = this.queue.shift()!;
191 1 : const request = this.requests.get(key)!;
192 1 : if (request.listeners.size === 0) {
193 1 : this.requests.delete(key);
194 1 : continue;
195 1 : }
196 1 : ++this.running;
197 1 : void request.fetch()
198 1 : .catch((): StorageTreeFileStat => ({ sizeBytes: null, modifiedMs: null, note: 'unavailable' }))
199 1 : .then(stat => {
200 1 : this.stats.set(key, stat);
201 1 : this.requests.delete(key);
202 1 : for (const listener of request.listeners) {
203 1 : listener(stat);
204 : }
205 1 : })
206 1 : .finally(() => {
207 1 : --this.running;
208 1 : this.pump();
209 1 : });
210 1 : }
211 1 : }
212 1 : }
|