Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion apps/desktop/src/renderer/components/files/treeHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { isUnavailableGitDecorationsError } from "./treeHelpers";
import {
hasLoadedDirectoryChildren,
isUnavailableGitDecorationsError,
parentPathForFileChange,
} from "./treeHelpers";

describe("isUnavailableGitDecorationsError", () => {
it("matches optional remote git decoration action availability failures", () => {
Expand All @@ -22,3 +26,39 @@ describe("isUnavailableGitDecorationsError", () => {
expect(isUnavailableGitDecorationsError(new Error("Action 'file.readFile' is not callable."))).toBe(false);
});
});

describe("file tree change refresh helpers", () => {
it("resolves changed paths to the directory that needs a scoped reload", () => {
expect(parentPathForFileChange("README.md")).toBe("");
expect(parentPathForFileChange("marketing/fastlane-brand-profile.md")).toBe("marketing");
expect(parentPathForFileChange("src/routes/app/page.tsx")).toBe("src/routes/app");
expect(parentPathForFileChange("src\\routes\\app\\page.tsx")).toBe("src/routes/app");
});

it("only treats directories with loaded children as refreshable", () => {
const tree = [
{
name: "marketing",
path: "marketing",
type: "directory" as const,
children: [
{
name: "existing.md",
path: "marketing/existing.md",
type: "file" as const,
},
],
},
{
name: "src",
path: "src",
type: "directory" as const,
},
];

expect(hasLoadedDirectoryChildren(tree, "")).toBe(true);
expect(hasLoadedDirectoryChildren(tree, "marketing")).toBe(true);
expect(hasLoadedDirectoryChildren(tree, "src")).toBe(false);
expect(hasLoadedDirectoryChildren(tree, "missing")).toBe(false);
});
});
13 changes: 13 additions & 0 deletions apps/desktop/src/renderer/components/files/treeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ export function fileTreeNodeByPath(nodes: FileTreeNode[]): Map<string, FileTreeN
return out;
}

export function parentPathForFileChange(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
if (!normalized) return "";
const index = normalized.lastIndexOf("/");
return index > 0 ? normalized.slice(0, index) : "";
}

export function hasLoadedDirectoryChildren(nodes: FileTreeNode[], directoryPath: string): boolean {
if (!directoryPath) return true;
const node = fileTreeNodeByPath(nodes).get(directoryPath);
return node?.type === "directory" && Array.isArray(node.children);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

/** Merge a freshly-fetched root listing while keeping already-loaded subtrees. */
export function mergeTreePreservingLoadedChildren(
nextNodes: FileTreeNode[],
Expand Down
126 changes: 96 additions & 30 deletions apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
defaultFilesWorkspaceId,
filesSessionKey,
formatFilesError,
hasLoadedDirectoryChildren,
isUnavailableGitDecorationsError,
mergeTreePreservingLoadedChildren,
parentPathForFileChange,
replaceTreeNodeChildren,
} from "../treeHelpers";
import {
Expand Down Expand Up @@ -45,6 +47,7 @@ import type { EditorThemeMode } from "./viewers/types";

const TREE_PAGE_SIZE = 2_000;
const MAX_AUTO_LOADED_CHILDREN = 10_000;
const MAX_QUEUED_TREE_PARENT_REFRESHES = 24;

// Module-level caches survive remounts (the route unmounts FilesWorkbench when you
// switch tabs), so re-opening Files shows the workspace + tree instantly instead of
Expand Down Expand Up @@ -126,6 +129,8 @@ export function FilesWorkbench({
const dragRef = useRef<{ groupId: string; path: string } | null>(null);
const workspaceIdRef = useRef(workspaceId);
workspaceIdRef.current = workspaceId;
const treeRef = useRef(tree);
treeRef.current = tree;

const store = useEditorGroupsStore();
const groupsState = store.sessions[sessionKey] ?? createInitialGroupsState();
Expand Down Expand Up @@ -187,14 +192,15 @@ export function FilesWorkbench({
}, [workspaces, globalLaneId]);

/* ---- Tree loading ---- */
const refreshRoot = useCallback(async () => {
const refreshRoot = useCallback(async (options: { preserveLoadedChildren?: boolean } = {}) => {
if (!workspaceId) return;
const reqId = workspaceId;
const preserveLoadedChildren = options.preserveLoadedChildren !== false;
try {
const nodes = await window.ade.files.listTree({ workspaceId: reqId, depth: 1, includeIgnored: true });
if (workspaceIdRef.current !== reqId) return;
setTree((prev) => {
const merged = mergeTreePreservingLoadedChildren(nodes, prev);
const merged = preserveLoadedChildren ? mergeTreePreservingLoadedChildren(nodes, prev) : nodes;
rootTreeCacheByKey.set(rootTreeCacheKey(projectRootPath, reqId), merged);
return merged;
});
Expand Down Expand Up @@ -233,33 +239,42 @@ export function FilesWorkbench({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, workspaceId, refreshRoot, projectRootPath]);

const loadDirectory = useCallback(
async (parentPath: string) => {
if (!workspaceId) return;
const reqId = workspaceId;
const fetchDirectoryChildren = useCallback(async (reqId: string, parentPath: string) => {
const children: FileTreeNode[] = [];
let offset = 0;
let loadMoreOffset: number | null = null;
for (;;) {
const page = await window.ade.files.listTreeChildren({
workspaceId: reqId,
parentPath,
offset,
limit: TREE_PAGE_SIZE,
includeIgnored: true,
});
if (workspaceIdRef.current !== reqId) return null;
children.push(...page.children);
if (page.nextOffset == null) break;
if (children.length >= MAX_AUTO_LOADED_CHILDREN) {
loadMoreOffset = page.nextOffset;
break;
}
offset = page.nextOffset;
}
return { children, loadMoreOffset };
}, []);

const refreshLoadedDirectory = useCallback(
async (parentPath: string, reqId = workspaceId) => {
if (!reqId) return;
setLoadingDirs((prev) => new Set(prev).add(parentPath));
try {
const children: FileTreeNode[] = [];
let offset = 0;
let loadMoreOffset: number | null = null;
for (;;) {
const page = await window.ade.files.listTreeChildren({
workspaceId: reqId,
parentPath,
offset,
limit: TREE_PAGE_SIZE,
includeIgnored: true,
});
if (workspaceIdRef.current !== reqId) return;
children.push(...page.children);
if (page.nextOffset == null) break;
if (children.length >= MAX_AUTO_LOADED_CHILDREN) {
loadMoreOffset = page.nextOffset;
break;
}
offset = page.nextOffset;
}
setTree((prev) => replaceTreeNodeChildren(prev, parentPath, children, loadMoreOffset));
const result = await fetchDirectoryChildren(reqId, parentPath);
if (!result || workspaceIdRef.current !== reqId) return;
setTree((prev) => {
const nextTree = replaceTreeNodeChildren(prev, parentPath, result.children, result.loadMoreOffset);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve loaded descendants when refreshing a directory

When a change lands directly in a loaded directory that also contains expanded subdirectories, listTreeChildren returns only that directory's immediate children. Replacing the node's children with that shallow result drops children/loadMoreOffset for already-expanded descendants, while expanded still contains those descendant paths; the tree then hides them and the first click merely collapses instead of reloading. Merge the refreshed children with previously loaded child subtrees, as root refresh does, before replacing.

Useful? React with 👍 / 👎.

rootTreeCacheByKey.set(rootTreeCacheKey(projectRootPath, reqId), nextTree);
return nextTree;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} catch (err) {
if (workspaceIdRef.current === reqId) setError(formatFilesError(err));
} finally {
Expand All @@ -270,7 +285,14 @@ export function FilesWorkbench({
});
}
},
[workspaceId],
[fetchDirectoryChildren, projectRootPath, workspaceId],
);

const loadDirectory = useCallback(
async (parentPath: string) => {
await refreshLoadedDirectory(parentPath);
},
[refreshLoadedDirectory],
);

const loadMoreChildren = useCallback(
Expand Down Expand Up @@ -334,14 +356,58 @@ export function FilesWorkbench({
useEffect(() => {
if (!active || !workspaceId) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const queuedParentPaths = new Set<string>();
let rootRefreshQueued = false;
let fullRootRefreshQueued = false;

const enqueuePathRefresh = (path: string | undefined) => {
if (!path) return;
const parentPath = parentPathForFileChange(path);
if (!parentPath) {
rootRefreshQueued = true;
return;
}
if (hasLoadedDirectoryChildren(treeRef.current, parentPath)) {
queuedParentPaths.add(parentPath);
Comment on lines +388 to +389

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh decorations for collapsed-directory edits

When the watcher reports a file change under a collapsed directory, hasLoadedDirectoryChildren is false and nothing is queued, so the flush never calls refreshTreeGitDecorations. In that scenario visible ancestor nodes such as a collapsed src keep stale Git status after external edits under them, whereas the previous full refresh path updated decorations for every watcher event.

Useful? React with 👍 / 👎.

if (queuedParentPaths.size > MAX_QUEUED_TREE_PARENT_REFRESHES) {
fullRootRefreshQueued = true;
}
}
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

const flushQueuedRefreshes = () => {
const reqId = workspaceIdRef.current;
const parentPaths = [...queuedParentPaths];
queuedParentPaths.clear();
const shouldRefreshRoot = rootRefreshQueued;
const shouldRefreshFullRoot = fullRootRefreshQueued;
rootRefreshQueued = false;
fullRootRefreshQueued = false;

if (!reqId) return;
if (shouldRefreshFullRoot) {
setExpanded(new Set());
void refreshRoot({ preserveLoadedChildren: false });
return;
}
if (shouldRefreshRoot) {
void refreshRoot();
}
for (const parentPath of parentPaths) {
void refreshLoadedDirectory(parentPath, reqId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh git decorations for scoped directory changes

For batches that only contain nested paths, this branch calls refreshLoadedDirectory without the refreshRoot() path that force-refreshes and applies git decorations. fileService.watchWorkspace invalidates the git-status cache before emitting, and listTreeChildren reads it with forceFresh:false, so the refreshed directory is rebuilt from the previous status snapshot; external adds/modifications under an expanded folder can leave badges stale until a root-level refresh happens.

Useful? React with 👍 / 👎.

}
};

const unsub = window.ade.files.onChange((ev) => {
if (ev.workspaceId !== workspaceIdRef.current) return;
// Drop the cached content for the changed path so a reopen re-reads disk.
invalidateFileContent(ev.workspaceId, ev.path);
if (ev.oldPath) invalidateFileContent(ev.workspaceId, ev.oldPath);
enqueuePathRefresh(ev.path);
enqueuePathRefresh(ev.oldPath);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
void refreshRoot();
flushQueuedRefreshes();
}, 200);
});
void window.ade.files.watchChanges({ workspaceId, includeIgnored: true }).catch(() => {});
Expand All @@ -350,7 +416,7 @@ export function FilesWorkbench({
unsub();
void window.ade.files.stopWatching({ workspaceId, includeIgnored: true }).catch(() => {});
};
}, [active, workspaceId, refreshRoot]);
}, [active, workspaceId, refreshLoadedDirectory, refreshRoot]);

/* ---- Open file ---- */
const openFile = useCallback(
Expand Down