Files
NetMesh/lib/sftpFileUtils.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

813 lines
23 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;
}
/** Local tree row from main-process `listLocalTree`. */
export interface LocalTreeListEntry {
localPath: string;
relativePath: string;
type: "file" | "directory";
size: number;
lastModified: number;
}
/** Live scan counters while expanding a dropped folder. */
export interface DropScanProgress {
fileCount: number;
directoryCount: number;
entryCount: number;
/** Optional UI label (e.g. root folder names). */
label?: string;
}
/**
* Sync snapshot of a drop. DataTransfer must be read before any await —
* after that, items/files may be empty.
*/
export interface CapturedDropRoot {
name: string;
isDirectory: boolean;
localPath?: string;
file?: File | null;
size?: number;
/** Folder-picker relative path when the File carries webkitRelativePath */
relativePath?: string;
/** webkit entry for Chromium walk fallback when no local path is available */
fsEntry?: FileSystemEntry;
}
export interface CapturedDropPayload {
roots: CapturedDropRoot[];
filesFallback: File[];
}
export interface MaterializeDropOptions {
listLocalTree?: (
path: string,
options?: {
onProgress?: (progress: DropScanProgress) => void;
abortSignal?: AbortSignal;
},
) => Promise<LocalTreeListEntry[]>;
onProgress?: (progress: DropScanProgress) => void;
/** Cooperative cancel for webkit walks and native listLocalTree. */
abortSignal?: AbortSignal;
isCancelled?: () => boolean;
}
export const getDropEntryLocalPath = (entry: DropEntry): string | undefined =>
entry.localPath ?? (entry.file ? getPathForFile(entry.file) : undefined);
const createDropEntriesFromFiles = (files: FileList | File[]): DropEntry[] => {
const results: DropEntry[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
results.push({
file,
localPath: getPathForFile(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;
}
function joinLocalRelativePath(rootPath: string, relativePath: string): string {
const normalizedRelative = relativePath.replace(/\\/g, "/");
const parts = normalizedRelative.split("/");
// relativePath is rooted at the drop root name; local path already points at that root.
const nested = parts.length > 1 ? parts.slice(1).join("/") : "";
if (!nested) return rootPath;
const separator = rootPath.includes("\\") ? "\\" : "/";
return rootPath + separator + nested.replace(/\//g, separator);
}
/**
* Process file system entries iteratively (non-recursive) to handle large folders.
* Prefer reconstructing local paths from the drop root so we can skip per-file
* `entry.file()` when Electron already exposed the folder path.
*/
export function isDropScanCancelledError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const code = (error as { code?: string }).code;
return code === "ERR_DROP_SCAN_CANCELLED" || code === "ERR_LOCAL_TREE_CANCELLED";
}
function throwIfDropScanCancelled(options: {
abortSignal?: AbortSignal;
isCancelled?: () => boolean;
}): void {
if (options.abortSignal?.aborted || options.isCancelled?.()) {
const error = new Error("Drop scan cancelled");
(error as Error & { code?: string }).code = "ERR_DROP_SCAN_CANCELLED";
throw error;
}
}
async function processEntriesIteratively(
rootEntries: FileSystemEntry[],
options: {
rootPathByName?: Map<string, string>;
onProgress?: (progress: DropScanProgress) => void;
abortSignal?: AbortSignal;
isCancelled?: () => boolean;
} = {},
): Promise<DropEntry[]> {
const results: DropEntry[] = [];
const rootPathByName = options.rootPathByName ?? new Map<string, string>();
// Index-based queue avoids O(n²) Array.shift on huge trees.
const queue: Array<{ entry: FileSystemEntry; basePath: string }> = [];
for (const entry of rootEntries) {
queue.push({ entry, basePath: "" });
}
let queueIndex = 0;
let processedCount = 0;
let fileCount = 0;
let directoryCount = 0;
const YIELD_INTERVAL = 100;
const PROGRESS_INTERVAL = 32;
const reportProgress = (force = false) => {
if (!options.onProgress) return;
if (!force && processedCount % PROGRESS_INTERVAL !== 0) return;
options.onProgress({
fileCount,
directoryCount,
entryCount: fileCount + directoryCount,
});
};
while (queueIndex < queue.length) {
throwIfDropScanCancelled(options);
const { entry, basePath } = queue[queueIndex++];
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
const rootName = relativePath.split("/")[0] ?? entry.name;
const rootLocalPath = rootPathByName.get(rootName);
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
if (rootLocalPath) {
// Native path is enough for stream upload; avoid Chromium File materialization.
results.push({
file: null,
localPath: joinLocalRelativePath(rootLocalPath, relativePath),
relativePath,
isDirectory: false,
});
fileCount++;
} else {
try {
const file = await entryToFile(fileEntry);
results.push({
file,
relativePath,
isDirectory: false,
});
fileCount++;
} catch (error) {
console.warn(`Failed to read file entry: ${entry.name}`, error);
}
}
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
results.push({
file: null,
localPath: rootLocalPath ? joinLocalRelativePath(rootLocalPath, relativePath) : undefined,
relativePath,
isDirectory: true,
});
directoryCount++;
try {
const reader = dirEntry.createReader();
const childEntries = await readAllDirectoryEntries(reader);
for (const childEntry of childEntries) {
queue.push({ entry: childEntry, basePath: relativePath });
}
} catch (error) {
console.warn(`Failed to read directory: ${entry.name}`, error);
}
}
processedCount++;
reportProgress();
if (processedCount % YIELD_INTERVAL === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}
reportProgress(true);
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;
}
}
/** Build a short label for the scanning task (folder names visible immediately). */
export function formatDropScanLabel(roots: readonly CapturedDropRoot[]): string {
const names = roots.map((root) => root.name).filter(Boolean);
if (names.length === 0) return "Scanning files...";
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]}, ${names[1]}`;
return `${names[0]}, ${names[1]} +${names.length - 2}`;
}
/**
* Synchronously capture drop roots. Must run during the drop/paste event —
* before any await — or DataTransfer becomes empty.
*/
export function captureDropPayload(dataTransfer: DataTransfer): CapturedDropPayload {
const filesFallback: File[] = [];
const files = dataTransfer.files;
for (let i = 0; i < files.length; i++) {
filesFallback.push(files[i]);
}
const roots: CapturedDropRoot[] = [];
const items = dataTransfer.items;
const relativePathForFile = (file: File): string | undefined => {
const relative = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
return relative && relative.length > 0 ? relative : undefined;
};
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === "function") {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== "file") continue;
const entry = item.webkitGetAsEntry();
const file = typeof item.getAsFile === "function" ? item.getAsFile() : (filesFallback[i] ?? null);
const localPath = file ? getPathForFile(file) : undefined;
const relativePath = file ? relativePathForFile(file) : undefined;
if (entry) {
roots.push({
name: entry.name,
isDirectory: entry.isDirectory,
localPath,
file: entry.isFile ? file : null,
size: file?.size,
relativePath: entry.isFile ? relativePath : undefined,
fsEntry: entry,
});
continue;
}
if (file) {
roots.push({
name: file.name,
isDirectory: false,
localPath: getPathForFile(file),
file,
size: file.size,
relativePath,
});
}
}
} else {
for (const file of filesFallback) {
roots.push({
name: file.name,
isDirectory: false,
localPath: getPathForFile(file),
file,
size: file.size,
relativePath: relativePathForFile(file),
});
}
}
return { roots, filesFallback };
}
/** Map main-process local tree rows into upload DropEntry records. */
export function localTreeToDropEntries(tree: readonly LocalTreeListEntry[]): DropEntry[] {
return tree.map((entry) => {
if (entry.type === "directory") {
return {
file: null,
localPath: entry.localPath,
relativePath: entry.relativePath,
isDirectory: true,
// Do not use directory metadata size in conflict / compressed totals.
};
}
return {
file: null,
localPath: entry.localPath,
relativePath: entry.relativePath,
isDirectory: false,
size: entry.size,
};
});
}
function countDropEntries(entries: readonly DropEntry[]): DropScanProgress {
let fileCount = 0;
let directoryCount = 0;
for (const entry of entries) {
if (entry.isDirectory) directoryCount += 1;
else fileCount += 1;
}
return {
fileCount,
directoryCount,
entryCount: fileCount + directoryCount,
};
}
/**
* Expand a captured drop into full DropEntry rows.
* Prefer Electron `listLocalTree` (native fs) for directory roots with a local
* path; fall back to Chromium FileSystemEntry walk only when necessary.
*/
export async function materializeDropEntries(
payload: CapturedDropPayload,
options: MaterializeDropOptions = {},
): Promise<DropEntry[]> {
const { listLocalTree, onProgress, abortSignal, isCancelled } = options;
const results: DropEntry[] = [];
let fileCount = 0;
let directoryCount = 0;
const report = (label?: string, forcePartial?: DropScanProgress) => {
if (!onProgress) return;
if (forcePartial) {
onProgress({
...forcePartial,
label,
});
return;
}
onProgress({
fileCount,
directoryCount,
entryCount: fileCount + directoryCount,
label,
});
};
const nativeDirectoryRoots: CapturedDropRoot[] = [];
const webkitDirectoryRoots: CapturedDropRoot[] = [];
const fileRoots: CapturedDropRoot[] = [];
for (const root of payload.roots) {
if (root.isDirectory) {
if (root.localPath && listLocalTree) {
nativeDirectoryRoots.push(root);
} else if (root.fsEntry) {
webkitDirectoryRoots.push(root);
} else {
console.warn(`[SFTP] Skipping directory drop root without path or entry: ${root.name}`);
}
continue;
}
fileRoots.push(root);
}
throwIfDropScanCancelled({ abortSignal, isCancelled });
// Parallel native walks — one IPC per root folder, much faster than webkit.
if (nativeDirectoryRoots.length > 0 && listLocalTree) {
// Cumulative progress across roots: each walk reports its own counts.
const partialByRoot = new Map<string, DropScanProgress>();
const emitCombined = (label?: string) => {
let files = fileCount;
let dirs = directoryCount;
for (const partial of partialByRoot.values()) {
files += partial.fileCount;
dirs += partial.directoryCount;
}
report(label, { fileCount: files, directoryCount: dirs, entryCount: files + dirs });
};
// Local controller so a single root failure aborts sibling walks too.
const siblingAbort = new AbortController();
const stopSiblingScans = () => {
try {
siblingAbort.abort();
} catch {
// ignore
}
};
if (abortSignal) {
if (abortSignal.aborted) stopSiblingScans();
else abortSignal.addEventListener("abort", stopSiblingScans, { once: true });
}
const walkSignal = siblingAbort.signal;
const walkCancelled = () => (
walkSignal.aborted || abortSignal?.aborted === true || isCancelled?.() === true
);
const walkPromises = nativeDirectoryRoots.map(async (root) => {
throwIfDropScanCancelled({ abortSignal: walkSignal, isCancelled: walkCancelled });
const tree = await listLocalTree(root.localPath!, {
abortSignal: walkSignal,
onProgress: (partial) => {
partialByRoot.set(root.localPath!, partial);
emitCombined(root.name);
},
});
return { root, tree };
});
let trees: Array<{ root: CapturedDropRoot; tree: LocalTreeListEntry[] }>;
try {
trees = await Promise.all(walkPromises);
} catch (error) {
stopSiblingScans();
// Drain remaining native walks so retry does not pile more I/O on top.
await Promise.allSettled(walkPromises);
throw error;
} finally {
abortSignal?.removeEventListener("abort", stopSiblingScans);
}
for (const { root, tree } of trees) {
const entries = localTreeToDropEntries(tree);
for (const entry of entries) {
results.push(entry);
if (entry.isDirectory) directoryCount += 1;
else fileCount += 1;
}
partialByRoot.delete(root.localPath!);
report(root.name);
}
}
for (const root of fileRoots) {
results.push({
file: root.file ?? null,
localPath: root.localPath,
relativePath: root.relativePath || root.name,
isDirectory: false,
size: root.size ?? root.file?.size,
});
fileCount += 1;
}
if (fileRoots.length > 0) {
report();
}
if (webkitDirectoryRoots.length > 0) {
const rootPathByName = new Map<string, string>();
for (const root of webkitDirectoryRoots) {
if (root.localPath) rootPathByName.set(root.name, root.localPath);
}
const walked = await processEntriesIteratively(
webkitDirectoryRoots.map((root) => root.fsEntry!).filter(Boolean),
{
rootPathByName,
abortSignal,
isCancelled,
onProgress: (partial) => {
report(undefined, {
fileCount: fileCount + partial.fileCount,
directoryCount: directoryCount + partial.directoryCount,
entryCount: fileCount + directoryCount + partial.entryCount,
});
},
},
);
// Attach reconstructed paths when we only know the root path.
for (const entry of walked) {
if (!entry.localPath) {
const rootName = entry.relativePath.split("/")[0];
const rootPath = rootPathByName.get(rootName);
if (rootPath) {
entry.localPath = joinLocalRelativePath(rootPath, entry.relativePath);
} else if (entry.file) {
entry.localPath = getPathForFile(entry.file);
}
}
results.push(entry);
if (entry.isDirectory) directoryCount += 1;
else fileCount += 1;
}
report();
}
if (results.length === 0 && payload.filesFallback.length > 0) {
const fallback = createDropEntriesFromFiles(payload.filesFallback);
const counts = countDropEntries(fallback);
report(undefined, counts);
return fallback;
}
report(undefined, { fileCount, directoryCount, entryCount: fileCount + directoryCount });
return results;
}
/**
* Extract all files and directories from a DataTransfer object.
* Supports both regular files and folders dropped from the OS.
*
* Prefer Electron native tree walk when local paths are available; otherwise
* use webkitGetAsEntry with a path-reconstruction fast path.
*/
export async function extractDropEntries(
dataTransfer: DataTransfer,
options: MaterializeDropOptions = {},
): Promise<DropEntry[]> {
const payload = captureDropPayload(dataTransfer);
const bridge = netcattyBridge.get();
return materializeDropEntries(payload, {
listLocalTree: options.listLocalTree
?? (bridge?.listLocalTree
? (path, treeOptions) => bridge.listLocalTree!(path, treeOptions)
: undefined),
onProgress: options.onProgress,
abortSignal: options.abortSignal,
isCancelled: options.isCancelled,
});
}