mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
7b8c944510
Surface the remote file owner in the SFTP list and tree, using the existing toggleable column menu. Parse the username from SFTP longname or ls output, then fall back to uid when a name is unavailable. Closes #3073
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
/**
|
|
* 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<string, IconDef>([
|
|
// 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 = <T extends { name: string; hidden?: boolean }>(
|
|
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 = <T extends { name: string; hidden?: boolean }>(
|
|
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 = <T extends { name: string }>(
|
|
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<T extends { name: string }> = {
|
|
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 = <T extends { name: string }>(
|
|
files: T[],
|
|
filter: string,
|
|
options: SftpTreeNameFilterOptions<T>,
|
|
): 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));
|
|
});
|
|
};
|