Skip to content

Commit

Permalink
Improved detection of GSC file changes, folder renames, folder move, …
Browse files Browse the repository at this point in the history
…etc...

Added new Output window "GSC File Logs" to diagnose changes to file system.
  • Loading branch information
eyza-cod2 committed Oct 28, 2024
1 parent af86d5a commit ee7d41b
Show file tree
Hide file tree
Showing 10 changed files with 565 additions and 146 deletions.
18 changes: 17 additions & 1 deletion src/Events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class Events {
// File rename
context.subscriptions.push(vscode.workspace.onDidRenameFiles(async e => {
try {
LoggerOutput.log("[Events] File has been renamed. " + e.files.map(f => vscode.workspace.asRelativePath(f.oldUri) + " -> " + vscode.workspace.asRelativePath(f.newUri)).join(", "));
LoggerOutput.log("[Events] File / folder has been renamed. " + e.files.map(f => vscode.workspace.asRelativePath(f.oldUri) + " -> " + vscode.workspace.asRelativePath(f.newUri)).join(", "));

} catch (error) {
Issues.handleError(error);
Expand Down Expand Up @@ -181,6 +181,22 @@ export class Events {
}






private static readonly onDidFileSystemChangeEvent = new vscode.EventEmitter<{type: ("create" | "delete" | "change"), uri: vscode.Uri, manual: boolean}>();
public static readonly onDidFileSystemChange = this.onDidFileSystemChangeEvent.event;

public static FileSystemChanged(type: "create" | "delete" | "change", uri: vscode.Uri, manual: boolean = false) {
this.onDidFileSystemChangeEvent.fire({type, uri, manual});
}






static GscFileCacheFileHasChanged(fileUri: vscode.Uri) {
LoggerOutput.log("[Events] GSC cache changed for file", vscode.workspace.asRelativePath(fileUri));

Expand Down
4 changes: 0 additions & 4 deletions src/GscFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ import { GscFiles } from './GscFiles';

export class GscFile {

/** URI as lower-case string */
id: string;

/** URI of the file */
uri: vscode.Uri;

Expand Down Expand Up @@ -43,7 +40,6 @@ export class GscFile {
if (uri === undefined) {
uri = vscode.Uri.parse("file://undefined");
}
this.id = uri.toString().toLowerCase();
this.uri = uri;

if (workspaceFolder !== undefined) {
Expand Down
75 changes: 68 additions & 7 deletions src/GscFileCache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import * as os from 'os';
import { GscFile } from './GscFile';
import { GscFiles } from './GscFiles';
import { ConfigErrorDiagnostics, GscConfig, GscGame, GscGameConfig, GscGameRootFolder } from './GscConfig';
Expand Down Expand Up @@ -34,18 +35,50 @@ export type GscFilesConfig = {
};


export class GscFileCache {

/**
* Generates a normalized identifier (ID) from a given vscode.Uri.
*
* This function creates a consistent and comparable ID from a vscode.Uri by:
* 1. Using the `scheme`, `authority`, and `path` components of the URI.
* 2. Converting the path to lowercase on Windows to ensure that file comparisons are case-insensitive
* (since Windows filesystems are typically case-insensitive).
* 3. Normalizing the path separators (`/`) to ensure consistency across platforms.
*
* @param uri - The URI to generate an ID from.
* @returns A string that represents a consistent ID for the given URI, usable for cross-platform file comparison.
*/
public static getUriId(uri: vscode.Uri): string {
let normalizedPath = uri.path; // it uses forward slashes

// Normalize the path for case-insensitive platforms (e.g., Windows)
if (os.platform() === 'win32') {
normalizedPath = normalizedPath.toLowerCase();
}

// Include the authority and other relevant parts of the Uri
const id = `${uri.scheme}://${uri.authority}${normalizedPath}`;

return id;
}
}


export class GscCachedFilesPerWorkspace {

private cachedFilesPerWorkspace: Map<string, GscWorkspaceFileData> = new Map();

createNewWorkspace(workspaceFolder: vscode.WorkspaceFolder): GscWorkspaceFileData {
const data = new GscWorkspaceFileData(workspaceFolder);
this.cachedFilesPerWorkspace.set(workspaceFolder.uri.toString(), data);
const id = GscFileCache.getUriId(workspaceFolder.uri);
this.cachedFilesPerWorkspace.set(id, data);
return data;
}

getWorkspace(workspaceUri: vscode.Uri): GscWorkspaceFileData | undefined {
return this.cachedFilesPerWorkspace.get(workspaceUri.toString());
const id = GscFileCache.getUriId(workspaceUri);
return this.cachedFilesPerWorkspace.get(id);
}

removeCachedFile(fileUri: vscode.Uri) {
Expand All @@ -61,14 +94,28 @@ export class GscCachedFilesPerWorkspace {
dataOfWorkspace.removeParsedFile(fileUri);
}

getParsedFilesByFileOrFolderPath(uri: vscode.Uri): GscFile[] | undefined {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
if (workspaceFolder === undefined) {
return;
}
const workspaceData = this.getWorkspace(workspaceFolder.uri);
if (workspaceData === undefined) {
return;
}
const files = workspaceData.getParsedFilesByFileOrFolderPath(uri);
return files;
}

removeWorkspace(workspaceUri: vscode.Uri) {
const workspaceData = this.getWorkspace(workspaceUri);
if (workspaceData === undefined) {
return false;
}
workspaceData.removeAllParsedFiles();

return this.cachedFilesPerWorkspace.delete(workspaceUri.toString());
const id = GscFileCache.getUriId(workspaceUri);
return this.cachedFilesPerWorkspace.delete(id);
}

getAllWorkspaces() {
Expand All @@ -95,24 +142,38 @@ export class GscWorkspaceFileData {
}

addParsedFile(gscFile: GscFile) {
if (!this.parsedFiles.has(gscFile.id)) {
const id = GscFileCache.getUriId(gscFile.uri);
if (!this.parsedFiles.has(id)) {
LoggerOutput.log("[GscFileCache] Added file to cache", vscode.workspace.asRelativePath(gscFile.uri));
} else {
LoggerOutput.log("[GscFileCache] Updated file in cache", vscode.workspace.asRelativePath(gscFile.uri));
}
this.parsedFiles.set(gscFile.id, gscFile);
this.parsedFiles.set(id, gscFile);

Events.GscFileCacheFileHasChanged(gscFile.uri);
}

getParsedFile(uri: vscode.Uri): GscFile | undefined {
const data = this.parsedFiles.get(uri.toString().toLowerCase());
const id = GscFileCache.getUriId(uri);
const data = this.parsedFiles.get(id);

return data;
}

getParsedFilesByFileOrFolderPath(uri: vscode.Uri): GscFile[] {
const files: GscFile[] = [];
const id = GscFileCache.getUriId(uri);
for (const [fileId, file] of this.parsedFiles) {
if (fileId === id || fileId.startsWith(id + "/")) {
files.push(file);
}
}
return files;
}

removeParsedFile(uri: vscode.Uri): boolean {
const removed = this.parsedFiles.delete(uri.toString().toLowerCase());
const id = GscFileCache.getUriId(uri);
const removed = this.parsedFiles.delete(id);

if (removed) {
LoggerOutput.log("[GscFileCache] Removed file from cache", vscode.workspace.asRelativePath(uri));
Expand Down
168 changes: 139 additions & 29 deletions src/GscFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { GscConfig, GscGameRootFolder } from './GscConfig';
import { LoggerOutput } from './LoggerOutput';
import { GscDiagnosticsCollection } from './GscDiagnosticsCollection';
import { Events } from './Events';
import { Issues } from './Issues';

/**
* On startup scan every .gsc file, parse it, and save the result into memory.
Expand Down Expand Up @@ -657,44 +658,159 @@ export class GscFiles {

private static handleFileChanges(context: vscode.ExtensionContext) {

this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*.gsc');
this.fileWatcher = vscode.workspace.createFileSystemWatcher('**');

// Subscribe to file system events
// If file is renamed, create event with new name is called first, then delete event with old name is called
this.fileWatcher.onDidDelete(this.onGscFileDelete, this, context.subscriptions);
this.fileWatcher.onDidCreate(this.onGscFileCreate, this, context.subscriptions);
this.fileWatcher.onDidChange(this.onGscFileChange, this, context.subscriptions);
this.fileWatcher.onDidDelete((uri) => this.onFileNotification("delete", uri), this, context.subscriptions);
this.fileWatcher.onDidCreate((uri) => this.onFileNotification("create", uri), this, context.subscriptions);
this.fileWatcher.onDidChange((uri) => this.onFileNotification("change", uri), this, context.subscriptions);
}

private static foldersWaitingForFileNotifications: Map<string, NodeJS.Timeout> = new Map();

private static onGscFileCreate(uri: vscode.Uri) {
LoggerOutput.log("[GscFiles] Detected new GSC file", vscode.workspace.asRelativePath(uri));
private static async onFileNotification(type: "create" | "delete" | "change", uri: vscode.Uri, manual: boolean = false) {
try {
// Notify about change
Events.FileSystemChanged(type, uri, manual);

// Delete of file / directory
if (type === "delete") {
LoggerOutput.logFile("[GscFiles] Detected '" + type + "' of file / folder", vscode.workspace.asRelativePath(uri));

// Remove files from cache that starts with this path
// At this point it might be directory or file
// If it is directory, it might contain GSC files (or other files)
// If it is file, it might be GSC file (or other file)
const filesToRemove = this.cachedFiles.getParsedFilesByFileOrFolderPath(uri);

if (filesToRemove && filesToRemove.length > 0) {
LoggerOutput.logFile("[GscFiles] - cached GSC files to remove: " + filesToRemove.length);
for (const file of filesToRemove) {
LoggerOutput.logFile("[GscFiles] - " + vscode.workspace.asRelativePath(file.uri));
}

// Parse the new file
void GscFiles.getFileData(uri, true, "new file created");
for (const file of filesToRemove) {
this.cachedFiles.removeCachedFile(file.uri);
}

// Re-diagnose all files, because this new file might solve reference errors in other files
this.updateDiagnosticsDebounced(undefined, "new file created");
}
// Re-diagnose all files, because this file might generate reference errors in other files
this.updateDiagnosticsDebounced(undefined, "file deleted");
} else {
LoggerOutput.logFile("[GscFiles] - no cached GSC files found to remove");
}

// Create or change of file / directory
} else {
var stat: vscode.FileStat;
try {
stat = await vscode.workspace.fs.stat(uri);
} catch (error) {
// Stat might fail if the file is deleted / renamed before the stat is called, or if the file is not accessible
// Ignore the error
LoggerOutput.logFile("[GscFiles] Detected '" + type + "', but unable to get file stats" + (manual ? ", manually triggered" : ""), vscode.workspace.asRelativePath(uri));
LoggerOutput.logFile("[GscFiles] - error: " + error);
return;
}

const isValidGscFile = this.isValidGscFile(uri.fsPath, stat);
const isDirectory = stat.type === vscode.FileType.Directory;

LoggerOutput.logFile("[GscFiles] Detected '" + type + "'" + (isValidGscFile ? ", GSC file" : "") + (isDirectory ? ", directory" : "") + (manual ? ", manually triggered" : ""), vscode.workspace.asRelativePath(uri));

if (isValidGscFile) {
if (type === "create") {

// Check if there is timer waiting for files in this directory (check path prefix)
for (const [pathPrefix, timer] of this.foldersWaitingForFileNotifications) {
if (uri.fsPath.startsWith(pathPrefix)) {
clearTimeout(timer);
this.foldersWaitingForFileNotifications.delete(pathPrefix);
LoggerOutput.logFile("[GscFiles] - canceled timer for directory check");
}
}

private static onGscFileDelete(uri: vscode.Uri) {
LoggerOutput.log("[GscFiles] Detected deleted GSC file", vscode.workspace.asRelativePath(uri));
LoggerOutput.logFile("[GscFiles] - running immediately forced file parsing + debounced diagnostics update for all", vscode.workspace.asRelativePath(uri));

this.cachedFiles.removeCachedFile(uri);
// Parse the new file
void GscFiles.getFileData(uri, true, "new file created");

// Re-diagnose all files, because this file might generate reference errors in other files
this.updateDiagnosticsDebounced(undefined, "file deleted");
}
// Re-diagnose all files, because this new file might solve reference errors in other files
this.updateDiagnosticsDebounced(undefined, "new file created");

} else if (type === "change") {
LoggerOutput.logFile("[GscFiles] - running debounced file parsing + diagnostics update for all", vscode.workspace.asRelativePath(uri));

/** GSC file was modified (saved) */
private static onGscFileChange(uri: vscode.Uri) {
LoggerOutput.log("[GscFiles] Detected GSC File change on disc", vscode.workspace.asRelativePath(uri));
// Force parsing the updated file and then update diagnostics for all files
this.parseFileAndDiagnoseDebounced(uri, true, "file " + vscode.workspace.asRelativePath(uri) + " changed on disc");
}

// Force parsing the updated file and then update diagnostics for all files
this.parseFileAndDiagnoseDebounced(uri, true, "file " + vscode.workspace.asRelativePath(uri) + " changed on disc");
// Its directory
} else if (isDirectory) {
if (type === "create") {
// New directory might contain GSC files (if the folder was moved)
// There is a bug when folder is moved - create event is not called for each file in the folder
// but when the folder is copied (so create folder even is also called), then create event is called for each file in the folder
// Solution: when folder is created, prepare timed callback that loads GSC files manually unless create event is called for each file in the folder

this.foldersWaitingForFileNotifications.set(uri.fsPath, setTimeout(async () => {
try {
this.foldersWaitingForFileNotifications.delete(uri.fsPath);

LoggerOutput.logFile("[GscFiles] Timer elapsed - check files in directory", vscode.workspace.asRelativePath(uri));

// Get all files in the new directory
const gscFileUris: vscode.Uri[] = [];
async function readDirectoryRecursive(directoryUri: vscode.Uri) {
const files = await vscode.workspace.fs.readDirectory(directoryUri);
for (const [file, fileType] of files) {
const fileUri = vscode.Uri.joinPath(directoryUri, file);
if (fileType === vscode.FileType.File && GscFiles.isValidGscFile(fileUri.fsPath)) {
gscFileUris.push(fileUri);
} else if (fileType === vscode.FileType.Directory) {
await readDirectoryRecursive(fileUri); // Recursive call
}
}
}
await readDirectoryRecursive(uri);

LoggerOutput.logFile("[GscFiles] - GSC files in directory: " + gscFileUris.length);
for (const gscFile of gscFileUris) {
LoggerOutput.logFile("[GscFiles] - " + vscode.workspace.asRelativePath(gscFile));
}

for (const gscFile of gscFileUris) {
void this.onFileNotification("create", gscFile, true);
}
} catch (error) {
Issues.handleError(new Error("Error while processing files in directory. " + error));
throw error;
}
}, 200));

LoggerOutput.logFile("[GscFiles] - added timer to check the directory for files");
}

// Non-gsc file
} else {
LoggerOutput.logFile("[GscFiles] - not GSC file or directory, ignoring");
}
}
} catch (error) {
Issues.handleError(new Error("Error while processing file change notification. " + error));
throw error;
}
}

static isValidGscFile(filePath: string, stats?: vscode.FileStat): boolean {
if (stats) {
return stats.type === vscode.FileType.File && path.extname(filePath).toLowerCase() === '.gsc';
} else {
return fs.existsSync(filePath) && fs.lstatSync(filePath).isFile() && path.extname(filePath).toLowerCase() === '.gsc';
}
}



/**
* An event that is emitted when a text document is changed. This usually happens when the contents changes but also when other things like the dirty-state changes.
* Note: Is saved also on document save
Expand Down Expand Up @@ -756,12 +872,6 @@ export class GscFiles {
}


static isValidGscFile(filePath: string): boolean {
return fs.existsSync(filePath) && fs.lstatSync(filePath).isFile() && path.extname(filePath).toLowerCase() === '.gsc';
}






Expand Down
Loading

0 comments on commit ee7d41b

Please sign in to comment.