diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index a21a41cd21f..abf835ab0fb 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -53,6 +53,11 @@ --sidebar-ring: #3a3837; --highlight-match: rgba(224, 120, 80, 0.2); --highlight-active: rgba(224, 120, 80, 0.5); + --diff-added: oklch(0.77 0.2 155); + --diff-modified: oklch(0.82 0.2 95); + --diff-deleted: oklch(0.65 0.2 25); + --diff-renamed: oklch(0.7 0.17 260); + --diff-copied: oklch(0.7 0.2 300); } /* Light theme fallback values - applied before hydration if user has light theme saved */ @@ -93,6 +98,11 @@ --sidebar-ring: oklch(0.708 0 0); --highlight-match: rgba(255, 211, 61, 0.35); --highlight-active: rgba(255, 150, 50, 0.55); + --diff-added: oklch(0.55 0.18 155); + --diff-modified: oklch(0.65 0.18 85); + --diff-deleted: oklch(0.52 0.22 25); + --diff-renamed: oklch(0.52 0.2 260); + --diff-copied: oklch(0.5 0.22 300); } @theme inline { @@ -134,6 +144,11 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-diff-added: var(--diff-added); + --color-diff-modified: var(--diff-modified); + --color-diff-deleted: var(--diff-deleted); + --color-diff-renamed: var(--diff-renamed); + --color-diff-copied: var(--diff-copied); } @layer base { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx index 33d324d6779..2b63dc65804 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx @@ -39,7 +39,10 @@ export function BaseBranchSelector({ - +
- +
{filtered.map((branch) => ( ); -} +}); -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 ( -
- -
{fileList}
-
- ); - }, [ - 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",