fix(files): harden drag-and-drop uploads

Co-authored-by: Serhii Dziupin <serkraser@gmail.com>

Co-authored-by: Alan Chen <2144783+alanzchen@users.noreply.github.com>
This commit is contained in:
Bohdan Triapitsyn
2026-08-18 23:16:46 +03:00
co-authored by Serhii Dziupin Alan Chen
parent 0c5e183c62
commit 99873a7b12
9 changed files with 297 additions and 73 deletions
@@ -44,6 +44,7 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon";
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { isFilesystemError } from '@/lib/api/files-errors';
import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation';
import { isBrowserClientRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -1044,9 +1045,18 @@ export const SidebarFilesTree: React.FC = () => {
const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length;
const failedCount = outcomes.filter((outcome) => outcome === 'failed').length;
const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict');
const uploadedPaths = droppedFiles.flatMap((file, index) => {
const name = getUploadName(file);
return outcomes[index] === 'uploaded' && name
? [normalizePath(`${directory}/${name}`)]
: [];
});
const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime;
try {
if (uploadedPaths.length > 0) {
notifyFileContentInvalidated({ runtimeKey: operationRuntime, paths: uploadedPaths });
}
if (uploadedCount > 0 && isCurrentDestination) {
await refreshDirectory(directory);
}
+27 -5
View File
@@ -48,8 +48,9 @@ import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile,
import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
import { subscribeToFileContentInvalidation } from '@/lib/fileContentInvalidation';
import { DiagramEditor } from '@/components/diagram';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view';
@@ -920,6 +921,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
const activeFileLoadIdRef = React.useRef(0);
const loadingFilePathRef = React.useRef<string | null>(null);
const [fileContentRevision, setFileContentRevision] = React.useState(0);
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
const [diagramSaved, setDiagramSaved] = React.useState(false);
const [contentDetectedBinary, setContentDetectedBinary] = React.useState(false);
@@ -2046,13 +2048,33 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
loadingFilePathRef.current = null;
}
});
}, [loadSelectedFile, loadedFilePath, selectedFile]);
}, [fileContentRevision, loadSelectedFile, loadedFilePath, selectedFile]);
// Sync isDirty to a ref so the polling interval can read the latest value
// without isDirty in its dependency array (avoids interval restart on every edit/save).
const isDirtyRef = React.useRef(isDirty);
isDirtyRef.current = isDirty;
React.useEffect(() => subscribeToFileContentInvalidation(({ runtimeKey, paths }) => {
const selectedPath = selectedFile?.path;
if (
runtimeKey !== getRuntimeKey()
|| !selectedPath
|| isDirtyRef.current
|| !paths.includes(normalizePath(selectedPath))
) {
return;
}
activeFileLoadIdRef.current += 1;
loadingFilePathRef.current = null;
lastLoadedFileStatRef.current = null;
setDesktopImageSrc('');
setFileError(null);
setLoadedFilePath(null);
setFileContentRevision((revision) => revision + 1);
}), [selectedFile?.path]);
// Poll open file for external changes.
// When a change is detected, reset loadedFilePath so the effect above
// triggers a single reload — no double-load.
@@ -3006,11 +3028,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
);
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}|${fileContentRevision}`
: '';
const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode
? selectedFile.path
? `${selectedFile.path}|${fileContentRevision}`
: '';
const assetAuthErrorFallback = t('filesView.error.readFileFailed');
@@ -3113,7 +3135,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
URL.revokeObjectURL(objectUrl);
}
};
}, [files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
}, [fileContentRevision, files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test';
import {
notifyFileContentInvalidated,
subscribeToFileContentInvalidation,
} from './fileContentInvalidation';
describe('fileContentInvalidation', () => {
test('publishes normalized paths within the captured runtime', () => {
const received: Array<{ runtimeKey: string; paths: readonly string[] }> = [];
const unsubscribe = subscribeToFileContentInvalidation((invalidation) => {
received.push(invalidation);
});
notifyFileContentInvalidated({
runtimeKey: ' runtime-a ',
paths: [' /repo/a.txt ', '/repo/a.txt', '', '/repo/b.txt'],
});
unsubscribe();
expect(received).toEqual([{
runtimeKey: 'runtime-a',
paths: ['/repo/a.txt', '/repo/b.txt'],
}]);
});
test('stops publishing after unsubscribe', () => {
let calls = 0;
const unsubscribe = subscribeToFileContentInvalidation(() => {
calls += 1;
});
unsubscribe();
notifyFileContentInvalidated({ runtimeKey: 'runtime-a', paths: ['/repo/a.txt'] });
expect(calls).toBe(0);
});
});
@@ -0,0 +1,25 @@
type FileContentInvalidation = {
runtimeKey: string;
paths: readonly string[];
};
type FileContentInvalidationListener = (invalidation: FileContentInvalidation) => void;
const listeners = new Set<FileContentInvalidationListener>();
export const notifyFileContentInvalidated = (invalidation: FileContentInvalidation): void => {
const runtimeKey = invalidation.runtimeKey.trim();
const paths = Array.from(new Set(invalidation.paths.map((path) => path.trim()).filter(Boolean)));
if (!runtimeKey || paths.length === 0) return;
for (const listener of listeners) {
listener({ runtimeKey, paths });
}
};
export const subscribeToFileContentInvalidation = (
listener: FileContentInvalidationListener,
): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};