From f081bba51639bc5da60503f9296a69fb70efd294 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 17 Dec 2025 17:45:41 -0800 Subject: [PATCH 1/8] merge main --- .../trpc/routers/changes/git-operations.ts | 157 ++++++++ .../src/lib/trpc/routers/changes/index.ts | 5 + .../Sidebar/ChangesView/ChangesView.tsx | 33 +- .../components/CommitInput/CommitInput.tsx | 356 ++++++++++++++++++ .../components/CommitInput/index.ts | 1 + 5 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/trpc/routers/changes/git-operations.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitInput/CommitInput.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitInput/index.ts diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts new file mode 100644 index 00000000000..847b18f4e17 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -0,0 +1,157 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import simpleGit from "simple-git"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +const execFileAsync = promisify(execFile); + +export const createGitOperationsRouter = () => { + return router({ + saveFile: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + content: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const fullPath = join(input.worktreePath, input.filePath); + await writeFile(fullPath, input.content, "utf-8"); + return { success: true }; + }), + + commit: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + message: z.string(), + }), + ) + .mutation( + async ({ input }): Promise<{ success: boolean; hash: string }> => { + const git = simpleGit(input.worktreePath); + const result = await git.commit(input.message); + return { success: true, hash: result.commit }; + }, + ), + + push: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + setUpstream: z.boolean().optional(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const git = simpleGit(input.worktreePath); + if (input.setUpstream) { + const branch = await git.revparse(["--abbrev-ref", "HEAD"]); + await git.push(["--set-upstream", "origin", branch.trim()]); + } else { + await git.push(); + } + return { success: true }; + }), + + pull: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const git = simpleGit(input.worktreePath); + try { + await git.pull(); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + // If upstream doesn't exist, provide a clearer error + if ( + message.includes("no such ref was fetched") || + message.includes("no tracking information") + ) { + throw new Error( + "No upstream branch to pull from. The remote branch may have been deleted.", + ); + } + throw error; + } + return { success: true }; + }), + + sync: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const git = simpleGit(input.worktreePath); + // Try to pull first, but handle case where upstream doesn't exist + try { + await git.pull(); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + // If upstream doesn't exist, skip pull and just push + if ( + message.includes("no such ref was fetched") || + message.includes("no tracking information") + ) { + // Just push instead + const branch = await git.revparse(["--abbrev-ref", "HEAD"]); + await git.push(["--set-upstream", "origin", branch.trim()]); + return { success: true }; + } + throw error; + } + await git.push(); + return { success: true }; + }), + + createPR: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + title: z.string(), + body: z.string().optional(), + draft: z.boolean().optional(), + }), + ) + .mutation( + async ({ + input, + }): Promise<{ success: boolean; url: string; number: number }> => { + const args = [ + "pr", + "create", + "--title", + input.title, + "--json", + "url,number", + ]; + if (input.body) { + args.push("--body", input.body); + } + if (input.draft) { + args.push("--draft"); + } + + const { stdout } = await execFileAsync("gh", args, { + cwd: input.worktreePath, + }); + const result = JSON.parse(stdout); + return { + success: true, + url: result.url, + number: result.number, + }; + }, + ), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/changes/index.ts b/apps/desktop/src/lib/trpc/routers/changes/index.ts index ef56da13b30..e931f8f54af 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/index.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/index.ts @@ -1,6 +1,7 @@ import { router } from "../.."; import { createBranchesRouter } from "./branches"; import { createFileContentsRouter } from "./file-contents"; +import { createGitOperationsRouter } from "./git-operations"; import { createStagingRouter } from "./staging"; import { createStatusRouter } from "./status"; @@ -9,6 +10,7 @@ export const createChangesRouter = () => { const statusRouter = createStatusRouter(); const fileContentsRouter = createFileContentsRouter(); const stagingRouter = createStagingRouter(); + const gitOperationsRouter = createGitOperationsRouter(); return router({ // Branch operations @@ -22,5 +24,8 @@ export const createChangesRouter = () => { // Staging operations ...stagingRouter._def.procedures, + + // Git operations (commit, push, pull, sync, createPR) + ...gitOperationsRouter._def.procedures, }); }; 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 61836d30ed1..f6ea865416a 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 @@ -9,6 +9,7 @@ import { useChangesStore } from "renderer/stores/changes"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; +import { CommitInput } from "./components/CommitInput"; import { CommitItem } from "./components/CommitItem"; import { FileList } from "./components/FileList"; @@ -38,6 +39,20 @@ export function ChangesView() { }, ); + const { data: githubStatus, refetch: refetchGithubStatus } = + trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId: activeWorkspace?.id ?? "" }, + { + enabled: !!activeWorkspace?.id, + refetchInterval: 10000, + }, + ); + + const handleRefresh = () => { + refetch(); + refetchGithubStatus(); + }; + const stageAllMutation = trpc.changes.stageAll.useMutation({ onSuccess: () => refetch(), onError: (error) => { @@ -178,18 +193,34 @@ export function ChangesView() { const unstagedFiles = [...status.unstaged, ...status.untracked]; + const hasStagedChanges = status.staged.length > 0; + const branchExistsOnRemote = githubStatus?.branchExistsOnRemote ?? false; + const hasExistingPR = !!githubStatus?.pr; + const prUrl = githubStatus?.pr?.url; + return (
refetch()} + onRefresh={handleRefresh} viewMode={fileListViewMode} onViewModeChange={setFileListViewMode} worktreePath={worktreePath} /> + + {!hasChanges ? (
No changes detected diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitInput/CommitInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitInput/CommitInput.tsx new file mode 100644 index 00000000000..a1c638f9517 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitInput/CommitInput.tsx @@ -0,0 +1,356 @@ +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { Textarea } from "@superset/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useState } from "react"; +import { + HiArrowDown, + HiArrowsUpDown, + HiArrowTopRightOnSquare, + HiArrowUp, + HiCheck, + HiChevronDown, +} from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; + +interface CommitInputProps { + worktreePath: string; + hasStagedChanges: boolean; + ahead: number; + behind: number; + branchExistsOnRemote: boolean; + hasExistingPR: boolean; + prUrl?: string; + onRefresh: () => void; +} + +export function CommitInput({ + worktreePath, + hasStagedChanges, + ahead, + behind, + branchExistsOnRemote, + hasExistingPR, + prUrl, + onRefresh, +}: CommitInputProps) { + const [commitMessage, setCommitMessage] = useState(""); + + const commitMutation = trpc.changes.commit.useMutation({ + onSuccess: () => { + toast.success("Changes committed successfully"); + setCommitMessage(""); + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to commit: ${error.message}`); + }, + }); + + const pushMutation = trpc.changes.push.useMutation({ + onSuccess: () => { + toast.success("Pushed successfully"); + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to push: ${error.message}`); + }, + }); + + const pullMutation = trpc.changes.pull.useMutation({ + onSuccess: () => { + toast.success("Pulled successfully"); + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to pull: ${error.message}`); + }, + }); + + const syncMutation = trpc.changes.sync.useMutation({ + onSuccess: () => { + toast.success("Synced successfully"); + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to sync: ${error.message}`); + }, + }); + + const createPRMutation = trpc.changes.createPR.useMutation({ + onSuccess: (data) => { + toast.success("PR created successfully"); + window.open(data.url, "_blank"); + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to create PR: ${error.message}`); + }, + }); + + const isPending = + commitMutation.isPending || + pushMutation.isPending || + pullMutation.isPending || + syncMutation.isPending || + createPRMutation.isPending; + + const handleCommit = () => { + if (!commitMessage.trim()) { + toast.error("Please enter a commit message"); + return; + } + commitMutation.mutate({ worktreePath, message: commitMessage.trim() }); + }; + + const handleCommitAndPush = () => { + if (!commitMessage.trim()) { + toast.error("Please enter a commit message"); + return; + } + commitMutation.mutate( + { worktreePath, message: commitMessage.trim() }, + { + onSuccess: () => { + pushMutation.mutate({ + worktreePath, + setUpstream: !branchExistsOnRemote, + }); + }, + }, + ); + }; + + const handleCommitPushAndCreatePR = () => { + if (!commitMessage.trim()) { + toast.error("Please enter a commit message"); + return; + } + commitMutation.mutate( + { worktreePath, message: commitMessage.trim() }, + { + onSuccess: () => { + pushMutation.mutate( + { worktreePath, setUpstream: !branchExistsOnRemote }, + { + onSuccess: () => { + createPRMutation.mutate({ + worktreePath, + title: commitMessage.trim().split("\n")[0], + }); + }, + }, + ); + }, + }, + ); + }; + + const handlePush = () => { + pushMutation.mutate({ worktreePath, setUpstream: !branchExistsOnRemote }); + }; + + const handlePull = () => { + pullMutation.mutate({ worktreePath }); + }; + + const handleSync = () => { + syncMutation.mutate({ worktreePath }); + }; + + const handleCreatePR = () => { + const title = commitMessage.trim().split("\n")[0] || "New pull request"; + createPRMutation.mutate({ worktreePath, title }); + }; + + const handleOpenPR = () => { + if (prUrl) { + window.open(prUrl, "_blank"); + } + }; + + const getMainButtonAction = (): { + label: string; + icon: React.ReactNode; + action: () => void; + disabled: boolean; + tooltip: string; + } => { + if (hasStagedChanges && commitMessage.trim()) { + return { + label: "Commit", + icon: , + action: handleCommit, + disabled: isPending, + tooltip: "Commit staged changes", + }; + } + if (ahead > 0 && behind > 0) { + return { + label: "Sync", + icon: , + action: handleSync, + disabled: isPending, + tooltip: `Sync: pull ${behind} commit${behind !== 1 ? "s" : ""}, push ${ahead} commit${ahead !== 1 ? "s" : ""}`, + }; + } + if (ahead > 0) { + return { + label: "Push", + icon: , + action: handlePush, + disabled: isPending, + tooltip: `Push ${ahead} commit${ahead !== 1 ? "s" : ""}`, + }; + } + if (behind > 0) { + return { + label: "Pull", + icon: , + action: handlePull, + disabled: isPending, + tooltip: `Pull ${behind} commit${behind !== 1 ? "s" : ""}`, + }; + } + return { + label: "Commit", + icon: , + action: handleCommit, + disabled: isPending || !hasStagedChanges || !commitMessage.trim(), + tooltip: hasStagedChanges + ? "Enter a commit message" + : "No staged changes to commit", + }; + }; + + const mainButton = getMainButtonAction(); + + return ( +
+