Normalize vfs (#558)

This commit is contained in:
Will Chen
2025-07-03 12:15:58 -07:00
committed by GitHub
parent 8260aa86e2
commit e3ea765de8
2 changed files with 98 additions and 109 deletions

View File

@@ -1,11 +1,11 @@
import { test, testSkipIfWindows } from "./helpers/test_helper"; import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
const MINIMAL_APP = "minimal-with-ai-rules"; const MINIMAL_APP = "minimal-with-ai-rules";
testSkipIfWindows("problems auto-fix - enabled", async ({ po }) => { test("problems auto-fix - enabled", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.importApp(MINIMAL_APP); await po.importApp(MINIMAL_APP);
await po.expectPreviewIframeIsVisible(); await po.expectPreviewIframeIsVisible();
@@ -18,41 +18,35 @@ testSkipIfWindows("problems auto-fix - enabled", async ({ po }) => {
await po.snapshotMessages({ replaceDumpPath: true }); await po.snapshotMessages({ replaceDumpPath: true });
}); });
testSkipIfWindows( test("problems auto-fix - gives up after 2 attempts", async ({ po }) => {
"problems auto-fix - gives up after 2 attempts", await po.setUp();
async ({ po }) => { await po.importApp(MINIMAL_APP);
await po.setUp(); await po.expectPreviewIframeIsVisible();
await po.importApp(MINIMAL_APP);
await po.expectPreviewIframeIsVisible();
await po.sendPrompt("tc=create-unfixable-ts-errors"); await po.sendPrompt("tc=create-unfixable-ts-errors");
await po.snapshotServerDump("all-messages", { dumpIndex: -2 }); await po.snapshotServerDump("all-messages", { dumpIndex: -2 });
await po.snapshotServerDump("all-messages", { dumpIndex: -1 }); await po.snapshotServerDump("all-messages", { dumpIndex: -1 });
await po.page.getByTestId("problem-summary").last().click(); await po.page.getByTestId("problem-summary").last().click();
await expect( await expect(
po.page.getByTestId("problem-summary").last(), po.page.getByTestId("problem-summary").last(),
).toMatchAriaSnapshot(); ).toMatchAriaSnapshot();
await po.snapshotMessages({ replaceDumpPath: true }); await po.snapshotMessages({ replaceDumpPath: true });
}, });
);
testSkipIfWindows( test("problems auto-fix - complex delete-rename-write", async ({ po }) => {
"problems auto-fix - complex delete-rename-write", await po.setUp();
async ({ po }) => { await po.importApp(MINIMAL_APP);
await po.setUp(); await po.expectPreviewIframeIsVisible();
await po.importApp(MINIMAL_APP);
await po.expectPreviewIframeIsVisible();
await po.sendPrompt("tc=create-ts-errors-complex"); await po.sendPrompt("tc=create-ts-errors-complex");
await po.snapshotServerDump("all-messages", { dumpIndex: -2 }); await po.snapshotServerDump("all-messages", { dumpIndex: -2 });
await po.snapshotServerDump("all-messages", { dumpIndex: -1 }); await po.snapshotServerDump("all-messages", { dumpIndex: -1 });
await po.snapshotMessages({ replaceDumpPath: true }); await po.snapshotMessages({ replaceDumpPath: true });
}, });
);
test("problems auto-fix - disabled", async ({ po }) => { test("problems auto-fix - disabled", async ({ po }) => {
await po.setUp({ disableAutoFixProblems: true }); await po.setUp({ disableAutoFixProblems: true });

View File

@@ -5,6 +5,7 @@ import {
getDyadRenameTags, getDyadRenameTags,
getDyadDeleteTags, getDyadDeleteTags,
} from "../ipc/processors/response_processor"; } from "../ipc/processors/response_processor";
import { normalizePath } from "../ipc/processors/normalizePath";
import log from "electron-log"; import log from "electron-log";
@@ -39,7 +40,39 @@ export abstract class BaseVirtualFileSystem {
protected baseDir: string; protected baseDir: string;
constructor(baseDir: string) { constructor(baseDir: string) {
this.baseDir = baseDir; this.baseDir = path.resolve(baseDir);
}
/**
* Normalize path for consistent cross-platform behavior
*/
private normalizePathForKey(filePath: string): string {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
// Normalize separators and handle case-insensitive Windows paths
const normalized = normalizePath(path.normalize(absolutePath));
// Intentionally do NOT lowercase for Windows which is case-insensitive
// because this avoids issues with path comparison.
//
// This is a trade-off and introduces a small edge case where
// e.g. foo.txt and Foo.txt are treated as different files by the VFS
// even though Windows treats them as the same file.
//
// This should be a pretty rare occurence and it's not worth the extra
// complexity to handle it.
return normalized;
}
/**
* Convert normalized path back to platform-appropriate format
*/
private denormalizePath(normalizedPath: string): string {
return process.platform === "win32"
? normalizedPath.replace(/\//g, "\\")
: normalizedPath;
} }
/** /**
@@ -69,43 +102,49 @@ export abstract class BaseVirtualFileSystem {
/** /**
* Write a file to the virtual filesystem * Write a file to the virtual filesystem
*/ */
public writeFile(relativePath: string, content: string): void { protected writeFile(relativePath: string, content: string): void {
const absolutePath = path.resolve(this.baseDir, relativePath); const absolutePath = path.resolve(this.baseDir, relativePath);
this.virtualFiles.set(absolutePath, content); const normalizedKey = this.normalizePathForKey(absolutePath);
this.virtualFiles.set(normalizedKey, content);
// Remove from deleted files if it was previously deleted // Remove from deleted files if it was previously deleted
this.deletedFiles.delete(absolutePath); this.deletedFiles.delete(normalizedKey);
} }
/** /**
* Delete a file from the virtual filesystem * Delete a file from the virtual filesystem
*/ */
public deleteFile(relativePath: string): void { protected deleteFile(relativePath: string): void {
const absolutePath = path.resolve(this.baseDir, relativePath); const absolutePath = path.resolve(this.baseDir, relativePath);
this.deletedFiles.add(absolutePath); const normalizedKey = this.normalizePathForKey(absolutePath);
this.deletedFiles.add(normalizedKey);
// Remove from virtual files if it exists there // Remove from virtual files if it exists there
this.virtualFiles.delete(absolutePath); this.virtualFiles.delete(normalizedKey);
} }
/** /**
* Rename a file in the virtual filesystem * Rename a file in the virtual filesystem
*/ */
public renameFile(fromPath: string, toPath: string): void { protected renameFile(fromPath: string, toPath: string): void {
const fromAbsolute = path.resolve(this.baseDir, fromPath); const fromAbsolute = path.resolve(this.baseDir, fromPath);
const toAbsolute = path.resolve(this.baseDir, toPath); const toAbsolute = path.resolve(this.baseDir, toPath);
const fromNormalized = this.normalizePathForKey(fromAbsolute);
const toNormalized = this.normalizePathForKey(toAbsolute);
// Mark old file as deleted // Mark old file as deleted
this.deletedFiles.add(fromAbsolute); this.deletedFiles.add(fromNormalized);
// If the source file exists in virtual files, move its content // If the source file exists in virtual files, move its content
if (this.virtualFiles.has(fromAbsolute)) { if (this.virtualFiles.has(fromNormalized)) {
const content = this.virtualFiles.get(fromAbsolute)!; const content = this.virtualFiles.get(fromNormalized)!;
this.virtualFiles.delete(fromAbsolute); this.virtualFiles.delete(fromNormalized);
this.virtualFiles.set(toAbsolute, content); this.virtualFiles.set(toNormalized, content);
} else { } else {
// Try to read from actual filesystem // Try to read from actual filesystem
try { try {
const content = fs.readFileSync(fromAbsolute, "utf8"); const content = fs.readFileSync(fromAbsolute, "utf8");
this.virtualFiles.set(toAbsolute, content); this.virtualFiles.set(toNormalized, content);
} catch (error) { } catch (error) {
// If we can't read the source file, we'll let the consumer handle it // If we can't read the source file, we'll let the consumer handle it
logger.warn( logger.warn(
@@ -116,7 +155,7 @@ export abstract class BaseVirtualFileSystem {
} }
// Remove destination from deleted files if it was previously deleted // Remove destination from deleted files if it was previously deleted
this.deletedFiles.delete(toAbsolute); this.deletedFiles.delete(toNormalized);
} }
/** /**
@@ -124,10 +163,15 @@ export abstract class BaseVirtualFileSystem {
*/ */
public getVirtualFiles(): VirtualFile[] { public getVirtualFiles(): VirtualFile[] {
return Array.from(this.virtualFiles.entries()).map( return Array.from(this.virtualFiles.entries()).map(
([absolutePath, content]) => ({ ([normalizedKey, content]) => {
path: path.relative(this.baseDir, absolutePath), // Convert normalized key back to relative path
content, const denormalizedPath = this.denormalizePath(normalizedKey);
}),
return {
path: path.relative(this.baseDir, denormalizedPath),
content,
};
},
); );
} }
@@ -135,84 +179,35 @@ export abstract class BaseVirtualFileSystem {
* Get all deleted file paths (relative to base directory) * Get all deleted file paths (relative to base directory)
*/ */
public getDeletedFiles(): string[] { public getDeletedFiles(): string[] {
return Array.from(this.deletedFiles).map((absolutePath) => return Array.from(this.deletedFiles).map((normalizedKey) => {
path.relative(this.baseDir, absolutePath), // Convert normalized key back to relative path
); const denormalizedPath = this.denormalizePath(normalizedKey);
} return path.relative(this.baseDir, denormalizedPath);
});
/**
* Get all files that should be considered (existing + virtual - deleted)
*/
public getAllFiles(): string[] {
const allFiles = new Set<string>();
// Add virtual files
for (const [absolutePath] of this.virtualFiles.entries()) {
allFiles.add(path.relative(this.baseDir, absolutePath));
}
// Add existing files (this is a simplified version - in practice you might want to scan the directory)
// This method is mainly for getting the current state, consumers can combine with directory scanning
return Array.from(allFiles);
}
/**
* Check if a file has been modified in the virtual filesystem
*/
public isFileModified(filePath: string): boolean {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return (
this.virtualFiles.has(absolutePath) || this.deletedFiles.has(absolutePath)
);
}
/**
* Clear all virtual changes
*/
public clear(): void {
this.virtualFiles.clear();
this.deletedFiles.clear();
}
/**
* Get the base directory
*/
public getBaseDir(): string {
return this.baseDir;
} }
/** /**
* Check if a file is deleted in the virtual filesystem * Check if a file is deleted in the virtual filesystem
*/ */
protected isDeleted(filePath: string): boolean { protected isDeleted(filePath: string): boolean {
const absolutePath = path.isAbsolute(filePath) const normalizedKey = this.normalizePathForKey(filePath);
? filePath return this.deletedFiles.has(normalizedKey);
: path.resolve(this.baseDir, filePath);
return this.deletedFiles.has(absolutePath);
} }
/** /**
* Check if a file exists in virtual files * Check if a file exists in virtual files
*/ */
protected hasVirtualFile(filePath: string): boolean { protected hasVirtualFile(filePath: string): boolean {
const absolutePath = path.isAbsolute(filePath) const normalizedKey = this.normalizePathForKey(filePath);
? filePath return this.virtualFiles.has(normalizedKey);
: path.resolve(this.baseDir, filePath);
return this.virtualFiles.has(absolutePath);
} }
/** /**
* Get virtual file content * Get virtual file content
*/ */
protected getVirtualFileContent(filePath: string): string | undefined { protected getVirtualFileContent(filePath: string): string | undefined {
const absolutePath = path.isAbsolute(filePath) const normalizedKey = this.normalizePathForKey(filePath);
? filePath return this.virtualFiles.get(normalizedKey);
: path.resolve(this.baseDir, filePath);
return this.virtualFiles.get(absolutePath);
} }
} }