{filtered.map((branch) => (
= {
- added: "text-green-400",
- copied: "text-purple-400",
- changed: "text-yellow-400",
- deleted: "text-red-400",
- modified: "text-yellow-400",
- renamed: "text-blue-400",
- untracked: "text-green-400",
+ added: "text-diff-added",
+ copied: "text-diff-copied",
+ changed: "text-diff-modified",
+ deleted: "text-diff-deleted",
+ modified: "text-diff-modified",
+ renamed: "text-diff-renamed",
+ untracked: "text-diff-added",
};
-const STATUS_LETTERS: Record = {
- added: "A",
- copied: "C",
- changed: "T",
- deleted: "D",
- modified: "M",
- renamed: "R",
- untracked: "U",
-};
+function getStatusIcon(status: FileStatus): ReactNode {
+ const iconClass = "w-3 h-3";
+ switch (status) {
+ case "added":
+ case "untracked":
+ return ;
+ case "modified":
+ case "changed":
+ return ;
+ case "deleted":
+ return ;
+ case "renamed":
+ return ;
+ case "copied":
+ return ;
+ default:
+ return null;
+ }
+}
function groupByFolder(
files: ChangedFile[],
@@ -48,13 +65,13 @@ function groupByFolder(
function StatusIndicator({ status }: { status: FileStatus }) {
return (
-
- {STATUS_LETTERS[status]}
+
+ {getStatusIcon(status)}
);
}
-function FileRow({
+const FileRow = memo(function FileRow({
file,
category,
onSelect,
@@ -89,9 +106,9 @@ function FileRow({
);
-}
+});
-function FolderGroup({
+const FolderGroup = memo(function FolderGroup({
folder,
files,
category,
@@ -124,7 +141,7 @@ function FolderGroup({
))}
);
-}
+});
function Section({
title,
@@ -185,7 +202,7 @@ interface ChangesFileListProps {
onSelectFile?: (path: string, category: ChangeCategory) => void;
}
-export function ChangesFileList({
+export const ChangesFileList = memo(function ChangesFileList({
files,
staged,
unstaged,
@@ -194,6 +211,8 @@ export function ChangesFileList({
category = "against-base",
onSelectFile,
}: ChangesFileListProps) {
+ const groups = useMemo(() => groupByFolder(files), [files]);
+
if (isLoading) {
return (
@@ -213,7 +232,6 @@ export function ChangesFileList({
);
}
- // If staged/unstaged are provided, show three sections
if (staged !== undefined && unstaged !== undefined) {
return (
@@ -242,8 +260,6 @@ export function ChangesFileList({
);
}
- // Single list (filtered by commit or uncommitted)
- const groups = groupByFolder(files);
return (
{groups.map((group) => (
@@ -257,4 +273,4 @@ export function ChangesFileList({
))}
);
-}
+});
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx
new file mode 100644
index 00000000000..bbac83f7061
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx
@@ -0,0 +1,171 @@
+import { GitBranch, Pencil } from "lucide-react";
+import { useRef, useState } from "react";
+import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema";
+import type { Branch, Commit } from "../../types";
+import { BaseBranchSelector } from "../BaseBranchSelector";
+import { CommitFilterDropdown } from "../CommitFilterDropdown";
+
+interface ChangesHeaderProps {
+ currentBranch: { name: string; aheadCount: number; behindCount: number };
+ defaultBranchName: string;
+ commitCount: number;
+ totalFiles: number;
+ totalAdditions: number;
+ totalDeletions: number;
+ filter: ChangesFilter;
+ onFilterChange: (filter: ChangesFilter) => void;
+ commits: Commit[];
+ uncommittedCount: number;
+ branches: Branch[];
+ onBaseBranchChange: (branchName: string) => void;
+ onRenameBranch: (newName: string) => void;
+ canRename: boolean;
+}
+
+export function ChangesHeader({
+ currentBranch,
+ defaultBranchName,
+ commitCount,
+ totalFiles,
+ totalAdditions,
+ totalDeletions,
+ onRenameBranch,
+ canRename,
+ filter,
+ onFilterChange,
+ commits,
+ uncommittedCount,
+ branches,
+ onBaseBranchChange,
+}: ChangesHeaderProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editValue, setEditValue] = useState(currentBranch.name);
+ const inputRef = useRef
(null);
+ const skipBlurRef = useRef(false);
+
+ const startEditing = () => {
+ setEditValue(currentBranch.name);
+ setIsEditing(true);
+ skipBlurRef.current = false;
+ requestAnimationFrame(() => inputRef.current?.select());
+ };
+
+ const handleSubmit = () => {
+ const trimmed = editValue.trim();
+ if (trimmed && trimmed !== currentBranch.name) {
+ onRenameBranch(trimmed);
+ }
+ setIsEditing(false);
+ };
+
+ return (
+
+
+
+ {isEditing ? (
+
setEditValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ skipBlurRef.current = true;
+ handleSubmit();
+ }
+ if (e.key === "Escape") {
+ skipBlurRef.current = true;
+ setIsEditing(false);
+ }
+ }}
+ onBlur={() => {
+ if (skipBlurRef.current) return;
+ handleSubmit();
+ }}
+ className="min-w-0 flex-1 truncate bg-transparent font-medium outline-none ring-1 ring-ring rounded-sm px-1"
+ />
+ ) : (
+ <>
+
{currentBranch.name}
+ {canRename && (
+
+
+
+ )}
+ >
+ )}
+
+
+
+ {commitCount} {commitCount === 1 ? "commit" : "commits"} from{" "}
+
+
+
+ {currentBranch.aheadCount > 0 && currentBranch.behindCount > 0 && (
+
+
Your branch and
+
+ origin/{currentBranch.name}
+
+
have diverged
+
+ {currentBranch.aheadCount} local not pushed,{" "}
+ {currentBranch.behindCount} remote to pull
+
+
+ )}
+ {currentBranch.aheadCount > 0 && currentBranch.behindCount === 0 && (
+
+
+ {currentBranch.aheadCount}{" "}
+ {currentBranch.aheadCount === 1 ? "commit" : "commits"} ahead of
+
+
+ origin/{currentBranch.name}
+
+
+ )}
+ {currentBranch.behindCount > 0 && currentBranch.aheadCount === 0 && (
+
+
+ {currentBranch.behindCount}{" "}
+ {currentBranch.behindCount === 1 ? "commit" : "commits"} behind
+
+
+ origin/{currentBranch.name}
+
+
+ )}
+
+
+
+
+ {totalFiles} files changed
+ {(totalAdditions > 0 || totalDeletions > 0) && (
+
+ {totalAdditions > 0 && (
+ +{totalAdditions}
+ )}
+ {totalAdditions > 0 && totalDeletions > 0 && " "}
+ {totalDeletions > 0 && (
+ -{totalDeletions}
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/index.ts
new file mode 100644
index 00000000000..2d44c6bf794
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/index.ts
@@ -0,0 +1 @@
+export { ChangesHeader } from "./ChangesHeader";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx
new file mode 100644
index 00000000000..abf4447b273
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx
@@ -0,0 +1,111 @@
+import type { AppRouter } from "@superset/host-service";
+import type { inferRouterOutputs } from "@trpc/server";
+import { memo } from "react";
+import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema";
+import type { ChangedFile } from "../../types";
+import { ChangesFileList } from "../ChangesFileList";
+import { ChangesHeader } from "../ChangesHeader";
+
+type RouterOutputs = inferRouterOutputs;
+
+interface ChangesTabContentProps {
+ status: {
+ data: RouterOutputs["git"]["getStatus"] | undefined;
+ isLoading: boolean;
+ };
+ commits: { data: RouterOutputs["git"]["listCommits"] | undefined };
+ branches: { data: RouterOutputs["git"]["listBranches"] | undefined };
+ commitFiles: {
+ data: { files: ChangedFile[] } | undefined;
+ isLoading: boolean;
+ };
+ filter: ChangesFilter;
+ filteredFiles: ChangedFile[];
+ fileCategory: "against-base" | "staged" | "unstaged";
+ totalChanges: number;
+ totalAdditions: number;
+ totalDeletions: number;
+ onSelectFile?: (
+ path: string,
+ category: "against-base" | "staged" | "unstaged",
+ ) => void;
+ onFilterChange: (filter: ChangesFilter) => void;
+ onBaseBranchChange: (branchName: string) => void;
+ onRenameBranch: (newName: string) => void;
+ canRenameBranch: boolean;
+}
+
+export const ChangesTabContent = memo(function ChangesTabContent({
+ status,
+ commits,
+ branches,
+ commitFiles,
+ filter,
+ filteredFiles,
+ fileCategory,
+ totalChanges,
+ totalAdditions,
+ totalDeletions,
+ onSelectFile,
+ onFilterChange,
+ onBaseBranchChange,
+ onRenameBranch,
+ canRenameBranch,
+}: ChangesTabContentProps) {
+ if (status.isLoading) {
+ return (
+
+ Loading changes...
+
+ );
+ }
+
+ if (!status.data) {
+ return (
+
+ Unable to load git status
+
+ );
+ }
+
+ return (
+
+ );
+});
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/index.ts
new file mode 100644
index 00000000000..a8468c8187b
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/index.ts
@@ -0,0 +1 @@
+export { ChangesTabContent } from "./ChangesTabContent";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/types.ts
new file mode 100644
index 00000000000..727d7505698
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/types.ts
@@ -0,0 +1,9 @@
+import type { AppRouter } from "@superset/host-service";
+import type { inferRouterOutputs } from "@trpc/server";
+
+type RouterOutputs = inferRouterOutputs;
+
+export type Commit = RouterOutputs["git"]["listCommits"]["commits"][number];
+export type Branch = RouterOutputs["git"]["listBranches"]["branches"][number];
+export type ChangedFile =
+ RouterOutputs["git"]["getStatus"]["againstBase"][number];
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx
index 9dc4f1418a1..219f326571b 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx
@@ -1,22 +1,14 @@
-import type { AppRouter } from "@superset/host-service";
import { toast } from "@superset/ui/sonner";
import { workspaceTrpc } from "@superset/workspace-client";
-import type { inferRouterOutputs } from "@trpc/server";
-import { GitBranch, Pencil } from "lucide-react";
-import { useCallback, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema";
import type { SidebarTabDefinition } from "../../types";
-import { BaseBranchSelector } from "./components/BaseBranchSelector";
-import { ChangesFileList } from "./components/ChangesFileList";
-import { CommitFilterDropdown } from "./components/CommitFilterDropdown";
+import { ChangesTabContent } from "./components/ChangesTabContent";
export type { ChangesFilter };
-type RouterOutputs = inferRouterOutputs;
-type Commit = RouterOutputs["git"]["listCommits"]["commits"][number];
-
interface UseChangesTabParams {
workspaceId: string;
onSelectFile?: (
@@ -25,164 +17,6 @@ interface UseChangesTabParams {
) => void;
}
-type Branch = RouterOutputs["git"]["listBranches"]["branches"][number];
-
-function ChangesHeader({
- currentBranch,
- defaultBranchName,
- commitCount,
- totalFiles,
- totalAdditions,
- totalDeletions,
- onRenameBranch,
- canRename,
- filter,
- onFilterChange,
- commits,
- uncommittedCount,
- branches,
- onBaseBranchChange,
-}: {
- currentBranch: { name: string; aheadCount: number; behindCount: number };
- defaultBranchName: string;
- commitCount: number;
- totalFiles: number;
- totalAdditions: number;
- totalDeletions: number;
- filter: ChangesFilter;
- onFilterChange: (filter: ChangesFilter) => void;
- commits: Commit[];
- uncommittedCount: number;
- branches: Branch[];
- onBaseBranchChange: (branchName: string) => void;
- onRenameBranch: (newName: string) => void;
- canRename: boolean;
-}) {
- const [isEditing, setIsEditing] = useState(false);
- const [editValue, setEditValue] = useState(currentBranch.name);
- const inputRef = useRef(null);
-
- const startEditing = () => {
- setEditValue(currentBranch.name);
- setIsEditing(true);
- requestAnimationFrame(() => inputRef.current?.select());
- };
-
- const handleSubmit = () => {
- const trimmed = editValue.trim();
- if (trimmed && trimmed !== currentBranch.name) {
- onRenameBranch(trimmed);
- }
- setIsEditing(false);
- };
-
- return (
-
- {/* Branch name */}
-
-
- {isEditing ? (
-
setEditValue(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleSubmit();
- if (e.key === "Escape") setIsEditing(false);
- }}
- onBlur={handleSubmit}
- className="min-w-0 flex-1 truncate bg-transparent font-medium outline-none ring-1 ring-ring rounded-sm px-1"
- />
- ) : (
- <>
-
{currentBranch.name}
- {canRename && (
-
-
-
- )}
- >
- )}
-
-
- {/* Commits from base */}
-
- {commitCount} {commitCount === 1 ? "commit" : "commits"} from{" "}
-
-
-
- {/* Remote status */}
- {currentBranch.aheadCount > 0 && currentBranch.behindCount > 0 && (
-
-
Your branch and
-
- origin/{currentBranch.name}
-
-
have diverged
-
- {currentBranch.aheadCount} local not pushed,{" "}
- {currentBranch.behindCount} remote to pull
-
-
- )}
- {currentBranch.aheadCount > 0 && currentBranch.behindCount === 0 && (
-
-
- {currentBranch.aheadCount}{" "}
- {currentBranch.aheadCount === 1 ? "commit" : "commits"} ahead of
-
-
- origin/{currentBranch.name}
-
-
- )}
- {currentBranch.behindCount > 0 && currentBranch.aheadCount === 0 && (
-
-
- {currentBranch.behindCount}{" "}
- {currentBranch.behindCount === 1 ? "commit" : "commits"} behind
-
-
- origin/{currentBranch.name}
-
-
- )}
-
- {/* Filter + stats */}
-
-
-
- {totalFiles} files changed
- {(totalAdditions > 0 || totalDeletions > 0) && (
-
- {totalAdditions > 0 && (
- +{totalAdditions}
- )}
- {totalAdditions > 0 && totalDeletions > 0 && " "}
- {totalDeletions > 0 && (
- -{totalDeletions}
- )}
-
- )}
-
-
-
- );
-}
-
export function useChangesTab({
workspaceId,
onSelectFile,
@@ -232,10 +66,30 @@ export function useChangesTab({
{ refetchInterval: 30_000, refetchOnWindowFocus: true },
);
- useWorkspaceEvent("git:changed", workspaceId, () => {
+ const invalidateGitQueries = useCallback(() => {
void statusUtils.git.getStatus.invalidate({ workspaceId });
void statusUtils.git.listCommits.invalidate({ workspaceId });
- });
+ }, [statusUtils, workspaceId]);
+
+ // Shared debounce for git:changed and fs:events — batches rapid events
+ // from either source into a single git status refresh.
+ const debounceRef = useRef | null>(null);
+ const debouncedInvalidate = useCallback(() => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ debounceRef.current = null;
+ invalidateGitQueries();
+ }, 300);
+ }, [invalidateGitQueries]);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: clear pending timer on workspace change
+ useEffect(() => {
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, [workspaceId]);
+
+ useWorkspaceEvent("git:changed", workspaceId, debouncedInvalidate);
+ useWorkspaceEvent("fs:events", workspaceId, debouncedInvalidate);
const renameBranchMutation = workspaceTrpc.git.renameBranch.useMutation();
@@ -260,7 +114,6 @@ export function useChangesTab({
[workspaceId, status.data?.currentBranch.name, renameBranchMutation],
);
- // Can only rename if branch hasn't been pushed (aheadCount === total commits means nothing pushed)
const canRenameBranch = !status.data?.currentBranch.upstream;
const commitFilesInput =
@@ -283,7 +136,6 @@ export function useChangesTab({
if (filter.kind === "commit" || filter.kind === "range") {
return commitFiles.data?.files ?? [];
}
- // "all" — deduplicate by path
const map = new Map();
for (const f of status.data.againstBase) map.set(f.path, f);
for (const f of status.data.staged) map.set(f.path, f);
@@ -295,102 +147,28 @@ export function useChangesTab({
const totalAdditions = filteredFiles.reduce((sum, f) => sum + f.additions, 0);
const totalDeletions = filteredFiles.reduce((sum, f) => sum + f.deletions, 0);
- const content = useMemo(() => {
- if (status.isLoading) {
- return (
-
- Loading changes...
-
- );
- }
-
- if (!status.data) {
- return (
-
- Unable to load git status
-
- );
- }
-
- let fileList: React.ReactNode;
-
- if (filter.kind === "commit" || filter.kind === "range") {
- fileList = (
-
- );
- } else if (filter.kind === "uncommitted") {
- fileList = (
-
- );
- } else {
- // Merge all files into a single flat list, deduplicating by path
- // (a file can appear in both againstBase and staged/unstaged)
- const allFilesMap = new Map<
- string,
- (typeof status.data.againstBase)[number]
- >();
- for (const f of status.data.againstBase) allFilesMap.set(f.path, f);
- for (const f of status.data.staged) allFilesMap.set(f.path, f);
- for (const f of status.data.unstaged) allFilesMap.set(f.path, f);
-
- fileList = (
-
- );
- }
-
- return (
-
- );
- }, [
- status.data,
- status.isLoading,
- filter,
- commitFiles.data,
- commitFiles.isLoading,
- commits.data,
- totalChanges,
- totalAdditions,
- totalDeletions,
- onSelectFile,
- setFilter,
- branches.data?.branches,
- canRenameBranch,
- handleRenameBranch,
- setBaseBranch,
- ]);
+ const fileCategory: "against-base" | "staged" | "unstaged" =
+ filter.kind === "uncommitted" ? "unstaged" : "against-base";
+
+ const content = (
+
+ );
return {
id: "changes",