/** * SFTP File Utilities * Helper functions for file type detection and extension handling */ import { netcattyBridge } from "../infrastructure/services/netcattyBridge"; // Known binary file extensions - files that should never be opened as text const BINARY_EXTENSIONS = new Set([ // Images 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff', 'tif', 'heic', 'heif', 'avif', 'jfif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef', // Audio 'mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a', 'aiff', 'opus', // Video 'mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'mpeg', 'mpg', // Archives 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'lz', 'lzma', 'zst', 'tgz', 'tbz2', 'txz', 'cab', 'iso', 'dmg', // Executables 'exe', 'dll', 'so', 'dylib', 'bin', 'app', 'msi', 'deb', 'rpm', 'apk', 'ipa', 'jar', 'war', 'ear', // Documents (binary formats) 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', // Fonts 'ttf', 'otf', 'woff', 'woff2', 'eot', // Database 'db', 'sqlite', 'sqlite3', 'mdb', 'accdb', // Object files 'o', 'obj', 'pyc', 'pyo', 'class', 'beam', // Other binary 'swf', 'fla', 'blend', 'unity3d', 'unitypackage', ]); // Language IDs for syntax highlighting const EXTENSION_TO_LANGUAGE: Record = { js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', pyw: 'python', pyi: 'python', sh: 'shell', bash: 'shell', zsh: 'shell', fish: 'shell', bat: 'batch', cmd: 'batch', ps1: 'powershell', psm1: 'powershell', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cc: 'cpp', cxx: 'cpp', java: 'java', kt: 'kotlin', kts: 'kotlin', go: 'go', rs: 'rust', rb: 'ruby', php: 'php', pl: 'perl', lua: 'lua', r: 'r', R: 'r', swift: 'swift', dart: 'dart', cs: 'csharp', fs: 'fsharp', vb: 'vb', html: 'html', htm: 'html', xhtml: 'html', css: 'css', scss: 'scss', sass: 'sass', less: 'less', json: 'json', jsonc: 'jsonc', json5: 'json5', xml: 'xml', xsl: 'xml', xslt: 'xml', yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'ini', conf: 'ini', cfg: 'ini', sql: 'sql', graphql: 'graphql', gql: 'graphql', md: 'markdown', markdown: 'markdown', mdx: 'markdown', txt: 'plaintext', log: 'plaintext', vue: 'vue', svelte: 'svelte', dockerfile: 'dockerfile', makefile: 'makefile', diff: 'diff', patch: 'diff', }; /** * Get the file extension from a filename * For files without extension, returns 'file' */ export function getFileExtension(fileName: string): string { const lastDot = fileName.lastIndexOf('.'); if (lastDot === -1 || lastDot === 0) { return 'file'; // No extension or hidden file without extension } return fileName.slice(lastDot + 1).toLowerCase(); } /** True when the filename has a real extension (e.g. `foo.txt`), not a dot-only name like `.git`. */ export function hasFileExtension(fileName: string): boolean { return fileName.lastIndexOf('.') > 0; } /** * Check if a file is definitely a binary file based on its extension. * Used to exclude files from "Edit" option in context menu. */ export function isKnownBinaryFile(fileName: string): boolean { const ext = getFileExtension(fileName); return BINARY_EXTENSIONS.has(ext); } /** * Get language ID for syntax highlighting */ export function getLanguageId(fileName: string): string { const ext = getFileExtension(fileName); return EXTENSION_TO_LANGUAGE[ext] || 'plaintext'; } /** * Get a user-friendly name for a language */ export function getLanguageName(languageId: string): string { const names: Record = { javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python', shell: 'Shell', batch: 'Batch', powershell: 'PowerShell', c: 'C', cpp: 'C++', java: 'Java', kotlin: 'Kotlin', go: 'Go', rust: 'Rust', ruby: 'Ruby', php: 'PHP', perl: 'Perl', lua: 'Lua', r: 'R', swift: 'Swift', dart: 'Dart', csharp: 'C#', fsharp: 'F#', vb: 'Visual Basic', html: 'HTML', css: 'CSS', scss: 'SCSS', sass: 'Sass', less: 'Less', json: 'JSON', jsonc: 'JSON with Comments', json5: 'JSON5', xml: 'XML', yaml: 'YAML', toml: 'TOML', ini: 'INI', sql: 'SQL', graphql: 'GraphQL', markdown: 'Markdown', plaintext: 'Plain Text', vue: 'Vue', svelte: 'Svelte', dockerfile: 'Dockerfile', makefile: 'Makefile', diff: 'Diff', }; return names[languageId] || languageId.charAt(0).toUpperCase() + languageId.slice(1); } /** * File opener application types * - 'builtin-editor': Built-in text editor (Monaco) * - 'system-app': External system application (stores path) */ export type FileOpenerType = 'builtin-editor' | 'system-app'; /** * System application info for file associations */ export interface SystemAppInfo { path: string; // Path to the executable/app name: string; // Display name } /** * File association record */ export interface FileAssociation { extension: string; openerType: FileOpenerType; systemApp?: SystemAppInfo; // Only set when openerType is 'system-app' } /** * Get all supported language IDs for syntax highlighting dropdown */ export function getSupportedLanguages(): { id: string; name: string }[] { const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE)); languageIds.add('plaintext'); return Array.from(languageIds) .map(id => ({ id, name: getLanguageName(id) })) .sort((a, b) => a.name.localeCompare(b.name)); } /** * Represents a file or directory entry from drag-and-drop * This includes the relative path for nested files in folders */ export interface DropEntry { file: File | null; // null for directory entries localPath?: string; size?: number; relativePath: string; // Path relative to the root of the drop (e.g., "folder/subfolder/file.txt") isDirectory: boolean; } const createDropEntriesFromFiles = (files: FileList | File[]): DropEntry[] => { const results: DropEntry[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; const path = getPathForFile(file); if (path) { (file as File & { path?: string }).path = path; } results.push({ file, relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name, isDirectory: false, }); } return results; }; /** * Convert a FileSystemEntry to a File */ function entryToFile(entry: FileSystemFileEntry): Promise { return new Promise((resolve, reject) => { entry.file(resolve, reject); }); } /** * Read all entries from a directory reader * Handles the fact that readEntries may not return all entries at once */ async function readAllDirectoryEntries( directoryReader: FileSystemDirectoryReader ): Promise { const allEntries: FileSystemEntry[] = []; // Keep reading until we get an empty result let entries: FileSystemEntry[]; do { entries = await new Promise((resolve, reject) => { directoryReader.readEntries(resolve, reject); }); for (const entry of entries) { allEntries.push(entry); } } while (entries.length > 0); return allEntries; } /** * Process file system entries iteratively (non-recursive) to handle large folders * Uses a queue-based approach to avoid stack overflow * @param rootEntries - The root entries to process * @returns Array of DropEntry objects with files and their relative paths */ async function processEntriesIteratively( rootEntries: FileSystemEntry[] ): Promise { const results: DropEntry[] = []; // Queue of entries to process: [entry, basePath] const queue: Array<{ entry: FileSystemEntry; basePath: string }> = []; // Initialize queue with root entries for (const entry of rootEntries) { queue.push({ entry, basePath: "" }); } let processedCount = 0; const YIELD_INTERVAL = 100; // Yield to main thread every N items while (queue.length > 0) { const { entry, basePath } = queue.shift()!; if (entry.isFile) { const fileEntry = entry as FileSystemFileEntry; try { const file = await entryToFile(fileEntry); results.push({ file, relativePath: basePath ? `${basePath}/${entry.name}` : entry.name, isDirectory: false, }); } catch (error) { console.warn(`Failed to read file entry: ${entry.name}`, error); } } else if (entry.isDirectory) { const dirEntry = entry as FileSystemDirectoryEntry; const currentPath = basePath ? `${basePath}/${entry.name}` : entry.name; // Add directory entry results.push({ file: null, relativePath: currentPath, isDirectory: true, }); try { const reader = dirEntry.createReader(); const childEntries = await readAllDirectoryEntries(reader); // Add child entries to the queue (not recursive!) for (const childEntry of childEntries) { queue.push({ entry: childEntry, basePath: currentPath }); } } catch (error) { console.warn(`Failed to read directory: ${entry.name}`, error); } } // Yield to main thread periodically to keep UI responsive processedCount++; if (processedCount % YIELD_INTERVAL === 0) { await new Promise(resolve => setTimeout(resolve, 0)); } } return results; } /** * Get the local file path for a File object using Electron's webUtils API * Falls back to the legacy file.path property if webUtils is not available */ export function getPathForFile(file: File): string | undefined { try { // Try Electron's webUtils API (exposed via preload) const path = netcattyBridge.get()?.getPathForFile?.(file); if (path) return path; // Fallback: try legacy file.path property return (file as File & { path?: string }).path; } catch { return undefined; } } /** * Extract all files and directories from a DataTransfer object * Supports both regular files and folders dropped from the OS * * Uses the webkitGetAsEntry() API for folder access, with fallback * to regular FileList for browsers that don't support it. * * @param dataTransfer - The DataTransfer object from a drop event * @returns Array of DropEntry objects with files and relative paths */ export async function extractDropEntries( dataTransfer: DataTransfer ): Promise { const items = dataTransfer.items; // Build a map of file/folder name to path from the original files in DataTransfer.files const filePathMap = new Map(); const filesWithPath = dataTransfer.files; for (let i = 0; i < filesWithPath.length; i++) { const f = filesWithPath[i]; const path = getPathForFile(f); if (path) { filePathMap.set(f.name, path); } } // Check if webkitGetAsEntry is supported (for folder access) if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') { // Collect all entries first (getAsEntry must be called synchronously) const entries: FileSystemEntry[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const entry = item.webkitGetAsEntry(); if (entry) { entries.push(entry); } } } // Process entries iteratively (non-recursive) to avoid stack overflow const results = await processEntriesIteratively(entries); if (results.length === 0) { return createDropEntriesFromFiles(dataTransfer.files); } // Restore the 'path' property for all files // Try to get the path directly from webUtils.getPathForFile for each file // This is more reliable than trying to reconstruct from folder paths for (const result of results) { if (result.file) { // First try to get path directly from the file const directPath = getPathForFile(result.file); if (directPath) { (result.file as File & { path?: string }).path = directPath; } else { // Fallback: try to reconstruct from root folder path const pathParts = result.relativePath.split('/'); const rootName = pathParts[0]; const rootPath = filePathMap.get(rootName); if (rootPath) { if (pathParts.length === 1) { // Root-level file: use the path directly (result.file as File & { path?: string }).path = rootPath; } else { // Nested file in a folder: construct full path // rootPath is the path to the root folder, we need to append the rest const restOfPath = pathParts.slice(1).join('/'); const separator = rootPath.includes('\\') ? '\\' : '/'; const fullPath = rootPath + separator + restOfPath.replace(/\//g, separator); (result.file as File & { path?: string }).path = fullPath; } } } } } return results; } else { // Fallback: use regular FileList (no folder support) // Files from FileList in Electron already have the 'path' property return createDropEntriesFromFiles(dataTransfer.files); } }