diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operation-types.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operation-types.ts new file mode 100644 index 00000000000..c8287bafdb5 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operation-types.ts @@ -0,0 +1,45 @@ +/** + * Shared types for git operation responses that carry non-fatal warnings and + * partial-failure classification. Frontend maps these to the unified + * GitOperationDialog for auto-repair notifications and sync-partial reporting. + */ + +export type GitOperationWarning = + | { + kind: "auto-published-upstream"; + /** Branch that was auto-published when a pull/sync found no upstream. */ + branch: string; + } + | { + kind: "post-push-fetch-failed"; + /** Stderr of the failed fetch after a successful push. */ + message: string; + } + | { + kind: "push-retargeted"; + /** Remote name the push was redirected to (usually the fork host for a PR). */ + remote: string; + /** Branch name on that remote. */ + targetBranch: string; + } + | { + kind: "post-checkout-hook-failed"; + /** Brief hook stderr. */ + message: string; + }; + +/** + * Thrown by sync() so the frontend can distinguish which stage (pull or push) + * failed and show a tailored dialog. Message is the underlying git stderr. + */ +export class GitSyncStageError extends Error { + readonly stage: "pull" | "push"; + readonly cause: unknown; + constructor(stage: "pull" | "push", cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + super(`[sync:${stage}] ${message}`); + this.name = "GitSyncStageError"; + this.stage = stage; + this.cause = cause; + } +} 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 fea31201338..baa65db7f8e 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -12,6 +12,10 @@ import { } from "../workspaces/utils/base-branch-config"; import { getCurrentBranch } from "../workspaces/utils/git"; import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client"; +import { + type GitOperationWarning, + GitSyncStageError, +} from "./git-operation-types"; import { isNoPullRequestFoundMessage, isUpstreamMissingError, @@ -68,6 +72,8 @@ export const createGitOperationsRouter = () => { z.object({ worktreePath: z.string(), message: z.string(), + /** Pass --no-verify to bypass pre-commit / commit-msg hooks. */ + skipHooks: z.boolean().optional(), }), ) .mutation( @@ -75,7 +81,8 @@ export const createGitOperationsRouter = () => { assertRegisteredWorktree(input.worktreePath); const git = await getGitWithShellPath(input.worktreePath); - const result = await git.commit(input.message); + const options = input.skipHooks ? ["--no-verify"] : undefined; + const result = await git.commit(input.message, options); clearStatusCacheForWorktree(input.worktreePath); return { success: true, hash: result.commit }; }, @@ -88,34 +95,59 @@ export const createGitOperationsRouter = () => { setUpstream: z.boolean().optional(), }), ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); - - const git = await getGitWithShellPath(input.worktreePath); - const hasUpstream = await hasUpstreamBranch(git); - const localBranch = await getLocalBranchOrThrow({ - worktreePath: input.worktreePath, - action: "push", - }); + .mutation( + async ({ + input, + }): Promise<{ + success: boolean; + warnings: GitOperationWarning[]; + }> => { + assertRegisteredWorktree(input.worktreePath); - if (input.setUpstream && !hasUpstream) { - await pushWithResolvedUpstream({ - git, - worktreePath: input.worktreePath, - localBranch, - }); - } else { - await pushCurrentBranch({ - git, + const git = await getGitWithShellPath(input.worktreePath); + const hasUpstream = await hasUpstreamBranch(git); + const localBranch = await getLocalBranchOrThrow({ worktreePath: input.worktreePath, - localBranch, + action: "push", }); - } + const warnings: GitOperationWarning[] = []; - await fetchCurrentBranch(git, input.worktreePath); - clearStatusCacheForWorktree(input.worktreePath); - return { success: true }; - }), + if (input.setUpstream && !hasUpstream) { + await pushWithResolvedUpstream({ + git, + worktreePath: input.worktreePath, + localBranch, + }); + warnings.push({ + kind: "auto-published-upstream", + branch: localBranch, + }); + } else { + await pushCurrentBranch({ + git, + worktreePath: input.worktreePath, + localBranch, + }); + } + + try { + await fetchCurrentBranch(git, input.worktreePath); + } catch (fetchError) { + const message = + fetchError instanceof Error + ? fetchError.message + : String(fetchError); + console.warn( + "[git/push] post-push fetch failed (non-fatal):", + message, + ); + warnings.push({ kind: "post-push-fetch-failed", message }); + } + + clearStatusCacheForWorktree(input.worktreePath); + return { success: true, warnings }; + }, + ), pull: publicProcedure .input( @@ -149,45 +181,84 @@ export const createGitOperationsRouter = () => { worktreePath: z.string(), }), ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); + .mutation( + async ({ + input, + }): Promise<{ + success: boolean; + warnings: GitOperationWarning[]; + }> => { + assertRegisteredWorktree(input.worktreePath); - const git = await getGitWithShellPath(input.worktreePath); - try { - await git.pull(["--rebase"]); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - if (isUpstreamMissingError(message)) { - const localBranch = await getLocalBranchOrThrow({ - worktreePath: input.worktreePath, - action: "push", - }); - await pushWithResolvedUpstream({ + const git = await getGitWithShellPath(input.worktreePath); + const warnings: GitOperationWarning[] = []; + + try { + await git.pull(["--rebase"]); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + if (isUpstreamMissingError(message)) { + const localBranch = await getLocalBranchOrThrow({ + worktreePath: input.worktreePath, + action: "push", + }); + await pushWithResolvedUpstream({ + git, + worktreePath: input.worktreePath, + localBranch, + }); + warnings.push({ + kind: "auto-published-upstream", + branch: localBranch, + }); + try { + await fetchCurrentBranch(git, input.worktreePath); + } catch (fetchError) { + const fetchMessage = + fetchError instanceof Error + ? fetchError.message + : String(fetchError); + warnings.push({ + kind: "post-push-fetch-failed", + message: fetchMessage, + }); + } + clearStatusCacheForWorktree(input.worktreePath); + return { success: true, warnings }; + } + throw new GitSyncStageError("pull", error); + } + + const localBranch = await getLocalBranchOrThrow({ + worktreePath: input.worktreePath, + action: "push", + }); + try { + await pushCurrentBranch({ git, worktreePath: input.worktreePath, localBranch, }); + } catch (pushError) { + throw new GitSyncStageError("push", pushError); + } + try { await fetchCurrentBranch(git, input.worktreePath); - clearStatusCacheForWorktree(input.worktreePath); - return { success: true }; + } catch (fetchError) { + const fetchMessage = + fetchError instanceof Error + ? fetchError.message + : String(fetchError); + warnings.push({ + kind: "post-push-fetch-failed", + message: fetchMessage, + }); } - throw error; - } - - const localBranch = await getLocalBranchOrThrow({ - worktreePath: input.worktreePath, - action: "push", - }); - await pushCurrentBranch({ - git, - worktreePath: input.worktreePath, - localBranch, - }); - await fetchCurrentBranch(git, input.worktreePath); - clearStatusCacheForWorktree(input.worktreePath); - return { success: true }; - }), + clearStatusCacheForWorktree(input.worktreePath); + return { success: true, warnings }; + }, + ), fetch: publicProcedure .input(z.object({ worktreePath: z.string() })) @@ -208,7 +279,9 @@ export const createGitOperationsRouter = () => { }), ) .mutation( - async ({ input }): Promise<{ success: boolean; url: string }> => { + async ({ + input, + }): Promise<{ success: boolean; url: string; isExisting: boolean }> => { assertRegisteredWorktree(input.worktreePath); const git = await getGitWithShellPath(input.worktreePath); @@ -287,7 +360,7 @@ export const createGitOperationsRouter = () => { if (existingPRUrl) { await fetchCurrentBranch(git, input.worktreePath); clearWorktreeStatusCaches(input.worktreePath); - return { success: true, url: existingPRUrl }; + return { success: true, url: existingPRUrl, isExisting: true }; } try { @@ -300,7 +373,7 @@ export const createGitOperationsRouter = () => { await fetchCurrentBranch(git, input.worktreePath); clearWorktreeStatusCaches(input.worktreePath); - return { success: true, url }; + return { success: true, url, isExisting: false }; } catch (error) { // If creation reports branch/tracking mismatch but an open PR exists, // recover by opening that existing PR instead of failing. @@ -310,7 +383,11 @@ export const createGitOperationsRouter = () => { if (recoveredPRUrl) { await fetchCurrentBranch(git, input.worktreePath); clearWorktreeStatusCaches(input.worktreePath); - return { success: true, url: recoveredPRUrl }; + return { + success: true, + url: recoveredPRUrl, + isExisting: true, + }; } throw error; } @@ -663,5 +740,81 @@ export const createGitOperationsRouter = () => { return { message: result }; }), + + forceUnlockIndex: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation( + async ({ + input, + }): Promise<{ removed: boolean; path: string | null }> => { + assertRegisteredWorktree(input.worktreePath); + const { isAbsolute, resolve } = await import("node:path"); + const { stat, unlink } = await import("node:fs/promises"); + + // Resolve the *real* git-dir. For linked worktrees ".git" is a + // file that points at ".git/worktrees/", where the actual + // index.lock lives. Falling back to "/.git" is fine for + // the non-linked case. + const git = await getGitWithShellPath(input.worktreePath); + let gitDir: string; + try { + const raw = (await git.raw(["rev-parse", "--git-dir"])).trim(); + gitDir = isAbsolute(raw) ? raw : resolve(input.worktreePath, raw); + } catch { + gitDir = resolve(input.worktreePath, ".git"); + } + + const candidates = [ + resolve(gitDir, "index.lock"), + resolve(gitDir, "HEAD.lock"), + resolve(gitDir, "shallow.lock"), + ]; + // Walk every candidate so that index.lock and HEAD.lock + // co-existing (e.g. after a crash during a branch switch) can + // both be cleared in a single call. `path` in the response + // is the first lock removed so the UI has something concrete + // to show; `removed` is true if at least one file was deleted. + let firstRemoved: string | null = null; + for (const candidate of candidates) { + // stat: only swallow ENOENT (file not present). Other stat + // errors (EACCES, EPERM, EIO) are real failures and should + // surface so the user learns why the unlock did not run. + try { + await stat(candidate); + } catch (statError) { + const code = (statError as NodeJS.ErrnoException).code; + if (code === "ENOENT") continue; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to inspect lock file ${candidate}: ${ + statError instanceof Error + ? statError.message + : String(statError) + }`, + }); + } + // unlink failures (EACCES/EPERM when the file exists but can + // not be removed) are propagated verbatim — never swallowed. + try { + await unlink(candidate); + } catch (unlinkError) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to remove lock file ${candidate}: ${ + unlinkError instanceof Error + ? unlinkError.message + : String(unlinkError) + }`, + }); + } + if (firstRemoved === null) firstRemoved = candidate; + } + if (firstRemoved !== null) { + clearStatusCacheForWorktree(input.worktreePath); + return { removed: true, path: firstRemoved }; + } + return { removed: false, path: null }; + }, + ), }); }; diff --git a/apps/desktop/src/renderer/lib/git/classifyGitError.ts b/apps/desktop/src/renderer/lib/git/classifyGitError.ts new file mode 100644 index 00000000000..4ec88bce6b3 --- /dev/null +++ b/apps/desktop/src/renderer/lib/git/classifyGitError.ts @@ -0,0 +1,472 @@ +/** + * Classifier for git stderr/error messages used by the unified GitOperationDialog. + * + * Given an Error or string + the operation context, return a kind enum plus + * extracted data for the dialog to render. Pure function — no side effects. + * + * Patterns are derived from: + * - apps/desktop/src/lib/trpc/routers/changes/utils/git-push.ts (isNonFastForwardPushError, etc.) + * - apps/desktop/src/lib/trpc/routers/changes/git-utils.ts (isUpstreamMissingError) + * - empirical git CLI stderr wording + */ + +export type GitOperationContext = + | "commit" + | "push" + | "pull" + | "sync" + | "fetch" + | "stash" + | "stash-pop" + | "merge-pr" + | "create-pr" + | "switch-branch" + | "create-branch" + | "stage" + | "unstage" + | "discard" + | "generic"; + +export type GitErrorKind = + // push + | "push-rejected" + | "push-protected-branch" + | "push-no-remote-for-pr" + // pull / merge + | "pull-conflict" + | "pull-overwrite" + | "pull-upstream-missing" + // commit + | "commit-hook-failed" + | "commit-gpg-failed" + | "commit-identity-missing" + | "nothing-to-commit" + // auth / network / remote + | "auth-failed" + | "network-error" + | "no-remote" + // stash + | "stash-pop-conflict" + | "nothing-to-stash" + // git state + | "index-lock" + | "detached-head" + // discard / fs + | "permission-denied" + // PR + | "pr-not-mergeable" + | "pr-already-done" + | "pr-not-found" + // branch + | "branch-name-collision" + | "branch-behind-upstream" + // non-git + | "non-git-repo" + // generic fallback + | "generic-error"; + +export interface ClassifiedGitError { + kind: GitErrorKind; + rawMessage: string; + context: GitOperationContext; + data: { + /** File list when conflict/overwrite patterns mention specific paths. */ + conflictFiles?: string[]; + overwriteFiles?: string[]; + /** Remote name if auto-detected from message. */ + remote?: string; + /** Branch name if extracted. */ + branch?: string; + /** Suggested new branch name when a collision is detected. */ + suggestedBranchName?: string; + /** Hook name for hook failures (pre-commit, commit-msg, etc.). */ + hookName?: string; + }; +} + +function normalizeMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function _includes(haystack: string, needle: string): boolean { + return haystack.toLowerCase().includes(needle.toLowerCase()); +} + +function _includesAny(haystack: string, needles: string[]): boolean { + const lower = haystack.toLowerCase(); + return needles.some((n) => lower.includes(n.toLowerCase())); +} + +function extractConflictFiles(message: string): string[] { + const files = new Set(); + const patterns = [ + /CONFLICT[^:]*:\s*(?:Merge conflict in\s+|content\):\s*Merge conflict in\s+)?([^\n]+)/gi, + /both modified:\s+([^\n]+)/gi, + /both added:\s+([^\n]+)/gi, + /both deleted:\s+([^\n]+)/gi, + /Auto-merging\s+([^\n]+)\nCONFLICT/gi, + ]; + for (const re of patterns) { + let match = re.exec(message); + while (match) { + // Keep filenames with spaces — git emits them literally in merge + // conflict messages (see `CONFLICT (content): Merge conflict in file + // with spaces.ts`). The previous `!file.includes(" ")` guard was + // inconsistent with extractOverwriteFiles and silently dropped valid + // paths. + const file = match[1]?.trim(); + if (file) { + files.add(file); + } + match = re.exec(message); + } + } + return Array.from(files); +} + +function extractOverwriteFiles(message: string): string[] { + const files = new Set(); + // "error: Your local changes to the following files would be overwritten by merge:\n\tfile1\n\tfile2" + const blockMatch = message.match( + /local changes to the following files would be overwritten[^\n]*\n([\s\S]*?)(?:\n[A-Z]|$)/i, + ); + if (blockMatch?.[1]) { + for (const line of blockMatch[1].split("\n")) { + const trimmed = line.replace(/^\s+/, "").trim(); + if (trimmed && !trimmed.toLowerCase().startsWith("please")) { + files.add(trimmed); + } + } + } + return Array.from(files); +} + +function extractHookName(message: string): string | undefined { + const match = message.match( + /(pre-commit|commit-msg|prepare-commit-msg|pre-push|post-checkout|pre-rebase)/i, + ); + return match?.[1]?.toLowerCase(); +} + +function classifyPushError(message: string): GitErrorKind | null { + const lower = message.toLowerCase(); + + // Protected branch (GitHub returns this through push stderr) + if ( + lower.includes("protected branch") || + lower.includes("gh006") || + lower.includes("(protected branch hook declined)") + ) { + return "push-protected-branch"; + } + + // Non-fast-forward / rejected + if ( + lower.includes("non-fast-forward") || + (lower.includes("failed to push some refs") && + (lower.includes("rejected") || + lower.includes("fetch first") || + lower.includes("tip of your current branch is behind") || + lower.includes("remote contains work"))) + ) { + return "push-rejected"; + } + + // No remote for existing PR (from our backend TRPCError wording) + if ( + lower.includes("couldn't find a git remote") || + lower.includes("couldn't find a remote for") + ) { + return "push-no-remote-for-pr"; + } + + return null; +} + +function classifyPullError(message: string): GitErrorKind | null { + const lower = message.toLowerCase(); + + if ( + lower.includes("conflict") || + lower.includes("unmerged") || + lower.includes("fix conflicts and then commit") + ) { + return "pull-conflict"; + } + + if ( + lower.includes( + "local changes to the following files would be overwritten", + ) || + lower.includes("would be overwritten by merge") || + lower.includes("would be overwritten by checkout") || + lower.includes("please commit your changes or stash") + ) { + return "pull-overwrite"; + } + + if ( + lower.includes("no tracking information") || + lower.includes("no such ref was fetched") || + lower.includes("couldn't find remote ref") || + lower.includes("no upstream configured") || + lower.includes("no upstream branch") + ) { + return "pull-upstream-missing"; + } + + return null; +} + +function classifyCommitError(message: string): GitErrorKind | null { + const lower = message.toLowerCase(); + + if ( + lower.includes("please tell me who you are") || + lower.includes("user.email") || + lower.includes("empty ident name") + ) { + return "commit-identity-missing"; + } + + if ( + lower.includes("gpg failed to sign") || + lower.includes("gpg: signing failed") || + lower.includes("secret key not available") || + lower.includes("no secret key") + ) { + return "commit-gpg-failed"; + } + + if ( + lower.includes("nothing to commit") || + lower.includes("no changes added to commit") + ) { + return "nothing-to-commit"; + } + + // Hook failure — check last because hook stderr can contain arbitrary text. + if ( + lower.includes("pre-commit") || + lower.includes("commit-msg hook") || + lower.includes("hook failed") || + lower.includes("husky") || + lower.match(/hook[^a-z]+(declined|failed|exited|returned)/) + ) { + return "commit-hook-failed"; + } + + return null; +} + +function classifyAuthOrNetwork(message: string): GitErrorKind | null { + const lower = message.toLowerCase(); + + if ( + lower.includes("authentication failed") || + lower.includes("could not read username") || + lower.includes("permission denied (publickey)") || + lower.includes("invalid credentials") || + lower.includes("bad credentials") || + lower.includes("requires authentication") || + lower.includes("http 401") || + lower.includes("http 403") || + lower.includes("http basic: access denied") || + (lower.includes("token ") && lower.includes("expired")) + ) { + return "auth-failed"; + } + + if ( + lower.includes("could not resolve host") || + lower.includes("connection timed out") || + lower.includes("connection refused") || + lower.includes("failed to connect") || + lower.includes("network is unreachable") || + lower.includes("network error") || + lower.includes("unable to access") || + lower.includes("operation timed out") + ) { + return "network-error"; + } + + return null; +} + +function classifyStashError( + message: string, + context: GitOperationContext, +): GitErrorKind | null { + const lower = message.toLowerCase(); + + if ( + lower.includes("no stash entries") || + lower.includes("no local changes to save") + ) { + return context === "stash" ? "nothing-to-stash" : null; + } + + if ( + context === "stash-pop" && + (lower.includes("conflict") || + lower.includes("could not apply") || + lower.includes("needs merge")) + ) { + return "stash-pop-conflict"; + } + + return null; +} + +function classifyMergePRError(message: string): GitErrorKind | null { + const lower = message.toLowerCase(); + + if (lower.includes("no pull request")) return "pr-not-found"; + if (lower.includes("already merged") || lower.includes("pr is closed")) { + return "pr-already-done"; + } + if ( + lower.includes("cannot be merged") || + lower.includes("not in mergeable") || + lower.includes("merge conflicts") || + lower.includes("required status checks") || + lower.includes("review is required") + ) { + return "pr-not-mergeable"; + } + return null; +} + +/** + * Classify a git error message into a structured kind + extracted data. + * Caller passes the operation context; we use it to disambiguate (e.g. a + * "conflict" message during stash-pop vs pull). + */ +export function classifyGitError( + error: unknown, + context: GitOperationContext, +): ClassifiedGitError { + const rawMessage = normalizeMessage(error); + const lower = rawMessage.toLowerCase(); + + // State issues come first — they apply regardless of context. + if ( + lower.includes("index.lock") || + lower.includes("unable to create '.git/index.lock'") + ) { + return { + kind: "index-lock", + rawMessage, + context, + data: {}, + }; + } + + if ( + lower.includes("detached head") || + lower.includes("head detached") || + (lower.includes("cannot") && lower.includes("from detached head")) + ) { + return { + kind: "detached-head", + rawMessage, + context, + data: {}, + }; + } + + if (lower.includes("not a git repository")) { + return { kind: "non-git-repo", rawMessage, context, data: {} }; + } + + if ( + lower.includes("permission denied") && + !lower.includes("publickey") && + !lower.includes("(publickey)") + ) { + return { kind: "permission-denied", rawMessage, context, data: {} }; + } + + if (lower.includes("branch is behind upstream")) { + return { kind: "branch-behind-upstream", rawMessage, context, data: {} }; + } + + // Context-specific dispatching. For sync, use the [sync:pull] / + // [sync:push] prefix planted by GitSyncStageError to pick the matching + // classifier directly; fall back to trying both for messages that do not + // carry the prefix (e.g. errors thrown from outside the sync flow). + let kind: GitErrorKind | null = null; + let syncStage: "pull" | "push" | null = null; + if (context === "sync") { + if (rawMessage.startsWith("[sync:pull]")) syncStage = "pull"; + else if (rawMessage.startsWith("[sync:push]")) syncStage = "push"; + } + + if (context === "push" || (context === "sync" && syncStage !== "pull")) { + kind = classifyPushError(rawMessage); + } + if ( + !kind && + (context === "pull" || (context === "sync" && syncStage !== "push")) + ) { + kind = classifyPullError(rawMessage); + } + if (!kind && context === "commit") { + kind = classifyCommitError(rawMessage); + } + if (!kind && (context === "stash" || context === "stash-pop")) { + kind = classifyStashError(rawMessage, context); + } + if (!kind && context === "merge-pr") { + kind = classifyMergePRError(rawMessage); + } + + // Auth/network applies to any remote op and is last priority. + if (!kind) { + kind = classifyAuthOrNetwork(rawMessage); + } + + // No remote configured + if ( + !kind && + (lower.includes("does not appear to be a git repository") || + lower.includes("no such remote")) + ) { + kind = "no-remote"; + } + + // Branch collision (create-branch) + if ( + !kind && + context === "create-branch" && + lower.includes("already exists") + ) { + kind = "branch-name-collision"; + } + + kind = kind ?? "generic-error"; + + return { + kind, + rawMessage, + context, + data: { + conflictFiles: + kind === "pull-conflict" || kind === "stash-pop-conflict" + ? extractConflictFiles(rawMessage) + : undefined, + overwriteFiles: + kind === "pull-overwrite" + ? extractOverwriteFiles(rawMessage) + : undefined, + hookName: + kind === "commit-hook-failed" ? extractHookName(rawMessage) : undefined, + }, + }; +} diff --git a/apps/desktop/src/renderer/lib/git/gitConfirmDialog.ts b/apps/desktop/src/renderer/lib/git/gitConfirmDialog.ts new file mode 100644 index 00000000000..f1b79f645d3 --- /dev/null +++ b/apps/desktop/src/renderer/lib/git/gitConfirmDialog.ts @@ -0,0 +1,52 @@ +/** + * Helper for building confirmation dialogs on top of GitOperationDialog. + * Use for user-decision flows like merge-pr / bulk-stage / workflow-dispatch + * — anywhere you want to pause a destructive or irreversible action behind a + * consistent modal instead of a toast or native confirm. + */ + +import type { ReactNode } from "react"; +import { + type GitOperationDialogActionVariant, + type GitOperationDialogTone, + openGitOperationDialog, +} from "renderer/stores/git-operation-dialog"; + +export interface GitConfirmDialogOptions { + kind: string; + tone?: GitOperationDialogTone; + title: string; + description?: string; + details?: string; + extraContent?: ReactNode; + confirmLabel: string; + confirmVariant?: GitOperationDialogActionVariant; + onConfirm: () => void | Promise; + secondaryLabel?: string; + onSecondary?: () => void | Promise; + dismissLabel?: string; +} + +export function showGitConfirmDialog(options: GitConfirmDialogOptions): void { + openGitOperationDialog({ + kind: options.kind, + tone: options.tone ?? "neutral", + title: options.title, + description: options.description, + details: options.details, + extraContent: options.extraContent, + dismissLabel: options.dismissLabel ?? "キャンセル", + primaryAction: { + label: options.confirmLabel, + variant: options.confirmVariant ?? "primary", + onClick: options.onConfirm, + }, + secondaryAction: options.onSecondary + ? { + label: options.secondaryLabel ?? "その他の操作", + variant: "outline", + onClick: options.onSecondary, + } + : undefined, + }); +} diff --git a/apps/desktop/src/renderer/lib/git/gitErrorDialog.ts b/apps/desktop/src/renderer/lib/git/gitErrorDialog.ts new file mode 100644 index 00000000000..bfdb75da8ae --- /dev/null +++ b/apps/desktop/src/renderer/lib/git/gitErrorDialog.ts @@ -0,0 +1,532 @@ +/** + * Convert a classified git error into a GitOperationDialogSpec and open the + * unified dialog. Call sites only provide the handlers that make sense for + * their operation — the builder picks which to show per kind. + */ + +import { + type GitOperationDialogSpec, + openGitOperationDialog, +} from "renderer/stores/git-operation-dialog"; +import { + type ClassifiedGitError, + classifyGitError, + type GitErrorKind, + type GitOperationContext, +} from "./classifyGitError"; + +export interface GitErrorHandlers { + /** Retry the exact same mutation that just failed. */ + retry?: () => void; + /** For push-rejected: pull with rebase then retry the push. */ + pullRebaseAndRetryPush?: () => void; + /** For push-rejected / force push scenarios. Dangerous — only show when provided. */ + forcePushWithLease?: () => void; + /** For pull-conflict / stash-pop-conflict: open the first conflict file. */ + openConflictFiles?: (files: string[]) => void; + /** For pull-conflict: abort the rebase/merge in progress. */ + abortOperation?: () => void; + /** For pull-overwrite: stash local changes then retry pull. */ + stashAndRetry?: () => void; + /** For pull-overwrite: discard local changes then retry pull. */ + discardAndRetry?: () => void; + /** For auth-failed: open GitHub settings / re-login flow. */ + openAuthSettings?: () => void; + /** For commit-identity-missing: present an inline name/email form. */ + openIdentitySetup?: () => void; + /** For commit-gpg-failed: commit once without signing. */ + commitWithoutSigning?: () => void; + /** For commit-hook-failed: bypass hooks and retry. */ + retryWithoutHooks?: () => void; + /** For push-partial-success: retry just the post-push fetch. */ + fetchOnlyRetry?: () => void; + /** For pr-not-mergeable / pr-already-done: open the PR page. */ + openPullRequestUrl?: () => void; + /** For index-lock: force-remove the stale lock file. */ + forceUnlockIndex?: () => void; + /** For non-git-repo: open the init git dialog. */ + openInitGitDialog?: () => void; + /** For detached-head: open create branch dialog. */ + openCreateBranchDialog?: () => void; + /** For push-protected-branch: open create branch dialog to move commits. */ + createBranchAndMoveCommits?: () => void; +} + +interface BuildSpecArgs { + classified: ClassifiedGitError; + handlers: GitErrorHandlers; +} + +function renderConflictFilesContent(files: string[] | undefined) { + if (!files || files.length === 0) return undefined; + return ( + files + .slice(0, 10) + .map((f) => `• ${f}`) + .join("\n") + + (files.length > 10 ? `\n… ほか ${files.length - 10} 件` : "") + ); +} + +function buildSpec({ + classified, + handlers, +}: BuildSpecArgs): GitOperationDialogSpec { + const { kind, rawMessage, data } = classified; + + switch (kind) { + case "push-rejected": + return { + kind, + tone: "warn", + title: "リモートに新しいコミットがあります", + description: + "push できませんでした。リモートにローカルにない変更があります。先にリモートの変更を取り込んでから再 push してください。", + details: rawMessage, + primaryAction: handlers.pullRebaseAndRetryPush + ? { + label: "pull --rebase して再push", + variant: "primary", + onClick: handlers.pullRebaseAndRetryPush, + } + : undefined, + secondaryAction: handlers.forcePushWithLease + ? { + label: "force push", + variant: "destructive", + onClick: handlers.forcePushWithLease, + } + : undefined, + }; + + case "push-protected-branch": + return { + kind, + tone: "danger", + title: "このブランチは保護されています", + description: + "リモート側の保護ルールで直接 push が禁止されています。新しいブランチを作って Pull Request を出してください。", + details: rawMessage, + primaryAction: handlers.createBranchAndMoveCommits + ? { + label: "新ブランチを作って移す", + variant: "primary", + onClick: handlers.createBranchAndMoveCommits, + } + : undefined, + }; + + case "push-no-remote-for-pr": + return { + kind, + tone: "warn", + title: "PR の head リポジトリ用の remote が見つかりません", + description: + "この PR は別のリポジトリ (fork) に push する必要がありますが、対応する git remote が登録されていません。", + details: rawMessage, + primaryAction: handlers.retry + ? { label: "再試行", variant: "primary", onClick: handlers.retry } + : undefined, + }; + + case "pull-conflict": { + const list = renderConflictFilesContent(data.conflictFiles); + return { + kind, + tone: "danger", + title: "pull 中に競合が発生しました", + description: + "rebase を一時停止しています。競合を解決してから続行するか、rebase を中断してください。", + details: list ?? rawMessage, + primaryAction: + handlers.openConflictFiles && + data.conflictFiles && + data.conflictFiles.length > 0 + ? { + label: "競合ファイルを開く", + variant: "primary", + onClick: () => + handlers.openConflictFiles?.(data.conflictFiles ?? []), + } + : undefined, + secondaryAction: handlers.abortOperation + ? { + label: "rebase を中断", + variant: "destructive", + onClick: handlers.abortOperation, + } + : undefined, + }; + } + + case "pull-overwrite": + return { + kind, + tone: "warn", + title: "未コミットの変更があるため pull できません", + description: + "次のファイルはリモートの更新と重なっており、このまま pull すると失われます。", + details: renderConflictFilesContent(data.overwriteFiles) ?? rawMessage, + primaryAction: handlers.stashAndRetry + ? { + label: "stash してから pull", + variant: "primary", + onClick: handlers.stashAndRetry, + } + : undefined, + secondaryAction: handlers.discardAndRetry + ? { + label: "変更を破棄して pull", + variant: "destructive", + onClick: handlers.discardAndRetry, + } + : undefined, + }; + + case "pull-upstream-missing": + return { + kind, + tone: "warn", + title: "追跡先のリモートブランチが見つかりません", + description: + "upstream が削除されたか、まだ publish されていません。このブランチを再 publish するか、追跡先を付け替えてください。", + details: rawMessage, + primaryAction: handlers.retry + ? { + label: "このブランチを publish", + variant: "primary", + onClick: handlers.retry, + } + : undefined, + }; + + case "commit-hook-failed": + return { + kind, + tone: "danger", + title: data.hookName + ? `${data.hookName} フックでコミットが拒否されました` + : "Git フックでコミットが拒否されました", + description: + "フックがエラーを返したため、コミットは作成されていません。内容を確認して修正するか、フックを一時的に無視して commit を進めることができます。", + details: rawMessage, + primaryAction: handlers.retry + ? { + label: "再試行", + variant: "primary", + onClick: handlers.retry, + } + : undefined, + secondaryAction: handlers.retryWithoutHooks + ? { + label: "フック無視で commit", + variant: "outline", + onClick: handlers.retryWithoutHooks, + } + : undefined, + }; + + case "commit-gpg-failed": + return { + kind, + tone: "warn", + title: "GPG 署名に失敗しました", + description: + "commit.gpgsign が有効ですが、署名キーが見つからない / 期限切れ / passphrase が不一致のためコミットを確定できません。", + details: rawMessage, + primaryAction: handlers.commitWithoutSigning + ? { + label: "署名なしで commit", + variant: "primary", + onClick: handlers.commitWithoutSigning, + } + : undefined, + }; + + case "commit-identity-missing": + return { + kind, + tone: "info", + title: "コミット作者情報が未設定です", + description: + "Git の user.name / user.email が設定されていないためコミットできません。ターミナルで以下を実行してください。", + details: `git config user.name "Your Name"\ngit config user.email "you@example.com"`, + primaryAction: handlers.retry + ? { + label: "設定後に再試行", + variant: "primary", + onClick: handlers.retry, + } + : undefined, + }; + + case "nothing-to-commit": + return { + kind, + tone: "info", + title: "コミットする変更がありません", + description: + "staged エリアが空です。ファイルを stage してから再度お試しください。", + primaryAction: handlers.retry + ? { + label: "最新状態に更新", + variant: "primary", + onClick: handlers.retry, + } + : undefined, + }; + + case "auth-failed": + return { + kind, + tone: "danger", + title: "GitHub の認証に失敗しました", + description: + "リモート操作が拒否されました。Personal Access Token の期限切れ / 権限不足 / SSH 鍵の不一致が考えられます。", + details: rawMessage, + primaryAction: handlers.openAuthSettings + ? { + label: "認証設定を開く", + variant: "primary", + onClick: handlers.openAuthSettings, + } + : undefined, + secondaryAction: handlers.retry + ? { label: "再試行", variant: "outline", onClick: handlers.retry } + : undefined, + }; + + case "network-error": + return { + kind, + tone: "warn", + title: "リモートに接続できません", + description: + "GitHub に到達できませんでした。ネットワーク / プロキシ / VPN / DNS を確認してください。", + details: rawMessage, + primaryAction: handlers.retry + ? { + label: "もう一度試す", + variant: "primary", + onClick: handlers.retry, + } + : undefined, + }; + + case "no-remote": + return { + kind, + tone: "info", + title: "リモートが設定されていません", + description: + "このリポジトリには remote が無いため push / pull できません。GitHub に publish するか、remote URL を設定してください。", + details: rawMessage, + }; + + case "stash-pop-conflict": { + const list = renderConflictFilesContent(data.conflictFiles); + return { + kind, + tone: "danger", + title: "stash の適用で競合が発生しました", + description: + "stash の内容と現在の作業ツリーが競合しています。stash 自体はまだ残っているので、解決してから drop できます。", + details: list ?? rawMessage, + primaryAction: + handlers.openConflictFiles && + data.conflictFiles && + data.conflictFiles.length > 0 + ? { + label: "競合ファイルを開く", + variant: "primary", + onClick: () => + handlers.openConflictFiles?.(data.conflictFiles ?? []), + } + : undefined, + }; + } + + case "nothing-to-stash": + return { + kind, + tone: "info", + title: "退避する変更がありません", + description: "作業ツリーはクリーンです。stash する必要はありません。", + }; + + case "pr-not-mergeable": + return { + kind, + tone: "danger", + title: "この PR はまだマージできません", + description: + "GitHub 側のチェックに通っていない項目があります。コンフリクト / 必須レビュー / CI を確認してください。", + details: rawMessage, + primaryAction: handlers.openPullRequestUrl + ? { + label: "GitHub で開く", + variant: "primary", + onClick: handlers.openPullRequestUrl, + } + : undefined, + secondaryAction: handlers.retry + ? { + label: "状態を再取得", + variant: "outline", + onClick: handlers.retry, + } + : undefined, + }; + + case "pr-already-done": + return { + kind, + tone: "info", + title: "この PR はすでに閉じられています", + description: + "merge 済みまたは close 済みのためマージ操作は不要です。ローカルの状態が古い可能性があります。", + details: rawMessage, + primaryAction: handlers.openPullRequestUrl + ? { + label: "GitHub で開く", + variant: "primary", + onClick: handlers.openPullRequestUrl, + } + : undefined, + secondaryAction: handlers.retry + ? { label: "状態を更新", variant: "outline", onClick: handlers.retry } + : undefined, + }; + + case "pr-not-found": + return { + kind, + tone: "info", + title: "このブランチに対応する Pull Request が見つかりません", + description: "先に PR を作成してからマージしてください。", + details: rawMessage, + }; + + case "index-lock": + return { + kind, + tone: "warn", + title: "別の Git 操作が実行中です", + description: + ".git/index.lock が残っています。他のエディタ / CLI が編集中か、前回の操作が異常終了した可能性があります。", + details: rawMessage, + primaryAction: handlers.retry + ? { + label: "もう一度試す", + variant: "primary", + onClick: handlers.retry, + } + : undefined, + secondaryAction: handlers.forceUnlockIndex + ? { + label: "強制解除", + variant: "destructive", + onClick: handlers.forceUnlockIndex, + } + : undefined, + }; + + case "detached-head": + return { + kind, + tone: "warn", + title: "ブランチが選ばれていません", + description: + "現在 detached HEAD のためこの操作はできません。新しいブランチを作るか、既存のブランチに切り替えてください。", + details: rawMessage, + primaryAction: handlers.openCreateBranchDialog + ? { + label: "ブランチを作る", + variant: "primary", + onClick: handlers.openCreateBranchDialog, + } + : undefined, + }; + + case "permission-denied": + return { + kind, + tone: "danger", + title: "ファイルの権限エラーです", + description: + "対象のファイルに書き込み権限がないか、他のプロセスが使用中です。", + details: rawMessage, + primaryAction: handlers.retry + ? { label: "再試行", variant: "primary", onClick: handlers.retry } + : undefined, + }; + + case "branch-name-collision": + return { + kind, + tone: "warn", + title: "同名のブランチがすでにあります", + description: "別の名前を入力するか、既存ブランチに切り替えてください。", + details: rawMessage, + }; + + case "branch-behind-upstream": + return { + kind, + tone: "warn", + title: "ブランチが upstream より遅れています", + description: + "先にリモートの変更を取り込む必要があります。pull/rebase してから再実行してください。", + details: rawMessage, + primaryAction: handlers.pullRebaseAndRetryPush + ? { + label: "pull --rebase して再試行", + variant: "primary", + onClick: handlers.pullRebaseAndRetryPush, + } + : undefined, + }; + + case "non-git-repo": + return { + kind, + tone: "info", + title: "このフォルダは Git リポジトリではありません", + description: + "Git を初期化すると Changes タブで履歴管理ができるようになります。", + primaryAction: handlers.openInitGitDialog + ? { + label: "Git を初期化", + variant: "primary", + onClick: handlers.openInitGitDialog, + } + : undefined, + }; + default: + return { + kind: "generic-error", + tone: "danger", + title: "Git 操作でエラーが発生しました", + description: "詳細は下の出力を確認してください。", + details: rawMessage, + primaryAction: handlers.retry + ? { label: "再試行", variant: "primary", onClick: handlers.retry } + : undefined, + }; + } +} + +/** + * Classify an error and open the GitOperationDialog with a kind-appropriate + * spec. Call this from mutation onError handlers in place of `toast.error`. + */ +export function showGitErrorDialog( + error: unknown, + context: GitOperationContext, + handlers: GitErrorHandlers = {}, +): GitErrorKind { + const classified = classifyGitError(error, context); + const spec = buildSpec({ classified, handlers }); + openGitOperationDialog(spec); + return classified.kind; +} diff --git a/apps/desktop/src/renderer/lib/git/gitWarningDialog.ts b/apps/desktop/src/renderer/lib/git/gitWarningDialog.ts new file mode 100644 index 00000000000..824b9a72802 --- /dev/null +++ b/apps/desktop/src/renderer/lib/git/gitWarningDialog.ts @@ -0,0 +1,114 @@ +/** + * Map GitOperationWarning values from backend responses (push/sync) to + * user-visible auto-repair notifications in the unified GitOperationDialog. + */ + +import type { GitOperationWarning } from "lib/trpc/routers/changes/git-operation-types"; +import { + type GitOperationDialogSpec, + openGitOperationDialog, +} from "renderer/stores/git-operation-dialog"; + +export type { GitOperationWarning }; + +export interface GitWarningHandlers { + /** Only for post-push-fetch-failed: retry the follow-up fetch. */ + fetchOnlyRetry?: () => void; + /** For auto-published-upstream: open PR create / remote page. */ + createPullRequest?: () => void; + /** For push-retargeted: jump to the existing PR. */ + openPullRequestUrl?: () => void; +} + +function buildWarningSpec( + warning: GitOperationWarning, + handlers: GitWarningHandlers, +): GitOperationDialogSpec | null { + switch (warning.kind) { + case "post-push-fetch-failed": + return { + kind: "push-partial-success", + tone: "info", + title: "push は成功しましたが最新情報の取得に失敗しました", + description: + "リモートへの反映は完了しています。ローカル表示を更新するための fetch だけが失敗しました。", + details: warning.message, + primaryAction: handlers.fetchOnlyRetry + ? { + label: "fetch だけ再試行", + variant: "primary", + onClick: handlers.fetchOnlyRetry, + } + : undefined, + }; + case "auto-published-upstream": + return { + kind: "sync-auto-published-upstream", + tone: "info", + title: "upstream が無かったため自動で publish しました", + description: `このブランチ (${warning.branch}) をリモートに publish し、追跡設定を作成しました。`, + primaryAction: handlers.createPullRequest + ? { + label: "PR を作る", + variant: "primary", + onClick: handlers.createPullRequest, + } + : undefined, + }; + case "push-retargeted": + return { + kind: "push-retargeted-existing-pr-head", + tone: "info", + title: "push 先を既存 PR の head ブランチに切り替えました", + description: `tracking と既存 PR の head がズレていたので、${warning.remote}/${warning.targetBranch} に push しました。`, + primaryAction: handlers.openPullRequestUrl + ? { + label: "PR を開く", + variant: "primary", + onClick: handlers.openPullRequestUrl, + } + : undefined, + }; + case "post-checkout-hook-failed": + return { + kind: "post-checkout-hook-failed-nonfatal", + tone: "warn", + title: + "ブランチは切り替わりましたが post-checkout フックが失敗しました", + description: + "切替自体は成功していますが、husky などの post-checkout フックが非 0 で終わっています。依存インストールや build script が走っていない可能性があります。", + details: warning.message, + }; + default: + return null; + } +} + +/** + * Show the first actionable warning from a response. Returns true if a dialog + * was opened. If multiple warnings arrive together, surface the most important + * one (post-push-fetch-failed > push-retargeted > auto-published-upstream). + */ +export function showGitWarningDialog( + warnings: readonly GitOperationWarning[] | undefined, + handlers: GitWarningHandlers = {}, +): boolean { + if (!warnings || warnings.length === 0) return false; + const priority: GitOperationWarning["kind"][] = [ + "post-push-fetch-failed", + "push-retargeted", + "post-checkout-hook-failed", + "auto-published-upstream", + ]; + for (const kind of priority) { + const match = warnings.find((w) => w.kind === kind); + if (match) { + const spec = buildWarningSpec(match, handlers); + if (spec) { + openGitOperationDialog(spec); + return true; + } + } + } + return false; +} diff --git a/apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx b/apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx index 7d46db4e676..d3f7f1addd3 100644 --- a/apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx +++ b/apps/desktop/src/renderer/react-query/projects/InitGitDialog.tsx @@ -24,7 +24,7 @@ export function InitGitDialog() { > - Initialize Git Repository? + Git リポジトリを初期化しますか?
{isSingle ? ( @@ -32,13 +32,13 @@ export function InitGitDialog() { {paths[0]?.split("/").pop()} {" "} - is not a git repository. Would you like to initialize one? + は Git リポジトリではありません。初期化しますか?

) : ( <>

- The following folders are not git repositories. Would you - like to initialize them? + 以下のフォルダは Git + リポジトリではありません。初期化しますか?

    {paths.map((p) => ( @@ -63,10 +63,10 @@ export function InitGitDialog() { disabled={isPending} onClick={() => onCancel?.()} > - Cancel + キャンセル diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 413656fadfc..bf690e43359 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -6,12 +6,12 @@ import { import { useState } from "react"; import { useBrowserFullscreenHandler } from "renderer/hooks/useBrowserFullscreenHandler"; import { useBrowserNewWindowHandler } from "renderer/hooks/useBrowserNewWindowHandler"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { isTearoffWindow, useReturnedTabListener, useTearoffInit, } from "renderer/hooks/useTearoffInit"; -import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useHotkey } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 0b4910b98d8..02265799da7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -25,6 +25,7 @@ import { showWorkspaceAutoNameWarningToast } from "renderer/lib/workspaces/showW import { LanguageServicesProvider } from "renderer/providers/LanguageServicesProvider"; import { InitGitDialog } from "renderer/react-query/projects/InitGitDialog"; import { DashboardNewWorkspaceModal } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal"; +import { GitOperationDialog } from "renderer/screens/main/components/GitOperationDialog"; import { WorkspaceInitEffects } from "renderer/screens/main/components/WorkspaceInitEffects"; import { useSettingsStore } from "renderer/stores/settings-state"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -215,6 +216,10 @@ function AuthenticatedLayout() { )} + {/* Rendered at the authenticated layout level so that + useCreateOrOpenPR / showGitErrorDialog prompts remain + visible even when the Changes sidebar is closed. */} + diff --git a/apps/desktop/src/renderer/screens/main/components/CreatePullRequestBaseRepoDialog/CreatePullRequestBaseRepoDialog.tsx b/apps/desktop/src/renderer/screens/main/components/CreatePullRequestBaseRepoDialog/CreatePullRequestBaseRepoDialog.tsx index 7868a5c4b81..1304fadfe3e 100644 --- a/apps/desktop/src/renderer/screens/main/components/CreatePullRequestBaseRepoDialog/CreatePullRequestBaseRepoDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/CreatePullRequestBaseRepoDialog/CreatePullRequestBaseRepoDialog.tsx @@ -33,11 +33,11 @@ function getSourceDescription( ): string { switch (source) { case "tracking": - return "Current branch tracking remote"; + return "現在のブランチの追跡先リモート"; case "upstream": - return "Upstream repository"; + return "Upstream リポジトリ"; default: - return "Current repository"; + return "このリポジトリ"; } } @@ -45,9 +45,9 @@ export function CreatePullRequestBaseRepoDialog({ open, options, isPending = false, - title = "Choose pull request base repository", - description = "This branch can open a pull request against more than one GitHub repository. Choose where the PR should target. The selection will be remembered for this branch.", - confirmLabel = "Continue", + title = "Pull Request の base リポジトリを選択", + description = "このブランチは複数の GitHub リポジトリに対して Pull Request を作成できます。どこに向けて PR を作るか選んでください。選択はこのブランチに記憶されます。", + confirmLabel = "続行", onOpenChange, onConfirm, }: CreatePullRequestBaseRepoDialogProps) { @@ -97,7 +97,7 @@ export function CreatePullRequestBaseRepoDialog({ className="h-7 px-3 text-xs" onClick={() => onOpenChange(false)} > - Cancel + キャンセル ; + case "warn": + return ; + case "ok": + return ; + case "info": + return ; + default: + return ; + } +} + +// Map our palette to shadcn Button's native variant prop so we don't need to +// override classes (which previously left the outline borders from the +// hardcoded variant="outline" bleeding into primary/destructive buttons). +// "destructive" stays available for truly destructive actions (force push, +// force unlock, discard). All other action variants collapse to +// default/outline/ghost — no custom colors. +type ShadcnButtonVariant = "default" | "destructive" | "outline" | "ghost"; + +function toShadcnVariant( + variant: GitOperationDialogActionVariant | undefined, +): ShadcnButtonVariant { + switch (variant) { + case "destructive": + return "destructive"; + case "outline": + return "outline"; + case "ghost": + return "ghost"; + default: + return "default"; + } +} + +function ActionButton({ + action, + isPending, + dialogId, +}: { + action: GitOperationDialogAction; + isPending: boolean; + dialogId: number; +}) { + const onClick = async () => { + const store = useGitOperationDialogStore.getState(); + try { + const result = action.onClick(); + if (result instanceof Promise) { + store.setPending(true, dialogId); + await result; + } + } catch (err) { + // Actions normally delegate error reporting to their own mutation + // onError handlers. Anything reaching here is an unexpected throw — + // surface it to the console instead of silently eating it. + console.error("[GitOperationDialog] action threw", err); + } finally { + // Both setPending and close are scoped to this button's dialogId so + // that a late-running action cannot clobber a dialog the user has + // opened in the meantime (e.g. if the action opens another dialog). + useGitOperationDialogStore.getState().setPending(false, dialogId); + useGitOperationDialogStore.getState().close(dialogId); + } + }; + + return ( + + ); +} + +export function GitOperationDialog() { + const spec = useGitOperationDialogStore((s) => s.spec); + const dialogId = useGitOperationDialogStore((s) => s.dialogId); + const isPending = useGitOperationDialogStore((s) => s.isPending); + const close = useGitOperationDialogStore((s) => s.close); + + const open = spec !== null; + const iconNode = toneToIcon(spec?.tone); + + return ( + { + if (!nextOpen && !isPending) close(dialogId); + }} + > + + {spec ? ( + <> + +
    + {spec.icon ?? iconNode} +
    + + {spec.title} + + {spec.description ? ( + + {spec.description} + + ) : null} +
    + {spec.extraContent ? ( +
    {spec.extraContent}
    + ) : null} + {spec.details ? ( +
    +
    +									{spec.details}
    +								
    +
    + ) : null} + + {spec.hideDismiss ? null : ( + + )} + {spec.tertiaryAction ? ( + + ) : null} + {spec.secondaryAction ? ( + + ) : null} + {spec.primaryAction ? ( + + ) : null} + + + ) : null} +
    +
    + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/GitOperationDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/GitOperationDialog/index.ts new file mode 100644 index 00000000000..206887b631b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/GitOperationDialog/index.ts @@ -0,0 +1 @@ +export { GitOperationDialog } from "./GitOperationDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index f98b6666bbe..30307e19ec0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useEffect, useMemo, useRef, useState } from "react"; import { VscRefresh } from "react-icons/vsc"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { showGitConfirmDialog } from "renderer/lib/git/gitConfirmDialog"; import { getGitHubPRCommentsQueryPolicy, getGitHubStatusQueryPolicy, @@ -21,6 +22,7 @@ import { DEFAULT_DIFFS_PANE_PERCENTAGE, useChangesStore, } from "renderer/stores/changes"; +import { useEditorDocumentsStore } from "renderer/stores/editor-state/useEditorDocumentsStore"; import { useTabsStore } from "renderer/stores/tabs/store"; import { pathsMatch, @@ -33,6 +35,21 @@ import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; import { DiscardConfirmDialog } from "./components/DiscardConfirmDialog"; + +const BULK_STAGE_CONFIRM_THRESHOLD = 10; + +function countDirtyDocumentsForWorkspace( + workspaceId: string | undefined, +): number { + if (!workspaceId) return 0; + const documents = useEditorDocumentsStore.getState().documents; + let count = 0; + for (const doc of Object.values(documents)) { + if (doc.dirty && doc.workspaceId === workspaceId) count++; + } + return count; +} + import { RepositoryPanel } from "./components/RepositoryPanel"; import { ReviewPanel } from "./components/ReviewPanel"; import { VerticalResizablePanels } from "./components/VerticalResizablePanels"; @@ -764,11 +781,43 @@ export function ChangesView({ worktreePath: worktreePath || "", filePaths: files.map((f) => f.path), }), - onShowDiscardStagedDialog: () => setShowDiscardStagedDialog(true), - onUnstageAll: () => - unstageAllMutation.mutate({ - worktreePath: worktreePath || "", - }), + onShowDiscardStagedDialog: () => { + const dirtyCount = countDirtyDocumentsForWorkspace(workspaceId); + if (dirtyCount > 0) { + showGitConfirmDialog({ + kind: "discard-unsaved-editor", + tone: "warn", + title: "エディタに保存していない変更があります", + description: `${dirtyCount} 件のファイルがエディタで未保存です。discard を続行すると保存前の編集内容が失われます。`, + confirmLabel: "続行して discard", + confirmVariant: "destructive", + onConfirm: () => setShowDiscardStagedDialog(true), + }); + return; + } + setShowDiscardStagedDialog(true); + }, + onUnstageAll: () => { + const count = stagedFiles.length; + const run = () => + unstageAllMutation.mutate({ + worktreePath: worktreePath || "", + }); + if (count < BULK_STAGE_CONFIRM_THRESHOLD) { + run(); + return; + } + showGitConfirmDialog({ + kind: "bulk-unstage-all-confirm", + tone: "warn", + title: `${count} 件のファイルをまとめて unstage しますか?`, + description: + "staged を全て解除します。ファイルの内容は変更されません。", + confirmLabel: "全て unstage", + confirmVariant: "primary", + onConfirm: run, + }); + }, isDiscardAllStagedPending: discardAllStagedMutation.isPending, isUnstageAllPending: unstageAllMutation.isPending, isStagedActioning: @@ -789,11 +838,43 @@ export function ChangesView({ filePaths: files.map((f) => f.path), }), onDiscardFile: handleDiscard, - onShowDiscardUnstagedDialog: () => setShowDiscardUnstagedDialog(true), - onStageAll: () => - stageAllMutation.mutate({ - worktreePath: worktreePath || "", - }), + onShowDiscardUnstagedDialog: () => { + const dirtyCount = countDirtyDocumentsForWorkspace(workspaceId); + if (dirtyCount > 0) { + showGitConfirmDialog({ + kind: "discard-unsaved-editor", + tone: "warn", + title: "エディタに保存していない変更があります", + description: `${dirtyCount} 件のファイルがエディタで未保存です。discard を続行すると保存前の編集内容が失われます。`, + confirmLabel: "続行して discard", + confirmVariant: "destructive", + onConfirm: () => setShowDiscardUnstagedDialog(true), + }); + return; + } + setShowDiscardUnstagedDialog(true); + }, + onStageAll: () => { + const count = combinedUnstaged.length; + const run = () => + stageAllMutation.mutate({ + worktreePath: worktreePath || "", + }); + if (count < BULK_STAGE_CONFIRM_THRESHOLD) { + run(); + return; + } + showGitConfirmDialog({ + kind: "bulk-stage-all-confirm", + tone: "warn", + title: `${count} 件のファイルをまとめて stage しますか?`, + description: + "意図しない生成ファイルや設定ファイルが混ざっていないか確認してください。", + confirmLabel: "全て stage", + confirmVariant: "primary", + onConfirm: run, + }); + }, isDiscardAllUnstagedPending: discardAllUnstagedMutation.isPending, isStageAllPending: stageAllMutation.isPending, isUnstagedActioning: @@ -1029,27 +1110,27 @@ export function ChangesView({ discardAllUnstagedMutation.mutate({ worktreePath: worktreePath || "", }) } - confirmLabel="Discard All" + confirmLabel="全て破棄" /> discardAllStagedMutation.mutate({ worktreePath: worktreePath || "", }) } - confirmLabel="Discard All" + confirmLabel="全て破棄" />
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index ea6ab88515b..80628b674ab 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -16,7 +16,6 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { @@ -40,6 +39,7 @@ import { } from "react-icons/vsc"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; +import { showGitErrorDialog } from "renderer/lib/git/gitErrorDialog"; import type { ChangesViewMode } from "../../types"; import { ViewModeToggle } from "../ViewModeToggle"; import { @@ -476,6 +476,26 @@ function CurrentBranchSelector({ }); return; } + if (msg.toLowerCase().includes("index.lock")) { + showGitErrorDialog(error, "switch-branch", { + retry: () => + switchBranch.mutate({ worktreePath, branch: variables.branch }), + forceUnlockIndex: () => { + forceUnlockIndexMutation + .mutateAsync({ worktreePath }) + .then(() => + switchBranch.mutate({ + worktreePath, + branch: variables.branch, + }), + ) + .catch((unlockError) => { + showGitErrorDialog(unlockError, "generic"); + }); + }, + }); + return; + } if (isGitBusyMessage(msg)) { setDialogState({ kind: "git-busy", @@ -498,9 +518,12 @@ function CurrentBranchSelector({ }); return; } - toast.error(`Failed to switch branch: ${msg}`); + showGitErrorDialog(error, "switch-branch"); }, }); + const forceUnlockIndexMutation = + electronTrpc.changes.forceUnlockIndex.useMutation(); + const createBranch = electronTrpc.changes.createBranch.useMutation({ onSuccess: () => { invalidateBranchQueries(); @@ -531,7 +554,7 @@ function CurrentBranchSelector({ }); return; } - toast.error(`Failed to create branch: ${message}`); + showGitErrorDialog(error, "create-branch"); }, }); const updateBaseBranch = electronTrpc.changes.updateBaseBranch.useMutation({ @@ -543,9 +566,7 @@ function CurrentBranchSelector({ setDialogState({ kind: "compare-detached-head" }); return; } - toast.error( - `Failed to update compare branch: ${error.message ?? "Unknown error"}`, - ); + showGitErrorDialog(error, "generic"); }, }); @@ -1171,8 +1192,8 @@ function FetchRemoteButton({ onRefresh(); }, onError: (error) => { - toast.error("Fetch failed", { - description: error.message, + showGitErrorDialog(error, "fetch", { + retry: () => fetchMutation.mutate({ worktreePath }), }); }, onSettled: () => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/PRButton/PRButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/PRButton/PRButton.tsx index 2059dab6e52..3df96047d63 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/PRButton/PRButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/PRButton/PRButton.tsx @@ -16,6 +16,8 @@ import { VscLoading, } from "react-icons/vsc"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { showGitConfirmDialog } from "renderer/lib/git/gitConfirmDialog"; +import { showGitErrorDialog } from "renderer/lib/git/gitErrorDialog"; import { CreatePullRequestBaseRepoDialog } from "renderer/screens/main/components/CreatePullRequestBaseRepoDialog"; import { PRIcon } from "renderer/screens/main/components/PRIcon"; import { useCreateOrOpenPR } from "renderer/screens/main/hooks"; @@ -46,10 +48,19 @@ export function PRButton({ toast.success("PR merged successfully", { id: context?.toastId }); onRefresh(); }, - onError: (error, _variables, context) => - toast.error(`Merge failed: ${error.message}`, { - id: context?.toastId, - }), + onError: (error, variables, context) => { + toast.dismiss(context?.toastId); + showGitErrorDialog(error, "merge-pr", { + retry: () => + mergePRMutation.mutate({ + worktreePath: variables.worktreePath, + strategy: variables.strategy, + }), + openPullRequestUrl: () => { + if (pr?.url) window.open(pr.url, "_blank", "noopener,noreferrer"); + }, + }); + }, }); const { @@ -67,8 +78,31 @@ export function PRButton({ const handleCreatePR = () => createOrOpenPR(); - const handleMergePR = (strategy: "merge" | "squash" | "rebase") => - mergePRMutation.mutate({ worktreePath, strategy }); + const handleMergePR = (strategy: "merge" | "squash" | "rebase") => { + if (!pr) return; + const strategyLabel = + strategy === "squash" + ? "squash して" + : strategy === "rebase" + ? "rebase して" + : "merge commit を作って"; + showGitConfirmDialog({ + kind: "merge-pr-confirm", + tone: "warn", + title: `#${pr.number} を ${strategyLabel}マージしますか?`, + description: + "この操作は GitHub 上で即座に実行され、取り消しには別途 revert PR が必要です。", + details: pr.title ? `${pr.title}\n${pr.url}` : pr.url, + confirmLabel: + strategy === "squash" + ? "Squash and merge" + : strategy === "rebase" + ? "Rebase and merge" + : "Create merge commit", + confirmVariant: "primary", + onConfirm: () => mergePRMutation.mutate({ worktreePath, strategy }), + }); + }; if (isLoading) { return ( @@ -152,8 +186,8 @@ export function PRButton({ open={baseRepoDialog.open} options={baseRepoDialog.options} isPending={isCreatePending} - title="Choose pull request base repository" - description="Choose which repository new pull requests from this branch should target. The selection will be remembered for this branch." + title="Pull Request の base リポジトリを選択" + description="このブランチから作成する Pull Request の送り先リポジトリを選んでください。選択はこのブランチに記憶されます。" onOpenChange={baseRepoDialog.onOpenChange} onConfirm={baseRepoDialog.onConfirm} /> @@ -266,9 +300,9 @@ export function PRButton({ open={baseRepoDialog.open} options={baseRepoDialog.options} isPending={isCreatePending} - title="Update pull request base repository" - description="Choose which repository new pull requests from this branch should target. This updates the saved preference for the current branch." - confirmLabel="Save" + title="PR base リポジトリを変更" + description="このブランチから作成する Pull Request の送り先リポジトリを選んでください。保存するとこのブランチの既定を更新します。" + confirmLabel="保存" onOpenChange={baseRepoDialog.onOpenChange} onConfirm={baseRepoDialog.onConfirm} /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx index 039096092ed..29646be7606 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx @@ -22,6 +22,8 @@ import { VscSync, } from "react-icons/vsc"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { showGitErrorDialog } from "renderer/lib/git/gitErrorDialog"; +import { showGitWarningDialog } from "renderer/lib/git/gitWarningDialog"; import { CreatePullRequestBaseRepoDialog } from "renderer/screens/main/components/CreatePullRequestBaseRepoDialog"; import { useCreateOrOpenPR } from "renderer/screens/main/hooks"; import { getPrimaryAction } from "./utils/getPrimaryAction"; @@ -58,21 +60,67 @@ export function CommitInput({ }: CommitInputProps) { const [isOpen, setIsOpen] = useState(false); + const stashIncludeUntrackedMutation = + electronTrpc.changes.stashIncludeUntracked.useMutation(); + const stashPopMutation = electronTrpc.changes.stashPop.useMutation(); + const commitMutation = electronTrpc.changes.commit.useMutation({ onSuccess: () => { toast.success("Committed"); setCommitMessage(""); onRefresh(); }, - onError: (error) => toast.error(`Commit failed: ${error.message}`), + onError: (error, variables) => { + // Retry must use the message that was actually submitted — not the + // current textarea value. Otherwise editing the input after a failed + // commit would silently change what gets retried. + const submittedMessage = variables.message; + showGitErrorDialog(error, "commit", { + retry: () => { + commitMutation.mutate({ + worktreePath, + message: submittedMessage, + }); + }, + retryWithoutHooks: () => { + commitMutation.mutate({ + worktreePath, + message: submittedMessage, + skipHooks: true, + }); + }, + }); + }, }); const pushMutation = electronTrpc.changes.push.useMutation({ - onSuccess: () => { + onSuccess: (result) => { toast.success("Pushed"); onRefresh(); + showGitWarningDialog(result?.warnings, { + fetchOnlyRetry: () => fetchMutation.mutate({ worktreePath }), + createPullRequest: () => createOrOpenPR(), + openPullRequestUrl: () => { + if (pullRequest?.url) { + window.open(pullRequest.url, "_blank", "noopener,noreferrer"); + } + }, + }); + }, + onError: (error) => { + showGitErrorDialog(error, "push", { + retry: () => pushMutation.mutate({ worktreePath, setUpstream: true }), + pullRebaseAndRetryPush: () => { + pullMutation.mutate( + { worktreePath }, + { + onSuccess: () => + pushMutation.mutate({ worktreePath, setUpstream: true }), + }, + ); + }, + }); }, - onError: (error) => toast.error(`Push failed: ${error.message}`), }); const pullMutation = electronTrpc.changes.pull.useMutation({ @@ -80,15 +128,69 @@ export function CommitInput({ toast.success("Pulled"); onRefresh(); }, - onError: (error) => toast.error(`Pull failed: ${error.message}`), + onError: (error) => { + showGitErrorDialog(error, "pull", { + retry: () => pullMutation.mutate({ worktreePath }), + stashAndRetry: () => { + // stash → pull → stash pop. Skipping the pop would silently + // leave the user's local changes on the stash stack after a + // successful pull, which is almost never what the user + // expected when they chose "stash してから pull". + stashIncludeUntrackedMutation.mutate( + { worktreePath }, + { + onSuccess: () => { + pullMutation.mutate( + { worktreePath }, + { + onSuccess: () => { + stashPopMutation.mutate( + { worktreePath }, + { + onError: (popError) => + showGitErrorDialog(popError, "stash-pop"), + }, + ); + }, + }, + ); + }, + onError: (stashError) => showGitErrorDialog(stashError, "stash"), + }, + ); + }, + }); + }, }); const syncMutation = electronTrpc.changes.sync.useMutation({ - onSuccess: () => { + onSuccess: (result) => { toast.success("Synced"); onRefresh(); + showGitWarningDialog(result?.warnings, { + fetchOnlyRetry: () => fetchMutation.mutate({ worktreePath }), + createPullRequest: () => createOrOpenPR(), + openPullRequestUrl: () => { + if (pullRequest?.url) { + window.open(pullRequest.url, "_blank", "noopener,noreferrer"); + } + }, + }); + }, + onError: (error) => { + showGitErrorDialog(error, "sync", { + retry: () => syncMutation.mutate({ worktreePath }), + pullRebaseAndRetryPush: () => { + pullMutation.mutate( + { worktreePath }, + { + onSuccess: () => + pushMutation.mutate({ worktreePath, setUpstream: true }), + }, + ); + }, + }); }, - onError: (error) => toast.error(`Sync failed: ${error.message}`), }); const { @@ -105,7 +207,11 @@ export function CommitInput({ toast.success("Fetched"); onRefresh(); }, - onError: (error) => toast.error(`Fetch failed: ${error.message}`), + onError: (error) => { + showGitErrorDialog(error, "fetch", { + retry: () => fetchMutation.mutate({ worktreePath }), + }); + }, }); const isPending = diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx index 2055c5aaf83..48669241254 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx @@ -25,7 +25,7 @@ export function DiscardConfirmDialog({ title, description, onConfirm, - confirmLabel = "Discard", + confirmLabel = "破棄", confirmDisabled = false, }: DiscardConfirmDialogProps) { return ( @@ -42,7 +42,7 @@ export function DiscardConfirmDialog({ className="h-7 px-3 text-xs" onClick={() => onOpenChange(false)} > - Cancel + キャンセル 0 ? cleaned : undefined; } - setPendingWorkflowId(workflowId); - try { - const result = await dispatchWorkflowMutation.mutateAsync({ - workspaceId, - workflowId, - ref: workflowRef.trim() || undefined, - inputs: filteredInputs, - }); - setTrackedWorkflowRuns((current) => - [ - { - workflowId, - workflowName, - ref: result.ref, - dispatchedAt: result.dispatchedAt, - }, - ...current.filter((item) => item.workflowId !== workflowId), - ].slice(0, 4), - ); - toast.success(`Triggered ${workflowName} on ${result.ref}`); - } catch (mutationError) { - const message = - mutationError instanceof Error - ? mutationError.message - : "Unknown error"; - toast.error(`Failed to run workflow: ${message}`); - } finally { - setPendingWorkflowId((current) => - current === workflowId ? null : current, - ); - } + const runDispatch = async () => { + setPendingWorkflowId(workflowId); + try { + const result = await dispatchWorkflowMutation.mutateAsync({ + workspaceId, + workflowId, + ref: workflowRef.trim() || undefined, + inputs: filteredInputs, + }); + setTrackedWorkflowRuns((current) => + [ + { + workflowId, + workflowName, + ref: result.ref, + dispatchedAt: result.dispatchedAt, + }, + ...current.filter((item) => item.workflowId !== workflowId), + ].slice(0, 4), + ); + toast.success(`Triggered ${workflowName} on ${result.ref}`); + } catch (mutationError) { + const message = + mutationError instanceof Error + ? mutationError.message + : "Unknown error"; + toast.error(`Failed to run workflow: ${message}`); + } finally { + setPendingWorkflowId((current) => + current === workflowId ? null : current, + ); + } + }; + + showGitConfirmDialog({ + kind: "workflow-dispatch-confirm", + tone: "warn", + title: `GitHub Actions ワークフローを実行しますか?`, + description: `${workflowName} を ${workflowRef.trim() || "デフォルトブランチ"} で手動起動します。remote 副作用のあるワークフローは特に注意してください。`, + confirmLabel: "実行", + confirmVariant: "primary", + onConfirm: () => { + void runDispatch(); + }, + }); }; const handleViewWorkflowLogs = async (runId: number) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx index 6a5df6f9aaf..b1f08096c19 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx @@ -39,6 +39,7 @@ import remarkGfm from "remark-gfm"; import { remarkAlert } from "remark-github-blockquote-alert"; import { CodeBlock } from "renderer/components/MarkdownRenderer/components/CodeBlock"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { showGitConfirmDialog } from "renderer/lib/git/gitConfirmDialog"; import { PRIcon } from "renderer/screens/main/components/PRIcon"; import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useTabsStore } from "renderer/stores/tabs"; @@ -289,7 +290,7 @@ export function ReviewPanel({ await onRefreshReview(scope); }; - const handleToggleDraftState = () => { + const runToggleDraftState = () => { if (!resolvedWorkspaceId || !pr) { return; } @@ -342,6 +343,25 @@ export function ReviewPanel({ }); }; + const handleToggleDraftState = () => { + if (!pr) return; + const isConvertingToDraft = pr.state !== "draft"; + if (!isConvertingToDraft) { + runToggleDraftState(); + return; + } + showGitConfirmDialog({ + kind: "pr-draft-state-confirm", + tone: "warn", + title: "この PR を draft に戻しますか?", + description: + "レビュアーのレビュー要求が解除され、reviewer workflow が巻き戻ります。", + confirmLabel: "draft に戻す", + confirmVariant: "primary", + onConfirm: runToggleDraftState, + }); + }; + const handleToggleThreadResolution = (comment: PullRequestComment) => { if (!resolvedWorkspaceId || !comment.threadId) { return; @@ -543,7 +563,7 @@ export function ReviewPanel({ }); }; - const handleRerunChecks = async (mode: "all" | "failed") => { + const runRerunChecks = async (mode: "all" | "failed") => { if (!resolvedWorkspaceId || pendingRerunMode) { return; } @@ -569,6 +589,33 @@ export function ReviewPanel({ } }; + const handleRerunChecks = async (mode: "all" | "failed") => { + if (mode === "failed") { + await runRerunChecks(mode); + return; + } + // Title is deliberately neutral so it cannot conflict with the primary + // button label. Primary runs the safer "failed only" path to avoid + // re-queuing every job on an errant Enter keypress; "全て再実行" is + // available as the secondary action for users who explicitly want it. + showGitConfirmDialog({ + kind: "rerun-all-checks-confirm", + tone: "warn", + title: "CI jobs を再実行しますか?", + description: + "通常は失敗したジョブのみ再実行します。すべてのジョブを再キューするとコストとノイズが大きくなるので注意してください。", + confirmLabel: "失敗分だけ再実行", + confirmVariant: "primary", + onConfirm: () => { + void runRerunChecks("failed"); + }, + secondaryLabel: "全て再実行", + onSecondary: () => { + void runRerunChecks("all"); + }, + }); + }; + const updateReviewers = async ({ add = [], remove = [], diff --git a/apps/desktop/src/renderer/screens/main/hooks/useCreateOrOpenPR/useCreateOrOpenPR.ts b/apps/desktop/src/renderer/screens/main/hooks/useCreateOrOpenPR/useCreateOrOpenPR.ts index 21a9e0d421f..548c127a685 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/useCreateOrOpenPR/useCreateOrOpenPR.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/useCreateOrOpenPR/useCreateOrOpenPR.ts @@ -1,6 +1,8 @@ import { toast } from "@superset/ui/sonner"; import { useCallback, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { showGitConfirmDialog } from "renderer/lib/git/gitConfirmDialog"; +import { showGitErrorDialog } from "renderer/lib/git/gitErrorDialog"; import type { CreatePullRequestBaseRepoOption } from "renderer/screens/main/components/CreatePullRequestBaseRepoDialog"; interface UseCreateOrOpenPROptions { @@ -49,6 +51,44 @@ export function useCreateOrOpenPR({ (baseRepoUrl?: string, allowOutOfDate = false) => { if (!worktreePath || isCreatePRPending) return; + const openResult = (result: { url: string; isExisting: boolean }) => { + if (result.isExisting) { + showGitConfirmDialog({ + kind: "create-pr-open-existing", + tone: "info", + title: "このブランチには既に Pull Request があります", + description: "新規作成せず既存の PR を開きますか?", + details: result.url, + confirmLabel: "既存 PR を開く", + confirmVariant: "primary", + onConfirm: () => { + window.open(result.url, "_blank", "noopener,noreferrer"); + toast.success("Opening GitHub..."); + onSuccess?.(); + }, + }); + return; + } + window.open(result.url, "_blank", "noopener,noreferrer"); + toast.success("Opening GitHub..."); + onSuccess?.(); + }; + + const retryWithAllow = () => { + void (async () => { + try { + const result = await mutateAsync({ + worktreePath, + allowOutOfDate: true, + baseRepoUrl, + }); + openResult(result); + } catch (retryError) { + showGitErrorDialog(retryError, "create-pr"); + } + })(); + }; + void (async () => { try { const result = await mutateAsync({ @@ -56,42 +96,26 @@ export function useCreateOrOpenPR({ allowOutOfDate, baseRepoUrl, }); - window.open(result.url, "_blank", "noopener,noreferrer"); - toast.success("Opening GitHub..."); - onSuccess?.(); + openResult(result); return; } catch (error) { const message = error instanceof Error ? error.message : String(error); - const isBehindUpstreamError = message.includes("behind upstream"); - if (!isBehindUpstreamError) { - toast.error(`Failed: ${message}`); + if (message.toLowerCase().includes("behind upstream")) { + showGitConfirmDialog({ + kind: "create-pr-behind-upstream", + tone: "warn", + title: "ブランチが upstream より遅れています", + description: + "リモートに新しいコミットがあります。このまま Pull Request を作成/更新しますか?", + details: message, + confirmLabel: "そのまま作成", + confirmVariant: "primary", + onConfirm: retryWithAllow, + }); return; } - - const shouldContinue = window.confirm( - `${message}\n\nCreate/open the pull request anyway?`, - ); - if (!shouldContinue) { - return; - } - } - - try { - const result = await mutateAsync({ - worktreePath, - allowOutOfDate: true, - baseRepoUrl, - }); - window.open(result.url, "_blank", "noopener,noreferrer"); - toast.success("Opening GitHub..."); - onSuccess?.(); - } catch (retryError) { - const retryMessage = - retryError instanceof Error - ? retryError.message - : String(retryError); - toast.error(`Failed: ${retryMessage}`); + showGitErrorDialog(error, "create-pr"); } })(); }, @@ -117,8 +141,7 @@ export function useCreateOrOpenPR({ runCreateOrOpenPR(result.selectedBaseRepoUrl ?? undefined); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`Failed: ${message}`); + showGitErrorDialog(error, "create-pr"); } })(); }, [ @@ -148,8 +171,7 @@ export function useCreateOrOpenPR({ mode: "configure", }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`Failed: ${message}`); + showGitErrorDialog(error, "create-pr"); } })(); }, [ @@ -171,8 +193,7 @@ export function useCreateOrOpenPR({ toast.success("Pull request base repository reset"); onSuccess?.(); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`Failed: ${message}`); + showGitErrorDialog(error, "create-pr"); } })(); }, [isUpdatingBaseRepo, onSuccess, updatePullRequestBaseRepo, worktreePath]); diff --git a/apps/desktop/src/renderer/stores/git-operation-dialog.ts b/apps/desktop/src/renderer/stores/git-operation-dialog.ts new file mode 100644 index 00000000000..139437c091f --- /dev/null +++ b/apps/desktop/src/renderer/stores/git-operation-dialog.ts @@ -0,0 +1,105 @@ +import type { ReactNode } from "react"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +export type GitOperationDialogTone = + | "info" + | "ok" + | "warn" + | "danger" + | "neutral"; + +/** + * Subset of shadcn Button variants available to GitOperationDialog actions. + * Deliberately narrow to keep the dialog surface monochrome — no amber/emerald + * /sky custom colors. Destructive is the only chromatic variant, reserved for + * truly irreversible actions (force push, force unlock, discard). + */ +export type GitOperationDialogActionVariant = + | "primary" + | "outline" + | "ghost" + | "destructive"; + +export interface GitOperationDialogAction { + label: string; + onClick: () => void | Promise; + variant?: GitOperationDialogActionVariant; + disabled?: boolean; +} + +export interface GitOperationDialogSpec { + /** Identifier for telemetry and tests (e.g. "push-rejected"). */ + kind: string; + tone?: GitOperationDialogTone; + icon?: ReactNode; + title: string; + description?: string; + /** Raw stderr or additional machine-readable details shown in a scrollable block. */ + details?: string; + /** Arbitrary rich content rendered between description and buttons (checklists, inputs, file lists). */ + extraContent?: ReactNode; + primaryAction?: GitOperationDialogAction; + secondaryAction?: GitOperationDialogAction; + tertiaryAction?: GitOperationDialogAction; + /** Label of the dismiss/cancel button. Defaults to "閉じる". */ + dismissLabel?: string; + /** If true, dismiss button is not shown. */ + hideDismiss?: boolean; +} + +interface GitOperationDialogState { + spec: GitOperationDialogSpec | null; + /** + * Monotonic token identifying the currently-rendered dialog. Incremented on + * every `open()` so that a late-running action's `finally` can only clear + * the dialog it originally opened (not a subsequent one the user opened in + * the meantime). + */ + dialogId: number; + isPending: boolean; + /** @returns the id of the dialog that was just opened. */ + open: (spec: GitOperationDialogSpec) => number; + /** If `id` is given, only updates when it matches the current dialogId. */ + setPending: (pending: boolean, id?: number) => void; + /** If `id` is given, only closes when it matches the current dialogId. */ + close: (id?: number) => void; +} + +export const useGitOperationDialogStore = create()( + devtools( + (set) => ({ + spec: null, + dialogId: 0, + isPending: false, + open: (spec) => { + let nextId = 0; + set((state) => { + nextId = state.dialogId + 1; + return { spec, dialogId: nextId, isPending: false }; + }); + return nextId; + }, + setPending: (isPending, id) => + set((state) => + id === undefined || id === state.dialogId ? { isPending } : state, + ), + close: (id) => + set((state) => + id === undefined || id === state.dialogId + ? { spec: null, isPending: false } + : state, + ), + }), + { name: "GitOperationDialogStore" }, + ), +); + +/** Convenience helper for call sites. Returns the opened dialog id. */ +export function openGitOperationDialog(spec: GitOperationDialogSpec): number { + return useGitOperationDialogStore.getState().open(spec); +} + +export function closeGitOperationDialog(id?: number): void { + useGitOperationDialogStore.getState().close(id); +}