diff --git a/.gitignore b/.gitignore index 5762fe7482b..50e063b0c70 100644 --- a/.gitignore +++ b/.gitignore @@ -85,5 +85,6 @@ superset-dev-data/ !.codex/config.toml !.codex/commands !.codex/prompts +.serena/ test-conflict-repo/ .amp/* diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index 74176937a6b..10566dc9bf2 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -1,4 +1,7 @@ +import { access } from "node:fs/promises"; +import { join, resolve } from "node:path"; import { worktrees } from "@superset/local-db"; +import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import type { SimpleGit } from "simple-git"; @@ -17,6 +20,19 @@ import { clearStatusCacheForWorktree } from "./utils/status-cache"; const DEFAULT_REF_SEARCH_LIMIT = 50; const MAX_REF_SEARCH_LIMIT = 200; +const GIT_PROGRESS_OPERATIONS = [ + { kind: "merge", path: "MERGE_HEAD" }, + { kind: "cherry-pick", path: "CHERRY_PICK_HEAD" }, + { kind: "revert", path: "REVERT_HEAD" }, + { kind: "bisect", path: "BISECT_LOG" }, +] as const; + +type BranchProgressOperation = + | "merge" + | "rebase" + | "cherry-pick" + | "revert" + | "bisect"; type SearchableRef = { name: string; @@ -42,6 +58,27 @@ type ParsedRefEntry = { const REF_FIELD_SEPARATOR = "\u001f"; const REF_RECORD_SEPARATOR = "\u001e"; +function normalizeBranchRef(branch: string): string { + if (branch.startsWith("refs/heads/")) { + return branch.slice("refs/heads/".length); + } + if (branch.startsWith("refs/remotes/origin/")) { + return branch.slice("refs/remotes/origin/".length); + } + if (branch.startsWith("remotes/origin/")) { + return branch.slice("remotes/origin/".length); + } + return branch; +} + +async function assertWorktreePathExists(worktreePath: string): Promise { + if (await pathExists(worktreePath)) return; + throw new TRPCError({ + code: "NOT_FOUND", + message: `Worktree path does not exist: ${worktreePath}`, + }); +} + export const createBranchesRouter = () => { return router({ getBranches: publicProcedure @@ -58,6 +95,7 @@ export const createBranchesRouter = () => { currentBranch: string | null; }> => { assertRegisteredWorktree(input.worktreePath); + await assertWorktreePathExists(input.worktreePath); const git = await getSimpleGitWithShellPath(input.worktreePath); @@ -189,15 +227,38 @@ export const createBranchesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitSwitchBranch(input.worktreePath, input.branch); + await assertWorktreePathExists(input.worktreePath); + const branch = normalizeBranchRef(input.branch); + await gitSwitchBranch(input.worktreePath, branch); const currentBranch = - (await getCurrentBranch(input.worktreePath)) ?? input.branch; + (await getCurrentBranch(input.worktreePath)) ?? branch; persistWorktreeBranch(input.worktreePath, currentBranch); clearStatusCacheForWorktree(input.worktreePath); return { success: true }; }), + getBranchGuardState: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .query( + async ({ + input, + }): Promise<{ + operationInProgress: BranchProgressOperation | null; + }> => { + assertRegisteredWorktree(input.worktreePath); + + const git = await getSimpleGitWithShellPath(input.worktreePath); + + return { + operationInProgress: await detectGitProgressOperation( + git, + input.worktreePath, + ), + }; + }, + ), + createBranch: publicProcedure .input( z.object({ @@ -357,6 +418,44 @@ function getPersistedWorktree(worktreePath: string) { .get(); } +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function detectGitProgressOperation( + git: SimpleGit, + worktreePath: string, +): Promise { + let gitDirPath: string; + + try { + const gitDir = (await git.revparse(["--git-dir"])).trim(); + gitDirPath = resolve(worktreePath, gitDir); + } catch { + return null; + } + + if ( + (await pathExists(join(gitDirPath, "rebase-merge"))) || + (await pathExists(join(gitDirPath, "rebase-apply"))) + ) { + return "rebase"; + } + + for (const candidate of GIT_PROGRESS_OPERATIONS) { + if (await pathExists(join(gitDirPath, candidate.path))) { + return candidate.kind; + } + } + + return null; +} + function persistWorktreeBranch(worktreePath: string, branch: string): void { const persistedWorktree = getPersistedWorktree(worktreePath); if (!persistedWorktree) { diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts index 1e3d5ab00dd..dbd94f3bf69 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts @@ -3,7 +3,7 @@ import type { SimpleGit } from "simple-git"; import { z } from "zod"; import { execGitWithShellPath } from "../../workspaces/utils/git-client"; import { getRepoContext } from "../../workspaces/utils/github"; -import { getPullRequestRepoArgs } from "../../workspaces/utils/github/repo-context"; +import { getPullRequestRepoNames } from "../../workspaces/utils/github/repo-context"; import { execWithShellEnv } from "../../workspaces/utils/shell-env"; import { buildPullRequestCompareUrl, @@ -24,32 +24,57 @@ async function findOpenPRByHeadCommit( return null; } - const repoArgs = getPullRequestRepoArgs(await getRepoContext(worktreePath)); - - const { stdout } = await execWithShellEnv( - "gh", - [ - "pr", - "list", - ...repoArgs, - "--state", - "open", - "--search", - `${headSha} is:pr`, - "--limit", - "20", - "--json", - "url,headRefOid", - ], - { cwd: worktreePath }, + const repoNames = getPullRequestRepoNames( + await getRepoContext(worktreePath), ); + const repoArgSets = + repoNames.length > 0 + ? repoNames.map((repoName) => ["--repo", repoName]) + : [[]]; + + for (const repoArgs of repoArgSets) { + try { + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + ...repoArgs, + "--state", + "open", + "--search", + `${headSha} is:pr`, + "--limit", + "20", + "--json", + "url,headRefOid", + ], + { cwd: worktreePath }, + ); - const parsed = JSON.parse(stdout) as Array<{ - url?: string; - headRefOid?: string; - }>; - const match = parsed.find((candidate) => candidate.headRefOid === headSha); - return match?.url?.trim() || null; + const parsed = JSON.parse(stdout) as Array<{ + url?: string; + headRefOid?: string; + }>; + const match = parsed.find( + (candidate) => candidate.headRefOid === headSha, + ); + if (match?.url?.trim()) { + return match.url.trim(); + } + } catch (error) { + console.warn( + "[git/findExistingOpenPRUrl] Failed repo-scoped commit-based PR lookup:", + { + worktreePath, + repoArgs, + message: error instanceof Error ? error.message : String(error), + }, + ); + } + } + + return null; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn( diff --git a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts index d1f4e538d76..92447405254 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts @@ -35,6 +35,7 @@ interface TrackingStatus { } const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024; +const MAX_UNTRACKED_LINE_COUNT_FILES = 200; const WORKER_DEBUG = process.env.SUPERSET_WORKER_DEBUG === "1"; function logWorkerWarning(message: string, error: unknown): void { @@ -74,6 +75,8 @@ async function applyUntrackedLineCount( worktreePath: string, untracked: ChangedFile[], ): Promise { + if (untracked.length > MAX_UNTRACKED_LINE_COUNT_FILES) return; + let worktreeReal: string; try { worktreeReal = await realpath(worktreePath); diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 4dde4b6e196..207abb6ebf7 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -21,6 +21,30 @@ const ExternalAppSchema = z.enum(EXTERNAL_APPS); const nonEditorSet = new Set(NON_EDITOR_APPS); +function isMissingExternalAppError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.message.includes("Unable to find application named") || + error.message.includes("Ensure the application is installed.") + ); +} + +function normalizeOpenInAppError(error: unknown): never { + if (isMissingExternalAppError(error)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Requested application is not available", + }); + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: error instanceof Error ? error.message : "Unknown error", + }); +} + /** Sets the global default editor if one hasn't been set yet. Skips non-editor apps. */ function ensureGlobalDefaultEditor(app: ExternalApp) { if (nonEditorSet.has(app)) return; @@ -80,7 +104,10 @@ async function openPathInApp( throw lastError; } - await shell.openPath(filePath); + const openError = await shell.openPath(filePath); + if (openError) { + throw new Error(openError); + } } /** @@ -118,7 +145,11 @@ export const createExternalRouter = () => { }), ) .mutation(async ({ input }) => { - await openPathInApp(input.path, input.app); + try { + await openPathInApp(input.path, input.app); + } catch (error) { + normalizeOpenInAppError(error); + } // Persist defaults only after successful launch if (input.projectId) { @@ -175,11 +206,18 @@ export const createExternalRouter = () => { // No preferred editor configured yet. // Fall back to OS default file handler so Cmd/Ctrl+click still works // even when Cursor (or any specific editor) isn't installed. - await shell.openPath(filePath); + const openError = await shell.openPath(filePath); + if (openError) { + throw new Error(openError); + } return; } - await openPathInApp(filePath, app); + try { + await openPathInApp(filePath, app); + } catch (error) { + normalizeOpenInAppError(error); + } }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index d4884419dec..91d2ce5dddb 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import type { GitHubStatus } from "@superset/local-db"; import { workspaces, worktrees } from "@superset/local-db"; +import { TRPCError } from "@trpc/server"; import { and, eq, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; @@ -19,18 +20,24 @@ import { refreshDefaultBranch, } from "../utils/git"; import { + clearGitHubCachesForWorktree, + extractNwoFromUrl, fetchCheckJobSteps, fetchGitHubPRComments, fetchGitHubPRStatus, type PullRequestCommentsTarget, } from "../utils/github"; +import { GHIdentityCandidatesResponseSchema } from "../utils/github/types"; +import { execWithShellEnv } from "../utils/shell-env"; const gitHubPRCommentsInputSchema = z.object({ workspaceId: z.string(), prNumber: z.number().int().positive().optional(), + prUrl: z.string().optional(), repoUrl: z.string().optional(), upstreamUrl: z.string().optional(), isFork: z.boolean().optional(), + forceFresh: z.boolean().optional(), }); function resolveCommentsPullRequestTarget({ @@ -58,6 +65,7 @@ function resolveCommentsPullRequestTarget({ return { prNumber, + prUrl: input.prUrl ?? githubStatus?.pr?.url, repoContext: { repoUrl, upstreamUrl, @@ -82,7 +90,7 @@ function hasMeaningfulGitHubStatusChange({ next, }: { current: GitHubStatus | null | undefined; - next: GitHubStatus; + next: GitHubStatus | null; }): boolean { return ( JSON.stringify(stripGitHubStatusTimestamp(current)) !== @@ -90,6 +98,276 @@ function hasMeaningfulGitHubStatusChange({ ); } +function resolveRepoPathForWorkspace(workspaceId: string): { + workspace: NonNullable>; + worktree: NonNullable> | null; + repoPath: string; +} { + const workspace = getWorkspace(workspaceId); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace ${workspaceId} not found`, + }); + } + + const worktree = workspace.worktreeId + ? (getWorktree(workspace.worktreeId) ?? null) + : null; + let repoPath: string | null = worktree?.path ?? null; + if (!repoPath && workspace.type === "branch") { + const project = getProject(workspace.projectId); + repoPath = project?.mainRepoPath ?? null; + } + + if (!repoPath) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "GitHub is not available for this workspace.", + }); + } + + return { workspace, worktree, repoPath }; +} + +async function getFreshPullRequestForWorkspace(workspaceId: string): Promise<{ + repoPath: string; + worktree: NonNullable> | null; + pullRequest: NonNullable; +}> { + const { repoPath, worktree } = resolveRepoPathForWorkspace(workspaceId); + clearGitHubCachesForWorktree(repoPath); + const githubStatus = await fetchGitHubPRStatus(repoPath); + const pullRequest = githubStatus?.pr ?? null; + + if (!pullRequest) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "No pull request found for this workspace.", + }); + } + + return { repoPath, worktree, pullRequest }; +} + +function resolvePullRequestTarget({ + workspaceId, + pullRequestNumber, + pullRequestUrl, +}: { + workspaceId: string; + pullRequestNumber?: number; + pullRequestUrl?: string; +}): { + repoPath: string; + worktree: NonNullable> | null; + repoNameWithOwner: string; + pullRequestNumber: number; +} { + const { repoPath, worktree } = resolveRepoPathForWorkspace(workspaceId); + const repoNameWithOwner = pullRequestUrl + ? extractNwoFromUrl(pullRequestUrl) + : null; + + if (!repoNameWithOwner || !pullRequestNumber) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Could not determine the pull request target.", + }); + } + + return { + repoPath, + worktree, + repoNameWithOwner, + pullRequestNumber, + }; +} + +function resolvePullRequestRepoTarget({ + workspaceId, + pullRequestUrl, +}: { + workspaceId: string; + pullRequestUrl?: string; +}): { + repoPath: string; + worktree: NonNullable> | null; + repoNameWithOwner: string; +} { + const { repoPath, worktree } = resolveRepoPathForWorkspace(workspaceId); + const repoNameWithOwner = pullRequestUrl + ? extractNwoFromUrl(pullRequestUrl) + : null; + + if (!repoNameWithOwner) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Could not determine the pull request repository.", + }); + } + + return { + repoPath, + worktree, + repoNameWithOwner, + }; +} + +function normalizeIdentityList(values: string[]): string[] { + return Array.from( + new Set(values.map((value) => value.trim()).filter(Boolean)), + ); +} + +async function updatePullRequestMembers({ + workspaceId, + kind, + add, + remove, + pullRequestNumber, + pullRequestUrl, +}: { + workspaceId: string; + kind: "reviewer" | "assignee"; + add: string[]; + remove: string[]; + pullRequestNumber?: number; + pullRequestUrl?: string; +}): Promise<{ success: true }> { + const normalizedAdd = normalizeIdentityList(add); + const normalizedRemove = normalizeIdentityList(remove); + + if (normalizedAdd.length === 0 && normalizedRemove.length === 0) { + return { success: true }; + } + + const { + repoPath, + worktree, + repoNameWithOwner, + pullRequestNumber: resolvedPr, + } = resolvePullRequestTarget({ + workspaceId, + pullRequestNumber, + pullRequestUrl, + }); + + const args = ["pr", "edit", String(resolvedPr), "--repo", repoNameWithOwner]; + + if (normalizedAdd.length > 0) { + args.push( + kind === "reviewer" ? "--add-reviewer" : "--add-assignee", + normalizedAdd.join(","), + ); + } + + if (normalizedRemove.length > 0) { + args.push( + kind === "reviewer" ? "--remove-reviewer" : "--remove-assignee", + normalizedRemove.join(","), + ); + } + + await execWithShellEnv("gh", args, { cwd: repoPath }); + clearGitHubCachesForWorktree(repoPath); + + if (worktree) { + localDb + .update(worktrees) + .set({ githubStatus: null }) + .where(eq(worktrees.id, worktree.id)) + .run(); + } + + return { success: true }; +} + +async function getPullRequestIdentityCandidates({ + workspaceId, + kind, + pullRequestUrl, +}: { + workspaceId: string; + kind: "reviewer" | "assignee"; + pullRequestUrl?: string; +}): Promise { + const { repoPath, repoNameWithOwner } = resolvePullRequestRepoTarget({ + workspaceId, + pullRequestUrl, + }); + + const [owner, name] = repoNameWithOwner.split("/"); + if (!owner || !name) { + return []; + } + + const fieldName = + kind === "assignee" ? "assignableUsers" : "mentionableUsers"; + const query = `query PullRequestIdentityCandidates($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + users: ${fieldName}(first: 100, after: $after) { + nodes { + login + } + pageInfo { + hasNextPage + endCursor + } + } + } +}`; + + const logins = new Set(); + let afterCursor: string | null = null; + + while (true) { + const args = [ + "api", + "graphql", + "-f", + `query=${query}`, + "-F", + `owner=${owner}`, + "-F", + `name=${name}`, + ]; + if (afterCursor) { + args.push("-F", `after=${afterCursor}`); + } + + const { stdout } = await execWithShellEnv("gh", args, { cwd: repoPath }); + const raw = JSON.parse(stdout) as unknown; + const parsed = GHIdentityCandidatesResponseSchema.safeParse(raw); + if (!parsed.success) { + console.warn( + "[GitHub] Failed to parse pull request identity candidates:", + parsed.error.message, + ); + break; + } + + const users = parsed.data.data.repository?.users; + if (!users) { + break; + } + + for (const user of users.nodes ?? []) { + if (user?.login) { + logins.add(user.login); + } + } + + if (!users.pageInfo.hasNextPage || !users.pageInfo.endCursor) { + break; + } + + afterCursor = users.pageInfo.endCursor; + } + + return [...logins]; +} + export const createGitStatusProcedures = () => { return router({ refreshGitStatus: publicProcedure @@ -174,7 +452,12 @@ export const createGitStatusProcedures = () => { }), getGitHubStatus: publicProcedure - .input(z.object({ workspaceId: z.string() })) + .input( + z.object({ + workspaceId: z.string(), + forceFresh: z.boolean().optional(), + }), + ) .query(async ({ input }) => { const workspace = getWorkspace(input.workspaceId); if (!workspace) { @@ -196,11 +479,14 @@ export const createGitStatusProcedures = () => { return null; } + if (input.forceFresh) { + clearGitHubCachesForWorktree(repoPath); + } + const freshStatus = await fetchGitHubPRStatus(repoPath); if ( worktree && - freshStatus && hasMeaningfulGitHubStatusChange({ current: worktree.githubStatus, next: freshStatus, @@ -237,6 +523,10 @@ export const createGitStatusProcedures = () => { return []; } + if (input.forceFresh) { + clearGitHubCachesForWorktree(repoPath); + } + const cachedGitHubStatus = worktree?.githubStatus ?? null; return fetchGitHubPRComments({ @@ -248,6 +538,166 @@ export const createGitStatusProcedures = () => { }); }), + getPullRequestIdentityCandidates: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + kind: z.enum(["reviewer", "assignee"]), + pullRequestUrl: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return getPullRequestIdentityCandidates(input); + }), + + setPullRequestDraftState: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + isDraft: z.boolean(), + }), + ) + .mutation(async ({ input }) => { + const { repoPath, worktree, pullRequest } = + await getFreshPullRequestForWorkspace(input.workspaceId); + + const isCurrentlyDraft = pullRequest.state === "draft"; + if (pullRequest.state !== "draft" && pullRequest.state !== "open") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Only open or draft pull requests can be updated from Review.", + }); + } + + if (input.isDraft === isCurrentlyDraft) { + return { success: true }; + } + + const repoNameWithOwner = extractNwoFromUrl(pullRequest.url); + if (!repoNameWithOwner) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Could not determine the pull request repository.", + }); + } + + const args = [ + "pr", + "ready", + String(pullRequest.number), + "--repo", + repoNameWithOwner, + ]; + if (input.isDraft) { + args.push("--undo"); + } + + await execWithShellEnv("gh", args, { cwd: repoPath }); + clearGitHubCachesForWorktree(repoPath); + + if (worktree) { + localDb + .update(worktrees) + .set({ githubStatus: null }) + .where(eq(worktrees.id, worktree.id)) + .run(); + } + + return { success: true }; + }), + + setPullRequestThreadResolution: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + threadId: z.string().min(1), + isResolved: z.boolean(), + }), + ) + .mutation(async ({ input }) => { + const { repoPath, worktree } = resolveRepoPathForWorkspace( + input.workspaceId, + ); + const mutationName = input.isResolved + ? "resolveReviewThread" + : "unresolveReviewThread"; + const mutationQuery = `mutation ${mutationName}($threadId: ID!) { + ${mutationName}(input: { threadId: $threadId }) { + thread { + id + isResolved + } + } +}`; + + await execWithShellEnv( + "gh", + [ + "api", + "graphql", + "-f", + `query=${mutationQuery}`, + "-F", + `threadId=${input.threadId}`, + ], + { cwd: repoPath }, + ); + + clearGitHubCachesForWorktree(repoPath); + if (worktree) { + localDb + .update(worktrees) + .set({ githubStatus: null }) + .where(eq(worktrees.id, worktree.id)) + .run(); + } + + return { success: true }; + }), + + updatePullRequestReviewers: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + add: z.array(z.string()).optional().default([]), + remove: z.array(z.string()).optional().default([]), + pullRequestNumber: z.number().int().positive().optional(), + pullRequestUrl: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + return updatePullRequestMembers({ + workspaceId: input.workspaceId, + kind: "reviewer", + add: input.add, + remove: input.remove, + pullRequestNumber: input.pullRequestNumber, + pullRequestUrl: input.pullRequestUrl, + }); + }), + + updatePullRequestAssignees: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + add: z.array(z.string()).optional().default([]), + remove: z.array(z.string()).optional().default([]), + pullRequestNumber: z.number().int().positive().optional(), + pullRequestUrl: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + return updatePullRequestMembers({ + workspaceId: input.workspaceId, + kind: "assignee", + add: input.add, + remove: input.remove, + pullRequestNumber: input.pullRequestNumber, + pullRequestUrl: input.pullRequestUrl, + }); + }), + getWorktreeInfo: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts index 56c8a333454..e211cfb1239 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts @@ -113,9 +113,11 @@ function getReviewThreadCommentId( function parseReviewThreadCommentNode({ comment, isResolved, + threadId, }: { comment: ReviewThreadCommentNode; isResolved: boolean; + threadId?: string; }): PullRequestComment | null { const id = getReviewThreadCommentId(comment); const body = comment.body?.trim(); @@ -133,6 +135,7 @@ function parseReviewThreadCommentNode({ createdAt: parseTimestamp(comment.createdAt), url: comment.url, kind: "review" as const, + threadId, path: comment.path, line: comment.line ?? comment.originalLine ?? undefined, isResolved, @@ -164,9 +167,11 @@ export function parsePaginatedApiArray(stdout: string): unknown[] { export function parseReviewThreadCommentsConnection({ comments, isResolved, + threadId, }: { comments: unknown; isResolved: boolean; + threadId?: string; }): PullRequestComment[] { const parsed = GHReviewThreadCommentsConnectionSchema.safeParse(comments); if (!parsed.success) { @@ -182,6 +187,7 @@ export function parseReviewThreadCommentsConnection({ const parsedComment = parseReviewThreadCommentNode({ comment, isResolved, + threadId, }); return parsedComment ? [parsedComment] : []; }) ?? [] @@ -201,6 +207,7 @@ export function parseReviewThreadCommentsResponse( return parseReviewThreadCommentsConnection({ comments: result.data.comments, isResolved: result.data.isResolved === true, + threadId: result.data.id ?? undefined, }); }), ); @@ -360,6 +367,7 @@ async function fetchAdditionalReviewThreadCommentsForThread({ ...parseReviewThreadCommentsConnection({ comments, isResolved, + threadId, }), ); afterCursor = @@ -453,6 +461,7 @@ async function fetchReviewThreadCommentsForPullRequest( ...parseReviewThreadCommentsConnection({ comments: thread.comments, isResolved, + threadId: thread.id ?? undefined, }), ); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index e4fabaa1f6d..3a84b2c8dad 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -27,6 +27,7 @@ import { export interface PullRequestCommentsTarget { prNumber: number; repoContext: Pick; + prUrl?: string | null; } export { clearGitHubCachesForWorktree }; @@ -34,6 +35,13 @@ export { clearGitHubCachesForWorktree }; function getPullRequestCommentsRepoNameWithOwner( target: PullRequestCommentsTarget, ): string | null { + const prRepoNameWithOwner = target.prUrl + ? extractNwoFromUrl(target.prUrl) + : null; + if (prRepoNameWithOwner) { + return prRepoNameWithOwner; + } + const targetUrl = target.repoContext.isFork ? target.repoContext.upstreamUrl : target.repoContext.repoUrl; @@ -75,6 +83,7 @@ async function resolvePullRequestCommentsTarget( return { prNumber: prInfo.number, repoContext, + prUrl: prInfo.url, }; } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts index 90f09953237..6564215c211 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts @@ -1,7 +1,7 @@ import type { CheckItem, GitHubStatus } from "@superset/local-db"; import { execGitWithShellPath } from "../git-client"; import { execWithShellEnv } from "../shell-env"; -import { getPullRequestRepoArgs } from "./repo-context"; +import { getPullRequestRepoNames } from "./repo-context"; import { type GHPRResponse, GHPRResponseSchema, @@ -9,7 +9,17 @@ import { } from "./types"; const PR_JSON_FIELDS = - "number,title,url,state,isDraft,mergedAt,additions,deletions,headRefOid,headRefName,headRepository,headRepositoryOwner,isCrossRepository,reviewDecision,statusCheckRollup,reviewRequests"; + "number,title,url,state,isDraft,mergedAt,additions,deletions,headRefOid,headRefName,headRepository,headRepositoryOwner,isCrossRepository,reviewDecision,statusCheckRollup,reviewRequests,assignees"; + +function getPullRequestRepoArgSets(repoContext?: RepoContext): string[][] { + const repoNames = getPullRequestRepoNames(repoContext); + + if (repoNames.length === 0) { + return [[]]; + } + + return repoNames.map((repoName) => ["--repo", repoName]); +} export async function getPRForBranch( worktreePath: string, @@ -213,29 +223,46 @@ async function findPRByHeadBranch( ): Promise { try { const matches = new Map(); + const repoArgSets = getPullRequestRepoArgSets(repoContext); + + for (const repoArgs of repoArgSets) { + for (const branchCandidate of getPRHeadBranchCandidates(localBranch)) { + let stdout: string; + try { + ({ stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + ...repoArgs, + "--state", + "all", + "--head", + branchCandidate, + "--limit", + "20", + "--json", + PR_JSON_FIELDS, + ], + { cwd: worktreePath }, + )); + } catch (error) { + console.warn( + "[GitHub/findPRByHeadBranch] Failed repo-scoped PR lookup:", + { + worktreePath, + repoArgs, + branchCandidate, + message: error instanceof Error ? error.message : String(error), + }, + ); + continue; + } - for (const branchCandidate of getPRHeadBranchCandidates(localBranch)) { - const { stdout } = await execWithShellEnv( - "gh", - [ - "pr", - "list", - ...getPullRequestRepoArgs(repoContext), - "--state", - "all", - "--head", - branchCandidate, - "--limit", - "20", - "--json", - PR_JSON_FIELDS, - ], - { cwd: worktreePath }, - ); - - for (const candidate of parsePRListResponse(stdout)) { - if (shouldAcceptPRMatch({ localBranch, pr: candidate, headSha })) { - matches.set(candidate.number, candidate); + for (const candidate of parsePRListResponse(stdout)) { + if (shouldAcceptPRMatch({ localBranch, pr: candidate, headSha })) { + matches.set(candidate.number, candidate); + } } } } @@ -269,28 +296,46 @@ async function findPRByHeadCommit( return null; } - const { stdout } = await execWithShellEnv( - "gh", - [ - "pr", - "list", - ...getPullRequestRepoArgs(repoContext), - "--state", - "all", - "--search", - `${headSha} is:pr`, - "--limit", - "20", - "--json", - PR_JSON_FIELDS, - ], - { cwd: worktreePath }, - ); + const exactHeadMatches: GHPRResponse[] = []; + for (const repoArgs of getPullRequestRepoArgSets(repoContext)) { + let stdout: string; + try { + ({ stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + ...repoArgs, + "--state", + "all", + "--search", + `${headSha} is:pr`, + "--limit", + "20", + "--json", + PR_JSON_FIELDS, + ], + { cwd: worktreePath }, + )); + } catch (error) { + console.warn( + "[GitHub/findPRByHeadCommit] Failed repo-scoped PR lookup:", + { + worktreePath, + repoArgs, + headSha, + message: error instanceof Error ? error.message : String(error), + }, + ); + continue; + } + + const candidates = parsePRListResponse(stdout); + exactHeadMatches.push( + ...candidates.filter((candidate) => candidate.headRefOid === headSha), + ); + } - const candidates = parsePRListResponse(stdout); - const exactHeadMatches = candidates.filter( - (candidate) => candidate.headRefOid === headSha, - ); const bestMatch = sortPRCandidates(exactHeadMatches, headSha)[0]; if (bestMatch) { return formatPRData(bestMatch); @@ -375,6 +420,7 @@ function formatPRData(data: GHPRResponse): NonNullable { checksStatus: computeChecksStatus(data.statusCheckRollup), checks: parseChecks(data.statusCheckRollup), requestedReviewers: parseReviewRequests(data.reviewRequests), + assignees: parseAssignees(data.assignees), }; } @@ -385,6 +431,11 @@ function parseReviewRequests( return requests.map((r) => r.login || r.slug || r.name || "").filter(Boolean); } +function parseAssignees(assignees: GHPRResponse["assignees"]): string[] { + if (!assignees || assignees.length === 0) return []; + return assignees.map((assignee) => assignee.login || "").filter(Boolean); +} + function mapPRState( state: GHPRResponse["state"], isDraft: boolean, diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/repo-context.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/repo-context.ts index 6091754b7ff..13f705c2452 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/repo-context.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/repo-context.ts @@ -139,13 +139,39 @@ export function normalizeGitHubUrl(remoteUrl: string): string | null { export function extractNwoFromUrl(normalizedUrl: string): string | null { try { - const path = new URL(normalizedUrl).pathname.slice(1); - return path || null; + const segments = new URL(normalizedUrl).pathname.split("/").filter(Boolean); + if (segments.length < 2) { + return null; + } + return `${segments[0]}/${segments[1]}`; } catch { return null; } } +export function getPullRequestRepoNames( + repoContext?: Pick | null, +): string[] { + if (!repoContext) { + return []; + } + + const candidates = [ + repoContext.repoUrl, + repoContext.isFork ? repoContext.upstreamUrl : null, + ]; + + return Array.from( + new Set( + candidates + .map((candidate) => normalizeGitHubUrl(candidate ?? "")) + .filter((candidate): candidate is string => Boolean(candidate)) + .map((candidate) => extractNwoFromUrl(candidate)) + .filter((candidate): candidate is string => Boolean(candidate)), + ), + ); +} + export function getPullRequestRepoArgs( repoContext?: Pick | null, ): string[] { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts index 11804581d36..b72420472de 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts @@ -32,6 +32,10 @@ export const GHReviewRequestSchema = z.object({ type: z.enum(["User", "Team"]).optional(), }); +export const GHUserSchema = z.object({ + login: z.string().optional(), +}); + export const GHCommentAuthorSchema = z.object({ login: z.string().optional(), avatar_url: z.string().optional(), @@ -78,6 +82,21 @@ export const GHPageInfoSchema = z.object({ endCursor: z.string().nullable(), }); +export const GHUsersConnectionSchema = z.object({ + nodes: z.array(GHUserSchema.nullable()).optional(), + pageInfo: GHPageInfoSchema, +}); + +export const GHIdentityCandidatesResponseSchema = z.object({ + data: z.object({ + repository: z + .object({ + users: GHUsersConnectionSchema, + }) + .nullable(), + }), +}); + export const GHReviewThreadCommentsConnectionSchema = z.object({ nodes: z.array(GHReviewThreadCommentSchema.nullable()).optional(), pageInfo: GHPageInfoSchema, @@ -155,6 +174,7 @@ export const GHPRResponseSchema = z.object({ statusCheckRollup: z.array(GHCheckContextSchema).nullable(), comments: z.array(GHCommentSchema).nullable().optional(), reviewRequests: z.array(GHReviewRequestSchema).nullable().optional(), + assignees: z.array(GHUserSchema).nullable().optional(), }); export const GHRepoResponseSchema = z.object({ diff --git a/apps/desktop/src/main/lib/app-state/index.ts b/apps/desktop/src/main/lib/app-state/index.ts index 00e9fe790f5..47f756bbfd2 100644 --- a/apps/desktop/src/main/lib/app-state/index.ts +++ b/apps/desktop/src/main/lib/app-state/index.ts @@ -1,5 +1,8 @@ import { JSONFilePreset } from "lowdb/node"; -import { APP_STATE_PATH } from "../app-environment"; +import { + APP_STATE_PATH, + ensureSupersetHomeDirExists, +} from "../app-environment"; import type { AppState } from "./schemas"; import { defaultAppState } from "./schemas"; @@ -36,6 +39,7 @@ function ensureValidShape(data: Partial): AppState { export async function initAppState(): Promise { if (_appState) return; + ensureSupersetHomeDirExists(); _appState = await JSONFilePreset(APP_STATE_PATH, defaultAppState); // Reshape data to ensure it has the correct structure (handles legacy formats) diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 7c335cf516d..334b77f05c4 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -1308,6 +1308,13 @@ export class TerminalHostClient extends EventEmitter { }); } + private isCreateOrAttachTimeoutError(error: unknown): boolean { + return ( + error instanceof Error && + error.message === "Request timeout: createOrAttach" + ); + } + /** * Send a notification (no pending request / no timeout). * @@ -1374,19 +1381,10 @@ export class TerminalHostClient extends EventEmitter { return `${sessionId}:${requestId}`; } - // =========================================================================== - // Public API - // =========================================================================== - - /** - * Create or attach to a terminal session - */ - async createOrAttach( + private throwIfCreateOrAttachCanceled( request: CreateOrAttachRequest, signal?: AbortSignal, - ): Promise { - throwIfAborted(signal); - await this.ensureConnected(); + ): void { throwIfAborted(signal); if ( request.requestId && @@ -1399,10 +1397,40 @@ export class TerminalHostClient extends EventEmitter { ) { throw new TerminalAttachCanceledError(); } - const response = await this.sendRequest( - "createOrAttach", - request, - ); + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Create or attach to a terminal session + */ + async createOrAttach( + request: CreateOrAttachRequest, + signal?: AbortSignal, + ): Promise { + this.throwIfCreateOrAttachCanceled(request, signal); + await this.ensureConnected(); + this.throwIfCreateOrAttachCanceled(request, signal); + let response: CreateOrAttachResponse; + try { + response = await this.sendRequest( + "createOrAttach", + request, + ); + } catch (error) { + if (!this.isCreateOrAttachTimeoutError(error)) { + throw error; + } + this.resetConnectionState({ emitDisconnected: false }); + await this.ensureConnected(); + this.throwIfCreateOrAttachCanceled(request, signal); + response = await this.sendRequest( + "createOrAttach", + request, + ); + } // Version skew: older daemons may not return pid - normalize undefined → null return { ...response, pid: response.pid ?? null }; } diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 374cddf3ff7..ccaf3801850 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import * as Sentry from "@sentry/electron/main"; import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; @@ -63,6 +64,18 @@ function getWorkspaceNameFromDb(workspaceId: string | undefined): string { let currentWindow: BrowserWindow | null = null; +function addWindowLifecycleBreadcrumb( + message: string, + data?: Record, +): void { + Sentry.addBreadcrumb({ + category: "window.lifecycle", + level: "info", + message, + data, + }); +} + // Routers receive this getter so they always see the current window, not a stale reference const getWindow = () => currentWindow; @@ -228,9 +241,11 @@ export async function MainWindow() { // macOS Sequoia+: occluded/minimized windows can lose compositor layers if (PLATFORM.IS_MAC) { window.on("restore", () => { + addWindowLifecycleBreadcrumb("main window restored"); window.webContents.invalidate(); }); window.on("show", () => { + addWindowLifecycleBreadcrumb("main window shown"); window.webContents.invalidate(); }); } @@ -303,6 +318,10 @@ export async function MainWindow() { ); window.webContents.on("render-process-gone", (_event, details) => { + addWindowLifecycleBreadcrumb("renderer process gone", { + reason: details.reason, + exitCode: details.exitCode, + }); console.error("[main-window] Renderer process gone:", details); if (window.isDestroyed()) return; @@ -337,6 +356,10 @@ export async function MainWindow() { }); window.on("close", () => { + addWindowLifecycleBreadcrumb("main window closing", { + isDestroyed: window.isDestroyed(), + isVisible: window.isVisible(), + }); // Save window state first, before any cleanup const isMaximized = window.isMaximized(); const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); diff --git a/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx b/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx index 98e5aa69d46..9e606b5e65d 100644 --- a/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx @@ -64,6 +64,7 @@ export function SearchDialog({ { prefix: suggestionPrefix, activeSuggestionRef, deleteSuggestion, + canOpenHistorySuggestions, openHistorySuggestions, } = useTerminalSuggestion({ commandBufferRef, @@ -432,6 +433,8 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { onAcceptWrite: handleSuggestionWrite, onExecuteCommand: handleSuggestionExecute, }); + const canOpenHistorySuggestionsRef = useRef(canOpenHistorySuggestions); + canOpenHistorySuggestionsRef.current = canOpenHistorySuggestions; const openHistorySuggestionsRef = useRef(openHistorySuggestions); openHistorySuggestionsRef.current = openHistorySuggestions; @@ -492,6 +495,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { unregisterPasteCallbackRef, defaultRestartCommandRef, activeSuggestionRef, + canOpenHistorySuggestionsRef, openHistorySuggestionsRef, }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 18455722f4c..596bc540efa 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -324,6 +324,8 @@ export interface KeyboardHandlerOptions { onWrite?: (data: string) => void; /** Ref to active suggestion for history navigation/acceptance */ activeSuggestionRef?: { current: ActiveSuggestionHandle | null }; + /** Whether shell history suggestions should open for the current input */ + canOpenSuggestions?: () => boolean; /** Opens shell history suggestions using the current input as prefix */ onOpenSuggestions?: () => void; } @@ -578,9 +580,9 @@ export function setupKeyboardHandler( suggestion.selectNext?.(); return false; } - if (options.onOpenSuggestions) { + if (options.canOpenSuggestions?.()) { event.preventDefault(); - options.onOpenSuggestions(); + options.onOpenSuggestions?.(); return false; } } @@ -592,9 +594,9 @@ export function setupKeyboardHandler( suggestion.selectPrev?.(); return false; } - if (options.onOpenSuggestions) { + if (options.canOpenSuggestions?.()) { event.preventDefault(); - options.onOpenSuggestions(); + options.onOpenSuggestions?.(); return false; } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index faaecf40735..034319ab923 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -149,6 +149,7 @@ export interface UseTerminalLifecycleOptions { unregisterPasteCallbackRef: MutableRefObject; defaultRestartCommandRef: MutableRefObject; activeSuggestionRef: MutableRefObject; + canOpenHistorySuggestionsRef: MutableRefObject<() => boolean>; openHistorySuggestionsRef: MutableRefObject<() => void>; } @@ -213,6 +214,7 @@ export function useTerminalLifecycle({ unregisterPasteCallbackRef, defaultRestartCommandRef, activeSuggestionRef, + canOpenHistorySuggestionsRef, openHistorySuggestionsRef, }: UseTerminalLifecycleOptions): UseTerminalLifecycleReturn { const [xtermInstance, setXtermInstance] = useState(null); @@ -711,6 +713,7 @@ export function useTerminalLifecycle({ onClear: handleClear, onWrite: handleWrite, activeSuggestionRef, + canOpenSuggestions: () => canOpenHistorySuggestionsRef.current(), onOpenSuggestions: () => openHistorySuggestionsRef.current(), }); const cleanupClickToMove = setupClickToMoveCursor(xterm, { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts index 83cec9093de..9a09beb3efe 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts @@ -23,6 +23,7 @@ export interface UseTerminalSuggestionReturn { prefix: string; activeSuggestionRef: React.MutableRefObject; deleteSuggestion: (cmd: string) => void; + canOpenHistorySuggestions: () => boolean; openHistorySuggestions: () => void; } @@ -122,14 +123,29 @@ export function useTerminalSuggestion({ [hasReceivedPromptMarkerRef, isAlternateScreenRef, isAtPromptRef], ); - const openHistorySuggestions = useCallback(() => { + const canOpenHistorySuggestions = useCallback(() => { const promptBlocked = hasReceivedPromptMarkerRef.current && !isAtPromptRef.current; if (!enabledRef.current || isAlternateScreenRef.current || promptBlocked) { + return false; + } + + const prefix = commandBufferRef.current; + return Boolean(prefix); + }, [ + commandBufferRef, + hasReceivedPromptMarkerRef, + isAlternateScreenRef, + isAtPromptRef, + ]); + + const openHistorySuggestions = useCallback(() => { + if (!canOpenHistorySuggestions()) { return; } const prefix = commandBufferRef.current; + requestTokenRef.current += 1; isOpenRef.current = true; setIsOpen(true); @@ -142,13 +158,7 @@ export function useTerminalSuggestion({ } void fetchSuggestions(prefix); - }, [ - commandBufferRef, - fetchSuggestions, - hasReceivedPromptMarkerRef, - isAlternateScreenRef, - isAtPromptRef, - ]); + }, [canOpenHistorySuggestions, commandBufferRef, fetchSuggestions]); useEffect(() => { const promptBlocked = @@ -170,6 +180,10 @@ export function useTerminalSuggestion({ const id = setInterval(() => { const current = commandBufferRef.current; if (current === lastPrefixRef.current) return; + if (!current) { + dismiss(); + return; + } lastPrefixRef.current = current; setTrackedInput(current); @@ -190,7 +204,7 @@ export function useTerminalSuggestion({ fetchTimerRef.current = null; } }; - }, [commandBufferRef, fetchSuggestions, isOpen]); + }, [commandBufferRef, dismiss, fetchSuggestions, isOpen]); const displaySuggestions = historySuggestions; @@ -310,6 +324,7 @@ export function useTerminalSuggestion({ prefix: trackedInput, activeSuggestionRef, deleteSuggestion, + canOpenHistorySuggestions, openHistorySuggestions, }; } 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 589dd911ea4..3ede1c9b575 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 @@ -1,7 +1,8 @@ +import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; -import { cn } from "@superset/ui/utils"; +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 { getGitHubPRCommentsQueryPolicy, @@ -15,7 +16,10 @@ import { import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useBranchSyncInvalidation } from "renderer/screens/main/hooks/useBranchSyncInvalidation"; import { useGitChangesStatus } from "renderer/screens/main/hooks/useGitChangesStatus"; -import { useChangesStore } from "renderer/stores/changes"; +import { + DEFAULT_DIFFS_PANE_PERCENTAGE, + useChangesStore, +} from "renderer/stores/changes"; import { useTabsStore } from "renderer/stores/tabs/store"; import { pathsMatch, @@ -24,12 +28,12 @@ import { } from "shared/absolute-paths"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import type { FileSystemChangeEvent } from "shared/file-tree-types"; -import { sidebarHeaderTabTriggerClassName } from "../headerTabStyles"; import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; import { DiscardConfirmDialog } from "./components/DiscardConfirmDialog"; import { ReviewPanel } from "./components/ReviewPanel"; +import { VerticalResizablePanels } from "./components/VerticalResizablePanels"; import { useOrderedSections } from "./hooks"; import { getPRActionState, shouldAutoCreatePRAfterPublish } from "./utils"; @@ -45,13 +49,51 @@ interface ChangesViewProps { } const INACTIVE_BRANCH_REFETCH_INTERVAL_MS = 10_000; +const MIN_DIFFS_PANE_HEIGHT = 220; +const MIN_REVIEW_PANE_HEIGHT = 180; interface PendingChangesRefresh { invalidateBranches: boolean; invalidateSelectedFile: boolean; } -type ChangesSidebarTab = "diffs" | "review"; +function buildGitHubCommentsQueryInput({ + workspaceId, + githubStatus, + forceFresh, +}: { + workspaceId: string; + githubStatus: + | { + pr?: { + number: number; + url: string; + } | null; + repoUrl?: string; + upstreamUrl?: string; + isFork?: boolean; + } + | null + | undefined; + forceFresh?: boolean; +}) { + if (!githubStatus?.pr) { + return { + workspaceId, + ...(forceFresh ? { forceFresh: true } : {}), + }; + } + + return { + workspaceId, + prNumber: githubStatus.pr.number, + prUrl: githubStatus.pr.url, + repoUrl: githubStatus.repoUrl, + upstreamUrl: githubStatus.upstreamUrl, + isFork: githubStatus.isFork, + ...(forceFresh ? { forceFresh: true } : {}), + }; +} function eventTargetsSelectedFile( event: FileSystemChangeEvent, @@ -111,14 +153,13 @@ export function ChangesView({ ) : false, ); - const activeTab = useChangesStore((s) => s.activeTab); - const isReviewTabActive = isActive && activeTab === "review"; + const isReviewVisible = isActive; const githubStatusQueryPolicy = getGitHubStatusQueryPolicy( "changes-sidebar", { hasWorkspaceId: !!workspaceId, isActive, - isReviewTabActive, + isReviewTabActive: isReviewVisible, }, ); @@ -299,7 +340,7 @@ export function ChangesView({ hasWorkspaceId: !!workspaceId, hasActivePullRequest: !!activePullRequest, isActive, - isReviewTabActive, + isReviewTabActive: isReviewVisible, }); const refreshTimerRef = useRef | null>(null); const pendingRefreshRef = useRef({ @@ -311,19 +352,13 @@ export function ChangesView({ isLoading: isGitHubCommentsLoading, refetch: refetchGitHubComments, } = electronTrpc.workspaces.getGitHubPRComments.useQuery( - { + buildGitHubCommentsQueryInput({ workspaceId: workspaceId ?? "", - ...(activePullRequest - ? { - prNumber: activePullRequest.number, - repoUrl: githubStatus?.repoUrl, - upstreamUrl: githubStatus?.upstreamUrl, - isFork: githubStatus?.isFork, - } - : {}), - }, + githubStatus, + }), githubPRCommentsQueryPolicy, ); + const [isReviewRefreshing, setIsReviewRefreshing] = useState(false); useBranchSyncInvalidation({ gitBranch: status?.branch ?? branchData?.currentBranch ?? undefined, @@ -339,6 +374,48 @@ export function ChangesView({ } }; + const handleReviewRefresh = async (scope: "full" | "status" = "full") => { + if (!workspaceId || isReviewRefreshing) { + return; + } + + setIsReviewRefreshing(true); + try { + const freshGitHubStatus = + await trpcUtils.workspaces.getGitHubStatus.fetch({ + workspaceId, + forceFresh: true, + }); + trpcUtils.workspaces.getGitHubStatus.setData( + { workspaceId }, + freshGitHubStatus, + ); + + if (scope === "full") { + const freshComments = + await trpcUtils.workspaces.getGitHubPRComments.fetch( + buildGitHubCommentsQueryInput({ + workspaceId, + githubStatus: freshGitHubStatus, + forceFresh: true, + }), + ); + trpcUtils.workspaces.getGitHubPRComments.setData( + buildGitHubCommentsQueryInput({ + workspaceId, + githubStatus: freshGitHubStatus, + }), + freshComments, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + toast.error(`Failed to refresh review: ${message}`); + } finally { + setIsReviewRefreshing(false); + } + }; + const handleDiscard = (file: ChangedFile) => { if (!worktreePath) return; if (file.status === "untracked" || file.status === "added") { @@ -356,11 +433,12 @@ export function ChangesView({ const { expandedSections, + diffsPanePercentage, fileListViewMode, sectionOrder, selectFile, getSelectedFile, - setActiveTab, + setDiffsPanePercentage, toggleSection, moveSection, setFileListViewMode, @@ -759,151 +837,176 @@ export function ChangesView({ return (
- setActiveTab(value as ChangesSidebarTab)} - className="flex flex-1 min-h-0 flex-col gap-0" - > -
- - + +
Diffs {againstMainCount} - - +
+
+ stashMutation.mutate({ worktreePath })} + onStashAsync={async () => { + await stashMutation.mutateAsync({ worktreePath }); + }} + onStashIncludeUntracked={() => + stashIncludeUntrackedMutation.mutate({ worktreePath }) + } + onStashIncludeUntrackedAsync={async () => { + await stashIncludeUntrackedMutation.mutateAsync({ + worktreePath, + }); + }} + onStashPop={() => stashPopMutation.mutate({ worktreePath })} + isStashPending={ + stashMutation.isPending || + stashIncludeUntrackedMutation.isPending || + stashPopMutation.isPending + } + onGenerateCommitMessage={() => + generateCommitMessageMutation.mutate({ worktreePath }) + } + isGeneratingCommitMessage={ + generateCommitMessageMutation.isPending + } + hasUncommittedChanges={ + stagedFiles.length > 0 || + unstagedFiles.length > 0 || + untrackedFiles.length > 0 + } + hasUntrackedFiles={untrackedFiles.length > 0} + hasConflictedFiles={conflictedFiles.length > 0} + isGitGraphOpen={isGitGraphOpen} + onToggleGitGraph={() => { + if (workspaceId && worktreePath) { + addGitGraphTab(workspaceId, worktreePath); + } + }} + /> +
+
+ +
+ + {!hasChanges ? ( +
+ No changes detected +
+ ) : ( +
+ {orderedSections + .filter((section) => section.count > 0) + .map((section) => ( + + {section.content} + + ))} +
)} - > +
+
+ } + bottom={ +
+
Review {reviewCommentCount} {activePullRequest ? ( ) : null} - - -
- - -
- stashMutation.mutate({ worktreePath })} - onStashIncludeUntracked={() => - stashIncludeUntrackedMutation.mutate({ worktreePath }) - } - onStashPop={() => stashPopMutation.mutate({ worktreePath })} - isStashPending={ - stashMutation.isPending || - stashIncludeUntrackedMutation.isPending || - stashPopMutation.isPending - } - onGenerateCommitMessage={() => - generateCommitMessageMutation.mutate({ worktreePath }) - } - isGeneratingCommitMessage={ - generateCommitMessageMutation.isPending - } - hasUncommittedChanges={ - stagedFiles.length > 0 || - unstagedFiles.length > 0 || - untrackedFiles.length > 0 - } - isGitGraphOpen={isGitGraphOpen} - onToggleGitGraph={() => { - if (workspaceId && worktreePath) { - addGitGraphTab(workspaceId, worktreePath); - } - }} - /> -
-
- -
- - {!hasChanges ? ( -
- No changes detected -
- ) : ( -
- {orderedSections - .filter((section) => section.count > 0) - .map((section) => ( - + + + + + Refresh review + + +
+
+
- )} -
- - - - - +
+ } + /> void; showViewModeToggle?: boolean; worktreePath: string; + currentBranch?: string | null; pr: GitHubStatus["pr"] | null; isPRStatusLoading: boolean; canCreatePR: boolean; createPRBlockedReason: string | null; onStash: () => void; + onStashAsync: () => Promise; onStashIncludeUntracked: () => void; + onStashIncludeUntrackedAsync: () => Promise; onStashPop: () => void; isStashPending: boolean; onGenerateCommitMessage: () => void; isGeneratingCommitMessage: boolean; hasUncommittedChanges: boolean; + hasUntrackedFiles: boolean; + hasConflictedFiles: boolean; isGitGraphOpen: boolean; onToggleGitGraph: () => void; } @@ -115,6 +115,46 @@ interface SearchableRefItem { checkedOutPath: string | null; } +function isCheckedOutElsewhereMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes("already checked out") || + normalized.includes("already used by worktree") + ); +} + +function isGitBusyMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes("could not lock") || + normalized.includes("unable to lock") || + (normalized.includes(".lock") && normalized.includes("file exists")) + ); +} + +function isReferenceMissingMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + (normalized.includes("pathspec") && normalized.includes("did not match")) || + normalized.includes("invalid reference") || + normalized.includes("unknown revision") || + normalized.includes("not a valid object name") || + normalized.includes("cannot be resolved to branch") + ); +} + +function isOverwriteConflictMessage(message: string): boolean { + return ( + message.includes("overwritten") || + message.includes("conflict") || + message.includes("Please commit") || + message.includes("would be overwritten") || + message.includes("上書き") || + message.includes("コミット") || + message.includes("スタッシュ") + ); +} + function getSearchableRefIcon(ref: Pick) { if (ref.kind === "tag") { return ; @@ -263,11 +303,23 @@ function BranchRefCommandItem({ function CurrentBranchSelector({ worktreePath, + currentBranch, hasUncommittedChanges, + hasUntrackedFiles, + hasConflictedFiles, + isStashPending, + onStashAsync, + onStashIncludeUntrackedAsync, onRefresh, }: { worktreePath: string; + currentBranch?: string | null; hasUncommittedChanges: boolean; + hasUntrackedFiles: boolean; + hasConflictedFiles: boolean; + isStashPending: boolean; + onStashAsync: () => Promise; + onStashIncludeUntrackedAsync: () => Promise; onRefresh: () => void; }) { const [open, setOpen] = useState(false); @@ -275,7 +327,8 @@ function CurrentBranchSelector({ const [mode, setMode] = useState("default"); const [selectedStartPoint, setSelectedStartPoint] = useState(null); - const [pendingBranch, setPendingBranch] = useState(null); + const [dialogState, setDialogState] = + useState(null); const utils = electronTrpc.useUtils(); const { data: branchData, isLoading } = electronTrpc.changes.getBranches.useQuery( @@ -286,6 +339,15 @@ function CurrentBranchSelector({ refetchOnWindowFocus: false, }, ); + const { data: branchGuardState } = + electronTrpc.changes.getBranchGuardState.useQuery( + { worktreePath }, + { + enabled: !!worktreePath, + staleTime: 2_000, + refetchOnWindowFocus: false, + }, + ); const { data: refSearchData, isLoading: isRefSearchLoading } = electronTrpc.changes.searchRefs.useQuery( { @@ -311,40 +373,146 @@ function CurrentBranchSelector({ onRefresh(); }; + type BranchActionTarget = + | { + action: "switch"; + branch: string; + } + | { + action: "create-from-ref"; + branch: string; + startPointRef: string; + startPointDisplayName: string | null; + }; + + const openDirtyActionDialog = ( + kind: "dirty-uncommitted" | "dirty-untracked" | "conflicted", + target: BranchActionTarget, + ) => { + setDialogState({ kind, target }); + setOpen(false); + }; + + const openOperationDialog = ( + target: BranchActionTarget, + operation?: BranchProgressOperation | null, + ) => { + setDialogState({ + kind: "operation-in-progress", + target, + operation: operation ?? null, + }); + setOpen(false); + }; + + const resetSelectorState = () => { + setOpen(false); + setSearch(""); + setMode("default"); + setSelectedStartPoint(null); + }; + + const runTargetAction = (target: BranchActionTarget) => { + setDialogState(null); + if (target.action === "switch") { + switchBranch.mutate({ worktreePath, branch: target.branch }); + resetSelectorState(); + return; + } + + createBranch.mutate({ + worktreePath, + branch: target.branch, + startPoint: target.startPointRef, + }); + resetSelectorState(); + }; + + const handleStashFailure = (target: BranchActionTarget, error: unknown) => { + const message = + error instanceof Error + ? error.message + : "stash に失敗したため、branch 操作を続けられませんでした。"; + setDialogState({ + kind: "stash-failed", + target, + message, + }); + }; + const switchBranch = electronTrpc.changes.switchBranch.useMutation({ onSuccess: () => { invalidateBranchQueries(); }, - onError: (error) => { + onError: (error, variables) => { const msg = error.message ?? ""; - // Check for uncommitted changes conflict in multiple languages - // (git error messages vary by LANG environment variable) - const isUncommittedConflict = - msg.includes("overwritten") || - msg.includes("conflict") || - msg.includes("Please commit") || - msg.includes("would be overwritten") || - // Japanese git messages - msg.includes("上書き") || - msg.includes("コミット") || - msg.includes("スタッシュ"); - if (isUncommittedConflict) { - toast.error( - "Could not switch branch. Your uncommitted changes conflict with the target branch. Please commit or stash your changes and try again.", - ); - } else { - toast.error(`Failed to switch branch: ${msg}`); + const target = { + action: "switch" as const, + branch: variables.branch, + }; + if (isCheckedOutElsewhereMessage(msg)) { + setDialogState({ + kind: "checked-out-elsewhere", + target, + }); + return; } + if (isGitBusyMessage(msg)) { + setDialogState({ + kind: "git-busy", + target, + message: msg, + }); + return; + } + if (isReferenceMissingMessage(msg)) { + setDialogState({ + kind: "reference-missing", + target, + }); + return; + } + if (isOverwriteConflictMessage(msg)) { + setDialogState({ + kind: hasUntrackedFiles ? "dirty-untracked" : "dirty-uncommitted", + target, + }); + return; + } + toast.error(`Failed to switch branch: ${msg}`); }, }); const createBranch = electronTrpc.changes.createBranch.useMutation({ onSuccess: () => { invalidateBranchQueries(); }, - onError: (error) => { - toast.error( - `Failed to create branch: ${error.message ?? "Unknown error"}`, - ); + onError: (error, variables) => { + const message = error.message ?? "Unknown error"; + const target = + variables.startPoint == null + ? null + : { + action: "create-from-ref" as const, + branch: variables.branch, + startPointRef: variables.startPoint, + startPointDisplayName: selectedStartPoint?.displayName ?? null, + }; + if (target && isGitBusyMessage(message)) { + setDialogState({ + kind: "git-busy", + target, + message, + }); + return; + } + if (target && isReferenceMissingMessage(message)) { + setDialogState({ + kind: "reference-missing", + target, + }); + return; + } + toast.error(`Failed to create branch: ${message}`); }, }); const updateBaseBranch = electronTrpc.changes.updateBaseBranch.useMutation({ @@ -352,13 +520,18 @@ function CurrentBranchSelector({ invalidateBranchQueries(); }, onError: (error) => { + if (error.message?.includes("Could not determine current branch")) { + setDialogState({ kind: "compare-detached-head" }); + return; + } toast.error( `Failed to update compare branch: ${error.message ?? "Unknown error"}`, ); }, }); - const currentBranch = branchData?.currentBranch ?? null; + const effectiveCurrentBranch = + currentBranch ?? branchData?.currentBranch ?? null; const effectiveBaseBranch = branchData?.worktreeBaseBranch ?? branchData?.defaultBranch ?? "main"; const existingBranchNames = useMemo( @@ -419,34 +592,88 @@ function CurrentBranchSelector({ createBranchName.toLowerCase(), ); - const doSwitch = (branch: string) => { - switchBranch.mutate({ worktreePath, branch }); - resetState(); - }; - const handleBranchSelect = (branch: string) => { - if (branch === currentBranch) { + const target = { + action: "switch" as const, + branch, + }; + if (branch === effectiveCurrentBranch) { setOpen(false); return; } + if (branchGuardState?.operationInProgress) { + openOperationDialog(target, branchGuardState.operationInProgress); + return; + } + if (hasConflictedFiles) { + openDirtyActionDialog("conflicted", target); + return; + } + if (hasUntrackedFiles) { + openDirtyActionDialog("dirty-untracked", target); + return; + } if (hasUncommittedChanges) { - setPendingBranch(branch); - setOpen(false); - } else { - doSwitch(branch); + openDirtyActionDialog("dirty-uncommitted", target); + return; } + runTargetAction(target); }; const handleCreateBranch = () => { if (!createBranchName || isCreateBranchNameTaken) { return; } - createBranch.mutate({ - worktreePath, + const currentBranchCreateTarget = { + action: "create-from-ref" as const, branch: createBranchName, - startPoint: selectedStartPoint?.ref, - }); - resetState(); + startPointRef: effectiveCurrentBranch ?? "HEAD", + startPointDisplayName: effectiveCurrentBranch, + }; + if (branchGuardState?.operationInProgress) { + openOperationDialog( + currentBranchCreateTarget, + branchGuardState.operationInProgress, + ); + return; + } + if (hasConflictedFiles) { + openDirtyActionDialog("conflicted", currentBranchCreateTarget); + return; + } + if (!selectedStartPoint) { + createBranch.mutate({ + worktreePath, + branch: createBranchName, + startPoint: null, + }); + resetSelectorState(); + return; + } + + const target = { + action: "create-from-ref" as const, + branch: createBranchName, + startPointRef: selectedStartPoint.ref, + startPointDisplayName: selectedStartPoint.displayName, + }; + if (branchGuardState?.operationInProgress) { + openOperationDialog(target, branchGuardState.operationInProgress); + return; + } + if (hasConflictedFiles) { + openDirtyActionDialog("conflicted", target); + return; + } + if (hasUntrackedFiles) { + openDirtyActionDialog("dirty-untracked", target); + return; + } + if (hasUncommittedChanges) { + openDirtyActionDialog("dirty-uncommitted", target); + return; + } + runTargetAction(target); }; const handleCompareBaseSelect = (branch: string | null) => { @@ -455,15 +682,7 @@ function CurrentBranchSelector({ baseBranch: branch && branch !== branchData?.defaultBranch ? branch : null, }); - resetState(); - }; - - const resetState = () => { - setOpen(false); - setSearch(""); - setMode("default"); - setSelectedStartPoint(null); - setPendingBranch(null); + resetSelectorState(); }; const canCreateBranch = @@ -498,6 +717,11 @@ function CurrentBranchSelector({ { + if (!effectiveCurrentBranch) { + setDialogState({ kind: "compare-detached-head" }); + setOpen(false); + return; + } setMode("compare-base"); setSearch(""); }} @@ -510,7 +734,7 @@ function CurrentBranchSelector({ {localBranchResults.length > 0 ? ( {localBranchResults.map((branch) => { - const isCurrent = branch.name === currentBranch; + const isCurrent = branch.name === effectiveCurrentBranch; const checkedOutPath = branch.checkedOutPath; const isDisabled = !!checkedOutPath && !isCurrent; @@ -532,14 +756,25 @@ function CurrentBranchSelector({ ) : null} {remoteBranchResults.length > 0 ? ( - {remoteBranchResults.map((branch) => ( - handleBranchSelect(branch.name)} - isDefault={branch.name === branchData?.defaultBranch} - /> - ))} + {remoteBranchResults.map((branch) => { + const isCurrent = branch.name === effectiveCurrentBranch; + const checkedOutPath = branch.checkedOutPath; + const isDisabled = !!checkedOutPath && !isCurrent; + + return ( + handleBranchSelect(branch.name)} + isCurrent={isCurrent} + isDefault={branch.name === branchData?.defaultBranch} + isDisabled={isDisabled} + statusLabel={ + checkedOutPath && !isCurrent ? "checked out" : null + } + /> + ); + })} ) : null} @@ -732,7 +967,7 @@ function CurrentBranchSelector({ @@ -768,36 +1003,56 @@ function CurrentBranchSelector({ - { - if (!open) setPendingBranch(null); + { + if (!nextOpen) { + setDialogState(null); + } }} - > - - - You have uncommitted changes - - Switching to{" "} - {pendingBranch} may - cause your uncommitted changes to be lost. -
-
- If you want to keep your changes, please commit or stash them - first. -
-
- - Cancel - pendingBranch && doSwitch(pendingBranch)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Switch anyway - - -
-
+ onContinueWithoutStash={() => { + if (dialogState?.target) { + runTargetAction(dialogState.target); + } + }} + onStashTrackedAndContinue={() => { + if (!dialogState?.target) { + return; + } + void onStashAsync() + .then(() => { + runTargetAction(dialogState.target as BranchActionTarget); + }) + .catch((error) => { + handleStashFailure( + dialogState.target as BranchActionTarget, + error, + ); + }); + }} + onStashAllAndContinue={() => { + if (!dialogState?.target) { + return; + } + void onStashIncludeUntrackedAsync() + .then(() => { + runTargetAction(dialogState.target as BranchActionTarget); + }) + .catch((error) => { + handleStashFailure( + dialogState.target as BranchActionTarget, + error, + ); + }); + }} + /> ); } @@ -922,17 +1177,22 @@ export function ChangesHeader({ onViewModeChange, showViewModeToggle = true, worktreePath, + currentBranch, pr, isPRStatusLoading, canCreatePR, createPRBlockedReason, onStash, + onStashAsync, onStashIncludeUntracked, + onStashIncludeUntrackedAsync, onStashPop, isStashPending, onGenerateCommitMessage, isGeneratingCommitMessage, hasUncommittedChanges, + hasUntrackedFiles, + hasConflictedFiles, isGitGraphOpen, onToggleGitGraph, }: ChangesHeaderProps) { @@ -966,7 +1226,13 @@ export function ChangesHeader({
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/BranchActionDialog/BranchActionDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/BranchActionDialog/BranchActionDialog.tsx new file mode 100644 index 00000000000..ec909ffe926 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/BranchActionDialog/BranchActionDialog.tsx @@ -0,0 +1,290 @@ +import { + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialog as BranchAlertDialog, + EnterEnabledAlertDialogContent, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import type { ReactNode } from "react"; +import { HiExclamationTriangle } from "react-icons/hi2"; +import { + LuGitBranch, + LuLock, + LuRefreshCcw, + LuShieldAlert, +} from "react-icons/lu"; + +export type BranchProgressOperation = + | "merge" + | "rebase" + | "cherry-pick" + | "revert" + | "bisect"; + +type BranchActionTarget = + | { + action: "switch"; + branch: string; + } + | { + action: "create-from-ref"; + branch: string; + startPointRef: string; + startPointDisplayName: string | null; + }; + +export interface BranchActionDialogState { + kind: + | "dirty-uncommitted" + | "dirty-untracked" + | "conflicted" + | "operation-in-progress" + | "checked-out-elsewhere" + | "reference-missing" + | "git-busy" + | "stash-failed" + | "compare-detached-head"; + target?: BranchActionTarget; + checkedOutPath?: string | null; + message?: string | null; + operation?: BranchProgressOperation | null; +} + +interface BranchActionDialogProps { + open: boolean; + state: BranchActionDialogState | null; + isPending?: boolean; + onOpenChange: (open: boolean) => void; + onContinueWithoutStash?: () => void; + onStashTrackedAndContinue?: () => void; + onStashAllAndContinue?: () => void; +} + +interface DialogCopy { + title: string; + description: string; + icon: ReactNode; + primaryLabel?: string; + secondaryLabel?: string; +} + +function getActionLabel(target?: BranchActionTarget): string { + if (!target) { + return "この操作"; + } + + if (target.action === "switch") { + return `「${target.branch}」へ切り替える操作`; + } + + if (target.startPointDisplayName) { + return `「${target.startPointDisplayName}」から「${target.branch}」を作る操作`; + } + + return `「${target.branch}」を作る操作`; +} + +function getOperationLabel(operation?: BranchProgressOperation | null): string { + switch (operation) { + case "merge": + return "merge"; + case "rebase": + return "rebase"; + case "cherry-pick": + return "cherry-pick"; + case "revert": + return "revert"; + case "bisect": + return "bisect"; + default: + return "Git"; + } +} + +function getDialogCopy(state: BranchActionDialogState | null): DialogCopy { + if (!state) { + return { + title: "", + description: "", + icon: null, + }; + } + + const actionLabel = getActionLabel(state.target); + + switch (state.kind) { + case "dirty-untracked": + return { + title: "未追跡ファイルがあります", + description: `${actionLabel}の前に、新規ファイルを退避する必要があります。未追跡ファイルも含めて stash するか、そのまま続けて Git の判定に任せてください。`, + icon: , + primaryLabel: + state.target?.action === "create-from-ref" + ? "stash して作成" + : "stash して切り替える", + secondaryLabel: + state.target?.action === "create-from-ref" + ? "そのまま作成する" + : "そのまま切り替える", + }; + case "dirty-uncommitted": + return { + title: "未コミットの変更があります", + description: `${actionLabel}の前に、いまの変更を退避できます。変更を残したい場合は stash してから続けてください。`, + icon: , + primaryLabel: + state.target?.action === "create-from-ref" + ? "stash して作成" + : "stash して切り替える", + secondaryLabel: + state.target?.action === "create-from-ref" + ? "そのまま作成する" + : "そのまま切り替える", + }; + case "conflicted": + return { + title: "競合を解決してからやり直してください", + description: + "この workspace には未解決の conflict があります。先に競合を解決しないと、branch の切り替えや別 ref からの作成は安全に進められません。", + icon: , + }; + case "operation-in-progress": + return { + title: `${getOperationLabel(state.operation)} を完了してからやり直してください`, + description: + "別の Git 操作が途中です。先にその操作を完了または中止してから、もう一度 branch 操作を行ってください。", + icon: , + }; + case "checked-out-elsewhere": + return { + title: "このブランチは別の workspace で開かれています", + description: state.checkedOutPath + ? `「${state.target?.action === "switch" ? state.target.branch : ""}」は別の workspace で checkout 済みです。\n${state.checkedOutPath}` + : "同じブランチは複数の worktree で同時に checkout できません。", + icon: , + }; + case "reference-missing": + return { + title: "選んだ参照が見つかりません", + description: + "ブランチ一覧を開いてからの間に、対象の branch や ref が削除された可能性があります。一覧を開き直して、最新の状態から選び直してください。", + icon: , + }; + case "git-busy": + return { + title: "別の Git 操作が進行中です", + description: + state.message ?? + "repository が一時的にロックされています。少し待ってから再試行してください。", + icon: , + }; + case "stash-failed": + return { + title: "変更の退避に失敗しました", + description: + state.message ?? + "stash に失敗したため、このまま branch 操作を続けられません。手動で変更を整理してからやり直してください。", + icon: , + }; + case "compare-detached-head": + return { + title: "compare branch は変更できません", + description: + "現在は detached HEAD のため、どの branch の設定として保存するか決められません。先に branch を checkout してから変更してください。", + icon: , + }; + default: + return { + title: "", + description: "", + icon: null, + }; + } +} + +export function BranchActionDialog({ + open, + state, + isPending = false, + onOpenChange, + onContinueWithoutStash, + onStashTrackedAndContinue, + onStashAllAndContinue, +}: BranchActionDialogProps) { + const copy = getDialogCopy(state); + const isDirtyUncommitted = state?.kind === "dirty-uncommitted"; + const isDirtyUntracked = state?.kind === "dirty-untracked"; + + return ( + + + +
+ {copy.icon} +
+ + {copy.title} + + + {copy.description} + +
+ + + {isDirtyUncommitted && onContinueWithoutStash ? ( + + ) : null} + {isDirtyUntracked && onContinueWithoutStash ? ( + + ) : null} + {isDirtyUncommitted && onStashTrackedAndContinue ? ( + + ) : null} + {isDirtyUntracked && onStashAllAndContinue ? ( + + ) : null} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/BranchActionDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/BranchActionDialog/index.ts new file mode 100644 index 00000000000..4f029fb7e3e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/components/BranchActionDialog/index.ts @@ -0,0 +1,5 @@ +export { + BranchActionDialog, + type BranchActionDialogState, + type BranchProgressOperation, +} from "./BranchActionDialog"; 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 515a45bc29b..5668c30ff51 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 @@ -1,10 +1,19 @@ import type { GitHubStatus, PullRequestComment } from "@superset/local-db"; import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { Button } from "@superset/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@superset/ui/collapsible"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Skeleton } from "@superset/ui/skeleton"; import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; @@ -15,6 +24,8 @@ import { LuChevronDown, LuCode, LuCopy, + LuLoaderCircle, + LuX, } from "react-icons/lu"; import { VscChevronRight } from "react-icons/vsc"; import ReactMarkdown from "react-markdown"; @@ -81,12 +92,33 @@ const CommentBody = memo(function CommentBody({ ); }); +function buildIdentitySummary(items: string[]): string { + if (items.length === 0) { + return "None"; + } + + if (items.length <= 2) { + return items.join(", "); + } + + return `${items.slice(0, 2).join(", ")} +${items.length - 2}`; +} + interface ReviewPanelProps { pr: GitHubStatus["pr"] | null; comments?: PullRequestComment[]; isLoading?: boolean; isCommentsLoading?: boolean; + commentsQueryInput?: { + workspaceId: string; + prNumber?: number; + prUrl?: string; + repoUrl?: string; + upstreamUrl?: string; + isFork?: boolean; + }; onOpenFile?: (path: string, line?: number) => void; + onRefreshReview?: (scope?: "full" | "status") => Promise; } export function ReviewPanel({ @@ -94,9 +126,12 @@ export function ReviewPanel({ comments = [], isLoading = false, isCommentsLoading = false, + commentsQueryInput, onOpenFile, + onRefreshReview, }: ReviewPanelProps) { const resolvedWorkspaceId = useWorkspaceId(); + const trpcUtils = electronTrpc.useUtils(); const addBrowserTab = useTabsStore((s) => s.addBrowserTab); const handleOpenUrl = useCallback( (url: string, e: React.MouseEvent) => { @@ -118,10 +153,47 @@ export function ReviewPanel({ const [expandedComments, setExpandedComments] = useState>( new Set(), ); + const [reviewerSearch, setReviewerSearch] = useState(""); + const [assigneeSearch, setAssigneeSearch] = useState(""); + const [pendingThreadId, setPendingThreadId] = useState(null); + const [isDraftTogglePending, setIsDraftTogglePending] = useState(false); + const [identityPopoverOpen, setIdentityPopoverOpen] = useState< + "reviewers" | "assignees" | null + >(null); + const [pendingIdentityGroup, setPendingIdentityGroup] = useState< + "reviewers" | "assignees" | null + >(null); const copiedActionResetTimeoutRef = useRef | null>(null); const copyToClipboardMutation = electronTrpc.external.copyText.useMutation(); + const setPullRequestDraftStateMutation = + electronTrpc.workspaces.setPullRequestDraftState.useMutation(); + const setPullRequestThreadResolutionMutation = + electronTrpc.workspaces.setPullRequestThreadResolution.useMutation(); + const updatePullRequestReviewersMutation = + electronTrpc.workspaces.updatePullRequestReviewers.useMutation(); + const updatePullRequestAssigneesMutation = + electronTrpc.workspaces.updatePullRequestAssignees.useMutation(); + const candidateKind = + identityPopoverOpen === "assignees" ? "assignee" : "reviewer"; + const canEditPullRequest = pr?.state === "open" || pr?.state === "draft"; + const { + data: identityCandidates = [], + isLoading: isIdentityCandidatesLoading, + } = electronTrpc.workspaces.getPullRequestIdentityCandidates.useQuery( + { + workspaceId: resolvedWorkspaceId ?? "", + kind: candidateKind, + pullRequestUrl: pr?.url, + }, + { + enabled: + !!resolvedWorkspaceId && !!identityPopoverOpen && !!canEditPullRequest, + staleTime: 60_000, + refetchOnWindowFocus: false, + }, + ); useEffect(() => { return () => { @@ -169,6 +241,121 @@ export function ReviewPanel({ }); }; + const refreshReview = async (scope: "full" | "status" = "full") => { + if (!onRefreshReview) { + return; + } + + await onRefreshReview(scope); + }; + + const handleToggleDraftState = () => { + if (!resolvedWorkspaceId || !pr) { + return; + } + + const nextIsDraft = pr.state !== "draft"; + const previousState = pr.state; + + setIsDraftTogglePending(true); + trpcUtils.workspaces.getGitHubStatus.setData( + { workspaceId: resolvedWorkspaceId }, + (current) => { + if (!current?.pr) { + return current; + } + + return { + ...current, + pr: { + ...current.pr, + state: nextIsDraft ? "draft" : "open", + }, + }; + }, + ); + + void setPullRequestDraftStateMutation + .mutateAsync({ + workspaceId: resolvedWorkspaceId, + isDraft: nextIsDraft, + }) + .then(() => refreshReview("status")) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown error"; + trpcUtils.workspaces.getGitHubStatus.setData( + { workspaceId: resolvedWorkspaceId }, + (current) => { + if (!current?.pr) { + return current; + } + + return { + ...current, + pr: { + ...current.pr, + state: previousState, + }, + }; + }, + ); + toast.error(`Failed to update pull request: ${message}`); + void refreshReview("status"); + }) + .finally(() => { + setIsDraftTogglePending(false); + }); + }; + + const handleToggleThreadResolution = (comment: PullRequestComment) => { + if (!resolvedWorkspaceId || !comment.threadId) { + return; + } + + const nextResolved = comment.isResolved !== true; + const updateThreadResolutionInCache = (isResolved: boolean) => { + if (!commentsQueryInput) { + return; + } + + trpcUtils.workspaces.getGitHubPRComments.setData( + commentsQueryInput, + (current) => + (current ?? []).map((item) => + item.threadId === comment.threadId + ? { + ...item, + isResolved, + } + : item, + ), + ); + }; + + setPendingThreadId(comment.threadId); + updateThreadResolutionInCache(nextResolved); + void setPullRequestThreadResolutionMutation + .mutateAsync({ + workspaceId: resolvedWorkspaceId, + threadId: comment.threadId, + isResolved: nextResolved, + }) + .then(() => refreshReview("full")) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown error"; + updateThreadResolutionInCache(comment.isResolved === true); + toast.error(`Failed to update conversation: ${message}`); + void refreshReview("full"); + }) + .finally(() => { + setPendingThreadId((current) => + current === comment.threadId ? null : current, + ); + }); + }; + const toggleCheckExpansion = (checkName: string) => { setExpandedChecks((prev) => { const next = new Set(prev); @@ -193,6 +380,89 @@ export function ReviewPanel({ }); }; + const applyOptimisticMemberUpdate = useCallback( + ({ + kind, + add = [], + remove = [], + }: { + kind: "reviewer" | "assignee"; + add?: string[]; + remove?: string[]; + }) => { + if (!resolvedWorkspaceId) { + return; + } + + const normalizedAdd = Array.from( + new Set(add.map((value) => value.trim()).filter(Boolean)), + ); + const normalizedRemove = new Set( + remove.map((value) => value.trim()).filter(Boolean), + ); + + trpcUtils.workspaces.getGitHubStatus.setData( + { workspaceId: resolvedWorkspaceId }, + (current) => { + if (!current?.pr) { + return current; + } + + const existingValues = + kind === "reviewer" + ? (current.pr.requestedReviewers ?? []) + : (current.pr.assignees ?? []); + const nextValues = Array.from( + new Set( + existingValues + .filter((value) => !normalizedRemove.has(value)) + .concat(normalizedAdd), + ), + ); + + return { + ...current, + pr: { + ...current.pr, + ...(kind === "reviewer" + ? { requestedReviewers: nextValues } + : { assignees: nextValues }), + }, + }; + }, + ); + }, + [resolvedWorkspaceId, trpcUtils], + ); + + const restoreOptimisticMemberUpdate = useCallback( + ({ kind, values }: { kind: "reviewer" | "assignee"; values: string[] }) => { + if (!resolvedWorkspaceId) { + return; + } + + trpcUtils.workspaces.getGitHubStatus.setData( + { workspaceId: resolvedWorkspaceId }, + (current) => { + if (!current?.pr) { + return current; + } + + return { + ...current, + pr: { + ...current.pr, + ...(kind === "reviewer" + ? { requestedReviewers: values } + : { assignees: values }), + }, + }; + }, + ); + }, + [resolvedWorkspaceId, trpcUtils], + ); + if (isLoading && !pr) { return (
@@ -210,6 +480,7 @@ export function ReviewPanel({ } const requestedReviewers = pr.requestedReviewers ?? []; + const assignees = pr.assignees ?? []; const relevantChecks = pr.checks.filter( (check) => check.status !== "skipped" && check.status !== "cancelled", @@ -238,9 +509,264 @@ export function ReviewPanel({ }); }; + const updateReviewers = async ({ + add = [], + remove = [], + onSuccess, + }: { + add?: string[]; + remove?: string[]; + onSuccess?: () => void; + }) => { + if (!resolvedWorkspaceId || pendingIdentityGroup === "reviewers") { + return; + } + + const previousReviewers = [...requestedReviewers]; + setPendingIdentityGroup("reviewers"); + try { + applyOptimisticMemberUpdate({ + kind: "reviewer", + add, + remove, + }); + await updatePullRequestReviewersMutation.mutateAsync({ + workspaceId: resolvedWorkspaceId, + add, + remove, + pullRequestNumber: pr?.number, + pullRequestUrl: pr?.url, + }); + onSuccess?.(); + void refreshReview("status"); + } catch (error) { + restoreOptimisticMemberUpdate({ + kind: "reviewer", + values: previousReviewers, + }); + const message = error instanceof Error ? error.message : "Unknown error"; + toast.error(`Failed to update reviewers: ${message}`); + void refreshReview("status"); + } finally { + setPendingIdentityGroup((current) => + current === "reviewers" ? null : current, + ); + } + }; + + const updateAssignees = async ({ + add = [], + remove = [], + onSuccess, + }: { + add?: string[]; + remove?: string[]; + onSuccess?: () => void; + }) => { + if (!resolvedWorkspaceId || pendingIdentityGroup === "assignees") { + return; + } + + const previousAssignees = [...assignees]; + setPendingIdentityGroup("assignees"); + try { + applyOptimisticMemberUpdate({ + kind: "assignee", + add, + remove, + }); + await updatePullRequestAssigneesMutation.mutateAsync({ + workspaceId: resolvedWorkspaceId, + add, + remove, + pullRequestNumber: pr?.number, + pullRequestUrl: pr?.url, + }); + onSuccess?.(); + void refreshReview("status"); + } catch (error) { + restoreOptimisticMemberUpdate({ + kind: "assignee", + values: previousAssignees, + }); + const message = error instanceof Error ? error.message : "Unknown error"; + toast.error(`Failed to update assignees: ${message}`); + void refreshReview("status"); + } finally { + setPendingIdentityGroup((current) => + current === "assignees" ? null : current, + ); + } + }; + + const handleRemoveReviewer = (reviewer: string) => { + setIdentityPopoverOpen(null); + setReviewerSearch(""); + void updateReviewers({ remove: [reviewer] }); + }; + + const handleRemoveAssignee = (assignee: string) => { + setIdentityPopoverOpen(null); + setAssigneeSearch(""); + void updateAssignees({ remove: [assignee] }); + }; + + const handleAddCandidate = ( + pendingGroup: "reviewers" | "assignees", + candidate: string, + ) => { + if (pendingIdentityGroup === pendingGroup) { + return; + } + setIdentityPopoverOpen(null); + if (pendingGroup === "assignees") { + setAssigneeSearch(""); + } else { + setReviewerSearch(""); + } + + if (pendingGroup === "reviewers") { + void updateReviewers({ add: [candidate] }); + return; + } + + void updateAssignees({ add: [candidate] }); + }; + const isActionsUrl = (url?: string) => url ? /\/actions\/runs\/\d+\/job\/\d+/.test(url) : false; + const renderIdentitySection = ({ + label, + items, + onRemove, + pendingGroup, + }: { + label: string; + items: string[]; + onRemove: (value: string) => void; + pendingGroup: "reviewers" | "assignees"; + }) => { + const isPending = pendingIdentityGroup === pendingGroup; + const isOpen = identityPopoverOpen === pendingGroup; + const summary = buildIdentitySummary(items); + const searchValue = + pendingGroup === "assignees" ? assigneeSearch : reviewerSearch; + const existingItems = new Set(items.map((item) => item.toLowerCase())); + const query = searchValue.trim().toLowerCase(); + const filteredCandidates = !isOpen + ? [] + : identityCandidates + .filter((candidate) => !existingItems.has(candidate.toLowerCase())) + .filter((candidate) => + query ? candidate.toLowerCase().includes(query) : true, + ) + .slice(0, 8); + + return ( +
+ { + setIdentityPopoverOpen(nextOpen ? pendingGroup : null); + if (!nextOpen) { + if (pendingGroup === "assignees") { + setAssigneeSearch(""); + } else { + setReviewerSearch(""); + } + } + }} + > + + + + event.stopPropagation()} + > + + + + + {isIdentityCandidatesLoading + ? "Loading..." + : "No candidates found"} + + {items.length > 0 + ? items.map((item) => ( + onRemove(item)} + className="flex items-center justify-between gap-2 text-xs" + > +
+ + {item} +
+ +
+ )) + : null} + {filteredCandidates.map((candidate) => ( + handleAddCandidate(pendingGroup, candidate)} + className="flex items-center justify-between gap-2 text-xs" + > + {candidate} + + + ))} +
+
+
+
+
+ ); + }; + const renderCommentList = (list: PullRequestComment[]) => list.map((comment) => { const age = formatShortAge(comment.createdAt); @@ -277,55 +803,85 @@ export function ReviewPanel({
-
- - {comment.authorLogin} - - - {getCommentKindText(comment)} - - +
+
+ + {comment.authorLogin} + + + {getCommentKindText(comment)} + +
{age ? ( {age} ) : null} + {!isExpanded && ( +

+ {getCommentPreviewText(comment.body)} +

+ )}
- {!isExpanded && ( -

- {getCommentPreviewText(comment.body)} -

- )}
{isExpanded && (
- {hasFileLocation && ( - - )} + {hasFileLocation || comment.threadId ? ( +
+ {hasFileLocation ? ( + + ) : null} + {comment.threadId ? ( + + ) : null} +
+ ) : null}
)} -
+
{comment.url ? ( -
- - {reviewDecisionConfig[pr.reviewDecision].label} - - {requestedReviewers.length > 0 && ( - - Awaiting {requestedReviewers.join(", ")} +
+
+ + {reviewDecisionConfig[pr.reviewDecision].label} - )} + {requestedReviewers.length > 0 && ( + + Awaiting {requestedReviewers.join(", ")} + + )} +
+ {pr.state === "open" || pr.state === "draft" ? ( + + ) : null} +
+
+ {renderIdentitySection({ + label: "Assignees", + items: assignees, + onRemove: handleRemoveAssignee, + pendingGroup: "assignees", + })} + {renderIdentitySection({ + label: "Reviewers", + items: requestedReviewers, + onRemove: handleRemoveReviewer, + pendingGroup: "reviewers", + })}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/VerticalResizablePanels/VerticalResizablePanels.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/VerticalResizablePanels/VerticalResizablePanels.tsx new file mode 100644 index 00000000000..b9d5e086b49 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/VerticalResizablePanels/VerticalResizablePanels.tsx @@ -0,0 +1,145 @@ +import { cn } from "@superset/ui/utils"; +import { + type MouseEvent as ReactMouseEvent, + type ReactNode, + useCallback, + useEffect, + useRef, +} from "react"; + +const HANDLE_SIZE = 6; + +interface VerticalResizablePanelsProps { + top: ReactNode; + bottom: ReactNode; + topSizePercentage: number; + onTopSizePercentageChange: (percentage: number) => void; + minTopHeight?: number; + minBottomHeight?: number; + defaultTopSizePercentage?: number; + className?: string; +} + +function clampTopSizePercentage( + percentage: number, + height: number, + minTopHeight: number, + minBottomHeight: number, +) { + const usableHeight = Math.max(height - HANDLE_SIZE, 1); + const minPercentage = (minTopHeight / usableHeight) * 100; + const maxPercentage = 100 - (minBottomHeight / usableHeight) * 100; + + if (minPercentage > maxPercentage) { + return Math.max(0, Math.min(100, percentage)); + } + + return Math.max(minPercentage, Math.min(maxPercentage, percentage)); +} + +export function VerticalResizablePanels({ + top, + bottom, + topSizePercentage, + onTopSizePercentageChange, + minTopHeight = 160, + minBottomHeight = 140, + defaultTopSizePercentage = 60, + className, +}: VerticalResizablePanelsProps) { + const containerRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + + const handleMouseDown = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const container = containerRef.current; + if (!container) return; + + const overlay = document.createElement("div"); + overlay.style.cssText = + "position:fixed;inset:0;z-index:9999;cursor:row-resize;"; + document.body.appendChild(overlay); + document.body.style.userSelect = "none"; + document.body.style.cursor = "row-resize"; + + const onMouseMove = (moveEvent: MouseEvent) => { + const rect = container.getBoundingClientRect(); + if (rect.height <= HANDLE_SIZE) return; + + const nextPercentage = + ((moveEvent.clientY - rect.top) / (rect.height - HANDLE_SIZE)) * 100; + onTopSizePercentageChange( + clampTopSizePercentage( + nextPercentage, + rect.height, + minTopHeight, + minBottomHeight, + ), + ); + }; + + const cleanup = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", cleanup); + window.removeEventListener("blur", cleanup); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + overlay.remove(); + cleanupRef.current = null; + }; + + cleanupRef.current = cleanup; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", cleanup); + window.addEventListener("blur", cleanup); + }, + [minBottomHeight, minTopHeight, onTopSizePercentageChange], + ); + + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onTopSizePercentageChange(defaultTopSizePercentage); + }, + [defaultTopSizePercentage, onTopSizePercentageChange], + ); + + useEffect(() => { + return () => { + cleanupRef.current?.(); + }; + }, []); + + return ( +
+
+ {top} +
+
+
{bottom}
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/VerticalResizablePanels/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/VerticalResizablePanels/index.ts new file mode 100644 index 00000000000..b3bcdf9fd7e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/VerticalResizablePanels/index.ts @@ -0,0 +1 @@ +export { VerticalResizablePanels } from "./VerticalResizablePanels"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx index 0a26efa38d1..ad16e085501 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx @@ -28,6 +28,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import type { CodeEditorAdapter } from "renderer/screens/main/components/WorkspaceView/ContentView/components"; import { getCodeSyntaxHighlighting } from "renderer/screens/main/components/WorkspaceView/utils/code-theme"; import { useResolvedTheme } from "renderer/stores/theme"; +import { getEditorTheme } from "shared/themes"; import { type BlameEntry, createBlamePlugin } from "./createBlamePlugin"; import { createCodeMirrorTheme } from "./createCodeMirrorTheme"; import { loadLanguageSupport } from "./loadLanguageSupport"; @@ -45,8 +46,141 @@ interface CodeEditorProps { blameEntries?: BlameEntry[]; } -function createCodeMirrorAdapter(view: EditorView): CodeEditorAdapter { +const HIGHLIGHT_CLEAR_DELAY_MS = 1800; +const HIGHLIGHT_RETRY_DELAY_MS = 80; +const HIGHLIGHT_MAX_RETRIES = 8; +const SCROLL_STABILIZE_DELAY_MS = 120; + +function createCodeMirrorAdapter( + view: EditorView, + jumpHighlightStyle: { + backgroundColor: string; + boxShadow: string; + }, +): CodeEditorAdapter { let disposed = false; + let highlightResetTimeout: ReturnType | null = null; + let scrollStabilizeTimeout: ReturnType | null = null; + let highlightRequestId = 0; + let highlightedLine: HTMLElement | null = null; + let highlightAnimation: Animation | null = null; + let highlightedLinePreviousStyle: { + backgroundColor: string; + boxShadow: string; + outline: string; + outlineOffset: string; + borderRadius: string; + transition: string; + } | null = null; + const pendingHighlightTimeouts = new Set(); + + const clearPendingHighlightTimeouts = () => { + for (const timeoutId of pendingHighlightTimeouts) { + window.clearTimeout(timeoutId); + } + pendingHighlightTimeouts.clear(); + }; + + const clearLineHighlight = () => { + if (!highlightedLine) { + return; + } + + if (highlightedLinePreviousStyle) { + highlightedLine.style.backgroundColor = + highlightedLinePreviousStyle.backgroundColor; + highlightedLine.style.boxShadow = highlightedLinePreviousStyle.boxShadow; + highlightedLine.style.outline = highlightedLinePreviousStyle.outline; + highlightedLine.style.outlineOffset = + highlightedLinePreviousStyle.outlineOffset; + highlightedLine.style.borderRadius = + highlightedLinePreviousStyle.borderRadius; + highlightedLine.style.transition = + highlightedLinePreviousStyle.transition; + } else { + highlightedLine.style.removeProperty("background-color"); + highlightedLine.style.removeProperty("box-shadow"); + highlightedLine.style.removeProperty("outline"); + highlightedLine.style.removeProperty("outline-offset"); + highlightedLine.style.removeProperty("border-radius"); + highlightedLine.style.removeProperty("transition"); + } + + highlightAnimation?.cancel(); + highlightAnimation = null; + highlightedLine = null; + highlightedLinePreviousStyle = null; + }; + + const highlightLineAt = (anchor: number, requestId: number, attempt = 0) => { + const timeoutId = window.setTimeout( + () => { + pendingHighlightTimeouts.delete(timeoutId); + if (disposed || requestId !== highlightRequestId) { + return; + } + + const domAtPos = view.domAtPos(anchor); + const domNode = + domAtPos.node instanceof HTMLElement + ? domAtPos.node + : domAtPos.node.parentElement; + const lineElement = domNode?.closest(".cm-line"); + if (!(lineElement instanceof HTMLElement)) { + if (attempt < HIGHLIGHT_MAX_RETRIES) { + highlightLineAt(anchor, requestId, attempt + 1); + } + return; + } + + clearLineHighlight(); + highlightedLinePreviousStyle = { + backgroundColor: lineElement.style.backgroundColor, + boxShadow: lineElement.style.boxShadow, + outline: lineElement.style.outline, + outlineOffset: lineElement.style.outlineOffset, + borderRadius: lineElement.style.borderRadius, + transition: lineElement.style.transition, + }; + lineElement.style.transition = + "background-color 1.2s ease-out, box-shadow 1.2s ease-out, outline-color 1.2s ease-out"; + lineElement.style.backgroundColor = jumpHighlightStyle.backgroundColor; + lineElement.style.boxShadow = jumpHighlightStyle.boxShadow; + lineElement.style.outline = `2px solid ${jumpHighlightStyle.backgroundColor}`; + lineElement.style.outlineOffset = "-1px"; + lineElement.style.borderRadius = "4px"; + highlightedLine = lineElement; + highlightAnimation = lineElement.animate( + [ + { + backgroundColor: jumpHighlightStyle.backgroundColor, + boxShadow: jumpHighlightStyle.boxShadow, + outlineColor: jumpHighlightStyle.backgroundColor, + }, + { + backgroundColor: jumpHighlightStyle.backgroundColor, + boxShadow: jumpHighlightStyle.boxShadow, + outlineColor: jumpHighlightStyle.backgroundColor, + offset: 0.35, + }, + { + backgroundColor: + highlightedLinePreviousStyle?.backgroundColor || "transparent", + boxShadow: highlightedLinePreviousStyle?.boxShadow || "none", + outlineColor: "transparent", + }, + ], + { + duration: HIGHLIGHT_CLEAR_DELAY_MS, + easing: "ease-out", + fill: "forwards", + }, + ); + }, + attempt === 0 ? 32 : HIGHLIGHT_RETRY_DELAY_MS, + ); + pendingHighlightTimeouts.add(timeoutId); + }; return { focus() { @@ -70,10 +204,48 @@ function createCodeMirrorAdapter(view: EditorView): CodeEditorAdapter { const offset = Math.min(column - 1, lineInfo.length); const anchor = lineInfo.from + Math.max(0, offset); + if (highlightResetTimeout) { + clearTimeout(highlightResetTimeout); + highlightResetTimeout = null; + } + view.dispatch({ selection: EditorSelection.cursor(anchor), - scrollIntoView: true, + effects: EditorView.scrollIntoView(anchor, { + y: "center", + yMargin: 48, + }), }); + highlightRequestId += 1; + const currentHighlightRequestId = highlightRequestId; + clearPendingHighlightTimeouts(); + highlightLineAt(anchor, currentHighlightRequestId); + if (scrollStabilizeTimeout) { + clearTimeout(scrollStabilizeTimeout); + } + scrollStabilizeTimeout = setTimeout(() => { + if (disposed) { + return; + } + + view.dispatch({ + effects: EditorView.scrollIntoView(anchor, { + y: "center", + yMargin: 48, + }), + }); + scrollStabilizeTimeout = null; + }, SCROLL_STABILIZE_DELAY_MS); + + highlightResetTimeout = setTimeout(() => { + if (disposed || currentHighlightRequestId !== highlightRequestId) { + return; + } + + clearLineHighlight(); + highlightResetTimeout = null; + }, HIGHLIGHT_CLEAR_DELAY_MS); + view.focus(); }, getSelectionLines() { @@ -158,6 +330,16 @@ function createCodeMirrorAdapter(view: EditorView): CodeEditorAdapter { dispose() { if (disposed) return; disposed = true; + if (highlightResetTimeout) { + clearTimeout(highlightResetTimeout); + highlightResetTimeout = null; + } + if (scrollStabilizeTimeout) { + clearTimeout(scrollStabilizeTimeout); + scrollStabilizeTimeout = null; + } + clearPendingHighlightTimeouts(); + clearLineHighlight(); view.destroy(); }, }; @@ -194,6 +376,7 @@ export function CodeEditor({ const editorFontFamily = fontSettings?.editorFontFamily ?? undefined; const editorFontSize = fontSettings?.editorFontSize ?? undefined; const activeTheme = useResolvedTheme(); + const editorTheme = getEditorTheme(activeTheme); onChangeRef.current = onChange; onSaveRef.current = onSave; @@ -269,7 +452,10 @@ export function CodeEditor({ state, parent: containerRef.current, }); - const adapter = createCodeMirrorAdapter(view); + const adapter = createCodeMirrorAdapter(view, { + backgroundColor: editorTheme.colors.search, + boxShadow: `inset 2px 0 0 ${editorTheme.colors.searchActive}`, + }); viewRef.current = view; if (editorRef) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme.ts index d15b2eeccec..c12fad9b920 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme.ts @@ -51,6 +51,11 @@ export function createCodeMirrorTheme( ".cm-activeLineGutter": { backgroundColor: editorTheme.colors.activeLine, }, + ".cm-line.cm-jump-highlight, .cm-line.cm-jump-highlight.cm-activeLine": { + backgroundColor: `${editorTheme.colors.searchActive}55`, + boxShadow: `inset 3px 0 0 ${editorTheme.colors.searchActive}, inset 0 0 0 9999px ${editorTheme.colors.searchActive}22`, + transition: "background-color 1.2s ease-out, box-shadow 1.2s ease-out", + }, "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { backgroundColor: editorTheme.colors.selection, diff --git a/apps/desktop/src/renderer/stores/changes/index.ts b/apps/desktop/src/renderer/stores/changes/index.ts index 79247a86a96..59b728ccb38 100644 --- a/apps/desktop/src/renderer/stores/changes/index.ts +++ b/apps/desktop/src/renderer/stores/changes/index.ts @@ -1 +1 @@ -export { useChangesStore } from "./store"; +export { DEFAULT_DIFFS_PANE_PERCENTAGE, useChangesStore } from "./store"; diff --git a/apps/desktop/src/renderer/stores/changes/store.ts b/apps/desktop/src/renderer/stores/changes/store.ts index 63389b88423..480fa1d3e35 100644 --- a/apps/desktop/src/renderer/stores/changes/store.ts +++ b/apps/desktop/src/renderer/stores/changes/store.ts @@ -16,6 +16,7 @@ import { type FileListViewMode = "grouped" | "compact" | "tree"; type ChangesSidebarTab = "diffs" | "review"; +export const DEFAULT_DIFFS_PANE_PERCENTAGE = 60; interface SelectedFileState { absolutePath: string; @@ -29,6 +30,7 @@ interface ChangesState { activeTab: ChangesSidebarTab; viewMode: DiffViewMode; fileListViewMode: FileListViewMode; + diffsPanePercentage: number; expandedSections: Record; sectionOrder: ChangeCategory[]; showRenderedMarkdown: Record; @@ -53,6 +55,7 @@ interface ChangesState { setActiveTab: (tab: ChangesSidebarTab) => void; setViewMode: (mode: DiffViewMode) => void; setFileListViewMode: (mode: FileListViewMode) => void; + setDiffsPanePercentage: (percentage: number) => void; toggleSection: (section: ChangeCategory) => void; setSectionExpanded: (section: ChangeCategory, expanded: boolean) => void; moveSection: (fromSection: ChangeCategory, toSection: ChangeCategory) => void; @@ -68,6 +71,7 @@ const initialState = { activeTab: "diffs" as ChangesSidebarTab, viewMode: "side-by-side" as DiffViewMode, fileListViewMode: "grouped" as FileListViewMode, + diffsPanePercentage: DEFAULT_DIFFS_PANE_PERCENTAGE, expandedSections: { conflicted: true, "against-base": true, @@ -159,6 +163,12 @@ export const useChangesStore = create()( set({ fileListViewMode: mode }); }, + setDiffsPanePercentage: (percentage) => { + set({ + diffsPanePercentage: Math.max(0, Math.min(100, percentage)), + }); + }, + toggleSection: (section) => { const { expandedSections } = get(); set({ @@ -232,7 +242,7 @@ export const useChangesStore = create()( }), { name: "changes-store", - version: 6, + version: 7, migrate: (persisted, version) => { const state = persisted as Record; if (version < 2) { @@ -255,6 +265,9 @@ export const useChangesStore = create()( expandedSections.conflicted = true; } } + if (version < 7) { + state.diffsPanePercentage = DEFAULT_DIFFS_PANE_PERCENTAGE; + } state.sectionOrder = normalizeChangeSectionOrder( state.sectionOrder as ChangeCategory[] | undefined, ); @@ -265,6 +278,7 @@ export const useChangesStore = create()( activeTab: state.activeTab, viewMode: state.viewMode, fileListViewMode: state.fileListViewMode, + diffsPanePercentage: state.diffsPanePercentage, expandedSections: state.expandedSections, sectionOrder: state.sectionOrder, showRenderedMarkdown: state.showRenderedMarkdown, diff --git a/apps/desktop/src/renderer/stores/sidebar-state.ts b/apps/desktop/src/renderer/stores/sidebar-state.ts index f0a17eae120..4d2be3411fd 100644 --- a/apps/desktop/src/renderer/stores/sidebar-state.ts +++ b/apps/desktop/src/renderer/stores/sidebar-state.ts @@ -13,7 +13,7 @@ export enum RightSidebarTab { export const DEFAULT_SIDEBAR_WIDTH = 250; export const MIN_SIDEBAR_WIDTH = 200; -export const MAX_SIDEBAR_WIDTH = 500; +export const MAX_SIDEBAR_WIDTH = 800; interface SidebarState { isSidebarOpen: boolean; diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index 83b6f2ded48..a9a549e06eb 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -33,6 +33,7 @@ export const pullRequestCommentSchema = z.object({ createdAt: z.number().optional(), url: z.string().optional(), kind: z.enum(["review", "conversation"]).optional(), + threadId: z.string().optional(), path: z.string().optional(), line: z.number().optional(), isResolved: z.boolean().optional(), @@ -62,6 +63,7 @@ export const gitHubStatusSchema = z.object({ checks: z.array(checkItemSchema), comments: z.array(pullRequestCommentSchema).optional(), requestedReviewers: z.array(z.string()).optional(), + assignees: z.array(z.string()).optional(), }) .nullable(), repoUrl: z.string(),