mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
2edbeae02c
Remove unused code, assets, and stale config entries.
466 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|