/** * SFTP utility functions for formatting and file type detection */ import { Database, ExternalLink, File, FileArchive, FileAudio, FileCode, FileImage, FileSpreadsheet, FileText, FileType, FileVideo, Folder, Globe, Key, Lock, Settings, Terminal, } from 'lucide-react'; import React from 'react'; import type { LucideIcon } from 'lucide-react'; import { SftpFileEntry } from '../../types'; import { formatDate as formatDateFromState, isNavigableDirectory, } from '../../application/state/sftp/utils'; export { isNavigableDirectory }; /** Accept undefined timestamps from file-list rows. */ export const formatDate = (timestamp: number | undefined): string => formatDateFromState(timestamp ?? 0); // Pre-built icon maps for O(1) lookup in getFileIcon type IconDef = [LucideIcon, string?]; const EXTENSION_ICON_MAP = new Map([ // Documents ['doc', [FileText, "text-blue-500"]], ['docx', [FileText, "text-blue-500"]], ['rtf', [FileText, "text-blue-500"]], ['odt', [FileText, "text-blue-500"]], ['xls', [FileSpreadsheet, "text-green-500"]], ['xlsx', [FileSpreadsheet, "text-green-500"]], ['csv', [FileSpreadsheet, "text-green-500"]], ['ods', [FileSpreadsheet, "text-green-500"]], ['ppt', [FileType, "text-orange-500"]], ['pptx', [FileType, "text-orange-500"]], ['odp', [FileType, "text-orange-500"]], ['pdf', [FileText, "text-red-500"]], // Code/Scripts ['js', [FileCode, "text-yellow-500"]], ['jsx', [FileCode, "text-yellow-500"]], ['ts', [FileCode, "text-yellow-500"]], ['tsx', [FileCode, "text-yellow-500"]], ['mjs', [FileCode, "text-yellow-500"]], ['cjs', [FileCode, "text-yellow-500"]], ['py', [FileCode, "text-blue-400"]], ['pyc', [FileCode, "text-blue-400"]], ['pyw', [FileCode, "text-blue-400"]], ['sh', [Terminal, "text-green-400"]], ['bash', [Terminal, "text-green-400"]], ['zsh', [Terminal, "text-green-400"]], ['fish', [Terminal, "text-green-400"]], ['bat', [Terminal, "text-green-400"]], ['cmd', [Terminal, "text-green-400"]], ['ps1', [Terminal, "text-green-400"]], ['c', [FileCode, "text-blue-600"]], ['cpp', [FileCode, "text-blue-600"]], ['h', [FileCode, "text-blue-600"]], ['hpp', [FileCode, "text-blue-600"]], ['cc', [FileCode, "text-blue-600"]], ['cxx', [FileCode, "text-blue-600"]], ['java', [FileCode, "text-orange-600"]], ['class', [FileCode, "text-orange-600"]], ['jar', [FileCode, "text-orange-600"]], ['go', [FileCode, "text-cyan-500"]], ['rs', [FileCode, "text-orange-400"]], ['rb', [FileCode, "text-red-400"]], ['php', [FileCode, "text-purple-500"]], ['html', [Globe, "text-orange-500"]], ['htm', [Globe, "text-orange-500"]], ['xhtml', [Globe, "text-orange-500"]], ['css', [FileCode, "text-blue-500"]], ['scss', [FileCode, "text-blue-500"]], ['sass', [FileCode, "text-blue-500"]], ['less', [FileCode, "text-blue-500"]], ['vue', [FileCode, "text-green-500"]], ['svelte', [FileCode, "text-green-500"]], // Config/Data ['json', [FileCode, "text-yellow-600"]], ['json5', [FileCode, "text-yellow-600"]], ['xml', [FileCode, "text-orange-400"]], ['xsl', [FileCode, "text-orange-400"]], ['xslt', [FileCode, "text-orange-400"]], ['yml', [Settings, "text-pink-400"]], ['yaml', [Settings, "text-pink-400"]], ['toml', [Settings, "text-gray-400"]], ['ini', [Settings, "text-gray-400"]], ['conf', [Settings, "text-gray-400"]], ['cfg', [Settings, "text-gray-400"]], ['config', [Settings, "text-gray-400"]], ['env', [Lock, "text-yellow-500"]], ['sql', [Database, "text-blue-400"]], ['sqlite', [Database, "text-blue-400"]], ['db', [Database, "text-blue-400"]], // Images ['jpg', [FileImage, "text-purple-400"]], ['jpeg', [FileImage, "text-purple-400"]], ['png', [FileImage, "text-purple-400"]], ['gif', [FileImage, "text-purple-400"]], ['bmp', [FileImage, "text-purple-400"]], ['webp', [FileImage, "text-purple-400"]], ['svg', [FileImage, "text-purple-400"]], ['ico', [FileImage, "text-purple-400"]], ['tiff', [FileImage, "text-purple-400"]], ['tif', [FileImage, "text-purple-400"]], ['heic', [FileImage, "text-purple-400"]], ['heif', [FileImage, "text-purple-400"]], ['avif', [FileImage, "text-purple-400"]], // Videos ['mp4', [FileVideo, "text-pink-500"]], ['mkv', [FileVideo, "text-pink-500"]], ['avi', [FileVideo, "text-pink-500"]], ['mov', [FileVideo, "text-pink-500"]], ['wmv', [FileVideo, "text-pink-500"]], ['flv', [FileVideo, "text-pink-500"]], ['webm', [FileVideo, "text-pink-500"]], ['m4v', [FileVideo, "text-pink-500"]], ['3gp', [FileVideo, "text-pink-500"]], ['mpeg', [FileVideo, "text-pink-500"]], ['mpg', [FileVideo, "text-pink-500"]], // Audio ['mp3', [FileAudio, "text-green-400"]], ['wav', [FileAudio, "text-green-400"]], ['flac', [FileAudio, "text-green-400"]], ['aac', [FileAudio, "text-green-400"]], ['ogg', [FileAudio, "text-green-400"]], ['m4a', [FileAudio, "text-green-400"]], ['wma', [FileAudio, "text-green-400"]], ['opus', [FileAudio, "text-green-400"]], ['aiff', [FileAudio, "text-green-400"]], // Archives ['zip', [FileArchive, "text-amber-500"]], ['rar', [FileArchive, "text-amber-500"]], ['7z', [FileArchive, "text-amber-500"]], ['tar', [FileArchive, "text-amber-500"]], ['gz', [FileArchive, "text-amber-500"]], ['bz2', [FileArchive, "text-amber-500"]], ['xz', [FileArchive, "text-amber-500"]], ['tgz', [FileArchive, "text-amber-500"]], ['tbz2', [FileArchive, "text-amber-500"]], ['lz', [FileArchive, "text-amber-500"]], ['lzma', [FileArchive, "text-amber-500"]], ['cab', [FileArchive, "text-amber-500"]], ['iso', [FileArchive, "text-amber-500"]], ['dmg', [FileArchive, "text-amber-500"]], // Executables ['exe', [File, "text-red-400"]], ['msi', [File, "text-red-400"]], ['app', [File, "text-red-400"]], ['deb', [File, "text-red-400"]], ['rpm', [File, "text-red-400"]], ['apk', [File, "text-red-400"]], ['ipa', [File, "text-red-400"]], ['dll', [File, "text-gray-500"]], ['so', [File, "text-gray-500"]], ['dylib', [File, "text-gray-500"]], // Keys/Certs ['pem', [Key, "text-yellow-400"]], ['crt', [Key, "text-yellow-400"]], ['cer', [Key, "text-yellow-400"]], ['key', [Key, "text-yellow-400"]], ['pub', [Key, "text-yellow-400"]], ['ppk', [Key, "text-yellow-400"]], // Text/Markdown ['md', [FileText, "text-gray-400"]], ['markdown', [FileText, "text-gray-400"]], ['mdx', [FileText, "text-gray-400"]], ['txt', [FileText, "text-muted-foreground"]], ['log', [FileText, "text-muted-foreground"]], ['text', [FileText, "text-muted-foreground"]], ]); /** * Format bytes with appropriate unit (B, KB, MB, GB) */ export const formatBytes = (bytes: number | string): string => { const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes; if (isNaN(numBytes) || numBytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(numBytes) / Math.log(1024)); const size = numBytes / Math.pow(1024, i); return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; }; /** * Format bytes for transfer display */ export const formatTransferBytes = (bytes: number): string => { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); const size = bytes / Math.pow(1024, i); return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; }; /** * Format speed with appropriate unit */ export const formatSpeed = (bytesPerSecond: number): string => { if (bytesPerSecond <= 0) return ''; if (bytesPerSecond >= 1024 * 1024) { return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`; } return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`; }; /** * Comprehensive file icon helper - returns JSX element based on file type. * Uses pre-built Map for O(1) extension lookup. */ export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => { if (entry.type === 'directory') return React.createElement(Folder, { size: 14 }); // For symlink files (not directories), show a special symlink icon if (entry.type === 'symlink' && entry.linkTarget !== 'directory') { return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" }); } const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() ?? '' : ''; const iconDef = EXTENSION_ICON_MAP.get(ext); if (iconDef) { const [Icon, className] = iconDef; return React.createElement(Icon, { size: 14, ...(className ? { className } : {}) }); } // Default return React.createElement(FileCode, { size: 14 }); }; // Sort / column layout — owned by application/state; re-exported for UI callers. export { DEFAULT_SFTP_COLUMN_VISIBILITY, normalizeSftpColumnVisibility, type ColumnWidths, type SftpColumnVisibility, type SortField, type SortOrder, } from '../../application/state/sftp/columnLayout'; export const isSftpColumnMenuKey = (key: string, shiftKey: boolean): boolean => key === 'ContextMenu' || (key === 'F10' && shiftKey); export const buildSftpColumnTemplate = ( columnWidths: ColumnWidths, visibleColumns: SftpColumnVisibility = DEFAULT_SFTP_COLUMN_VISIBILITY, ): string => { const columns = [`minmax(140px, ${columnWidths.name}fr)`]; if (visibleColumns.modified) columns.push(`minmax(0, ${columnWidths.modified}fr)`); if (visibleColumns.size) columns.push(`minmax(52px, ${columnWidths.size}fr)`); if (visibleColumns.type) columns.push(`minmax(64px, ${columnWidths.type}fr)`); if (visibleColumns.owner) columns.push(`minmax(56px, ${columnWidths.owner}fr)`); return columns.join(' '); }; export const sortSftpEntries = ( entries: SftpFileEntry[], sortField: SortField, sortOrder: SortOrder, directoriesFirst = true, ): SftpFileEntry[] => { if (!entries.length) return entries; const sorted = [...entries].sort((a, b) => { const aIsDir = isNavigableDirectory(a); const bIsDir = isNavigableDirectory(b); if (directoriesFirst) { if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; } let cmp = 0; switch (sortField) { case 'name': cmp = a.name.localeCompare(b.name); break; case 'size': cmp = (a.size || 0) - (b.size || 0); break; case 'modified': cmp = (a.lastModified || 0) - (b.lastModified || 0); break; case 'type': { const extA = aIsDir ? 'folder' : a.name.split('.').pop()?.toLowerCase() || ''; const extB = bIsDir ? 'folder' : b.name.split('.').pop()?.toLowerCase() || ''; cmp = extA.localeCompare(extB); break; } case 'owner': cmp = (a.owner || '').localeCompare(b.owner || ''); break; } return sortOrder === 'asc' ? cmp : -cmp; }); return sorted; }; /** * Check if a file is hidden * - Windows: checks the `hidden` attribute (set by localFsBridge) * - Unix/Linux (remote): also treats dotfiles (names starting with '.') as hidden /** * A file is considered hidden if: * - It has the Windows hidden attribute (`hidden === true`), OR * - Its name starts with a dot (Unix/Linux dotfile convention) * * The ".." parent directory entry is never considered hidden. */ const isHiddenFile = ( file: T, ): boolean => { if (file.name === "..") return false; // Windows hidden attribute if (file.hidden === true) return true; // Unix/Linux dotfile convention if (file.name.startsWith(".")) return true; return false; }; /** * Filter files based on hidden file visibility setting. * Filters Windows hidden files and Unix/Linux dotfiles on all connections. * Always preserves ".." parent directory entry. */ export const filterHiddenFiles = ( files: T[], showHiddenFiles: boolean, ): T[] => { if (showHiddenFiles) return files; return files.filter((f) => !isHiddenFile(f)); }; /** * Filter files by search term (case-insensitive substring match on name). * Always preserves ".." parent directory entry. Empty/whitespace terms are no-ops. */ export const filterSftpEntriesByName = ( files: T[], filter: string, ): T[] => { const term = filter.trim().toLowerCase(); if (!term) return files; return files.filter( (f) => f.name === ".." || f.name.toLowerCase().includes(term), ); }; export type SftpTreeNameFilterOptions = { parentPath: string; joinPath: (parentPath: string, name: string) => string; isDirectory: (entry: T) => boolean; /** Loaded children for a directory path; undefined means not loaded yet. */ getChildren: (entryPath: string) => T[] | undefined; }; /** * Tree-aware name filter: keeps list-view match rules, and also keeps directory * ancestors when an expanded loaded descendant matches. Collapsed, loading, * error, and unloaded directories only appear when their own name matches * (callers should treat those paths as unavailable in getChildren; no * server-side recursive search). */ export const filterSftpTreeEntriesByName = ( files: T[], filter: string, options: SftpTreeNameFilterOptions, ): T[] => { const term = filter.trim().toLowerCase(); if (!term) return files; const subtreeHasMatch = (entries: T[], parentPath: string): boolean => { for (const entry of entries) { if (entry.name === "..") continue; if (entry.name.toLowerCase().includes(term)) return true; if (!options.isDirectory(entry)) continue; const entryPath = options.joinPath(parentPath, entry.name); const children = options.getChildren(entryPath); if (children && subtreeHasMatch(children, entryPath)) return true; } return false; }; return files.filter((entry) => { if (entry.name === "..") return true; if (entry.name.toLowerCase().includes(term)) return true; if (!options.isDirectory(entry)) return false; const entryPath = options.joinPath(options.parentPath, entry.name); const children = options.getChildren(entryPath); return Boolean(children && subtreeHasMatch(children, entryPath)); }); };