diff --git a/AGENTS.md b/AGENTS.md index d37d2efe5d6..a610d44b9e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,13 +77,30 @@ app/ ├── page.tsx ├── dashboard/ │ ├── page.tsx -│ └── components/ -│ └── MetricsChart/ -│ ├── MetricsChart.tsx -│ ├── MetricsChart.test.tsx # Tests co-located -│ ├── index.ts -│ ├── useMetricsData.ts # Hook used only here -│ └── constants.ts +│ ├── components/ +│ │ └── MetricsChart/ +│ │ ├── MetricsChart.tsx +│ │ ├── MetricsChart.test.tsx # Tests co-located +│ │ ├── index.ts +│ │ └── constants.ts +│ ├── hooks/ # Hooks used only in dashboard +│ │ └── useMetrics/ +│ │ ├── useMetrics.ts +│ │ ├── useMetrics.test.ts +│ │ └── index.ts +│ ├── utils/ # Utils used only in dashboard +│ │ └── formatData/ +│ │ ├── formatData.ts +│ │ ├── formatData.test.ts +│ │ └── index.ts +│ ├── stores/ # Stores used only in dashboard +│ │ └── dashboardStore/ +│ │ ├── dashboardStore.ts +│ │ └── index.ts +│ └── providers/ # Providers for dashboard context +│ └── DashboardProvider/ +│ ├── DashboardProvider.tsx +│ └── index.ts └── components/ ├── Sidebar/ │ ├── Sidebar.tsx @@ -121,6 +138,10 @@ components/ # Used in 2+ pages (last resort) 3. **One component per file**: No multi-component files 4. **Co-locate dependencies**: Utils, hooks, constants, config, tests, stories live next to the file using them +### Exception: shadcn/ui Components + +The `src/components/ui/`, `src/components/ai-elements`, and `src/components/react-flow/` directories contain shadcn/ui components. These use **kebab-case single files** (e.g., `button.tsx`, `base-node.tsx`) instead of the folder structure above. This is intentional—shadcn CLI expects this format for updates via `bunx shadcn@latest add`. + ## Database Rules - Schema in `packages/db/src/` diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3237a6683be..eb5d944cf7a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -51,6 +51,7 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dnd-core": "^16.0.1", "dotenv": "^17.2.3", "electron-router-dom": "^2.1.0", 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 new file mode 100644 index 00000000000..42841adb026 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -0,0 +1,219 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { CheckItem, GitHubStatus } from "main/lib/db/schemas"; +import { + type GHPRResponse, + GHPRResponseSchema, + GHRepoResponseSchema, +} from "./types"; + +const execFileAsync = promisify(execFile); + +// Cache for GitHub status (10 second TTL) +const cache = new Map(); +const CACHE_TTL_MS = 10_000; + +/** + * Fetches GitHub PR status for a worktree using the `gh` CLI. + * Returns null if `gh` is not installed, not authenticated, or on error. + * Results are cached for 30 seconds. + */ +export async function fetchGitHubPRStatus( + worktreePath: string, +): Promise { + // Check cache first + const cached = cache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.data; + } + + try { + // First, get the repo URL + const repoUrl = await getRepoUrl(worktreePath); + if (!repoUrl) { + return null; + } + + // Try to get PR info for current branch + const prInfo = await getPRForCurrentBranch(worktreePath); + + const result: GitHubStatus = { + pr: prInfo, + repoUrl, + lastRefreshed: Date.now(), + }; + + // Cache the result + cache.set(worktreePath, { data: result, timestamp: Date.now() }); + + return result; + } catch { + // Any error (gh not installed, not auth'd, etc.) - return null + return null; + } +} + +async function getRepoUrl(worktreePath: string): Promise { + try { + const { stdout } = await execFileAsync( + "gh", + ["repo", "view", "--json", "url"], + { + cwd: worktreePath, + }, + ); + const raw = JSON.parse(stdout); + const result = GHRepoResponseSchema.safeParse(raw); + if (!result.success) { + console.error("[GitHub] Repo schema validation failed:", result.error); + console.error("[GitHub] Raw data:", JSON.stringify(raw, null, 2)); + return null; + } + return result.data.url; + } catch { + return null; + } +} + +async function getPRForCurrentBranch( + worktreePath: string, +): Promise { + try { + // Get the current branch name explicitly (worktrees don't work well with gh's auto-detection) + const { stdout: branchName } = await execFileAsync( + "git", + ["rev-parse", "--abbrev-ref", "HEAD"], + { cwd: worktreePath }, + ); + const branch = branchName.trim(); + + // Use execFile with args array to prevent command injection + const { stdout } = await execFileAsync( + "gh", + [ + "pr", + "view", + branch, + "--json", + "number,title,url,state,isDraft,mergedAt,additions,deletions,reviewDecision,statusCheckRollup", + ], + { cwd: worktreePath }, + ); + const raw = JSON.parse(stdout); + const result = GHPRResponseSchema.safeParse(raw); + if (!result.success) { + console.error("[GitHub] PR schema validation failed:", result.error); + console.error("[GitHub] Raw data:", JSON.stringify(raw, null, 2)); + throw new Error("PR schema validation failed"); + } + const data = result.data; + + const checks = parseChecks(data.statusCheckRollup); + + return { + number: data.number, + title: data.title, + url: data.url, + state: mapPRState(data.state, data.isDraft), + mergedAt: data.mergedAt ? new Date(data.mergedAt).getTime() : undefined, + additions: data.additions, + deletions: data.deletions, + reviewDecision: mapReviewDecision(data.reviewDecision), + checksStatus: computeChecksStatus(data.statusCheckRollup), + checks, + }; + } catch (error) { + // "no pull requests found" is not an error - just no PR + if ( + error instanceof Error && + error.message.includes("no pull requests found") + ) { + return null; + } + // Re-throw other errors to be caught by parent + throw error; + } +} + +function mapPRState( + state: GHPRResponse["state"], + isDraft: boolean, +): NonNullable["state"] { + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + if (isDraft) return "draft"; + return "open"; +} + +function mapReviewDecision( + decision: GHPRResponse["reviewDecision"], +): NonNullable["reviewDecision"] { + if (decision === "APPROVED") return "approved"; + if (decision === "CHANGES_REQUESTED") return "changes_requested"; + return "pending"; +} + +function parseChecks(rollup: GHPRResponse["statusCheckRollup"]): CheckItem[] { + if (!rollup || rollup.length === 0) { + return []; + } + + return rollup.map((ctx) => { + // CheckRun uses 'name', StatusContext uses 'context' + const name = ctx.name || ctx.context || "Unknown check"; + // CheckRun uses 'detailsUrl', StatusContext uses 'targetUrl' + const url = ctx.detailsUrl || ctx.targetUrl; + // StatusContext uses 'state', CheckRun uses 'conclusion' + const rawStatus = ctx.state || ctx.conclusion; + + let status: CheckItem["status"]; + if (rawStatus === "SUCCESS") { + status = "success"; + } else if ( + rawStatus === "FAILURE" || + rawStatus === "ERROR" || + rawStatus === "TIMED_OUT" + ) { + status = "failure"; + } else if (rawStatus === "SKIPPED" || rawStatus === "NEUTRAL") { + status = "skipped"; + } else if (rawStatus === "CANCELLED") { + status = "cancelled"; + } else { + status = "pending"; + } + + return { name, status, url }; + }); +} + +function computeChecksStatus( + rollup: GHPRResponse["statusCheckRollup"], +): NonNullable["checksStatus"] { + if (!rollup || rollup.length === 0) { + return "none"; + } + + let hasFailure = false; + let hasPending = false; + + for (const ctx of rollup) { + // StatusContext uses 'state', CheckRun uses 'conclusion' + const status = ctx.state || ctx.conclusion; + + if (status === "FAILURE" || status === "ERROR" || status === "TIMED_OUT") { + hasFailure = true; + } else if ( + status === "PENDING" || + status === "" || + status === null || + status === undefined + ) { + hasPending = true; + } + } + + if (hasFailure) return "failure"; + if (hasPending) return "pending"; + return "success"; +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts new file mode 100644 index 00000000000..75fcdd5296d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts @@ -0,0 +1 @@ +export { fetchGitHubPRStatus } from "./github"; 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 new file mode 100644 index 00000000000..1f11b544e8d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +// Zod schemas for gh CLI output validation +export const GHCheckContextSchema = z.object({ + name: z.string().optional(), + context: z.string().optional(), // StatusContext uses 'context' instead of 'name' + state: z.enum(["SUCCESS", "FAILURE", "PENDING", "ERROR"]).optional(), + status: z.string().optional(), // CheckRun status: COMPLETED, IN_PROGRESS, etc. + conclusion: z + .enum([ + "SUCCESS", + "FAILURE", + "CANCELLED", + "SKIPPED", + "TIMED_OUT", + "ACTION_REQUIRED", + "NEUTRAL", + "", // Can be empty string when in progress + ]) + .optional(), + detailsUrl: z.string().optional(), + targetUrl: z.string().optional(), // StatusContext uses 'targetUrl' instead of 'detailsUrl' + startedAt: z.string().optional(), + completedAt: z.string().optional(), + workflowName: z.string().optional(), +}); + +export const GHPRResponseSchema = z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + isDraft: z.boolean(), + mergedAt: z.string().nullable(), + additions: z.number(), + deletions: z.number(), + reviewDecision: z + .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", ""]) + .nullable(), + // statusCheckRollup is an array directly, not { contexts: [...] } + statusCheckRollup: z.array(GHCheckContextSchema).nullable(), +}); + +export const GHRepoResponseSchema = z.object({ + url: z.string(), +}); + +export type GHPRResponse = z.infer; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index e526dbfa2c8..0c542780bbb 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -15,6 +15,7 @@ import { removeWorktree, worktreeExists, } from "./utils/git"; +import { fetchGitHubPRStatus } from "./utils/github"; import { loadSetupConfig } from "./utils/setup"; import { runTeardown } from "./utils/teardown"; import { getWorktreePath } from "./utils/worktree"; @@ -558,6 +559,67 @@ export const createWorkspacesRouter = () => { return { gitStatus }; }), + + getGitHubStatus: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(async ({ input }) => { + const workspace = db.data.workspaces.find( + (w) => w.id === input.workspaceId, + ); + if (!workspace) { + return null; + } + + const worktree = db.data.worktrees.find( + (wt) => wt.id === workspace.worktreeId, + ); + if (!worktree) { + return null; + } + + // Always fetch fresh data on hover + const freshStatus = await fetchGitHubPRStatus(worktree.path); + + // Update cache if we got data + if (freshStatus) { + await db.update((data) => { + const wt = data.worktrees.find((w) => w.id === worktree.id); + if (wt) { + wt.githubStatus = freshStatus; + } + }); + } + + return freshStatus; + }), + + getWorktreeInfo: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => { + const workspace = db.data.workspaces.find( + (w) => w.id === input.workspaceId, + ); + if (!workspace) { + return null; + } + + const worktree = db.data.worktrees.find( + (wt) => wt.id === workspace.worktreeId, + ); + if (!worktree) { + return null; + } + + // Extract worktree name from path (last segment) + const worktreeName = worktree.path.split("/").pop() ?? worktree.branch; + + return { + worktreeName, + createdAt: worktree.createdAt, + gitStatus: worktree.gitStatus ?? null, + githubStatus: worktree.githubStatus ?? null, + }; + }), }); }; diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index a24e1bb3d6b..e319ee7fff3 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -16,6 +16,12 @@ export interface GitStatus { lastRefreshed: number; } +export interface CheckItem { + name: string; + status: "success" | "failure" | "pending" | "skipped" | "cancelled"; + url?: string; +} + export interface GitHubStatus { pr: { number: number; @@ -23,6 +29,11 @@ export interface GitHubStatus { url: string; state: "open" | "draft" | "merged" | "closed"; mergedAt?: number; + additions: number; + deletions: number; + reviewDecision: "approved" | "changes_requested" | "pending"; + checksStatus: "success" | "failure" | "pending" | "none"; + checks: CheckItem[]; } | null; repoUrl: string; lastRefreshed: number; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx new file mode 100644 index 00000000000..11772b29e53 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -0,0 +1,115 @@ +import { Button } from "@superset/ui/button"; +import { formatDistanceToNow } from "date-fns"; +import { LoaderCircle, TriangleAlert } from "lucide-react"; +import { FaGithub } from "react-icons/fa"; +import { trpc } from "renderer/lib/trpc"; +import { ChecksList } from "./components/ChecksList"; +import { ChecksSummary } from "./components/ChecksSummary"; +import { PRStatusBadge } from "./components/PRStatusBadge"; +import { ReviewStatus } from "./components/ReviewStatus"; + +interface WorkspaceHoverCardContentProps { + workspaceId: string; +} + +export function WorkspaceHoverCardContent({ + workspaceId, +}: WorkspaceHoverCardContentProps) { + const { data: worktreeInfo } = trpc.workspaces.getWorktreeInfo.useQuery( + { workspaceId }, + { enabled: !!workspaceId }, + ); + + const { data: githubStatus, isLoading: isLoadingGithub } = + trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId }, + { enabled: !!workspaceId }, + ); + + const pr = githubStatus?.pr; + const needsRebase = worktreeInfo?.gitStatus?.needsRebase; + + return ( +
+ {/* Header: Worktree name + age */} + {worktreeInfo?.worktreeName && ( +
+ + {worktreeInfo.worktreeName} + + {worktreeInfo?.createdAt && ( + + {formatDistanceToNow(worktreeInfo.createdAt, { addSuffix: true })} + + )} +
+ )} + + {/* Needs Rebase Warning */} + {needsRebase && ( +
+ + Behind main, needs rebase +
+ )} + + {/* PR Section */} + {isLoadingGithub ? ( +
+ + Loading PR... +
+ ) : pr ? ( +
+ {/* PR Header: Number + Status + Diff Stats */} +
+
+ + #{pr.number} + + +
+
+ +{pr.additions} + + -{pr.deletions} + +
+
+ + {/* PR Title */} +

{pr.title}

+ + {/* Checks & Review - only for open PRs */} + {pr.state === "open" && ( +
+
+ + · + +
+ {pr.checks.length > 0 && } +
+ )} + + {/* View on GitHub button */} + +
+ ) : githubStatus ? ( +
+ No PR for this branch +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx new file mode 100644 index 00000000000..fcf726cb48d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx @@ -0,0 +1,44 @@ +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { CheckItem } from "main/lib/db/schemas"; +import { useState } from "react"; +import { CheckItemRow } from "./components/CheckItemRow"; + +interface ChecksListProps { + checks: CheckItem[]; +} + +export function ChecksList({ checks }: ChecksListProps) { + const [expanded, setExpanded] = useState(false); + + // Filter out skipped/cancelled for display count, but show all when expanded + const relevantChecks = checks.filter( + (c) => c.status !== "skipped" && c.status !== "cancelled", + ); + + if (relevantChecks.length === 0) return null; + + return ( +
+ + + {expanded && ( +
+ {relevantChecks.map((check) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx new file mode 100644 index 00000000000..6962dae4a2d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx @@ -0,0 +1,42 @@ +import { Check, LoaderCircle, Minus, X } from "lucide-react"; +import type { CheckItem } from "main/lib/db/schemas"; + +interface CheckItemRowProps { + check: CheckItem; +} + +export function CheckItemRow({ check }: CheckItemRowProps) { + const statusConfig = { + success: { icon: Check, className: "text-emerald-500" }, + failure: { icon: X, className: "text-destructive-foreground" }, + pending: { icon: LoaderCircle, className: "text-amber-500" }, + skipped: { icon: Minus, className: "text-muted-foreground" }, + cancelled: { icon: Minus, className: "text-muted-foreground" }, + }; + + const { icon: Icon, className } = statusConfig[check.status]; + + const content = ( + + + {check.name} + + ); + + if (check.url) { + return ( + + {content} + + ); + } + + return
{content}
; +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts new file mode 100644 index 00000000000..dbc5ff57432 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts @@ -0,0 +1 @@ +export { CheckItemRow } from "./CheckItemRow"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts new file mode 100644 index 00000000000..7fcb36d6b4e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts @@ -0,0 +1 @@ +export { ChecksList } from "./ChecksList"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx new file mode 100644 index 00000000000..aa1b0447300 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx @@ -0,0 +1,43 @@ +import { Check, LoaderCircle, X } from "lucide-react"; +import type { CheckItem } from "main/lib/db/schemas"; + +interface ChecksSummaryProps { + checks: CheckItem[]; + status: "success" | "failure" | "pending" | "none"; +} + +export function ChecksSummary({ checks, status }: ChecksSummaryProps) { + if (status === "none") return null; + + const passing = checks.filter((c) => c.status === "success").length; + const total = checks.filter( + (c) => c.status !== "skipped" && c.status !== "cancelled", + ).length; + + const config = { + success: { + icon: Check, + className: "text-emerald-500", + }, + failure: { + icon: X, + className: "text-destructive-foreground", + }, + pending: { + icon: LoaderCircle, + className: "text-amber-500", + }, + }; + + const { icon: Icon, className } = config[status]; + const label = total > 0 ? `${passing}/${total} checks` : "Checks"; + + return ( + + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts new file mode 100644 index 00000000000..25d7c97e309 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts @@ -0,0 +1 @@ +export { ChecksSummary } from "./ChecksSummary"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx new file mode 100644 index 00000000000..13d4cfcc3c4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx @@ -0,0 +1,27 @@ +interface PRStatusBadgeProps { + state: "open" | "draft" | "merged" | "closed"; +} + +export function PRStatusBadge({ state }: PRStatusBadgeProps) { + const styles = { + open: "bg-emerald-500/15 text-emerald-500", + draft: "bg-muted text-muted-foreground", + merged: "bg-violet-500/15 text-violet-500", + closed: "bg-destructive/15 text-destructive-foreground", + }; + + const labels = { + open: "Open", + draft: "Draft", + merged: "Merged", + closed: "Closed", + }; + + return ( + + {labels[state]} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts new file mode 100644 index 00000000000..5f1e1bb361c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts @@ -0,0 +1 @@ +export { PRStatusBadge } from "./PRStatusBadge"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx new file mode 100644 index 00000000000..1895ebb0b65 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx @@ -0,0 +1,18 @@ +interface ReviewStatusProps { + status: "approved" | "changes_requested" | "pending"; +} + +export function ReviewStatus({ status }: ReviewStatusProps) { + const config = { + approved: { label: "Approved", className: "text-emerald-500" }, + changes_requested: { + label: "Changes requested", + className: "text-destructive-foreground", + }, + pending: { label: "Review pending", className: "text-muted-foreground" }, + }; + + const { label, className } = config[status]; + + return {label}; +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts new file mode 100644 index 00000000000..ee4628b0bf1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts @@ -0,0 +1 @@ +export { ReviewStatus } from "./ReviewStatus"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts new file mode 100644 index 00000000000..907ac164558 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts @@ -0,0 +1 @@ +export { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index 9c442580a80..bc5908df427 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -105,6 +105,7 @@ export function WorkspaceItem({ return ( <> diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx index c55863d005e..aa913f9453d 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx @@ -5,17 +5,25 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@superset/ui/context-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@superset/ui/hover-card"; import type { ReactNode } from "react"; import { trpc } from "renderer/lib/trpc"; +import { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; interface WorkspaceItemContextMenuProps { children: ReactNode; + workspaceId: string; worktreePath: string; onRename: () => void; } export function WorkspaceItemContextMenu({ children, + workspaceId, worktreePath, onRename, }: WorkspaceItemContextMenuProps) { @@ -28,15 +36,22 @@ export function WorkspaceItemContextMenu({ }; return ( - - {children} - - Rename - - - Open in Finder - - - + + + + {children} + + + Rename + + + Open in Finder + + + + + + + ); } diff --git a/bun.lock b/bun.lock index 1840e210699..8367b2a3267 100644 --- a/bun.lock +++ b/bun.lock @@ -92,6 +92,7 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dnd-core": "^16.0.1", "dotenv": "^17.2.3", "electron-router-dom": "^2.1.0", @@ -1414,6 +1415,8 @@ "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],