Files
Netcatty/lib/sftpFileUtils.ts
T
陈大猫 2edbeae02c Remove unused code and assets
Remove unused code, assets, and stale config entries.
2026-07-03 10:47:59 +08:00

466 lines
13 KiB
TypeScript

/**
* 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<string, string> = {
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<string, string> = {
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<File> {
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<FileSystemEntry[]> {
const allEntries: FileSystemEntry[] = [];
// Keep reading until we get an empty result
let entries: FileSystemEntry[];
do {
entries = await new Promise<FileSystemEntry[]>((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<DropEntry[]> {
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<void>(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<DropEntry[]> {
const items = dataTransfer.items;
// Build a map of file/folder name to path from the original files in DataTransfer.files
const filePathMap = new Map<string, string>();
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);
}
}