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:
co-authored by
Serhii Dziupin
Alan Chen
parent
0c5e183c62
commit
99873a7b12
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user