diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index 0a9ef07af66..d9f05b6c603 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,5 +1,4 @@ import { TRPCError } from "@trpc/server"; -import { shell } from "electron"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -75,6 +74,155 @@ async function pushWithSetUpstream({ ]); } +function _buildCommitMessagePrompt({ + stagedFiles, + diffStat, + detailedDiff, +}: { + stagedFiles: string; + diffStat: string; + detailedDiff: string; +}): string { + return [ + "Generate a concise conventional commit message for these changes.", + "Use format: (): ", + "Types: feat, fix, refactor, docs, style, test, chore, perf", + "Keep the description under 72 characters.", + "Only output the commit message, nothing else.", + "", + "Files changed:", + stagedFiles, + "", + "Diff stat:", + diffStat, + "", + "Diff:", + detailedDiff, + ].join("\n"); +} + +interface DiffContext { + stagedFiles: string; + diffStat: string; + detailedDiff: string; +} + +async function generateMessageFromDiff(ctx: DiffContext): Promise { + // Parse file operations from name-status output + const lines = ctx.stagedFiles.trim().split("\n").filter(Boolean); + const ops: { type: string; file: string }[] = lines.map((line) => { + const [status, ...rest] = line.split("\t"); + return { type: status.trim(), file: rest.join("\t").trim() }; + }); + + if (ops.length === 0) { + return "chore: update files"; + } + + // Detect common patterns + const allAdded = ops.every((o) => o.type.startsWith("A")); + const allDeleted = ops.every((o) => o.type.startsWith("D")); + const allRenamed = ops.every((o) => o.type.startsWith("R")); + const _allModified = ops.every((o) => o.type.startsWith("M")); + + // Extract scope from common directory + const paths = ops.map((o) => o.file); + const scope = getCommonScope(paths); + const scopePart = scope ? `(${scope})` : ""; + + // Detect type from diff content + const diffLower = ctx.detailedDiff.toLowerCase(); + const isTest = + paths.some((p) => p.includes("test") || p.includes("spec")) || + diffLower.includes("describe(") || + diffLower.includes("it(") || + diffLower.includes("test("); + const isDocs = paths.some( + (p) => p.endsWith(".md") || p.includes("docs/") || p.includes("README"), + ); + + if (ops.length === 1) { + const op = ops[0]; + const fileName = op.file.split("/").pop() || op.file; + if (op.type.startsWith("A")) { + const type = isTest ? "test" : isDocs ? "docs" : "feat"; + return `${type}${scopePart}: add ${fileName}`; + } + if (op.type.startsWith("D")) { + return `chore${scopePart}: remove ${fileName}`; + } + if (op.type.startsWith("R")) { + return `refactor${scopePart}: rename ${fileName}`; + } + } + + // Multi-file operations + if (allAdded) { + const type = isTest ? "test" : isDocs ? "docs" : "feat"; + return `${type}${scopePart}: add ${ops.length} files`; + } + if (allDeleted) { + return `chore${scopePart}: remove ${ops.length} files`; + } + if (allRenamed) { + return `refactor${scopePart}: rename ${ops.length} files`; + } + + // Analyze diff for fix indicators + const isFix = + diffLower.includes("fix") || + diffLower.includes("bug") || + diffLower.includes("error") || + diffLower.includes("issue"); + + const type = isTest ? "test" : isDocs ? "docs" : isFix ? "fix" : "feat"; + + // Generate a short description from the filenames + const fileNames = paths.map((p) => p.split("/").pop()).filter(Boolean); + const uniqueNames = [...new Set(fileNames)]; + const desc = + uniqueNames.length <= 3 + ? `update ${uniqueNames.join(", ")}` + : `update ${uniqueNames.length} files`; + + return `${type}${scopePart}: ${desc}`; +} + +function getCommonScope(paths: string[]): string { + if (paths.length === 0) return ""; + + const parts = paths.map((p) => p.split("/")); + const minLen = Math.min(...parts.map((p) => p.length)); + + let commonDepth = 0; + for (let i = 0; i < minLen - 1; i++) { + const segment = parts[0][i]; + if (parts.every((p) => p[i] === segment)) { + commonDepth = i + 1; + } else { + break; + } + } + + if (commonDepth === 0) return ""; + + // Use the deepest common directory as scope + const scopePath = parts[0].slice(0, commonDepth).join("/"); + + // Simplify known directory patterns + if (scopePath.includes("apps/")) { + const match = scopePath.match(/apps\/([^/]+)/); + if (match) return match[1]; + } + if (scopePath.includes("packages/")) { + const match = scopePath.match(/packages\/([^/]+)/); + if (match) return match[1]; + } + + // Return the last segment of the common path + return parts[0][commonDepth - 1]; +} + function shouldRetryPushWithUpstream(message: string): boolean { const lowerMessage = message.toLowerCase(); return ( @@ -93,6 +241,45 @@ export const createGitOperationsRouter = () => { // NOTE: saveFile is defined in file-contents.ts with hardened path validation // Do NOT add saveFile here - it would overwrite the secure version + generateCommitMessage: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ message: string }> => { + assertRegisteredWorktree(input.worktreePath); + + const git = simpleGit(input.worktreePath); + + // Get the staged diff + const diff = await git.diff(["--cached", "--stat"]); + if (!diff.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No staged changes to generate a message for", + }); + } + + // Get detailed diff (limited to avoid huge payloads) + const detailedDiff = await git.diff(["--cached"]); + const truncatedDiff = + detailedDiff.length > 8000 + ? `${detailedDiff.slice(0, 8000)}\n... (truncated)` + : detailedDiff; + + // Get the list of staged files for context + const stagedFiles = await git.diff(["--cached", "--name-status"]); + + const message = await generateMessageFromDiff({ + stagedFiles, + diffStat: diff, + detailedDiff: truncatedDiff, + }); + + return { message }; + }), + commit: publicProcedure .input( z.object({ @@ -210,10 +397,20 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + title: z.string().optional(), + body: z.string().optional(), + draft: z.boolean().optional(), + baseBranch: z.string().optional(), }), ) .mutation( - async ({ input }): Promise<{ success: boolean; url: string }> => { + async ({ + input, + }): Promise<{ + success: boolean; + url: string; + number: number; + }> => { assertRegisteredWorktree(input.worktreePath); const git = simpleGit(input.worktreePath); @@ -224,7 +421,6 @@ export const createGitOperationsRouter = () => { if (!hasUpstream) { await pushWithSetUpstream({ git, branch }); } else { - // Push any unpushed commits try { await git.push(); } catch (error) { @@ -238,26 +434,105 @@ export const createGitOperationsRouter = () => { } } - // Get the remote URL to construct the GitHub compare URL - const remoteUrl = (await git.remote(["get-url", "origin"])) || ""; - const repoMatch = remoteUrl - .trim() - .match(/github\.com[:/](.+?)(?:\.git)?$/); + // Build gh pr create arguments + const args = ["pr", "create"]; + + const prTitle = input.title || branch.replace(/[-_/]/g, " ").trim(); + args.push("--title", prTitle); + + if (input.body) { + args.push("--body", input.body); + } else { + args.push("--body", ""); + } + + if (input.draft) { + args.push("--draft"); + } - if (!repoMatch) { - throw new Error("Could not determine GitHub repository URL"); + if (input.baseBranch) { + args.push("--base", input.baseBranch); } - const repo = repoMatch[1].replace(/\.git$/, ""); - const url = `https://github.com/${repo}/compare/${branch}?expand=1`; + try { + const { stdout } = await execWithShellEnv("gh", args, { + cwd: input.worktreePath, + }); + const url = stdout.trim(); + + // Extract PR number from URL (e.g., https://github.com/org/repo/pull/123) + const prNumberMatch = url.match(/\/pull\/(\d+)/); + const prNumber = prNumberMatch + ? Number.parseInt(prNumberMatch[1], 10) + : 0; + + await fetchCurrentBranch(git); - await shell.openExternal(url); - await fetchCurrentBranch(git); + return { success: true, url, number: prNumber }; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.error("[git/createPR] Failed to create PR:", message); - return { success: true, url }; + if (message.includes("already exists")) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A pull request already exists for this branch", + }); + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to create PR: ${message}`, + }); + } }, ), + generatePRBody: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + baseBranch: z.string().optional(), + }), + ) + .query(async ({ input }): Promise<{ title: string; body: string }> => { + assertRegisteredWorktree(input.worktreePath); + + const git = simpleGit(input.worktreePath); + const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); + const base = input.baseBranch || "main"; + + // Get commit log for the branch + let logOutput = ""; + try { + logOutput = await git.raw([ + "log", + `origin/${base}..HEAD`, + "--format=%s", + ]); + } catch { + // Fall back to just the branch name + } + + const commits = logOutput.trim().split("\n").filter(Boolean); + + // Generate title from branch name + const title = branch + .replace(/^(feat|fix|chore|refactor|docs|test|perf)[/-]/i, "") + .replace(/[-_/]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); + + // Generate body from commits + const body = + commits.length > 0 + ? `## Changes\n\n${commits.map((c) => `- ${c}`).join("\n")}` + : ""; + + return { title, body }; + }), + mergePR: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index ccadcc611c0..bd2fa76fb5e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { LuFileCode, LuLoader } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useChangesStore } from "renderer/stores/changes"; +import { useDiffCommentsStore } from "renderer/stores/diff-comments"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import { getStatusColor, @@ -11,6 +12,7 @@ import { } from "../../../RightSidebar/ChangesView/utils"; import { createFileKey, useScrollContext } from "../../context"; import { DiffViewer } from "../DiffViewer"; +import { DiffCommentThread } from "../InlineComment"; import { LightDiffViewer } from "../LightDiffViewer"; import { FileDiffHeader } from "./components/FileDiffHeader"; import { useFileDiffEdit } from "./hooks/useFileDiffEdit"; @@ -213,6 +215,9 @@ export function FileDiffSection({ }, ); + const commentCount = useDiffCommentsStore((s) => + s.getFileCommentCount(worktreePath, file.path), + ); const statusBadgeColor = getStatusColor(file.status); const statusIndicator = getStatusIndicator(file.status); const showStats = file.additions > 0 || file.deletions > 0; @@ -244,6 +249,7 @@ export function FileDiffSection({ onUnstage={onUnstage} onDiscard={onDiscard} isActioning={isActioning} + commentCount={commentCount} /> @@ -269,25 +275,31 @@ export function FileDiffSection({ Loading diff... ) : shouldRenderEditor ? ( - isEditing ? ( - - ) : ( - + {isEditing ? ( + + ) : ( + + )} + - ) + ) : (
{diffData ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/components/FileDiffHeader/FileDiffHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/components/FileDiffHeader/FileDiffHeader.tsx index 83f97cad510..bec7e0cc874 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/components/FileDiffHeader/FileDiffHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/components/FileDiffHeader/FileDiffHeader.tsx @@ -2,7 +2,11 @@ import { Button } from "@superset/ui/button"; import { Checkbox } from "@superset/ui/checkbox"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { HiMiniMinus, HiMiniPlus } from "react-icons/hi2"; +import { + HiChatBubbleLeftRight, + HiMiniMinus, + HiMiniPlus, +} from "react-icons/hi2"; import { LuCheck, LuChevronDown, @@ -33,6 +37,7 @@ interface FileDiffHeaderProps { onUnstage?: () => void; onDiscard?: () => void; isActioning: boolean; + commentCount?: number; } export function FileDiffHeader({ @@ -54,6 +59,7 @@ export function FileDiffHeader({ onUnstage, onDiscard, isActioning, + commentCount = 0, }: FileDiffHeaderProps) { const hasAction = onStage || onUnstage; const isDeleteAction = file.status === "untracked" || file.status === "added"; @@ -143,6 +149,13 @@ export function FileDiffHeader({
+ {commentCount > 0 && ( + + + {commentCount} + + )} + {showStats && ( {file.additions > 0 && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InlineComment/DiffCommentThread.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InlineComment/DiffCommentThread.tsx new file mode 100644 index 00000000000..bf0370b2474 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InlineComment/DiffCommentThread.tsx @@ -0,0 +1,159 @@ +import { Button } from "@superset/ui/button"; +import { Textarea } from "@superset/ui/textarea"; +import { useState } from "react"; +import { HiChatBubbleLeftRight, HiPaperAirplane } from "react-icons/hi2"; +import { useDiffCommentsStore } from "renderer/stores/diff-comments"; +import { InlineComment } from "./InlineComment"; + +interface DiffCommentThreadProps { + worktreePath: string; + filePath: string; +} + +export function DiffCommentThread({ + worktreePath, + filePath, +}: DiffCommentThreadProps) { + const comments = useDiffCommentsStore((s) => + s.getFileComments(worktreePath, filePath), + ); + const { addComment, deleteComment, editComment } = useDiffCommentsStore(); + + const [isAdding, setIsAdding] = useState(false); + const [newText, setNewText] = useState(""); + const [newLine, setNewLine] = useState(""); + + const handleAdd = () => { + if (!newText.trim()) return; + addComment({ + worktreePath, + filePath, + lineNumber: newLine ? Number.parseInt(newLine, 10) : 0, + side: "modified", + text: newText.trim(), + }); + setNewText(""); + setNewLine(""); + setIsAdding(false); + }; + + const handleDelete = (commentId: string) => { + deleteComment({ worktreePath, filePath, commentId }); + }; + + const handleEdit = (commentId: string, text: string) => { + editComment({ worktreePath, filePath, commentId, text }); + }; + + const sortedComments = [...comments].sort( + (a, b) => a.lineNumber - b.lineNumber || a.createdAt - b.createdAt, + ); + + if (sortedComments.length === 0 && !isAdding) { + return ( +
+ +
+ ); + } + + return ( +
+ {sortedComments.length > 0 && ( +
+ {sortedComments.map((comment) => ( + + ))} +
+ )} + + {isAdding ? ( +
+
+ + setNewLine(e.target.value)} + placeholder="#" + className="h-5 w-14 text-[10px] px-1.5 rounded border border-border bg-background text-foreground" + /> +
+