Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -1,62 +1,53 @@
import { useCallback, useEffect, useState } from "react";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { workspaceTrpc } from "@superset/workspace-client";
import { useCallback, useMemo } from "react";
import { useWorkspaceEvent } from "../useWorkspaceEvent";
import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl";

export interface DiffStats {
additions: number;
deletions: number;
}

/**
* Fetches diff stats for a single workspace, auto-updates on git changes.
* Just pass the workspaceId — host resolution is handled internally.
* Diff stats for a single workspace, derived from the shared `git.getStatus`
* query cache. Subscribes to `git:changed` and invalidates the query — React
* Query collapses concurrent invalidations from sibling consumers (e.g.
* `useGitStatus`, multiple sidebar tiles) into a single refetch.
*/
export function useDiffStats(workspaceId: string): DiffStats | null {
const [stats, setStats] = useState<DiffStats | null>(null);
const hostUrl = useWorkspaceHostUrl(workspaceId);

const fetchStats = useCallback(async () => {
if (!hostUrl) return;
try {
const client = getHostServiceClientByUrl(hostUrl);
const status = await client.git.getStatus.query({ workspaceId });

// Deduplicate by path — a file can appear in multiple categories
const byPath = new Map<
string,
{ additions: number; deletions: number }
>();
for (const file of status.againstBase) {
byPath.set(file.path, file);
}
for (const file of status.staged) {
byPath.set(file.path, file);
}
for (const file of status.unstaged) {
byPath.set(file.path, file);
}

let additions = 0;
let deletions = 0;
for (const file of byPath.values()) {
additions += file.additions;
deletions += file.deletions;
}

setStats({ additions, deletions });
} catch {
// Host unavailable or workspace deleted
const utils = workspaceTrpc.useUtils();
const { data: status } = workspaceTrpc.git.getStatus.useQuery(
{ workspaceId },
{
enabled: Boolean(workspaceId),
// Match the pre-RQ behavior: only update on `git:changed`, never
// on focus. Multiple sidebar tiles each have their own query key,
// so focus refetch would re-fan out the very work this hook is
// supposed to consolidate.
refetchOnWindowFocus: false,
},
);
Comment on lines +18 to +28
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Implicit refetchOnWindowFocus behavior change

The old implementation only fetched on mount and on git:changed events — there was no window-focus refetch. workspaceTrpc.git.getStatus.useQuery() without an explicit refetchOnWindowFocus: false inherits React Query's default of true, so every time the user switches back to the app, each visible workspace tile issues its own getStatus call. getStatus spawns several git processes (status, numstat, diff, ls-files), so on window focus with N workspace tiles open the host-service still sees N concurrent getStatus spawns — exactly the multi-consumer refetch pattern the PR set out to fix. Passing { refetchOnWindowFocus: false } alongside { enabled: Boolean(workspaceId) } restores the previous event-only update semantics while keeping the shared-cache benefit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts
Line: 18-21

Comment:
**Implicit `refetchOnWindowFocus` behavior change**

The old implementation only fetched on mount and on `git:changed` events — there was no window-focus refetch. `workspaceTrpc.git.getStatus.useQuery()` without an explicit `refetchOnWindowFocus: false` inherits React Query's default of `true`, so every time the user switches back to the app, each visible workspace tile issues its own `getStatus` call. `getStatus` spawns several git processes (status, numstat, diff, ls-files), so on window focus with N workspace tiles open the host-service still sees N concurrent `getStatus` spawns — exactly the multi-consumer refetch pattern the PR set out to fix. Passing `{ refetchOnWindowFocus: false }` alongside `{ enabled: Boolean(workspaceId) }` restores the previous event-only update semantics while keeping the shared-cache benefit.

How can I resolve this? If you propose a fix, please make it concise.


const invalidate = useCallback(() => {
void utils.git.getStatus.invalidate({ workspaceId });
}, [utils, workspaceId]);

useWorkspaceEvent("git:changed", workspaceId, invalidate);

return useMemo<DiffStats | null>(() => {
if (!status) return null;

// Deduplicate by path — a file can appear in multiple categories.
const byPath = new Map<string, { additions: number; deletions: number }>();
for (const file of status.againstBase) byPath.set(file.path, file);
for (const file of status.staged) byPath.set(file.path, file);
for (const file of status.unstaged) byPath.set(file.path, file);

let additions = 0;
let deletions = 0;
for (const file of byPath.values()) {
additions += file.additions;
deletions += file.deletions;
}
}, [hostUrl, workspaceId]);

useEffect(() => {
void fetchStats();
}, [fetchStats]);

useWorkspaceEvent("git:changed", workspaceId, () => {
void fetchStats();
});

return stats;
return { additions, deletions };
}, [status]);
}
37 changes: 21 additions & 16 deletions packages/host-service/src/trpc/router/git/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,27 +65,32 @@ export const gitRouter = router({
const worktreePath = resolveWorktreePath(ctx, input.workspaceId);
const git = await ctx.git(worktreePath);

const currentBranchName = (
await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "")
).trim();
const base = await resolveBaseComparison(git);

let branchNames: string[] = [];
// `%(HEAD)` emits "*" for the checked-out branch, " " otherwise.
// Single spawn — independent of branch count. Only `name`/`isHead`
// are read by the v2 sidebar's BaseBranchSelector; the other
// per-branch fields the previous implementation computed (upstream,
// ahead/behind, last-commit) cost 4 spawns each and were unused.
let branches: { name: string; isHead: boolean }[] = [];
try {
const raw = await git.raw([
"branch",
"--list",
"--format=%(refname:short)",
"for-each-ref",
"refs/heads/",
"--format=%(HEAD)\t%(refname:short)",
]);
branchNames = raw.trim().split("\n").filter(Boolean);
branches = raw
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const tab = line.indexOf("\t");
if (tab < 0) return { name: line, isHead: false };
return {
isHead: line.slice(0, tab) === "*",
name: line.slice(tab + 1),
};
});
} catch {}

const branches = await Promise.all(
branchNames.map((name) =>
buildBranch(git, name, name === currentBranchName, base?.baseRef),
),
);

return { branches };
}),

Expand Down
Loading