fix(files): incremental directory refresh on create/rename/delete (#721)

Replace the full-tree nuke-and-reload in refreshRoot() with a targeted
refreshDirectory(parentPath) that only evicts and reloads the single
parent directory whose contents changed.

Before: every create/rename/delete call cleared the entire childrenByDir
cache and reloaded only the root, causing all expanded subdirectories to
flash empty and visually reset.

After: only the parent directory of the affected entry is reloaded in-place;
every other expanded directory keeps its cached children, so the tree state
is preserved and there is no visible flash or scroll-position reset.

Applies to both FilesView and SidebarFilesTree.
This commit is contained in:
Nguyễn Ngô Thượng
2026-03-20 18:25:58 +02:00
committed by GitHub
parent 683d0c1798
commit b4949c6e33
2 changed files with 59 additions and 16 deletions
@@ -423,6 +423,24 @@ export const SidebarFilesTree: React.FC = () => {
await loadDirectory(root);
}, [loadDirectory, root]);
/**
* Incrementally refresh a single directory without nuking the rest of the
* tree. Only the given directory is reloaded in-place; every other expanded
* directory keeps its cached children so the UI does not flash/reset.
*/
const refreshDirectory = React.useCallback(async (dirPath: string) => {
if (!dirPath) {
await refreshRoot();
return;
}
const normalized = normalizePath(dirPath);
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.delete(normalized);
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalized);
await loadDirectory(normalized);
}, [loadDirectory, refreshRoot]);
React.useEffect(() => {
if (!root) return;
@@ -587,7 +605,7 @@ export const SidebarFilesTree: React.FC = () => {
.then(async (result) => {
if (result.success) {
toast.success('File created');
await refreshRoot();
await refreshDirectory(parentPath);
}
closeDialog();
})
@@ -611,7 +629,7 @@ export const SidebarFilesTree: React.FC = () => {
.then(async (result) => {
if (result.success) {
toast.success('Folder created');
await refreshRoot();
await refreshDirectory(parentPath);
}
closeDialog();
})
@@ -641,7 +659,7 @@ export const SidebarFilesTree: React.FC = () => {
.then(async (result) => {
if (result.success) {
toast.success('Renamed successfully');
await refreshRoot();
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, oldPath);
}
@@ -663,15 +681,17 @@ export const SidebarFilesTree: React.FC = () => {
return;
}
await files.delete(dialogData.path)
const deletedPath = dialogData.path;
const parentDir = deletedPath.split('/').slice(0, -1).join('/');
await files.delete(deletedPath)
.then(async (result) => {
if (result.success) {
toast.success('Deleted successfully');
await refreshRoot();
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, dialogData.path);
removeOpenPathsByPrefix(root, deletedPath);
}
if (selectedPath === dialogData.path || (selectedPath && selectedPath.startsWith(dialogData.path + '/'))) {
if (selectedPath === deletedPath || (selectedPath && selectedPath.startsWith(deletedPath + '/'))) {
setSelectedPath(root, null);
}
}
@@ -683,7 +703,7 @@ export const SidebarFilesTree: React.FC = () => {
}
done();
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath]);
}, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath]);
// --- Tree rendering (matching FilesView with indent guides) ---
+31 -8
View File
@@ -835,6 +835,27 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
await loadDirectory(root);
}, [loadDirectory, root]);
/**
* Incrementally refresh a single directory without nuking the rest of the
* tree. After the operation the parent directory is reloaded in-place so
* the new/renamed/deleted entry becomes visible immediately while every
* other expanded directory keeps its cached children.
*/
const refreshDirectory = React.useCallback(async (dirPath: string) => {
if (!dirPath) {
await refreshRoot();
return;
}
const normalized = normalizePath(dirPath);
// Remove from loaded set so loadDirectory will actually fetch again.
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.delete(normalized);
// Also cancel any in-flight request for this dir so the new fetch wins.
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalized);
await loadDirectory(normalized);
}, [loadDirectory, refreshRoot]);
const lastFilesViewDirRef = React.useRef<string>('');
const lastFilesViewTreeKeyRef = React.useRef<string>('');
@@ -905,7 +926,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
.then(async (result) => {
if (result.success) {
toast.success('File created');
await refreshRoot();
await refreshDirectory(parentPath);
}
finishDialogOperation();
})
@@ -928,7 +949,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
.then(async (result) => {
if (result.success) {
toast.success('Folder created');
await refreshRoot();
await refreshDirectory(parentPath);
}
finishDialogOperation();
})
@@ -959,7 +980,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
.then(async (result) => {
if (result.success) {
toast.success('Renamed successfully');
await refreshRoot();
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, oldPath);
}
@@ -990,15 +1011,17 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
await files.delete(dialogData.path)
const deletedPath = dialogData.path;
const parentDir = deletedPath.split('/').slice(0, -1).join('/');
await files.delete(deletedPath)
.then(async (result) => {
if (result.success) {
toast.success('Deleted successfully');
await refreshRoot();
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, dialogData.path);
removeOpenPathsByPrefix(root, deletedPath);
}
if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(`${dialogData.path}/`)) {
if (selectedFile?.path === deletedPath || selectedFile?.path.startsWith(`${deletedPath}/`)) {
if (root) {
setSelectedPath(root, null);
}
@@ -1019,7 +1042,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
done();
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]);
}, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]);
React.useEffect(() => {
if (!currentDirectory) {