diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts index 2877176b818..567ab1a7aca 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -135,3 +135,71 @@ export async function gitUnstageAll(worktreePath: string): Promise { const git = simpleGit(worktreePath); await git.reset(["HEAD"]); } + +/** + * Discard all unstaged changes (modified and deleted files). + * + * Uses `git checkout -- .` to restore all tracked files to HEAD state. + * Does NOT affect untracked files. + */ +export async function gitDiscardAllUnstaged( + worktreePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.checkout(["--", "."]); +} + +/** + * Discard all staged changes by unstaging then discarding. + * + * Uses `git reset HEAD` followed by `git checkout -- .`. + * Does NOT affect untracked files. + */ +export async function gitDiscardAllStaged(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.reset(["HEAD"]); + await git.checkout(["--", "."]); +} + +/** + * Stash all tracked changes. + * + * Uses `git stash push` to save current work-in-progress. + */ +export async function gitStash(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.stash(["push"]); +} + +/** + * Stash all changes including untracked files. + * + * Uses `git stash push --include-untracked`. + */ +export async function gitStashIncludeUntracked( + worktreePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.stash(["push", "--include-untracked"]); +} + +/** + * Pop the most recent stash. + * + * Uses `git stash pop` to apply and remove the top stash entry. + * Throws if no stash exists or if there are conflicts. + */ +export async function gitStashPop(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.stash(["pop"]); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts index 8fdb09c9e7a..d147bcc7bcd 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts @@ -10,8 +10,13 @@ export { gitCheckoutFile, + gitDiscardAllStaged, + gitDiscardAllUnstaged, gitStageAll, gitStageFile, + gitStash, + gitStashIncludeUntracked, + gitStashPop, gitSwitchBranch, gitUnstageAll, gitUnstageFile, diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts index 678e1304c88..3a86468b1e6 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -2,8 +2,13 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; import { gitCheckoutFile, + gitDiscardAllStaged, + gitDiscardAllUnstaged, gitStageAll, gitStageFile, + gitStash, + gitStashIncludeUntracked, + gitStashPop, gitUnstageAll, gitUnstageFile, secureFs, @@ -72,5 +77,40 @@ export const createStagingRouter = () => { await secureFs.delete(input.worktreePath, input.filePath); return { success: true }; }), + + discardAllUnstaged: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + await gitDiscardAllUnstaged(input.worktreePath); + return { success: true }; + }), + + discardAllStaged: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + await gitDiscardAllStaged(input.worktreePath); + return { success: true }; + }), + + stash: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + await gitStash(input.worktreePath); + return { success: true }; + }), + + stashIncludeUntracked: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + await gitStashIncludeUntracked(input.worktreePath); + return { success: true }; + }), + + stashPop: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + await gitStashPop(input.worktreePath); + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx index 9c598663b06..07870092da5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx @@ -1,9 +1,18 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useParams } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { HiMiniMinus, HiMiniPlus } from "react-icons/hi2"; +import { LuUndo2 } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useChangesStore } from "renderer/stores/changes"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; @@ -128,6 +137,68 @@ export function ChangesView({ }, }); + const discardAllUnstagedMutation = + electronTrpc.changes.discardAllUnstaged.useMutation({ + onSuccess: () => { + toast.success("Discarded all unstaged changes"); + refetch(); + }, + onError: (error) => { + console.error("Failed to discard all unstaged:", error); + toast.error(`Failed to discard: ${error.message}`); + }, + }); + + const discardAllStagedMutation = + electronTrpc.changes.discardAllStaged.useMutation({ + onSuccess: () => { + toast.success("Discarded all staged changes"); + refetch(); + }, + onError: (error) => { + console.error("Failed to discard all staged:", error); + toast.error(`Failed to discard: ${error.message}`); + }, + }); + + const stashMutation = electronTrpc.changes.stash.useMutation({ + onSuccess: () => { + toast.success("Changes stashed"); + refetch(); + }, + onError: (error) => { + console.error("Failed to stash:", error); + toast.error(`Failed to stash: ${error.message}`); + }, + }); + + const stashIncludeUntrackedMutation = + electronTrpc.changes.stashIncludeUntracked.useMutation({ + onSuccess: () => { + toast.success("All changes stashed (including untracked)"); + refetch(); + }, + onError: (error) => { + console.error("Failed to stash:", error); + toast.error(`Failed to stash: ${error.message}`); + }, + }); + + const stashPopMutation = electronTrpc.changes.stashPop.useMutation({ + onSuccess: () => { + toast.success("Stash applied and removed"); + refetch(); + }, + onError: (error) => { + console.error("Failed to pop stash:", error); + toast.error(`Failed to pop stash: ${error.message}`); + }, + }); + + const [showDiscardUnstagedDialog, setShowDiscardUnstagedDialog] = + useState(false); + const [showDiscardStagedDialog, setShowDiscardStagedDialog] = useState(false); + const handleDiscard = (file: ChangedFile) => { if (!worktreePath) return; if (file.status === "untracked" || file.status === "added") { @@ -285,6 +356,16 @@ export function ChangesView({ onViewModeChange={setFileListViewMode} worktreePath={worktreePath} workspaceId={workspaceId} + onStash={() => stashMutation.mutate({ worktreePath })} + onStashIncludeUntracked={() => + stashIncludeUntrackedMutation.mutate({ worktreePath }) + } + onStashPop={() => stashPopMutation.mutate({ worktreePath })} + isStashPending={ + stashMutation.isPending || + stashIncludeUntrackedMutation.isPending || + stashPopMutation.isPending + } /> ) : (
- {/* Against base branch */} - {/* Commits */} - {/* Staged */} toggleSection("staged")} actions={ - - - - - Unstage all - +
+ + + + + + Discard all staged + + + + + + + Unstage all + +
} >
- {/* Unstaged */} toggleSection("unstaged")} actions={ - - - - - Stage all - +
+ + + + + + Discard all unstaged + + + + + + + Stage all + +
} >
)} + + + + + + Discard all unstaged changes? + + + This will revert all unstaged modifications. Untracked files will + not be affected. This action cannot be undone. + + + + + + + + + + + + + + Discard all staged changes? + + + This will unstage and revert all staged changes. Untracked files + will not be affected. This action cannot be undone. + + + + + + + + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx index 03e9f2e1af8..75e0a769598 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx @@ -34,8 +34,7 @@ export function CategorySection({ onOpenChange={onToggle} className="min-w-0 overflow-hidden" > - {/* Section header */} -
+
{actions}
}
- {/* Section content */} {children} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 172baed4071..8eca56bb855 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -1,4 +1,11 @@ import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; import { Select, SelectContent, @@ -10,6 +17,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useEffect, useRef, useState } from "react"; import { HiArrowPath } from "react-icons/hi2"; import { LuLoaderCircle } from "react-icons/lu"; +import { VscGitStash, VscGitStashApply } from "react-icons/vsc"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { PRIcon } from "renderer/screens/main/components/PRIcon"; import { usePRStatus } from "renderer/screens/main/hooks"; @@ -23,159 +31,228 @@ interface ChangesHeaderProps { onViewModeChange: (mode: ChangesViewMode) => void; worktreePath: string; workspaceId?: string; + onStash: () => void; + onStashIncludeUntracked: () => void; + onStashPop: () => void; + isStashPending: boolean; } -export function ChangesHeader({ - onRefresh, - viewMode, - onViewModeChange, - worktreePath, - workspaceId, -}: ChangesHeaderProps) { - const [isManualRefresh, setIsManualRefresh] = useState(false); +function BaseBranchSelector({ worktreePath }: { worktreePath: string }) { + const { baseBranch, setBaseBranch } = useChangesStore(); + const { data: branchData, isLoading } = + electronTrpc.changes.getBranches.useQuery( + { worktreePath }, + { enabled: !!worktreePath }, + ); + + const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? "main"; + const sortedBranches = [...(branchData?.remote ?? [])].sort((a, b) => { + if (a === branchData?.defaultBranch) return -1; + if (b === branchData?.defaultBranch) return 1; + return a.localeCompare(b); + }); + + const handleChange = (value: string) => { + if (value === branchData?.defaultBranch && baseBranch === null) return; + setBaseBranch(value); + }; + + if (isLoading || !branchData) { + return ( + + {effectiveBaseBranch} + + ); + } + + return ( + + + + Change base branch + + + ); +} + +function StashDropdown({ + onStash, + onStashIncludeUntracked, + onStashPop, + isPending, +}: { + onStash: () => void; + onStashIncludeUntracked: () => void; + onStashPop: () => void; + isPending: boolean; +}) { + return ( + + + + + + + + + Stash operations + + + + + + Stash Changes + + + + Stash (Include Untracked) + + + + + Pop Stash + + + + ); +} + +function RefreshButton({ onRefresh }: { onRefresh: () => void }) { + const [isSpinning, setIsSpinning] = useState(false); const timeoutRef = useRef(null); - const handleRefresh = () => { - setIsManualRefresh(true); + const handleClick = () => { + setIsSpinning(true); onRefresh(); - // Clear any existing timeout - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - // Stop spinning after a short delay - timeoutRef.current = setTimeout(() => { - setIsManualRefresh(false); - }, 600); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setIsSpinning(false), 600); }; - // Cleanup timeout on unmount useEffect(() => { return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } + if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); - const { baseBranch, setBaseBranch } = useChangesStore(); - - const { data: branchData, isLoading } = - electronTrpc.changes.getBranches.useQuery( - { worktreePath }, - { enabled: !!worktreePath }, - ); + return ( + + + + + + Refresh changes + + + ); +} - const { pr, isLoading: isPRLoading } = usePRStatus({ +function PRStatusLink({ workspaceId }: { workspaceId?: string }) { + const { pr, isLoading } = usePRStatus({ workspaceId, refetchInterval: 10000, }); - const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? "main"; - const availableBranches = branchData?.remote ?? []; + if (isLoading) { + return ( + + ); + } - const sortedBranches = [...availableBranches].sort((a, b) => { - if (a === branchData?.defaultBranch) return -1; - if (b === branchData?.defaultBranch) return 1; - return a.localeCompare(b); - }); + if (!pr) return null; - const handleChange = (value: string) => { - if (value === branchData?.defaultBranch && baseBranch === null) { - return; - } - setBaseBranch(value); - }; + return ( + + + + + + #{pr.number} + + + + + View PR on GitHub + + + ); +} +export function ChangesHeader({ + onRefresh, + viewMode, + onViewModeChange, + worktreePath, + workspaceId, + onStash, + onStashIncludeUntracked, + onStashPop, + isStashPending, +}: ChangesHeaderProps) { return ( -
-
+
+
Base: - {isLoading || !branchData ? ( - - {effectiveBaseBranch} - - ) : ( - - - - Change base branch - - - )} +
-
+ +
+ - - - - - - Refresh changes - - - - {/* PR Status Icon */} - {isPRLoading ? ( - - ) : pr ? ( - - - - - - #{pr.number} - - - - - View PR on GitHub - - - ) : null} + +
);