Skip to content

Commit

Permalink
Add file manager
Browse files Browse the repository at this point in the history
  • Loading branch information
slietar committed Jun 27, 2023
1 parent d783c72 commit 3f5152f
Show file tree
Hide file tree
Showing 6 changed files with 310 additions and 155 deletions.
255 changes: 255 additions & 0 deletions app/electron/src/file-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import chokidar from 'chokidar';
import { WebContents } from 'electron';
import { Stats } from 'fs';
import fs from 'node:fs/promises';
import { Deferred, defer } from 'pr1-shared';

import { DocumentChange } from './interfaces';
import { rootLogger } from './logger';
import { Pool } from './util';


export interface FileState {
contents: string | null;
lastExternalModificationDate: number;
lastModificationDate: number;
queryId: number | null;
queryDeferred: Deferred<void> | null;
subscriberCount: 0; // Includes windows that are waiting for the file to be written before watching it
watchers: Set<WebContents>;
}


export type FilePath = string;

export class FileManager {
private fileLocks = new Map<FilePath, Promise<void>>();
private fileStates = new Map<FilePath, FileState>();
private logger = rootLogger.getChild('fileManager');
private pool = new Pool(this.logger);
private watcher: chokidar.FSWatcher;

constructor() {
this.watcher = chokidar.watch([], {
awaitWriteFinish: {
stabilityThreshold: 500
}
});

this.watcher.on('add', (filePath) => {
this.logger.debug(`Detected new file: ${filePath}`);
this.pool.add(async () => void await this.detectChange(filePath));
});

this.watcher.on('change', (filePath) => {
this.logger.debug(`Detected changed file: ${filePath}`);
this.pool.add(async () => void await this.detectChange(filePath));
});

this.watcher.on('unlink', (filePath) => {
this.logger.debug(`Detected deleted file: ${filePath}`);
this.pool.add(async () => void await this.detectChange(filePath));
});
}

async dispose() {
this.logger.debug('Disposing');

this.fileStates.clear();
await this.watcher.close();
}

private async acquireFile(filePath: string) {
while (this.fileLocks.has(filePath)) {
await this.fileLocks.get(filePath)!;
}
}

private async lockFile<T>(filePath: string, handler: (() => Promise<T>)): Promise<T> {
await this.acquireFile(filePath);

let deferred = defer();
this.fileLocks.set(filePath, deferred.promise);

try {
return await handler();
} finally {
deferred.resolve();
this.fileLocks.delete(filePath);
}
}

private createChange(filePath: string): DocumentChange {
let fileState = this.fileStates.get(filePath)!;

return (fileState.contents !== null)
? {
instance: {
contents: fileState.contents,
lastExternalModificationDate: fileState.lastExternalModificationDate,
lastModificationDate: fileState.lastModificationDate
},
status: 'ok'
}
: {
instance: null,
status: 'missing'
};
}

private async detectChange(filePath: string) {
let fileState = this.fileStates.get(filePath)!;
let queryId = (fileState.queryId ?? -1) + 1;

fileState.queryDeferred ??= defer();
fileState.queryId = queryId;

await this.lockFile(filePath, async () => {
if (fileState.queryId !== queryId) {
return;
}

fileState.queryId = null;

let stats: Stats | null;

try {
stats = await fs.stat(filePath);
} catch (err: any) {
if (err.code === 'ENOENT') {
stats = null;
} else {
throw err;
}
}

if (fileState.queryId !== null) {
return;
}

fileState.queryDeferred!.resolve();
fileState.queryDeferred = null;

let isChange = false;

if (stats) {
// If this is an external modification
if (stats.mtimeMs !== fileState.lastModificationDate) {
fileState.contents = (await fs.readFile(filePath)).toString();
fileState.lastModificationDate = stats.mtimeMs;
fileState.lastExternalModificationDate = stats.mtimeMs;
isChange = true;
}
} else {
if (fileState.contents !== null) {
fileState.contents = null;
fileState.lastModificationDate = 0;
fileState.lastExternalModificationDate = 0;
isChange = true;
}
}

if (isChange) {
let change = this.createChange(filePath);

for (let watcher of fileState.watchers) {
watcher.send('drafts.change', filePath, change);
};
}
});
}

async unwatchFile(filePath: string, webContents: WebContents) {
let fileState = this.fileStates.get(filePath);

if (fileState) {
fileState.watchers.delete(webContents);
fileState.subscriberCount -= 1;

if (fileState.subscriberCount < 1) {
this.watcher.unwatch(filePath);
await this.acquireFile(filePath);
}

if (fileState.subscriberCount < 1) {
this.fileStates.delete(filePath);
}
}
}

async watchFile(filePath: string, webContents: WebContents) {
let fileState = this.fileStates.get(filePath);

if (!fileState) {
fileState = {
contents: null,
queryDeferred: null,
queryId: null,
lastExternalModificationDate: 0,
lastModificationDate: 0,
subscriberCount: 0,
watchers: new Set()
};

this.fileStates.set(filePath, fileState);
}

fileState.subscriberCount += 1;

if (fileState.subscriberCount === 1) {
await this.detectChange(filePath);
} else {
while (fileState.queryDeferred) {
await fileState.queryDeferred.promise;
}
}

let change = this.createChange(filePath);

this.watcher.add(filePath);
fileState.watchers.add(webContents);

webContents.on('destroyed', () => {
this.pool.add(async () => void await this.unwatchFile(filePath, webContents));
});

return change;
}

async writeFile(filePath: FilePath, contents: Buffer | string) {
let fileState = this.fileStates.get(filePath);
let queryId = (fileState?.queryId ?? -1) + 1;

if (fileState) {
fileState.queryDeferred ??= defer();
fileState.queryId = queryId;
}

await this.lockFile(filePath, async () => {
if (fileState) {
fileState.queryId = null;
}

await fs.writeFile(filePath, contents);
let stats = await fs.stat(filePath);

if (fileState) {
fileState.contents = contents.toString();
fileState.lastModificationDate = stats.mtimeMs;

let change: DocumentChange = {
instance: {
contents: null,
lastExternalModificationDate: fileState.lastExternalModificationDate,
lastModificationDate: fileState.lastModificationDate
},
status: 'ok'
};

for (let watcher of fileState.watchers) {
watcher.send('drafts.change', filePath, change);
}
}
});
}
}
1 change: 0 additions & 1 deletion app/electron/src/host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ export class HostWindow {
}

focus() {
// TODO: Investigate
if (this.browserWindow) {
if (this.browserWindow.isMinimized()) {
this.browserWindow.restore();
Expand Down
2 changes: 0 additions & 2 deletions app/electron/src/host/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ let newNavigation = {
oldNavigation.addEventListener('navigate', (event: any) => {
event.preventDefault();

console.log('Navigate', event);

let defaultPrevented = false;

listener({
Expand Down
Loading

0 comments on commit 3f5152f

Please sign in to comment.