diff --git a/apps/code/package.json b/apps/code/package.json index b700dd947..36d34a521 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -125,6 +125,7 @@ "@opentelemetry/semantic-conventions": "^1.39.0", "@parcel/watcher": "^2.5.6", "@phosphor-icons/react": "^2.1.10", + "@pierre/diffs": "^1.1.7", "@posthog/agent": "workspace:*", "@posthog/electron-trpc": "workspace:*", "@posthog/git": "workspace:*", diff --git a/apps/code/src/main/services/git/create-pr-saga.ts b/apps/code/src/main/services/git/create-pr-saga.ts new file mode 100644 index 000000000..c2808d557 --- /dev/null +++ b/apps/code/src/main/services/git/create-pr-saga.ts @@ -0,0 +1,205 @@ +import { getGitOperationManager } from "@posthog/git/operation-manager"; +import { getHeadSha } from "@posthog/git/queries"; +import { Saga, type SagaLogger } from "@posthog/shared"; +import type { LlmCredentials } from "../llm-gateway/schemas"; +import type { + ChangedFile, + CommitOutput, + CreatePrProgressPayload, + GitSyncStatus, + PublishOutput, + PushOutput, +} from "./schemas"; + +export interface CreatePrSagaInput { + directoryPath: string; + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + draft?: boolean; + credentials?: LlmCredentials; +} + +export interface CreatePrSagaOutput { + prUrl: string | null; +} + +export interface CreatePrDeps { + getCurrentBranch(dir: string): Promise; + createBranch(dir: string, name: string): Promise; + checkoutBranch( + dir: string, + name: string, + ): Promise<{ previousBranch: string; currentBranch: string }>; + getChangedFilesHead(dir: string): Promise; + generateCommitMessage( + dir: string, + credentials: LlmCredentials, + ): Promise<{ message: string }>; + commit(dir: string, message: string): Promise; + getSyncStatus(dir: string): Promise; + push(dir: string): Promise; + publish(dir: string): Promise; + generatePrTitleAndBody( + dir: string, + credentials: LlmCredentials, + ): Promise<{ title: string; body: string }>; + createPr( + dir: string, + title?: string, + body?: string, + draft?: boolean, + ): Promise<{ success: boolean; message: string; prUrl: string | null }>; + onProgress( + step: CreatePrProgressPayload["step"], + message: string, + prUrl?: string, + ): void; +} + +export class CreatePrSaga extends Saga { + readonly sagaName = "CreatePrSaga"; + private deps: CreatePrDeps; + + constructor(deps: CreatePrDeps, logger?: SagaLogger) { + super(logger); + this.deps = deps; + } + + protected async execute( + input: CreatePrSagaInput, + ): Promise { + const { directoryPath, draft, credentials } = input; + let { commitMessage, prTitle, prBody } = input; + + if (input.branchName) { + this.deps.onProgress( + "creating-branch", + `Creating branch ${input.branchName}...`, + ); + + const originalBranch = await this.readOnlyStep( + "get-original-branch", + () => this.deps.getCurrentBranch(directoryPath), + ); + + await this.step({ + name: "creating-branch", + execute: () => this.deps.createBranch(directoryPath, input.branchName!), + rollback: async () => { + if (originalBranch) { + await this.deps.checkoutBranch(directoryPath, originalBranch); + } + }, + }); + } + + const changedFiles = await this.readOnlyStep("check-changes", () => + this.deps.getChangedFilesHead(directoryPath), + ); + + if (changedFiles.length > 0) { + if (!commitMessage && credentials) { + this.deps.onProgress("committing", "Generating commit message..."); + const generated = await this.readOnlyStep( + "generate-commit-message", + async () => { + try { + return await this.deps.generateCommitMessage( + directoryPath, + credentials, + ); + } catch { + return null; + } + }, + ); + if (generated) commitMessage = generated.message; + } + + if (!commitMessage) { + throw new Error("Commit message is required."); + } + + this.deps.onProgress("committing", "Committing changes..."); + + const preCommitSha = await this.readOnlyStep("get-pre-commit-sha", () => + getHeadSha(directoryPath), + ); + + await this.step({ + name: "committing", + execute: async () => { + const result = await this.deps.commit(directoryPath, commitMessage!); + if (!result.success) throw new Error(result.message); + return result; + }, + rollback: async () => { + const manager = getGitOperationManager(); + await manager.executeWrite(directoryPath, (git) => + git.reset(["--soft", preCommitSha]), + ); + }, + }); + } + + this.deps.onProgress("pushing", "Pushing to remote..."); + + const syncStatus = await this.readOnlyStep("check-sync-status", () => + this.deps.getSyncStatus(directoryPath), + ); + + await this.step({ + name: "pushing", + execute: async () => { + const result = syncStatus.hasRemote + ? await this.deps.push(directoryPath) + : await this.deps.publish(directoryPath); + if (!result.success) throw new Error(result.message); + return result; + }, + rollback: async () => {}, // no meaningful rollback can happen here w/o force push + }); + + if ((!prTitle || !prBody) && credentials) { + this.deps.onProgress("creating-pr", "Generating PR description..."); + const generated = await this.readOnlyStep( + "generate-pr-description", + async () => { + try { + return await this.deps.generatePrTitleAndBody( + directoryPath, + credentials, + ); + } catch { + return null; + } + }, + ); + if (generated) { + if (!prTitle) prTitle = generated.title; + if (!prBody) prBody = generated.body; + } + } + + this.deps.onProgress("creating-pr", "Creating pull request..."); + + const prResult = await this.step({ + name: "creating-pr", + execute: async () => { + const result = await this.deps.createPr( + directoryPath, + prTitle || undefined, + prBody || undefined, + draft, + ); + if (!result.success) throw new Error(result.message); + return result; + }, + rollback: async () => {}, + }); + + return { prUrl: prResult.prUrl }; + } +} diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index 310856ada..99c2421d7 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -228,9 +228,18 @@ export type PrStatusOutput = z.infer; // Create PR operation export const createPrInput = z.object({ directoryPath: z.string(), - title: z.string().optional(), - body: z.string().optional(), + flowId: z.string(), + branchName: z.string().optional(), + commitMessage: z.string().optional(), + prTitle: z.string().optional(), + prBody: z.string().optional(), draft: z.boolean().optional(), + credentials: z + .object({ + apiKey: z.string(), + apiHost: z.string(), + }) + .optional(), }); export type CreatePrInput = z.infer; @@ -380,10 +389,22 @@ export const syncOutput = z.object({ export type SyncOutput = z.infer; +export const createPrStep = z.enum([ + "creating-branch", + "committing", + "pushing", + "creating-pr", + "complete", + "error", +]); + +export type CreatePrStep = z.infer; + export const createPrOutput = z.object({ success: z.boolean(), message: z.string(), prUrl: z.string().nullable(), + failedStep: createPrStep.nullable(), state: gitStateSnapshotSchema.optional(), }); @@ -414,3 +435,12 @@ export const searchGithubIssuesInput = z.object({ }); export const searchGithubIssuesOutput = z.array(githubIssueSchema); + +export const createPrProgressPayload = z.object({ + flowId: z.string(), + step: createPrStep, + message: z.string(), + prUrl: z.string().optional(), +}); + +export type CreatePrProgressPayload = z.infer; diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 740071fcf..790c631dd 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -32,11 +32,13 @@ import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; import type { LlmCredentials } from "../llm-gateway/schemas"; import type { LlmGatewayService } from "../llm-gateway/service"; +import { CreatePrSaga } from "./create-pr-saga"; import type { ChangedFile, CloneProgressPayload, CommitOutput, CreatePrOutput, + CreatePrProgressPayload, DetectRepoResult, DiffStats, DiscardFileChangesOutput, @@ -61,10 +63,12 @@ const fsPromises = fs.promises; export const GitServiceEvent = { CloneProgress: "cloneProgress", + CreatePrProgress: "createPrProgress", } as const; export interface GitServiceEvents { [GitServiceEvent.CloneProgress]: CloneProgressPayload; + [GitServiceEvent.CreatePrProgress]: CreatePrProgressPayload; } const log = logger.scope("git-service"); @@ -460,6 +464,91 @@ export class GitService extends TypedEventEmitter { }; } + public async createPr(input: { + directoryPath: string; + flowId: string; + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + draft?: boolean; + credentials?: { apiKey: string; apiHost: string }; + }): Promise { + const { directoryPath, flowId } = input; + + const emitProgress = ( + step: CreatePrProgressPayload["step"], + message: string, + prUrl?: string, + ) => { + this.emit(GitServiceEvent.CreatePrProgress, { + flowId, + step, + message, + prUrl, + }); + }; + + const saga = new CreatePrSaga( + { + getCurrentBranch: (dir) => getCurrentBranch(dir), + createBranch: (dir, name) => this.createBranch(dir, name), + checkoutBranch: (dir, name) => this.checkoutBranch(dir, name), + getChangedFilesHead: (dir) => this.getChangedFilesHead(dir), + generateCommitMessage: (dir, creds) => + this.generateCommitMessage(dir, creds), + commit: (dir, msg) => this.commit(dir, msg), + getSyncStatus: (dir) => this.getGitSyncStatus(dir), + push: (dir) => this.push(dir), + publish: (dir) => this.publish(dir), + generatePrTitleAndBody: (dir, creds) => + this.generatePrTitleAndBody(dir, creds), + createPr: (dir, title, body, draft) => + this.createPrViaGh(dir, title, body, draft), + onProgress: emitProgress, + }, + log, + ); + + const result = await saga.run({ + directoryPath, + branchName: input.branchName, + commitMessage: input.commitMessage, + prTitle: input.prTitle, + prBody: input.prBody, + draft: input.draft, + credentials: input.credentials, + }); + + if (!result.success) { + emitProgress("error", result.error); + return { + success: false, + message: result.error, + prUrl: null, + failedStep: result.failedStep as CreatePrOutput["failedStep"], + }; + } + + const state = await this.getStateSnapshot(directoryPath, { + includePrStatus: true, + }); + + emitProgress( + "complete", + "Pull request created", + result.data.prUrl ?? undefined, + ); + + return { + success: true, + message: "Pull request created", + prUrl: result.data.prUrl, + failedStep: null, + state, + }; + } + public async getPrTemplate( directoryPath: string, ): Promise { @@ -629,12 +718,12 @@ export class GitService extends TypedEventEmitter { } } - public async createPr( + private async createPrViaGh( directoryPath: string, title?: string, body?: string, draft?: boolean, - ): Promise { + ): Promise<{ success: boolean; message: string; prUrl: string | null }> { const args = ["pr", "create"]; if (title) { args.push("--title", title); @@ -656,18 +745,10 @@ export class GitService extends TypedEventEmitter { const prUrlMatch = result.stdout.match(/https:\/\/github\.com\/[^\s]+/); const prUrl = prUrlMatch?.[0] ?? null; - const state = await this.getStateSnapshot(directoryPath, { - includeChangedFiles: false, - includeDiffStats: false, - includeLatestCommit: false, - includePrStatus: true, - }); - return { success: true, message: "Pull request created", prUrl, - state, }; } diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 384bc1c96..d91d4319d 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -233,14 +233,7 @@ export const gitRouter = router({ createPr: publicProcedure .input(createPrInput) .output(createPrOutput) - .mutation(({ input }) => - getService().createPr( - input.directoryPath, - input.title, - input.body, - input.draft, - ), - ), + .mutation(({ input }) => getService().createPr(input)), openPr: publicProcedure .input(openPrInput) @@ -301,4 +294,14 @@ export const gitRouter = router({ input.limit, ), ), + + onCreatePrProgress: publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(GitServiceEvent.CreatePrProgress, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), }); diff --git a/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx b/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx index abe820823..3f487fe4e 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx +++ b/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx @@ -1,11 +1,12 @@ import { openSearchPanel } from "@codemirror/search"; import { EditorView } from "@codemirror/view"; -import { DotsThree } from "@phosphor-icons/react"; -import { Box, DropdownMenu, Flex, IconButton, Text } from "@radix-ui/themes"; +import { Columns, Rows } from "@phosphor-icons/react"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; import { useEffect, useMemo } from "react"; import { useCodeMirror } from "../hooks/useCodeMirror"; import { useEditorExtensions } from "../hooks/useEditorExtensions"; import { useDiffViewerStore } from "../stores/diffViewerStore"; +import { DiffSettingsMenu } from "./DiffSettingsMenu"; interface CodeMirrorDiffEditorProps { originalContent: string; @@ -14,6 +15,7 @@ interface CodeMirrorDiffEditorProps { relativePath?: string; onContentChange?: (content: string) => void; onRefresh?: () => void; + hideToolbar?: boolean; } export function CodeMirrorDiffEditor({ @@ -23,22 +25,16 @@ export function CodeMirrorDiffEditor({ relativePath, onContentChange, onRefresh, + hideToolbar, }: CodeMirrorDiffEditorProps) { const viewMode = useDiffViewerStore((s) => s.viewMode); const toggleViewMode = useDiffViewerStore((s) => s.toggleViewMode); - const wordWrap = useDiffViewerStore((s) => s.wordWrap); - const toggleWordWrap = useDiffViewerStore((s) => s.toggleWordWrap); const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles); - const toggleLoadFullFiles = useDiffViewerStore((s) => s.toggleLoadFullFiles); const wordDiffs = useDiffViewerStore((s) => s.wordDiffs); - const toggleWordDiffs = useDiffViewerStore((s) => s.toggleWordDiffs); const hideWhitespaceChanges = useDiffViewerStore( (s) => s.hideWhitespaceChanges, ); - const toggleHideWhitespaceChanges = useDiffViewerStore( - (s) => s.toggleHideWhitespaceChanges, - ); - const extensions = useEditorExtensions(filePath, !onContentChange, true); + const extensions = useEditorExtensions(filePath, true, true); const options = useMemo( () => ({ original: originalContent, @@ -84,6 +80,10 @@ export function CodeMirrorDiffEditor({ document.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [instanceRef]); + if (hideToolbar) { + return
; + } + return ( )} - - - - - - - - - - {viewMode === "split" ? "Unified view" : "Split view"} - - - - - {wordWrap ? "Disable word wrap" : "Enable word wrap"} - - - - - {loadFullFiles ? "Collapse unchanged" : "Load full files"} - - - - - {wordDiffs ? "Disable word diffs" : "Enable word diffs"} - - - - - {hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"} - - - - {onRefresh && ( - <> - - - Refresh - - - )} - - + + + +
diff --git a/apps/code/src/renderer/features/code-editor/components/DiffEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/DiffEditorPanel.tsx deleted file mode 100644 index 7061378ec..000000000 --- a/apps/code/src/renderer/features/code-editor/components/DiffEditorPanel.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { PanelMessage } from "@components/ui/PanelMessage"; -import { CodeMirrorDiffEditor } from "@features/code-editor/components/CodeMirrorDiffEditor"; -import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor"; -import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; -import { getRelativePath } from "@features/code-editor/utils/pathUtils"; -import { isImageFile } from "@features/message-editor/utils/imageUtils"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { Box, Flex } from "@radix-ui/themes"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect } from "react"; - -interface DiffEditorPanelProps { - taskId: string; - task: Task; - absolutePath: string; -} - -export function DiffEditorPanel({ - taskId, - task: _task, - absolutePath, -}: DiffEditorPanelProps) { - const trpc = useTRPC(); - const repoPath = useCwd(taskId); - const filePath = getRelativePath(absolutePath, repoPath); - const isImage = isImageFile(absolutePath); - const queryClient = useQueryClient(); - const closeDiffTabsForFile = usePanelLayoutStore( - (s) => s.closeDiffTabsForFile, - ); - - const { data: changedFiles = [], isLoading: loadingChangelist } = useQuery( - trpc.git.getChangedFilesHead.queryOptions( - { directoryPath: repoPath as string }, - { enabled: !!repoPath, staleTime: 30_000 }, - ), - ); - - const fileInfo = changedFiles.find((f) => f.path === filePath); - const isFileStillChanged = !!fileInfo; - const status = fileInfo?.status ?? "modified"; - const originalPath = fileInfo?.originalPath ?? filePath; - const isDeleted = status === "deleted"; - const isNew = status === "untracked" || status === "added"; - - const { data: modifiedContent, isLoading: loadingModified } = useQuery( - trpc.fs.readRepoFile.queryOptions( - { repoPath: repoPath as string, filePath }, - { enabled: !!repoPath && !isDeleted, staleTime: 30_000 }, - ), - ); - - const { data: originalContent, isLoading: loadingOriginal } = useQuery( - trpc.git.getFileAtHead.queryOptions( - { directoryPath: repoPath as string, filePath: originalPath }, - { enabled: !!repoPath && !isNew, staleTime: 30_000 }, - ), - ); - - const imageQuery = useQuery( - trpc.fs.readFileAsBase64.queryOptions( - { filePath: absolutePath }, - { enabled: isImage, staleTime: Infinity }, - ), - ); - - const handleRefresh = useCallback(() => { - if (!repoPath) return; - queryClient.invalidateQueries( - trpc.fs.readRepoFile.queryFilter({ repoPath, filePath }), - ); - queryClient.invalidateQueries(trpc.git.getFileAtHead.pathFilter()); - queryClient.invalidateQueries( - trpc.git.getChangedFilesHead.queryFilter({ directoryPath: repoPath }), - ); - }, [repoPath, filePath, queryClient, trpc]); - - const handleContentChange = useCallback( - async (newContent: string) => { - if (!repoPath) return; - - try { - await trpcClient.fs.writeRepoFile.mutate({ - repoPath, - filePath, - content: newContent, - }); - - queryClient.invalidateQueries( - trpc.fs.readRepoFile.queryFilter({ repoPath, filePath }), - ); - queryClient.invalidateQueries( - trpc.git.getChangedFilesHead.queryFilter({ directoryPath: repoPath }), - ); - } catch (_error) {} - }, - [repoPath, filePath, queryClient, trpc], - ); - - const isLoading = - loadingChangelist || - (!isDeleted && loadingModified) || - (!isNew && loadingOriginal); - - const hasNoChanges = - !!repoPath && - !isLoading && - (!isFileStillChanged || - (!isDeleted && !isNew && originalContent === modifiedContent)); - - useEffect(() => { - if (hasNoChanges) { - closeDiffTabsForFile(taskId, filePath); - } - }, [hasNoChanges, closeDiffTabsForFile, taskId, filePath]); - - if (isImage) { - if (imageQuery.isLoading) { - return Loading image...; - } - if (imageQuery.error || !imageQuery.data) { - return ( - Failed to load image - ); - } - const mimeType = getImageMimeType(absolutePath); - return ( - - {filePath} - - ); - } - - if (!repoPath) { - return No repository path available; - } - - if (isLoading) { - return Loading diff...; - } - - if (hasNoChanges) { - return null; - } - - const showDiff = !isDeleted && !isNew; - const content = isDeleted ? originalContent : modifiedContent; - - return ( - - {showDiff ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/code/src/renderer/features/code-editor/components/DiffSettingsMenu.tsx b/apps/code/src/renderer/features/code-editor/components/DiffSettingsMenu.tsx new file mode 100644 index 000000000..75624ef16 --- /dev/null +++ b/apps/code/src/renderer/features/code-editor/components/DiffSettingsMenu.tsx @@ -0,0 +1,63 @@ +import { DotsThree } from "@phosphor-icons/react"; +import { DropdownMenu, IconButton, Text } from "@radix-ui/themes"; +import { useDiffViewerStore } from "../stores/diffViewerStore"; + +interface DiffSettingsMenuProps { + onRefresh?: () => void; +} + +export function DiffSettingsMenu({ onRefresh }: DiffSettingsMenuProps) { + const wordWrap = useDiffViewerStore((s) => s.wordWrap); + const toggleWordWrap = useDiffViewerStore((s) => s.toggleWordWrap); + const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles); + const toggleLoadFullFiles = useDiffViewerStore((s) => s.toggleLoadFullFiles); + const wordDiffs = useDiffViewerStore((s) => s.wordDiffs); + const toggleWordDiffs = useDiffViewerStore((s) => s.toggleWordDiffs); + const hideWhitespaceChanges = useDiffViewerStore( + (s) => s.hideWhitespaceChanges, + ); + const toggleHideWhitespaceChanges = useDiffViewerStore( + (s) => s.toggleHideWhitespaceChanges, + ); + + return ( + + + + + + + + + + {wordWrap ? "Disable word wrap" : "Enable word wrap"} + + + + + {loadFullFiles ? "Collapse unchanged" : "Load full files"} + + + + + {wordDiffs ? "Disable word diffs" : "Enable word diffs"} + + + + + {hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"} + + + + {onRefresh && ( + <> + + + Refresh + + + )} + + + ); +} diff --git a/apps/code/src/renderer/features/code-editor/hooks/useEditorExtensions.ts b/apps/code/src/renderer/features/code-editor/hooks/useEditorExtensions.ts index d957114fb..b9e29ab2b 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/useEditorExtensions.ts +++ b/apps/code/src/renderer/features/code-editor/hooks/useEditorExtensions.ts @@ -39,7 +39,7 @@ export function useEditorExtensions( theme, mergeViewTheme, EditorView.editable.of(!readOnly), - ...(readOnly ? [EditorState.readOnly.of(true)] : []), + ...(readOnly && !isDiff ? [EditorState.readOnly.of(true)] : []), ...(languageExtension ? [languageExtension] : []), ]; }, [filePath, isDarkMode, readOnly, isDiff, wordWrap]); diff --git a/apps/code/src/renderer/features/code-review/components/ReviewFileDiff.tsx b/apps/code/src/renderer/features/code-review/components/ReviewFileDiff.tsx new file mode 100644 index 000000000..b438414af --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/ReviewFileDiff.tsx @@ -0,0 +1,85 @@ +import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; +import { MultiFileDiff } from "@pierre/diffs/react"; +import { Flex, Spinner, Text } from "@radix-ui/themes"; +import { useTRPC } from "@renderer/trpc/client"; +import type { GitFileStatus } from "@shared/types"; +import { useThemeStore } from "@stores/themeStore"; +import { useQuery } from "@tanstack/react-query"; +import { memo, useMemo } from "react"; + +interface ReviewFileDiffProps { + filePath: string; + repoPath: string; + status: GitFileStatus; + originalPath?: string; +} + +export const ReviewFileDiff = memo(function ReviewFileDiff({ + filePath, + repoPath, + status, + originalPath, +}: ReviewFileDiffProps) { + const trpc = useTRPC(); + + const isDeleted = status === "deleted"; + const isNew = status === "untracked" || status === "added"; + const effectiveOriginalPath = originalPath ?? filePath; + + const { data: modifiedContent, isLoading: loadingModified } = useQuery( + trpc.fs.readRepoFile.queryOptions( + { repoPath, filePath }, + { enabled: !isDeleted, staleTime: 30_000 }, + ), + ); + + const { data: originalContent, isLoading: loadingOriginal } = useQuery( + trpc.git.getFileAtHead.queryOptions( + { directoryPath: repoPath, filePath: effectiveOriginalPath }, + { enabled: !isNew, staleTime: 30_000 }, + ), + ); + + const viewMode = useDiffViewerStore((s) => s.viewMode); + const wordWrap = useDiffViewerStore((s) => s.wordWrap); + const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles); + const wordDiffs = useDiffViewerStore((s) => s.wordDiffs); + const isDarkMode = useThemeStore((s) => s.isDarkMode); + + const options = useMemo( + () => ({ + diffStyle: viewMode as "split" | "unified", + overflow: (wordWrap ? "wrap" : "scroll") as "wrap" | "scroll", + expandUnchanged: loadFullFiles, + lineDiffType: (wordDiffs ? "word" : "none") as "word" | "none", + themeType: (isDarkMode ? "dark" : "light") as "dark" | "light", + disableFileHeader: true, + theme: { dark: "github-dark" as const, light: "github-light" as const }, + }), + [viewMode, wordWrap, loadFullFiles, wordDiffs, isDarkMode], + ); + + const isLoading = + (!isDeleted && loadingModified) || (!isNew && loadingOriginal); + + if (isLoading) { + return ( + + + + Loading... + + + ); + } + + const fileName = filePath.split("/").pop() || filePath; + + return ( + + ); +}); diff --git a/apps/code/src/renderer/features/code-review/components/ReviewFileHeader.tsx b/apps/code/src/renderer/features/code-review/components/ReviewFileHeader.tsx new file mode 100644 index 000000000..baea54db5 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/ReviewFileHeader.tsx @@ -0,0 +1,97 @@ +import { FileIcon } from "@components/ui/FileIcon"; +import { getStatusIndicator } from "@features/git-interaction/utils/gitFileStatus"; +import { CaretDown, CaretRight } from "@phosphor-icons/react"; +import { Badge, Flex, Text } from "@radix-ui/themes"; +import type { ChangedFile } from "@shared/types"; +import { memo, useCallback } from "react"; + +interface ReviewFileHeaderProps { + file: ChangedFile; + isExpanded: boolean; + onToggle: (filePath: string) => void; +} + +export const ReviewFileHeader = memo(function ReviewFileHeader({ + file, + isExpanded, + onToggle, +}: ReviewFileHeaderProps) { + const handleToggle = useCallback( + () => onToggle(file.path), + [onToggle, file.path], + ); + const fileName = file.path.split("/").pop() || file.path; + const indicator = getStatusIndicator(file.status); + const hasLineStats = + file.linesAdded !== undefined || file.linesRemoved !== undefined; + + return ( + + {isExpanded ? ( + + ) : ( + + )} + + + {fileName} + + + {file.originalPath + ? `${file.originalPath} \u2192 ${file.path}` + : file.path} + + + {hasLineStats && ( + + {(file.linesAdded ?? 0) > 0 && ( + + +{file.linesAdded} + + )} + {(file.linesRemoved ?? 0) > 0 && ( + + -{file.linesRemoved} + + )} + + )} + + {indicator.label} + + + + ); +}); diff --git a/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx b/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx new file mode 100644 index 000000000..834a98c67 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx @@ -0,0 +1,168 @@ +import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import { useCwd } from "@features/sidebar/hooks/useCwd"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type { Task } from "@shared/types"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useReviewStore } from "../stores/reviewStore"; +import { ReviewFileDiff } from "./ReviewFileDiff"; +import { ReviewFileHeader } from "./ReviewFileHeader"; +import { ReviewToolbar } from "./ReviewToolbar"; + +interface ReviewPageProps { + taskId: string; + task: Task; +} + +const COLLAPSE_THRESHOLD = 20; + +export function ReviewPage({ taskId }: ReviewPageProps) { + const repoPath = useCwd(taskId); + const { changedFiles, changesLoading } = useGitQueries(repoPath); + + const [collapsedFiles, setCollapsedFiles] = useState>(() => { + if (changedFiles.length > COLLAPSE_THRESHOLD) { + return new Set(changedFiles.map((f) => f.path)); + } + return new Set(); + }); + + const fileRefs = useRef>(new Map()); + + // Subscribe to scroll target without causing re-renders. + // Only re-renders if the file was collapsed and needs expanding. + useEffect(() => { + return useReviewStore.subscribe((state, prev) => { + if (!state.scrollTarget || state.scrollTarget === prev.scrollTarget) + return; + + const target = state.scrollTarget; + useReviewStore.getState().setScrollTarget(null); + + // Expand if collapsed — this is the only thing that triggers a re-render + setCollapsedFiles((prev) => { + if (prev.has(target)) { + const next = new Set(prev); + next.delete(target); + return next; + } + return prev; + }); + + requestAnimationFrame(() => { + const el = fileRefs.current.get(target); + if (el) { + el.scrollIntoView({ block: "start" }); + } + }); + }); + }, []); + + const toggleFile = useCallback((filePath: string) => { + setCollapsedFiles((prev) => { + const next = new Set(prev); + if (next.has(filePath)) { + next.delete(filePath); + } else { + next.add(filePath); + } + return next; + }); + }, []); + + const expandAll = useCallback(() => { + setCollapsedFiles(new Set()); + }, []); + + const collapseAll = useCallback(() => { + setCollapsedFiles(new Set(changedFiles.map((f) => f.path))); + }, [changedFiles]); + + const allExpanded = collapsedFiles.size === 0; + + const setFileRef = useCallback( + (filePath: string) => (el: HTMLDivElement | null) => { + if (el) { + fileRefs.current.set(filePath, el); + } else { + fileRefs.current.delete(filePath); + } + }, + [], + ); + + const { linesAdded, linesRemoved } = useMemo(() => { + let added = 0; + let removed = 0; + for (const file of changedFiles) { + added += file.linesAdded ?? 0; + removed += file.linesRemoved ?? 0; + } + return { linesAdded: added, linesRemoved: removed }; + }, [changedFiles]); + + if (!repoPath) { + return ( + + + No repository path available + + + ); + } + + if (changesLoading) { + return ( + + + Loading changes... + + + ); + } + + if (changedFiles.length === 0) { + return ( + + + No file changes to review + + + ); + } + + return ( + + + + {changedFiles.map((file) => { + const isExpanded = !collapsedFiles.has(file.path); + + return ( + + + {isExpanded && ( + + )} + + ); + })} + + + ); +} diff --git a/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx b/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx new file mode 100644 index 000000000..ede643025 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx @@ -0,0 +1,85 @@ +import { DiffSettingsMenu } from "@features/code-editor/components/DiffSettingsMenu"; +import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; +import { ArrowsIn, ArrowsOut, Columns, Rows } from "@phosphor-icons/react"; +import { Button, Flex, Text } from "@radix-ui/themes"; +import { memo } from "react"; + +interface ReviewToolbarProps { + fileCount: number; + linesAdded: number; + linesRemoved: number; + allExpanded: boolean; + onExpandAll: () => void; + onCollapseAll: () => void; +} + +export const ReviewToolbar = memo(function ReviewToolbar({ + fileCount, + linesAdded, + linesRemoved, + allExpanded, + onExpandAll, + onCollapseAll, +}: ReviewToolbarProps) { + const viewMode = useDiffViewerStore((s) => s.viewMode); + const toggleViewMode = useDiffViewerStore((s) => s.toggleViewMode); + + return ( + + + {fileCount} file{fileCount !== 1 ? "s" : ""} changed + + + {linesAdded > 0 && ( + +{linesAdded} + )} + {linesRemoved > 0 && ( + -{linesRemoved} + )} + + + + + + + + + + + ); +}); diff --git a/apps/code/src/renderer/features/code-review/stores/reviewStore.ts b/apps/code/src/renderer/features/code-review/stores/reviewStore.ts new file mode 100644 index 000000000..80c84865f --- /dev/null +++ b/apps/code/src/renderer/features/code-review/stores/reviewStore.ts @@ -0,0 +1,23 @@ +import { create } from "zustand"; + +interface ReviewStoreState { + scrollTarget: string | null; + activeFilePath: string | null; +} + +interface ReviewStoreActions { + setScrollTarget: (filePath: string | null) => void; + setActiveFilePath: (filePath: string | null) => void; +} + +type ReviewStore = ReviewStoreState & ReviewStoreActions; + +export const useReviewStore = create()((set) => ({ + scrollTarget: null, + activeFilePath: null, + setScrollTarget: (filePath) => + filePath + ? set({ scrollTarget: filePath, activeFilePath: filePath }) + : set({ scrollTarget: null }), + setActiveFilePath: (filePath) => set({ activeFilePath: filePath }), +})); diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx new file mode 100644 index 000000000..306d4f1eb --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx @@ -0,0 +1,347 @@ +import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import type { CreatePrStep } from "@features/git-interaction/types"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CreatePrDialog } from "./CreatePrDialog"; + +function setStoreState(overrides: { + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + createPrOpen?: boolean; + createPrStep?: CreatePrStep; + createPrError?: string | null; + createPrNeedsBranch?: boolean; + createPrNeedsCommit?: boolean; + createPrBaseBranch?: string | null; + createPrDraft?: boolean; + createPrFailedStep?: CreatePrStep | null; + isGeneratingCommitMessage?: boolean; + isGeneratingPr?: boolean; + isSubmitting?: boolean; +}) { + useGitInteractionStore.setState({ + branchName: "", + commitMessage: "", + prTitle: "", + prBody: "", + createPrOpen: true, + createPrStep: "idle", + createPrError: null, + createPrNeedsBranch: false, + createPrNeedsCommit: false, + createPrBaseBranch: null, + createPrDraft: false, + createPrFailedStep: null, + isGeneratingCommitMessage: false, + isGeneratingPr: false, + isSubmitting: false, + ...overrides, + }); +} + +const noop = () => {}; + +const meta: Meta = { + title: "Git/CreatePrDialog", + component: CreatePrDialog, + parameters: { layout: "centered" }, +}; + +export default meta; + +export const SetupFull: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrNeedsCommit: true, + createPrBaseBranch: "main", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupCommitOnly: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupPushOnly: StoryObj = { + decorators: [ + (Story) => { + setStoreState({}); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupWithDraft: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + createPrDraft: true, + prTitle: "Add user authentication", + prBody: "Closes #123", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupWithError: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrError: "Branch name is required.", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupGeneratingCommitMessage: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + isGeneratingCommitMessage: true, + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupGeneratingPr: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + isGeneratingPr: true, + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCreatingBranch: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrNeedsCommit: true, + createPrStep: "creating-branch", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCommitting: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrNeedsCommit: true, + createPrStep: "committing", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingPushing: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + createPrStep: "pushing", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCreatingPr: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + createPrStep: "creating-pr", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingError: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrNeedsCommit: true, + createPrStep: "error", + createPrError: "Failed to push: remote rejected (permission denied)", + createPrFailedStep: "pushing", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx new file mode 100644 index 000000000..d044f8432 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx @@ -0,0 +1,322 @@ +import { + ErrorContainer, + GenerateButton, +} from "@features/git-interaction/components/GitInteractionDialogs"; +import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import type { CreatePrStep } from "@features/git-interaction/types"; +import { + CheckCircle, + Circle, + GitPullRequest, + XCircle, +} from "@phosphor-icons/react"; +import { + Button, + Checkbox, + Dialog, + Flex, + Spinner, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; + +const ICON_SIZE = 14; + +interface StepDef { + id: CreatePrStep; + label: string; +} + +function StepIndicator({ + steps, + currentStep, + failedStep, +}: { + steps: StepDef[]; + currentStep: CreatePrStep; + failedStep?: CreatePrStep | null; +}) { + const stepOrder: CreatePrStep[] = [ + "creating-branch", + "committing", + "pushing", + "creating-pr", + "complete", + ]; + + const currentIndex = stepOrder.indexOf(currentStep); + const isError = currentStep === "error"; + + return ( + + {steps.map((step) => { + const stepIndex = stepOrder.indexOf(step.id); + const isComplete = + currentStep === "complete" || stepIndex < currentIndex; + const isActive = step.id === currentStep; + const isFailed = isError && step.id === failedStep; + + let icon: React.ReactNode; + if (isFailed) { + icon = ; + } else if (isComplete) { + icon = ; + } else if (isActive) { + icon = ; + } else { + icon = ; + } + + return ( + + + {icon} + + + {step.label} + + + ); + })} + + ); +} + +export interface CreatePrDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentBranch: string | null; + diffStats: { filesChanged: number; linesAdded: number; linesRemoved: number }; + isSubmitting: boolean; + onSubmit: () => void; + onGenerateCommitMessage: () => void; + onGeneratePr: () => void; +} + +export function CreatePrDialog({ + open, + onOpenChange, + currentBranch, + diffStats, + isSubmitting, + onSubmit, + onGenerateCommitMessage, + onGeneratePr, +}: CreatePrDialogProps) { + const store = useGitInteractionStore(); + const { actions } = store; + + const { createPrStep: step } = store; + const isExecuting = step !== "idle" && step !== "complete"; + + // Build the step list based on what's needed + const steps: StepDef[] = []; + if (store.createPrNeedsBranch) { + steps.push({ + id: "creating-branch", + label: `Create branch ${store.branchName || ""}`.trim(), + }); + } + if (store.createPrNeedsCommit) { + steps.push({ id: "committing", label: "Commit changes" }); + } + steps.push({ id: "pushing", label: "Push to remote" }); + steps.push({ id: "creating-pr", label: "Create pull request" }); + + return ( + + + + + + + {isExecuting ? "Creating PR..." : "Create PR"} + + + + {!isExecuting && ( + <> + {store.createPrNeedsBranch && ( + + + Branch + + actions.setBranchName(e.target.value)} + placeholder="branch-name" + size="1" + autoFocus + /> + {currentBranch && ( + + from {currentBranch} + + )} + + )} + + {store.createPrNeedsCommit && ( + + + + Commit message + + + + {diffStats.filesChanged} file + {diffStats.filesChanged === 1 ? "" : "s"} + + + +{diffStats.linesAdded} + + + -{diffStats.linesRemoved} + + + + +