|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import Utils from "./utils.js";
|
|
|
|
|
|
const FileSystem = {
|
|
|
currentHandle: null,
|
|
|
files: [],
|
|
|
folderName: "",
|
|
|
fileContentCache: new Map(),
|
|
|
cacheMaxSize: 50,
|
|
|
|
|
|
|
|
|
isSupported() {
|
|
|
return "showDirectoryPicker" in window;
|
|
|
},
|
|
|
|
|
|
|
|
|
async openFolder() {
|
|
|
if (!this.isSupported()) {
|
|
|
Utils.toast.error("File System Access API not supported in this browser. Try Chrome or Edge.");
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
const handle = await window.showDirectoryPicker({
|
|
|
mode: "read"
|
|
|
});
|
|
|
|
|
|
this.currentHandle = handle;
|
|
|
this.folderName = handle.name;
|
|
|
|
|
|
Utils.toast.info("Reading folder...");
|
|
|
const files = await this.readDirectory(handle);
|
|
|
this.files = files;
|
|
|
|
|
|
Utils.toast.success(`Loaded ${files.length} files from "${this.folderName}"`);
|
|
|
return {
|
|
|
handle,
|
|
|
name: this.folderName,
|
|
|
files
|
|
|
};
|
|
|
} catch (err) {
|
|
|
if (err.name === "AbortError") {
|
|
|
Utils.toast.info("Folder selection cancelled");
|
|
|
} else {
|
|
|
console.error("Failed to open folder:", err);
|
|
|
Utils.toast.error("Failed to open folder: " + err.message);
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
},
|
|
|
|
|
|
|
|
|
async readDirectory(dirHandle, path = "", maxFiles = 1000) {
|
|
|
const files = [];
|
|
|
const maxFileSizeMB = 5;
|
|
|
|
|
|
try {
|
|
|
for await (const entry of dirHandle.values()) {
|
|
|
if (files.length >= maxFiles) {
|
|
|
Utils.toast.warning(`Reached max file limit (${maxFiles})`);
|
|
|
break;
|
|
|
}
|
|
|
|
|
|
const fullPath = path ? `${path}/${entry.name}` : entry.name;
|
|
|
|
|
|
if (entry.kind === "directory") {
|
|
|
|
|
|
if (this.shouldSkipDirectory(entry.name)) continue;
|
|
|
|
|
|
|
|
|
const subFiles = await this.readDirectory(entry, fullPath, maxFiles - files.length);
|
|
|
files.push(...subFiles);
|
|
|
} else if (entry.kind === "file") {
|
|
|
|
|
|
if (!Utils.isTextFile(entry.name)) continue;
|
|
|
|
|
|
try {
|
|
|
const file = await entry.getFile();
|
|
|
|
|
|
|
|
|
if (file.size > maxFileSizeMB * 1024 * 1024) {
|
|
|
|
|
|
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
files.push({
|
|
|
name: entry.name,
|
|
|
path: fullPath,
|
|
|
type: entry.kind,
|
|
|
size: file.size,
|
|
|
extension: Utils.getFileExtension(entry.name),
|
|
|
language: Utils.getLanguageFromExtension(Utils.getFileExtension(entry.name)),
|
|
|
handle: entry
|
|
|
});
|
|
|
} catch (err) {
|
|
|
console.warn(`Failed to read file ${fullPath}:`, err);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} catch (err) {
|
|
|
console.error("Failed to read directory:", err);
|
|
|
}
|
|
|
|
|
|
return files;
|
|
|
},
|
|
|
|
|
|
|
|
|
shouldSkipDirectory(name) {
|
|
|
const nameLower = name.toLowerCase();
|
|
|
const skipDirs = [
|
|
|
"node_modules",
|
|
|
".git",
|
|
|
".vscode",
|
|
|
"dist",
|
|
|
"build",
|
|
|
".cache",
|
|
|
"coverage",
|
|
|
".next",
|
|
|
".nuxt",
|
|
|
"__pycache__",
|
|
|
"vendor",
|
|
|
"target"
|
|
|
];
|
|
|
return skipDirs.includes(nameLower) || name.startsWith(".");
|
|
|
},
|
|
|
|
|
|
|
|
|
async readFile(fileEntry) {
|
|
|
try {
|
|
|
|
|
|
const cacheKey = fileEntry.path;
|
|
|
if (this.fileContentCache.has(cacheKey)) {
|
|
|
|
|
|
|
|
|
return this.fileContentCache.get(cacheKey);
|
|
|
}
|
|
|
|
|
|
const file = await fileEntry.handle.getFile();
|
|
|
|
|
|
|
|
|
if (file.size > 1024 * 1024) {
|
|
|
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
|
|
console.warn(`Reading large file: ${fileEntry.path} (${sizeMB}MB)`);
|
|
|
|
|
|
|
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
|
throw new Error(`File too large (${sizeMB}MB). Maximum size is 5MB.`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const content = await file.text();
|
|
|
|
|
|
|
|
|
if (this.fileContentCache.size >= this.cacheMaxSize) {
|
|
|
|
|
|
const firstKey = this.fileContentCache.keys().next().value;
|
|
|
this.fileContentCache.delete(firstKey);
|
|
|
}
|
|
|
this.fileContentCache.set(cacheKey, content);
|
|
|
|
|
|
return content;
|
|
|
} catch (err) {
|
|
|
console.error("Failed to read file:", err);
|
|
|
throw err;
|
|
|
}
|
|
|
},
|
|
|
|
|
|
|
|
|
getFileByPath(path) {
|
|
|
|
|
|
if (!path || typeof path !== "string") {
|
|
|
console.warn("Invalid file path provided");
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
|
|
|
const normalizedPath = path.replace(/\\/g, "/");
|
|
|
|
|
|
|
|
|
if (
|
|
|
normalizedPath.includes("..") ||
|
|
|
normalizedPath.startsWith("/") ||
|
|
|
normalizedPath.includes(":") ||
|
|
|
/\.\.[\/\\]/.test(path) ||
|
|
|
/%2e%2e/i.test(path)
|
|
|
) {
|
|
|
console.warn("Potentially malicious path detected:", path);
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
return this.files.find(f => f.path === normalizedPath);
|
|
|
},
|
|
|
|
|
|
|
|
|
searchFiles(query) {
|
|
|
if (!query) return this.files;
|
|
|
|
|
|
query = query.toLowerCase();
|
|
|
return this.files.filter(f => f.name.toLowerCase().includes(query) || f.path.toLowerCase().includes(query));
|
|
|
},
|
|
|
|
|
|
|
|
|
getStats() {
|
|
|
if (this.files.length === 0) {
|
|
|
return {
|
|
|
totalFiles: 0,
|
|
|
totalSize: 0,
|
|
|
languages: {},
|
|
|
fileTypes: {}
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const stats = {
|
|
|
totalFiles: this.files.length,
|
|
|
totalSize: this.files.reduce((sum, f) => sum + f.size, 0),
|
|
|
languages: {},
|
|
|
fileTypes: {}
|
|
|
};
|
|
|
|
|
|
this.files.forEach(file => {
|
|
|
|
|
|
const lang = file.language || "unknown";
|
|
|
stats.languages[lang] = (stats.languages[lang] || 0) + 1;
|
|
|
|
|
|
|
|
|
const ext = file.extension || "none";
|
|
|
stats.fileTypes[ext] = (stats.fileTypes[ext] || 0) + 1;
|
|
|
});
|
|
|
|
|
|
return stats;
|
|
|
},
|
|
|
|
|
|
|
|
|
buildTree() {
|
|
|
const tree = {
|
|
|
name: this.folderName,
|
|
|
type: "directory",
|
|
|
children: []
|
|
|
};
|
|
|
|
|
|
this.files.forEach(file => {
|
|
|
const parts = file.path.split("/");
|
|
|
let current = tree;
|
|
|
|
|
|
parts.forEach((part, index) => {
|
|
|
const isLast = index === parts.length - 1;
|
|
|
|
|
|
if (isLast) {
|
|
|
current.children.push({
|
|
|
name: part,
|
|
|
type: "file",
|
|
|
path: file.path,
|
|
|
file
|
|
|
});
|
|
|
} else {
|
|
|
let dir = current.children.find(c => c.name === part && c.type === "directory");
|
|
|
if (!dir) {
|
|
|
dir = {
|
|
|
name: part,
|
|
|
type: "directory",
|
|
|
children: []
|
|
|
};
|
|
|
current.children.push(dir);
|
|
|
}
|
|
|
current = dir;
|
|
|
}
|
|
|
});
|
|
|
});
|
|
|
|
|
|
|
|
|
const sortTree = node => {
|
|
|
if (node.children) {
|
|
|
node.children.sort((a, b) => {
|
|
|
if (a.type === b.type) {
|
|
|
return a.name.localeCompare(b.name);
|
|
|
}
|
|
|
return a.type === "directory" ? -1 : 1;
|
|
|
});
|
|
|
node.children.forEach(sortTree);
|
|
|
}
|
|
|
};
|
|
|
sortTree(tree);
|
|
|
|
|
|
return tree;
|
|
|
},
|
|
|
|
|
|
|
|
|
close() {
|
|
|
|
|
|
this.currentHandle = null;
|
|
|
this.files = [];
|
|
|
this.folderName = "";
|
|
|
|
|
|
|
|
|
if (this.fileContentCache) {
|
|
|
this.fileContentCache.clear();
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
};
|
|
|
|
|
|
export default FileSystem;
|
|
|
|