diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 7b8845c0ded..205dbbcc078 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -23,7 +23,7 @@ const deviceName = process.env.DEVICE_NAME; const auth = authToken && cloudApiUrl ? new JwtAuthProvider(authToken) : undefined; -const app = createApp({ +const { app, injectWebSocket } = createApp({ credentials: new LocalCredentialProvider(), auth, cloudApiUrl, @@ -38,6 +38,7 @@ const server = serve( process.stdout.write(`${JSON.stringify({ port: info.port })}\n`); }, ); +injectWebSocket(server); const shutdown = () => { server.close(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index 04fec366d00..815311dacd3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -10,7 +10,8 @@ interface DashboardSidebarProps { export function DashboardSidebar({ isCollapsed = false, }: DashboardSidebarProps) { - const { groups, toggleProjectCollapsed } = useDashboardSidebarData(); + const { groups, refreshWorkspacePullRequest, toggleProjectCollapsed } = + useDashboardSidebarData(); const workspaceShortcutLabels = useDashboardSidebarShortcuts(groups); return ( @@ -24,6 +25,7 @@ export function DashboardSidebar({ project={project} isSidebarCollapsed={isCollapsed} workspaceShortcutLabels={workspaceShortcutLabels} + onWorkspaceHover={refreshWorkspacePullRequest} onToggleCollapse={toggleProjectCollapsed} /> ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index e6569d67b40..13fc81d25a2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -15,6 +15,7 @@ interface DashboardSidebarProjectSectionProps { project: DashboardSidebarProject; isSidebarCollapsed?: boolean; workspaceShortcutLabels: Map; + onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; } @@ -22,6 +23,7 @@ export function DashboardSidebarProjectSection({ project, isSidebarCollapsed = false, workspaceShortcutLabels, + onWorkspaceHover, onToggleCollapse, }: DashboardSidebarProjectSectionProps) { const allSections = useMemo( @@ -66,13 +68,13 @@ export function DashboardSidebarProjectSection({ >
onToggleCollapse(project.id)} />
@@ -106,11 +108,11 @@ export function DashboardSidebarProjectSection({ { - projectId: string; projectName: string; githubOwner: string | null; isCollapsed: boolean; totalWorkspaceCount: number; workspaces: DashboardSidebarWorkspace[]; workspaceShortcutLabels: Map; + onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: () => void; } @@ -24,13 +24,13 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef< >( ( { - projectId, projectName, githubOwner, isCollapsed, totalWorkspaceCount, workspaces, workspaceShortcutLabels, + onWorkspaceHover, onToggleCollapse, className, ...props @@ -83,13 +83,9 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef<
{workspaces.map((workspace) => ( onWorkspaceHover(workspace.id)} shortcutLabel={workspaceShortcutLabels.get(workspace.id)} isCollapsed /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 0d4aba81595..6e8365dbbd7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -4,22 +4,22 @@ import { DashboardSidebarSection as DashboardSidebarSectionComponent } from "../ import { DashboardSidebarWorkspaceItem } from "../../../DashboardSidebarWorkspaceItem"; interface DashboardSidebarExpandedProjectContentProps { - projectId: string; isCollapsed: boolean; projectChildren: DashboardSidebarProjectChild[]; allSections: Array<{ id: string; name: string }>; workspaceShortcutLabels: Map; + onWorkspaceHover: (workspaceId: string) => void | Promise; onDeleteSection: (sectionId: string) => void; onRenameSection: (sectionId: string, name: string) => void; onToggleSectionCollapse: (sectionId: string) => void; } export function DashboardSidebarExpandedProjectContent({ - projectId, isCollapsed, projectChildren, allSections, workspaceShortcutLabels, + onWorkspaceHover, onDeleteSection, onRenameSection, onToggleSectionCollapse, @@ -39,12 +39,8 @@ export function DashboardSidebarExpandedProjectContent({ child.type === "workspace" ? ( onWorkspaceHover(child.workspace.id)} shortcutLabel={workspaceShortcutLabels.get( child.workspace.id, )} @@ -52,10 +48,11 @@ export function DashboardSidebarExpandedProjectContent({ ) : ( ; workspaceShortcutLabels: Map; + onWorkspaceHover: (workspaceId: string) => void | Promise; onDelete: (sectionId: string) => void; onRename: (sectionId: string, name: string) => void; onToggleCollapse: (sectionId: string) => void; } export function DashboardSidebarSection({ - projectId, section, workspaceShortcutLabels, + onWorkspaceHover, onDelete, onRename, onToggleCollapse, @@ -72,9 +73,9 @@ export function DashboardSidebarSection({
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/DashboardSidebarSectionContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/DashboardSidebarSectionContent.tsx index 5ef55b998bc..fbc6e4202e8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/DashboardSidebarSectionContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/DashboardSidebarSectionContent.tsx @@ -3,15 +3,15 @@ import type { DashboardSidebarSection } from "../../../../types"; import { DashboardSidebarWorkspaceItem } from "../../../DashboardSidebarWorkspaceItem"; interface DashboardSidebarSectionContentProps { - projectId: string; section: DashboardSidebarSection; workspaceShortcutLabels: Map; + onWorkspaceHover: (workspaceId: string) => void | Promise; } export function DashboardSidebarSectionContent({ - projectId, section, workspaceShortcutLabels, + onWorkspaceHover, }: DashboardSidebarSectionContentProps) { return ( @@ -26,13 +26,9 @@ export function DashboardSidebarSectionContent({
{section.workspaces.map((workspace) => ( onWorkspaceHover(workspace.id)} shortcutLabel={workspaceShortcutLabels.get(workspace.id)} /> ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index a7258f69cb5..322c1c25c54 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,3 +1,4 @@ +import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; import { DashboardSidebarExpandedWorkspaceRow } from "./components/DashboardSidebarExpandedWorkspaceRow"; @@ -7,26 +8,26 @@ import { useDashboardSidebarWorkspaceItemActions } from "./hooks/useDashboardSid import { getWorkspaceRowMocks } from "./utils"; interface DashboardSidebarWorkspaceItemProps { - id: string; - projectId: string; - accentColor?: string | null; - hostType: "local-device" | "remote-device" | "cloud"; - name: string; - branch: string; + workspace: DashboardSidebarWorkspace; + onHoverCardOpen?: () => void; shortcutLabel?: string; isCollapsed?: boolean; } export function DashboardSidebarWorkspaceItem({ - id, - projectId, - accentColor = null, - hostType, - name, - branch, + workspace, + onHoverCardOpen, shortcutLabel, isCollapsed = false, }: DashboardSidebarWorkspaceItemProps) { + const { + id, + projectId, + accentColor = null, + hostType, + name, + branch, + } = workspace; const mockData = getWorkspaceRowMocks(id); const { cancelRename, @@ -57,10 +58,12 @@ export function DashboardSidebarWorkspaceItem({ <> } @@ -108,10 +111,12 @@ export function DashboardSidebarWorkspaceItem({ <> } @@ -126,10 +131,7 @@ export function DashboardSidebarWorkspaceItem({ onDelete={() => setIsDeleteDialogOpen(true)} > { - accentColor?: string | null; - hostType: DashboardSidebarWorkspaceHostType; - name: string; - branch: string; + workspace: DashboardSidebarWorkspace; isActive: boolean; isRenaming: boolean; renameValue: string; @@ -34,10 +31,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< >( ( { - accentColor = null, - hostType, - name, - branch, + workspace, isActive, isRenaming, renameValue, @@ -54,8 +48,15 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< }, ref, ) => { + const { + accentColor = null, + hostType, + name, + branch, + pullRequest, + } = workspace; const showBranchSubtitle = !!name && name !== branch; - const showSubtitle = showBranchSubtitle || !!mockData.pr; + const showSubtitle = showBranchSubtitle || !!pullRequest; const showsStandaloneActiveStripe = accentColor == null; return ( @@ -174,10 +175,11 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< )} - {mockData.pr && ( + {pullRequest && ( )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index f92a643fda8..46c1cb9cdf9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -31,6 +31,7 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect interface DashboardSidebarWorkspaceContextMenuProps { hoverCardContent?: React.ReactNode; projectId: string; + onHoverCardOpen?: () => void; onCreateSection: () => void; onMoveToSection: (sectionId: string | null) => void; onOpenInFinder: () => void; @@ -43,6 +44,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { export function DashboardSidebarWorkspaceContextMenu({ projectId, + onHoverCardOpen, hoverCardContent, onCreateSection, onMoveToSection, @@ -142,6 +144,11 @@ export function DashboardSidebarWorkspaceContextMenu({ open={isContextMenuOpen ? false : undefined} openDelay={400} closeDelay={100} + onOpenChange={(open) => { + if (open) { + onHoverCardOpen?.(); + } + }} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx index 3b8b2b9114c..fd04c73a833 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx @@ -1,78 +1,186 @@ import { Button } from "@superset/ui/button"; -import { LuExternalLink, LuGitBranch, LuGlobe } from "react-icons/lu"; +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { formatDistanceToNow } from "date-fns"; +import { FaGithub } from "react-icons/fa"; +import { LuExternalLink, LuGlobe, LuTriangleAlert } from "react-icons/lu"; +import { useHotkeyDisplay } from "renderer/stores/hotkeys"; +import type { DashboardSidebarWorkspace } from "../../../../types"; import type { WorkspaceRowMockData } from "../../utils"; -import { DashboardSidebarWorkspaceStatusBadge } from "../DashboardSidebarWorkspaceStatusBadge"; +import { ChecksList } from "./components/ChecksList"; +import { ChecksSummary } from "./components/ChecksSummary"; +import { PullRequestStatusBadge } from "./components/PullRequestStatusBadge"; +import { ReviewStatus } from "./components/ReviewStatus"; interface DashboardSidebarWorkspaceHoverCardContentProps { - name: string; - branch: string; + workspace: DashboardSidebarWorkspace; mockData: WorkspaceRowMockData; } export function DashboardSidebarWorkspaceHoverCardContent({ - name, - branch, + workspace, mockData, }: DashboardSidebarWorkspaceHoverCardContentProps) { + const { + name, + branch, + pullRequest, + repoUrl, + branchExistsOnRemote, + previewUrl, + needsRebase, + behindCount, + createdAt, + } = workspace; + const openPRDisplay = useHotkeyDisplay("OPEN_PR"); + const hasOpenPRShortcut = !( + openPRDisplay.length === 1 && openPRDisplay[0] === "Unassigned" + ); + const hasCustomAlias = !!name && name !== branch; + + const previewButton = previewUrl ? ( + + ) : null; + return (
-
{name || branch}
+ {hasCustomAlias &&
{name}
}
Branch -
- {branch} - -
+ {repoUrl && branchExistsOnRemote ? ( + + {branch} + + + ) : ( + + {branch} + + )}
- - Updated a few minutes ago + + {formatDistanceToNow(createdAt, { addSuffix: true })}
-
- Mocked preview of the legacy workspace hover card. -
+ {needsRebase && ( +
+ + + Behind main by {behindCount ?? "?"} commit + {behindCount !== 1 && "s"}, needs rebase + +
+ )} - {mockData.pr ? ( -
+ {pullRequest ? ( +
-
- +
+ + #{pullRequest.number} + + + {pullRequest.state === "open" && pullRequest.reviewDecision && ( + + )}
-
+
+{mockData.diffStats.additions} - + -{mockData.diffStats.deletions}
-

{mockData.pr.title}

-
- - + {previewButton} +
+ ) : repoUrl ? ( +
+
+ No PR for this branch +
+ {previewButton} +
+ ) : previewButton ? ( + + Open Preview + +
) : null}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/ChecksList.tsx new file mode 100644 index 00000000000..d4ca12bd5cf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/ChecksList.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import type { DashboardSidebarWorkspacePullRequestCheck } from "../../../../../../types"; +import { CheckItemRow } from "./components/CheckItemRow"; + +interface ChecksListProps { + checks: DashboardSidebarWorkspacePullRequestCheck[]; +} + +export function ChecksList({ checks }: ChecksListProps) { + const [expanded, setExpanded] = useState(false); + + const relevantChecks = checks.filter( + (check) => check.status !== "skipped" && check.status !== "cancelled", + ); + + if (relevantChecks.length === 0) return null; + + return ( +
+ + + {expanded && ( +
+ {relevantChecks.map((check) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx new file mode 100644 index 00000000000..cd759b42a67 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx @@ -0,0 +1,44 @@ +import { LuCheck, LuLoaderCircle, LuMinus, LuX } from "react-icons/lu"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import type { DashboardSidebarWorkspacePullRequestCheck } from "../../../../../../../../types"; + +interface CheckItemRowProps { + check: DashboardSidebarWorkspacePullRequestCheck; +} + +export function CheckItemRow({ check }: CheckItemRowProps) { + const statusConfig = { + success: { icon: LuCheck, className: "text-emerald-500" }, + failure: { icon: LuX, className: "text-destructive-foreground" }, + pending: { icon: LuLoaderCircle, className: "text-amber-500" }, + skipped: { icon: LuMinus, className: "text-muted-foreground" }, + cancelled: { icon: LuMinus, 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/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/components/CheckItemRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/components/CheckItemRow/index.ts new file mode 100644 index 00000000000..dbc5ff57432 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/components/CheckItemRow/index.ts @@ -0,0 +1 @@ +export { CheckItemRow } from "./CheckItemRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/index.ts new file mode 100644 index 00000000000..7fcb36d6b4e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksList/index.ts @@ -0,0 +1 @@ +export { ChecksList } from "./ChecksList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/ChecksSummary.tsx new file mode 100644 index 00000000000..324621068e1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/ChecksSummary.tsx @@ -0,0 +1,45 @@ +import { LuCheck, LuLoaderCircle, LuX } from "react-icons/lu"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import type { DashboardSidebarWorkspacePullRequestCheck } from "../../../../../../types"; + +interface ChecksSummaryProps { + checks: DashboardSidebarWorkspacePullRequestCheck[]; + status: "success" | "failure" | "pending" | "none"; +} + +export function ChecksSummary({ checks, status }: ChecksSummaryProps) { + if (status === "none") return null; + + const passing = checks.filter((check) => check.status === "success").length; + const total = checks.filter( + (check) => check.status !== "skipped" && check.status !== "cancelled", + ).length; + + const config = { + success: { + icon: LuCheck, + className: "text-emerald-500", + }, + failure: { + icon: LuX, + className: "text-destructive-foreground", + }, + pending: { + icon: LuLoaderCircle, + 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/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/index.ts new file mode 100644 index 00000000000..25d7c97e309 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/index.ts @@ -0,0 +1 @@ +export { ChecksSummary } from "./ChecksSummary"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/PullRequestStatusBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/PullRequestStatusBadge.tsx new file mode 100644 index 00000000000..f09b92bb6d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/PullRequestStatusBadge.tsx @@ -0,0 +1,27 @@ +interface PullRequestStatusBadgeProps { + state: "open" | "draft" | "merged" | "closed"; +} + +export function PullRequestStatusBadge({ state }: PullRequestStatusBadgeProps) { + 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/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/index.ts new file mode 100644 index 00000000000..281a5763b7d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/index.ts @@ -0,0 +1 @@ +export { PullRequestStatusBadge } from "./PullRequestStatusBadge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/ReviewStatus.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/ReviewStatus.tsx new file mode 100644 index 00000000000..04a0c509b3a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/ReviewStatus.tsx @@ -0,0 +1,38 @@ +interface ReviewStatusProps { + status: "approved" | "changes_requested" | "pending"; + requestedReviewers?: string[]; +} + +export function ReviewStatus({ + status, + requestedReviewers, +}: ReviewStatusProps) { + const config = { + approved: { + label: "Approved", + className: "bg-emerald-500/15 text-emerald-500", + }, + changes_requested: { + label: "Changes requested", + className: "bg-destructive/15 text-destructive-foreground", + }, + pending: { + label: + requestedReviewers && requestedReviewers.length > 0 + ? `Awaiting ${requestedReviewers.join(", ")}` + : "Review pending", + className: "bg-amber-500/15 text-amber-500", + }, + }; + + const { label, className } = config[status]; + + return ( + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/index.ts new file mode 100644 index 00000000000..ee4628b0bf1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/index.ts @@ -0,0 +1 @@ +export { ReviewStatus } from "./ReviewStatus"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceStatusBadge/DashboardSidebarWorkspaceStatusBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceStatusBadge/DashboardSidebarWorkspaceStatusBadge.tsx index 908a3d0e80d..ec41e580572 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceStatusBadge/DashboardSidebarWorkspaceStatusBadge.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceStatusBadge/DashboardSidebarWorkspaceStatusBadge.tsx @@ -1,19 +1,22 @@ import { cn } from "@superset/ui/utils"; import { LuCircleDot, LuGitMerge, LuGitPullRequest } from "react-icons/lu"; - -type MockPrState = "open" | "merged" | "closed" | "draft"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { DashboardSidebarWorkspacePullRequest } from "../../../../types"; interface DashboardSidebarWorkspaceStatusBadgeProps { - state: MockPrState; + state: DashboardSidebarWorkspacePullRequest["state"]; prNumber?: number; + prUrl?: string; className?: string; } export function DashboardSidebarWorkspaceStatusBadge({ state, prNumber, + prUrl, className, }: DashboardSidebarWorkspaceStatusBadgeProps) { + const openUrl = electronTrpc.external.openUrl.useMutation(); const iconClass = "h-3 w-3"; const config = { @@ -56,12 +59,24 @@ export function DashboardSidebarWorkspaceStatusBadge({ }; const { icon, bgColor } = config[state]; + const isClickable = !!prUrl; + + const handleClick = (event: React.MouseEvent) => { + if (!prUrl) return; + event.stopPropagation(); + openUrl.mutate(prUrl); + }; return ( -
@@ -71,6 +86,6 @@ export function DashboardSidebarWorkspaceStatusBadge({ #{prNumber} )} -
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts index d8d17164ba4..cd42813fca8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts @@ -1,18 +1,11 @@ import type { ActivePaneStatus } from "shared/tabs-types"; -type MockPrState = "open" | "merged" | "closed" | "draft"; - export interface WorkspaceRowMockData { diffStats: { additions: number; deletions: number; }; workspaceStatus: ActivePaneStatus | null; - pr: { - state: MockPrState; - number: number; - title: string; - } | null; } function getSeed(input: string): number { @@ -26,9 +19,7 @@ export function getWorkspaceRowMocks( workspaceId: string, ): WorkspaceRowMockData { const seed = getSeed(workspaceId); - const prStates: MockPrState[] = ["open", "draft", "merged", "closed"]; const paneStatuses: ActivePaneStatus[] = ["permission", "working", "review"]; - const hasPr = seed % 5 !== 0; const status = seed % 6 === 0 ? paneStatuses[seed % paneStatuses.length] : null; @@ -38,12 +29,5 @@ export function getWorkspaceRowMocks( deletions: (seed % 9) + 1, }, workspaceStatus: status, - pr: hasPr - ? { - state: prStates[seed % prStates.length], - number: 100 + (seed % 900), - title: "Polish workspace sidebar visuals", - } - : null, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 3b0c4642449..cd88ec66649 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -1,9 +1,14 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider"; +import { MOCK_ORG_ID } from "shared/constants"; import type { DashboardSidebarProject, DashboardSidebarProjectChild, @@ -12,9 +17,18 @@ import type { } from "../../types"; export function useDashboardSidebarData() { + const { data: session } = authClient.useSession(); const collections = useCollections(); + const { services } = useHostService(); const { toggleProjectCollapsed } = useDashboardSidebarState(); const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + const activeHostService = + activeOrganizationId !== null + ? (services.get(activeOrganizationId) ?? null) + : null; const { data: sidebarProjects = [] } = useLiveQuery( (q) => @@ -36,6 +50,7 @@ export function useDashboardSidebarData() { slug: projects.slug, githubRepositoryId: projects.githubRepositoryId, githubOwner: repos?.owner ?? null, + githubRepoName: repos?.name ?? null, createdAt: projects.createdAt, updatedAt: projects.updatedAt, isCollapsed: sidebarProjects.isCollapsed, @@ -90,6 +105,59 @@ export function useDashboardSidebarData() { [collections], ); + const localWorkspaceIds = useMemo( + () => + sidebarWorkspaces + .filter( + (workspace) => + workspace.deviceType !== "cloud" && + workspace.deviceClientId === deviceInfo?.deviceId, + ) + .map((workspace) => workspace.id) + .sort(), + [deviceInfo?.deviceId, sidebarWorkspaces], + ); + + const { data: pullRequestData, refetch: refetchPullRequests } = useQuery({ + queryKey: [ + "dashboard-sidebar", + "pull-requests", + activeOrganizationId, + localWorkspaceIds, + ], + enabled: activeHostService !== null && localWorkspaceIds.length > 0, + refetchInterval: 15_000, + queryFn: () => + activeHostService?.client.pullRequests.getByWorkspaces.query({ + workspaceIds: localWorkspaceIds, + }) ?? Promise.resolve({ workspaces: [] }), + }); + + const refreshWorkspacePullRequest = useCallback( + async (workspaceId: string) => { + if (!activeHostService || !localWorkspaceIds.includes(workspaceId)) { + return; + } + + await activeHostService.client.pullRequests.refreshByWorkspaces.mutate({ + workspaceIds: [workspaceId], + }); + await refetchPullRequests(); + }, + [activeHostService, localWorkspaceIds, refetchPullRequests], + ); + + const localPullRequestsByWorkspaceId = useMemo( + () => + new Map( + (pullRequestData?.workspaces ?? []).map((workspace) => [ + workspace.workspaceId, + workspace.pullRequest, + ]), + ), + [pullRequestData?.workspaces], + ); + const groups = useMemo(() => { const projectsById = new Map< string, @@ -149,6 +217,19 @@ export function useDashboardSidebarData() { accentColor: null, name: workspace.name, branch: workspace.branch, + pullRequest: + hostType === "local-device" + ? (localPullRequestsByWorkspaceId.get(workspace.id) ?? null) + : null, + repoUrl: + project.githubOwner && project.githubRepoName + ? `https://github.com/${project.githubOwner}/${project.githubRepoName}` + : null, + branchExistsOnRemote: + project.githubOwner !== null && project.githubRepoName !== null, + previewUrl: null, + needsRebase: null, + behindCount: null, createdAt: workspace.createdAt, updatedAt: workspace.updatedAt, }; @@ -188,6 +269,7 @@ export function useDashboardSidebarData() { }); }, [ deviceInfo?.deviceId, + localPullRequestsByWorkspaceId, sidebarProjects, sidebarSections, sidebarWorkspaces, @@ -195,6 +277,8 @@ export function useDashboardSidebarData() { return { groups, + refetchPullRequests, + refreshWorkspacePullRequest, toggleProjectCollapsed, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index eed328ce9b1..6baff5cd8d1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -3,6 +3,23 @@ export type DashboardSidebarWorkspaceHostType = | "remote-device" | "cloud"; +export interface DashboardSidebarWorkspacePullRequestCheck { + name: string; + status: "success" | "failure" | "pending" | "skipped" | "cancelled"; + url: string | null; +} + +export interface DashboardSidebarWorkspacePullRequest { + url: string; + number: number; + title: string; + state: "open" | "merged" | "closed" | "draft"; + reviewDecision: "approved" | "changes_requested" | "pending" | null; + requestedReviewers?: string[]; + checksStatus: "success" | "failure" | "pending" | "none"; + checks: DashboardSidebarWorkspacePullRequestCheck[]; +} + export interface DashboardSidebarWorkspace { id: string; projectId: string; @@ -11,6 +28,12 @@ export interface DashboardSidebarWorkspace { accentColor: string | null; name: string; branch: string; + pullRequest: DashboardSidebarWorkspacePullRequest | null; + repoUrl: string | null; + branchExistsOnRemote: boolean; + previewUrl: string | null; + needsRebase: boolean | null; + behindCount: number | null; createdAt: Date; updatedAt: Date; } @@ -42,6 +65,7 @@ export interface DashboardSidebarProject { slug: string; githubRepositoryId: string | null; githubOwner: string | null; + githubRepoName: string | null; createdAt: Date; updatedAt: Date; isCollapsed: boolean; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx new file mode 100644 index 00000000000..4040e457cf3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx @@ -0,0 +1,172 @@ +import { Button } from "@superset/ui/button"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal as XTerm } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useWorkspaceHostUrl } from "../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; + +interface WorkspaceTerminalProps { + workspaceId: string; +} + +type TerminalServerMessage = + | { + type: "data"; + data: string; + } + | { + type: "error"; + message: string; + } + | { + type: "exit"; + exitCode: number; + signal: number; + }; + +export function WorkspaceTerminal({ workspaceId }: WorkspaceTerminalProps) { + const hostUrl = useWorkspaceHostUrl(); + const containerRef = useRef(null); + const [connectionState, setConnectionState] = useState< + "connecting" | "open" | "closed" + >("connecting"); + const [reconnectKey, setReconnectKey] = useState(0); + + const websocketUrl = useMemo(() => { + const url = new URL(`/terminal/${workspaceId}`, hostUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("reconnect", String(reconnectKey)); + return url.toString(); + }, [hostUrl, reconnectKey, workspaceId]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const fitAddon = new FitAddon(); + const terminal = new XTerm({ + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 12, + theme: { + background: "#14100f", + foreground: "#f5efe9", + }, + }); + terminal.loadAddon(fitAddon); + terminal.open(container); + fitAddon.fit(); + terminal.focus(); + + setConnectionState("connecting"); + const socket = new WebSocket(websocketUrl); + + const sendResize = () => { + if (socket.readyState !== WebSocket.OPEN) { + return; + } + + socket.send( + JSON.stringify({ + type: "resize", + cols: terminal.cols, + rows: terminal.rows, + }), + ); + }; + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + sendResize(); + }); + resizeObserver.observe(container); + + const onTerminalDataDispose = terminal.onData((data) => { + if (socket.readyState !== WebSocket.OPEN) { + return; + } + + socket.send( + JSON.stringify({ + type: "input", + data, + }), + ); + }); + + socket.addEventListener("open", () => { + setConnectionState("open"); + sendResize(); + }); + + socket.addEventListener("message", (event) => { + let message: TerminalServerMessage; + try { + message = JSON.parse(String(event.data)) as TerminalServerMessage; + } catch { + terminal.writeln("\r\n[terminal] invalid server payload"); + return; + } + + if (message.type === "data") { + terminal.write(message.data); + return; + } + + if (message.type === "error") { + terminal.writeln(`\r\n[terminal] ${message.message}`); + return; + } + + terminal.writeln( + `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, + ); + }); + + socket.addEventListener("close", () => { + setConnectionState("closed"); + }); + + socket.addEventListener("error", () => { + terminal.writeln("\r\n[terminal] websocket error"); + }); + + return () => { + resizeObserver.disconnect(); + onTerminalDataDispose.dispose(); + socket.close(); + terminal.dispose(); + }; + }, [websocketUrl]); + + return ( +
+
+
+

terminal

+

+ {connectionState === "open" + ? "Connected" + : connectionState === "connecting" + ? "Connecting..." + : "Disconnected"} +

+
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/index.ts new file mode 100644 index 00000000000..0c06f554ca5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/index.ts @@ -0,0 +1 @@ +export { WorkspaceTerminal } from "./WorkspaceTerminal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index d265a2da7ec..35d571b3786 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -2,6 +2,7 @@ import { eq, useLiveQuery } from "@tanstack/react-db"; import { createFileRoute } from "@tanstack/react-router"; import { workspaceTrpc } from "renderer/lib/workspace-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { WorkspaceTerminal } from "./components/WorkspaceTerminal"; export const Route = createFileRoute( "/_authenticated/_dashboard/v2-workspace/$workspaceId/", @@ -67,7 +68,7 @@ function V2WorkspaceContent({ }); return ( -
+

{workspaceName}

@@ -75,6 +76,8 @@ function V2WorkspaceContent({

+ +
@@ -96,7 +99,7 @@ function Section({ }; }) { return ( -
+

{title}

{query.isPending ? (

Loading...

diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx index d02a6471b42..fb8bb57bb72 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; -import type { ReactNode } from "react"; +import { createContext, type ReactNode, useContext } from "react"; import { workspaceTrpc } from "renderer/lib/workspace-trpc"; import superjson from "superjson"; @@ -19,6 +19,7 @@ type WorkspaceClients = { }; const workspaceClientsCache = new Map(); +const WorkspaceHostUrlContext = createContext(null); function getWorkspaceClients( cacheKey: string, @@ -63,8 +64,22 @@ export function WorkspaceTrpcProvider({ const { queryClient, trpcClient } = getWorkspaceClients(cacheKey, hostUrl); return ( - - {children} - + + + + {children} + + + ); } + +export function useWorkspaceHostUrl() { + const hostUrl = useContext(WorkspaceHostUrlContext); + if (!hostUrl) { + throw new Error( + "useWorkspaceHostUrl must be used within WorkspaceTrpcProvider", + ); + } + return hostUrl; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx index 790c83866c2..a0cd6208ef3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx @@ -87,11 +87,24 @@ export function DashboardNewWorkspaceDraftProvider({ const updateDraft = useCallback( (patch: Partial) => { - setState((state) => ({ - ...state, - ...patch, - draftVersion: state.draftVersion + 1, - })); + setState((state) => { + const entries = Object.entries(patch) as Array< + [ + keyof DashboardNewWorkspaceDraft, + DashboardNewWorkspaceDraft[keyof DashboardNewWorkspaceDraft], + ] + >; + const hasChanges = entries.some(([key, value]) => state[key] !== value); + if (!hasChanges) { + return state; + } + + return { + ...state, + ...patch, + draftVersion: state.draftVersion + 1, + }; + }); }, [], ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx index eceb80460a2..fe5b9b3ab0c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { useDashboardNewWorkspaceDraft } from "../../DashboardNewWorkspaceDraftContext"; import { DashboardNewWorkspaceFormHeader } from "./components/DashboardNewWorkspaceFormHeader"; import { DashboardNewWorkspaceListTabContent } from "./components/DashboardNewWorkspaceListTabContent"; @@ -16,13 +17,18 @@ export function DashboardNewWorkspaceForm({ preSelectedProjectId, }: DashboardNewWorkspaceFormProps) { const { draft, updateDraft } = useDashboardNewWorkspaceDraft(); + const handleSelectProject = useCallback( + (selectedProjectId: string | null) => { + updateDraft({ selectedProjectId }); + }, + [updateDraft], + ); const { githubRepository, githubRepositoryId } = useDashboardNewWorkspaceProjectSelection({ isOpen, preSelectedProjectId, selectedProjectId: draft.selectedProjectId, - onSelectProject: (selectedProjectId) => - updateDraft({ selectedProjectId }), + onSelectProject: handleSelectProject, }); const resolvedLocalProjectId = useResolvedLocalProject(githubRepository); @@ -59,9 +65,7 @@ export function DashboardNewWorkspaceForm({ selectedProjectId={draft.selectedProjectId} onSelectTab={(activeTab) => updateDraft({ activeTab })} onSelectHostTarget={(hostTarget) => updateDraft({ hostTarget })} - onSelectProject={(selectedProjectId) => - updateDraft({ selectedProjectId }) - } + onSelectProject={handleSelectProject} /> {isListTab ? ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx index 45f2c93e6be..d9d45c15bee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx @@ -10,7 +10,7 @@ interface DashboardNewWorkspaceFormHeaderProps { selectedProjectId: string | null; onSelectTab: (tab: DashboardNewWorkspaceTab) => void; onSelectHostTarget: (hostTarget: WorkspaceHostTarget) => void; - onSelectProject: (projectId: string) => void; + onSelectProject: (projectId: string | null) => void; } export function DashboardNewWorkspaceFormHeader({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/useDashboardNewWorkspaceProjectSelection.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/useDashboardNewWorkspaceProjectSelection.ts index 1297b6f3814..fcd87eb2a64 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/useDashboardNewWorkspaceProjectSelection.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/useDashboardNewWorkspaceProjectSelection.ts @@ -62,7 +62,10 @@ export function useDashboardNewWorkspaceProjectSelection({ (project) => project.id === selectedProjectId, ); if (!hasSelectedProject) { - onSelectProject(v2Projects[0]?.id ?? null); + const nextProjectId = v2Projects[0]?.id ?? null; + if (nextProjectId !== selectedProjectId) { + onSelectProject(nextProjectId); + } } }, [ selectedProjectId, diff --git a/bun.lock b/bun.lock index 9a1fc3f0e64..07561278ab6 100644 --- a/bun.lock +++ b/bun.lock @@ -738,6 +738,7 @@ "version": "0.1.0", "dependencies": { "@hono/node-server": "^1.14.1", + "@hono/node-ws": "^1.3.0", "@hono/trpc-server": "^0.3.4", "@octokit/rest": "^22.0.1", "@superset/trpc": "workspace:*", @@ -746,6 +747,7 @@ "better-sqlite3": "12.6.2", "drizzle-orm": "0.45.1", "hono": "^4.8.5", + "node-pty": "^1.1.0", "simple-git": "^3.30.0", "superjson": "^2.2.5", "zod": "^4.3.5", @@ -1484,6 +1486,8 @@ "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="], + "@hono/trpc-server": ["@hono/trpc-server@0.3.4", "", { "peerDependencies": { "@trpc/server": "^10.10.0 || >11.0.0-rc", "hono": ">=4.*" } }, "sha512-xFOPjUPnII70FgicDzOJy1ufIoBTu8eF578zGiDOrYOrYN8CJe140s9buzuPkX+SwJRYK8LjEBHywqZtxdm8aA=="], "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], diff --git a/packages/host-service/drizzle/0001_famous_mindworm.sql b/packages/host-service/drizzle/0001_famous_mindworm.sql new file mode 100644 index 00000000000..3e805c6547a --- /dev/null +++ b/packages/host-service/drizzle/0001_famous_mindworm.sql @@ -0,0 +1,34 @@ +CREATE TABLE `pull_requests` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `repo_provider` text NOT NULL, + `repo_owner` text NOT NULL, + `repo_name` text NOT NULL, + `pr_number` integer NOT NULL, + `url` text NOT NULL, + `title` text NOT NULL, + `state` text NOT NULL, + `is_draft` integer DEFAULT false NOT NULL, + `head_branch` text NOT NULL, + `head_sha` text NOT NULL, + `review_decision` text, + `checks_status` text DEFAULT 'none' NOT NULL, + `checks_json` text DEFAULT '[]' NOT NULL, + `last_fetched_at` integer, + `error` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `pull_requests_project_id_idx` ON `pull_requests` (`project_id`);--> statement-breakpoint +CREATE INDEX `pull_requests_repo_branch_idx` ON `pull_requests` (`repo_provider`,`repo_owner`,`repo_name`,`head_branch`);--> statement-breakpoint +CREATE UNIQUE INDEX `pull_requests_repo_pr_unique` ON `pull_requests` (`repo_provider`,`repo_owner`,`repo_name`,`pr_number`);--> statement-breakpoint +ALTER TABLE `projects` ADD `repo_provider` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `repo_owner` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `repo_name` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `repo_url` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `remote_name` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `head_sha` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `pull_request_id` text REFERENCES pull_requests(id);--> statement-breakpoint +CREATE INDEX `workspaces_pull_request_id_idx` ON `workspaces` (`pull_request_id`); \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0001_snapshot.json b/packages/host-service/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000000..0d3e2240c29 --- /dev/null +++ b/packages/host-service/drizzle/meta/0001_snapshot.json @@ -0,0 +1,388 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f5887d62-b5ad-4441-9648-d4456be25acb", + "prevId": "6af1c5ed-ea02-45ae-8582-299afac9295e", + "tables": { + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_branch_idx": { + "name": "workspaces_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index fbab491061a..af209bd1b35 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1773188543980, "tag": "0000_initial_projects_workspaces", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1773705162679, + "tag": "0001_famous_mindworm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 67322bb0434..017e0aa3239 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@hono/node-server": "^1.14.1", + "@hono/node-ws": "^1.3.0", "@hono/trpc-server": "^0.3.4", "@octokit/rest": "^22.0.1", "@superset/trpc": "workspace:*", @@ -37,6 +38,7 @@ "better-sqlite3": "12.6.2", "drizzle-orm": "0.45.1", "hono": "^4.8.5", + "node-pty": "1.1.0", "simple-git": "^3.30.0", "superjson": "^2.2.5", "zod": "^4.3.5" diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 7799463e901..981a1c5e11e 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -1,14 +1,18 @@ import { homedir } from "node:os"; import { join } from "node:path"; +import { createNodeWebSocket } from "@hono/node-ws"; import { trpcServer } from "@hono/trpc-server"; +import { Octokit } from "@octokit/rest"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { createApiClient } from "./api"; import type { AuthProvider } from "./auth/types"; import { createDb } from "./db"; +import { createGitFactory } from "./git/createGitFactory"; import { LocalCredentialProvider } from "./git/providers"; import type { CredentialProvider } from "./git/types"; -import { createContextFactory } from "./trpc/context"; +import { PullRequestRuntimeManager } from "./runtime/pull-requests"; +import { registerWorkspaceTerminalRoute } from "./terminal/terminal"; import { appRouter } from "./trpc/router"; export interface CreateAppOptions { @@ -20,7 +24,12 @@ export interface CreateAppOptions { deviceName?: string; } -export function createApp(options?: CreateAppOptions) { +export interface CreateAppResult { + app: Hono; + injectWebSocket: ReturnType["injectWebSocket"]; +} + +export function createApp(options?: CreateAppOptions): CreateAppResult { const credentials = options?.credentials ?? new LocalCredentialProvider(); const api = @@ -30,25 +39,50 @@ export function createApp(options?: CreateAppOptions) { const dbPath = options?.dbPath ?? join(homedir(), ".superset", "host.db"); const db = createDb(dbPath); - - const createContext = createContextFactory({ - credentials, - api, + const git = createGitFactory(credentials); + const github = async () => { + const token = await credentials.getToken("github.com"); + if (!token) { + throw new Error( + "No GitHub token available. Set GITHUB_TOKEN/GH_TOKEN or authenticate via git credential manager.", + ); + } + return new Octokit({ auth: token }); + }; + const pullRequestRuntime = new PullRequestRuntimeManager({ db, - deviceClientId: options?.deviceClientId, - deviceName: options?.deviceName, + git, + github, }); + pullRequestRuntime.start(); + const runtime = { + pullRequests: pullRequestRuntime, + }; const app = new Hono(); + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); app.use("*", cors()); + registerWorkspaceTerminalRoute({ + app, + db, + upgradeWebSocket, + }); app.use( "/trpc/*", trpcServer({ router: appRouter, - createContext: () => - createContext() as unknown as Record, + createContext: async () => + ({ + git, + github, + api, + db, + runtime, + deviceClientId: options?.deviceClientId ?? null, + deviceName: options?.deviceName ?? null, + }) as Record, }), ); - return app; + return { app, injectWebSocket }; } diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index d75d66607bd..d6b88c7f18a 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -1,10 +1,21 @@ -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; export const projects = sqliteTable( "projects", { id: text().primaryKey(), repoPath: text("repo_path").notNull(), + repoProvider: text("repo_provider"), + repoOwner: text("repo_owner"), + repoName: text("repo_name"), + repoUrl: text("repo_url"), + remoteName: text("remote_name"), createdAt: integer("created_at") .notNull() .$defaultFn(() => Date.now()), @@ -12,6 +23,52 @@ export const projects = sqliteTable( (table) => [index("projects_repo_path_idx").on(table.repoPath)], ); +export const pullRequests = sqliteTable( + "pull_requests", + { + id: text().primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + repoProvider: text("repo_provider").notNull(), + repoOwner: text("repo_owner").notNull(), + repoName: text("repo_name").notNull(), + prNumber: integer("pr_number").notNull(), + url: text().notNull(), + title: text().notNull(), + state: text().notNull(), + isDraft: integer("is_draft", { mode: "boolean" }).notNull().default(false), + headBranch: text("head_branch").notNull(), + headSha: text("head_sha").notNull(), + reviewDecision: text("review_decision"), + checksStatus: text("checks_status").notNull().default("none"), + checksJson: text("checks_json").notNull().default("[]"), + lastFetchedAt: integer("last_fetched_at"), + error: text(), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + updatedAt: integer("updated_at") + .notNull() + .$defaultFn(() => Date.now()), + }, + (table) => [ + index("pull_requests_project_id_idx").on(table.projectId), + index("pull_requests_repo_branch_idx").on( + table.repoProvider, + table.repoOwner, + table.repoName, + table.headBranch, + ), + uniqueIndex("pull_requests_repo_pr_unique").on( + table.repoProvider, + table.repoOwner, + table.repoName, + table.prNumber, + ), + ], +); + export const workspaces = sqliteTable( "workspaces", { @@ -21,6 +78,10 @@ export const workspaces = sqliteTable( .references(() => projects.id, { onDelete: "cascade" }), worktreePath: text("worktree_path").notNull(), branch: text().notNull(), + headSha: text("head_sha"), + pullRequestId: text("pull_request_id").references(() => pullRequests.id, { + onDelete: "set null", + }), createdAt: integer("created_at") .notNull() .$defaultFn(() => Date.now()), @@ -28,5 +89,6 @@ export const workspaces = sqliteTable( (table) => [ index("workspaces_project_id_idx").on(table.projectId), index("workspaces_branch_idx").on(table.branch), + index("workspaces_pull_request_id_idx").on(table.pullRequestId), ], ); diff --git a/packages/host-service/src/runtime/pull-requests/index.ts b/packages/host-service/src/runtime/pull-requests/index.ts new file mode 100644 index 00000000000..4ea1fd133a5 --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/index.ts @@ -0,0 +1,6 @@ +export { + PullRequestRuntimeManager, + type PullRequestRuntimeManagerOptions, + type PullRequestStateSnapshot, + type PullRequestWorkspaceSnapshot, +} from "./pull-requests"; diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts new file mode 100644 index 00000000000..94fb5be8516 --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -0,0 +1,436 @@ +import { randomUUID } from "node:crypto"; +import type { Octokit } from "@octokit/rest"; +import { and, eq, inArray } from "drizzle-orm"; +import type { HostDb } from "../../db"; +import { projects, pullRequests, workspaces } from "../../db/schema"; +import type { GitFactory } from "../../git/types"; +import { fetchRepositoryPullRequests } from "./utils/github-query"; +import { parseGitHubRemote } from "./utils/parse-github-remote"; +import { + type ChecksStatus, + coerceChecksStatus, + coercePullRequestState, + coerceReviewDecision, + computeChecksStatus, + mapPullRequestState, + mapReviewDecision, + type PullRequestCheck, + type PullRequestState, + parseCheckContexts, + parseChecksJson, + type ReviewDecision, +} from "./utils/pull-request-mappers"; + +const BRANCH_SYNC_INTERVAL_MS = 10_000; +const PROJECT_REFRESH_INTERVAL_MS = 15_000; + +type RepoProvider = "github"; + +export interface PullRequestStateSnapshot { + url: string; + number: number; + title: string; + state: PullRequestState; + reviewDecision: ReviewDecision; + checksStatus: ChecksStatus; + checks: PullRequestCheck[]; +} + +export interface PullRequestWorkspaceSnapshot { + workspaceId: string; + pullRequest: PullRequestStateSnapshot | null; + error: string | null; + lastFetchedAt: string | null; +} + +export interface PullRequestRuntimeManagerOptions { + db: HostDb; + git: GitFactory; + github: () => Promise; +} + +interface NormalizedRepoIdentity { + provider: RepoProvider; + owner: string; + name: string; + url: string; + remoteName: string; +} + +export class PullRequestRuntimeManager { + private readonly db: HostDb; + private readonly git: GitFactory; + private readonly github: () => Promise; + private branchSyncTimer: ReturnType | null = null; + private projectRefreshTimer: ReturnType | null = null; + private readonly inFlightProjects = new Map>(); + private readonly nextProjectRefreshAt = new Map(); + + constructor(options: PullRequestRuntimeManagerOptions) { + this.db = options.db; + this.git = options.git; + this.github = options.github; + } + + start() { + if (this.branchSyncTimer || this.projectRefreshTimer) return; + + this.branchSyncTimer = setInterval(() => { + void this.syncWorkspaceBranches(); + }, BRANCH_SYNC_INTERVAL_MS); + this.projectRefreshTimer = setInterval(() => { + void this.refreshEligibleProjects(); + }, PROJECT_REFRESH_INTERVAL_MS); + + void this.syncWorkspaceBranches(); + void this.refreshEligibleProjects(true); + } + + stop() { + if (this.branchSyncTimer) clearInterval(this.branchSyncTimer); + if (this.projectRefreshTimer) clearInterval(this.projectRefreshTimer); + this.branchSyncTimer = null; + this.projectRefreshTimer = null; + } + + async getPullRequestsByWorkspaces( + workspaceIds: string[], + ): Promise { + if (workspaceIds.length === 0) return []; + + const rows = this.db + .select({ + workspaceId: workspaces.id, + pullRequestUrl: pullRequests.url, + pullRequestNumber: pullRequests.prNumber, + pullRequestTitle: pullRequests.title, + pullRequestState: pullRequests.state, + pullRequestReviewDecision: pullRequests.reviewDecision, + pullRequestChecksStatus: pullRequests.checksStatus, + pullRequestChecksJson: pullRequests.checksJson, + pullRequestLastFetchedAt: pullRequests.lastFetchedAt, + pullRequestError: pullRequests.error, + }) + .from(workspaces) + .leftJoin(pullRequests, eq(workspaces.pullRequestId, pullRequests.id)) + .where(inArray(workspaces.id, workspaceIds)) + .all(); + + return rows.map((row) => ({ + workspaceId: row.workspaceId, + pullRequest: + row.pullRequestUrl && + row.pullRequestNumber !== null && + row.pullRequestNumber !== undefined + ? { + url: row.pullRequestUrl, + number: row.pullRequestNumber, + title: row.pullRequestTitle ?? "", + state: coercePullRequestState(row.pullRequestState), + reviewDecision: coerceReviewDecision( + row.pullRequestReviewDecision, + ), + checksStatus: coerceChecksStatus(row.pullRequestChecksStatus), + checks: parseChecksJson(row.pullRequestChecksJson), + } + : null, + error: row.pullRequestError ?? null, + lastFetchedAt: row.pullRequestLastFetchedAt + ? new Date(row.pullRequestLastFetchedAt).toISOString() + : null, + })); + } + + async refreshPullRequestsByWorkspaces(workspaceIds: string[]): Promise { + if (workspaceIds.length === 0) return; + + const rows = this.db + .select({ + projectId: workspaces.projectId, + }) + .from(workspaces) + .where(inArray(workspaces.id, workspaceIds)) + .all(); + + const projectIds = [...new Set(rows.map((row) => row.projectId))]; + await Promise.all( + projectIds.map((projectId) => this.refreshProject(projectId, true)), + ); + } + + private async syncWorkspaceBranches(): Promise { + const allWorkspaces = this.db.select().from(workspaces).all(); + const changedProjectIds = new Set(); + + for (const workspace of allWorkspaces) { + try { + const git = await this.git(workspace.worktreePath); + const [rawBranch, rawHeadSha] = await Promise.all([ + git.revparse(["--abbrev-ref", "HEAD"]), + git.revparse(["HEAD"]), + ]); + const branch = rawBranch.trim(); + const headSha = rawHeadSha.trim(); + + if (branch === workspace.branch && headSha === workspace.headSha) { + continue; + } + + this.db + .update(workspaces) + .set({ + branch, + headSha, + }) + .where(eq(workspaces.id, workspace.id)) + .run(); + + changedProjectIds.add(workspace.projectId); + } catch (error) { + console.warn( + "[host-service:pull-request-runtime] Failed to sync workspace branch", + { + workspaceId: workspace.id, + worktreePath: workspace.worktreePath, + error, + }, + ); + } + } + + await Promise.all( + [...changedProjectIds].map((projectId) => + this.refreshProject(projectId, true), + ), + ); + } + + private async refreshEligibleProjects(force = false): Promise { + const rows = this.db + .select({ + projectId: workspaces.projectId, + }) + .from(workspaces) + .all(); + const projectIds = [...new Set(rows.map((row) => row.projectId))]; + await Promise.all( + projectIds.map((projectId) => this.refreshProject(projectId, force)), + ); + } + + private async refreshProject( + projectId: string, + force = false, + ): Promise { + const now = Date.now(); + const existing = this.inFlightProjects.get(projectId); + if (existing) { + await existing; + return; + } + + const nextEligibleRefreshAt = this.nextProjectRefreshAt.get(projectId) ?? 0; + if (!force && nextEligibleRefreshAt > now) { + return; + } + + const refreshPromise = this.performProjectRefresh(projectId) + .catch((error) => { + console.warn( + "[host-service:pull-request-runtime] Project refresh failed", + { + projectId, + error, + }, + ); + }) + .finally(() => { + this.inFlightProjects.delete(projectId); + this.nextProjectRefreshAt.set( + projectId, + Date.now() + PROJECT_REFRESH_INTERVAL_MS, + ); + }); + + this.inFlightProjects.set(projectId, refreshPromise); + await refreshPromise; + } + + private async performProjectRefresh(projectId: string): Promise { + const repo = await this.getProjectRepository(projectId); + if (!repo) return; + + const projectWorkspaces = this.db + .select() + .from(workspaces) + .where(eq(workspaces.projectId, projectId)) + .all(); + if (projectWorkspaces.length === 0) return; + + const branchNames = [ + ...new Set(projectWorkspaces.map((workspace) => workspace.branch)), + ]; + const branchToPullRequest = await this.fetchRepoPullRequests( + projectId, + repo, + branchNames, + ); + + for (const workspace of projectWorkspaces) { + const match = branchToPullRequest.get(workspace.branch) ?? null; + this.db + .update(workspaces) + .set({ + pullRequestId: match?.id ?? null, + }) + .where(eq(workspaces.id, workspace.id)) + .run(); + } + } + + private async getProjectRepository( + projectId: string, + ): Promise { + const project = this.db.query.projects + .findFirst({ where: eq(projects.id, projectId) }) + .sync(); + if (!project) return null; + + if ( + project.repoProvider === "github" && + project.repoOwner && + project.repoName && + project.repoUrl && + project.remoteName + ) { + return { + provider: "github", + owner: project.repoOwner, + name: project.repoName, + url: project.repoUrl, + remoteName: project.remoteName, + }; + } + + const git = await this.git(project.repoPath); + const remoteName = "origin"; + let remoteUrl: string; + try { + const value = await git.remote(["get-url", remoteName]); + if (typeof value !== "string") { + return null; + } + remoteUrl = value.trim(); + } catch { + return null; + } + + const parsedRemote = parseGitHubRemote(remoteUrl); + if (!parsedRemote) return null; + + this.db + .update(projects) + .set({ + repoProvider: parsedRemote.provider, + repoOwner: parsedRemote.owner, + repoName: parsedRemote.name, + repoUrl: parsedRemote.url, + remoteName, + }) + .where(eq(projects.id, projectId)) + .run(); + + return { + ...parsedRemote, + remoteName, + }; + } + + private async fetchRepoPullRequests( + projectId: string, + repo: NormalizedRepoIdentity, + branches: string[], + ): Promise> { + const octokit = await this.github(); + const nodes = await fetchRepositoryPullRequests(octokit, { + owner: repo.owner, + name: repo.name, + }); + + const wantedBranches = new Set(branches); + const latestByBranch = new Map(); + + for (const node of nodes) { + if (!wantedBranches.has(node.headRefName)) continue; + const existing = latestByBranch.get(node.headRefName); + if ( + !existing || + new Date(node.updatedAt).getTime() > + new Date(existing.updatedAt).getTime() + ) { + latestByBranch.set(node.headRefName, node); + } + } + + const branchToRow = new Map(); + const now = Date.now(); + + for (const [branch, node] of latestByBranch) { + const existing = this.db.query.pullRequests + .findFirst({ + where: and( + eq(pullRequests.repoProvider, repo.provider), + eq(pullRequests.repoOwner, repo.owner), + eq(pullRequests.repoName, repo.name), + eq(pullRequests.prNumber, node.number), + ), + }) + .sync(); + + const rowId = existing?.id ?? randomUUID(); + const checks = parseCheckContexts( + node.statusCheckRollup?.contexts?.nodes ?? [], + ); + const data = { + projectId, + repoProvider: repo.provider, + repoOwner: repo.owner, + repoName: repo.name, + prNumber: node.number, + url: node.url, + title: node.title, + state: mapPullRequestState(node.state, node.isDraft), + isDraft: node.isDraft, + headBranch: node.headRefName, + headSha: node.headRefOid, + reviewDecision: mapReviewDecision(node.reviewDecision), + checksStatus: computeChecksStatus(checks), + checksJson: JSON.stringify(checks), + lastFetchedAt: now, + error: null, + updatedAt: now, + }; + + if (existing) { + this.db + .update(pullRequests) + .set(data) + .where(eq(pullRequests.id, rowId)) + .run(); + } else { + this.db + .insert(pullRequests) + .values({ + id: rowId, + createdAt: now, + ...data, + }) + .run(); + } + + branchToRow.set(branch, { id: rowId }); + } + + return branchToRow; + } +} diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts new file mode 100644 index 00000000000..88dcd636d2e --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/github-query.ts @@ -0,0 +1,26 @@ +import type { Octokit } from "@octokit/rest"; +import { PULL_REQUESTS_QUERY } from "./query"; +import type { + GraphQLPullRequestNode, + PullRequestsGraphQLResult, +} from "./types"; + +export async function fetchRepositoryPullRequests( + octokit: Octokit, + repository: { + owner: string; + name: string; + }, +): Promise { + const result = await octokit.graphql( + PULL_REQUESTS_QUERY, + { + owner: repository.owner, + repo: repository.name, + }, + ); + + return (result.repository?.pullRequests?.nodes ?? []).filter( + (node): node is GraphQLPullRequestNode => node !== null, + ); +} diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts new file mode 100644 index 00000000000..864c78a90b9 --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/index.ts @@ -0,0 +1,5 @@ +export { fetchRepositoryPullRequests } from "./github-query"; +export type { + GraphQLCheckContextNode, + GraphQLPullRequestNode, +} from "./types"; diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts new file mode 100644 index 00000000000..0073f238276 --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/query.ts @@ -0,0 +1,45 @@ +export const PULL_REQUESTS_QUERY = ` + query PullRequestsForSidebar($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: [OPEN, CLOSED, MERGED], orderBy: { field: UPDATED_AT, direction: DESC }) { + nodes { + number + title + url + state + isDraft + headRefName + headRefOid + reviewDecision + updatedAt + statusCheckRollup { + contexts(first: 50) { + nodes { + __typename + ... on CheckRun { + name + conclusion + detailsUrl + status + startedAt + completedAt + checkSuite { + workflowRun { + databaseId + } + } + } + ... on StatusContext { + context + state + targetUrl + createdAt + } + } + } + } + } + } + } + } +`; diff --git a/packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts b/packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts new file mode 100644 index 00000000000..1372e841a53 --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/utils/github-query/types.ts @@ -0,0 +1,52 @@ +export interface GraphQLCheckRunNode { + __typename: "CheckRun"; + name: string; + conclusion: string | null; + detailsUrl: string | null; + status: string; + startedAt: string | null; + completedAt: string | null; + checkSuite: { + workflowRun: { + databaseId: number | null; + } | null; + } | null; +} + +export interface GraphQLStatusContextNode { + __typename: "StatusContext"; + context: string; + state: string; + targetUrl: string | null; + createdAt: string | null; +} + +export type GraphQLCheckContextNode = + | GraphQLCheckRunNode + | GraphQLStatusContextNode + | null; + +export interface GraphQLPullRequestNode { + number: number; + title: string; + url: string; + state: "OPEN" | "CLOSED" | "MERGED"; + isDraft: boolean; + headRefName: string; + headRefOid: string; + reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; + updatedAt: string; + statusCheckRollup: { + contexts: { + nodes: GraphQLCheckContextNode[]; + } | null; + } | null; +} + +export interface PullRequestsGraphQLResult { + repository?: { + pullRequests?: { + nodes?: Array; + }; + } | null; +} diff --git a/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts b/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts new file mode 100644 index 00000000000..25ae243201e --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts @@ -0,0 +1,4 @@ +export { + type ParsedGitHubRemote, + parseGitHubRemote, +} from "./parse-github-remote"; diff --git a/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/parse-github-remote.ts b/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/parse-github-remote.ts new file mode 100644 index 00000000000..48a5ca3b0b8 --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/parse-github-remote.ts @@ -0,0 +1,31 @@ +export interface ParsedGitHubRemote { + provider: "github"; + owner: string; + name: string; + url: string; +} + +export function parseGitHubRemote( + remoteUrl: string, +): ParsedGitHubRemote | null { + const trimmed = remoteUrl.trim(); + const patterns = [ + /^git@github\.com:(?[^/]+)\/(?[^/]+?)(?:\.git)?$/, + /^ssh:\/\/git@github\.com\/(?[^/]+)\/(?[^/]+?)(?:\.git)?$/, + /^https:\/\/github\.com\/(?[^/]+)\/(?[^/]+?)(?:\.git)?\/?$/, + ]; + + for (const pattern of patterns) { + const match = pattern.exec(trimmed); + if (!match?.groups?.owner || !match.groups.name) continue; + + return { + provider: "github", + owner: match.groups.owner, + name: match.groups.name, + url: `https://github.com/${match.groups.owner}/${match.groups.name}`, + }; + } + + return null; +} diff --git a/packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/index.ts b/packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/index.ts new file mode 100644 index 00000000000..c4808a2bc29 --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/index.ts @@ -0,0 +1,14 @@ +export { + type ChecksStatus, + coerceChecksStatus, + coercePullRequestState, + coerceReviewDecision, + computeChecksStatus, + mapPullRequestState, + mapReviewDecision, + type PullRequestCheck, + type PullRequestState, + parseCheckContexts, + parseChecksJson, + type ReviewDecision, +} from "./pull-request-mappers"; diff --git a/packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/pull-request-mappers.ts b/packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/pull-request-mappers.ts new file mode 100644 index 00000000000..b3f3637e319 --- /dev/null +++ b/packages/host-service/src/runtime/pull-requests/utils/pull-request-mappers/pull-request-mappers.ts @@ -0,0 +1,199 @@ +import type { + GraphQLCheckContextNode, + GraphQLPullRequestNode, +} from "../github-query"; + +export type PullRequestState = "open" | "draft" | "merged" | "closed"; +export type ReviewDecision = + | "approved" + | "changes_requested" + | "pending" + | null; +export type ChecksStatus = "success" | "failure" | "pending" | "none"; +type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"; + +export interface PullRequestCheck { + name: string; + status: CheckStatus; + url: string | null; +} + +export function mapPullRequestState( + state: GraphQLPullRequestNode["state"], + isDraft: boolean, +): PullRequestState { + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + if (isDraft) return "draft"; + return "open"; +} + +export function mapReviewDecision( + value: GraphQLPullRequestNode["reviewDecision"], +): ReviewDecision { + if (value === "APPROVED") return "approved"; + if (value === "CHANGES_REQUESTED") return "changes_requested"; + if (value === "REVIEW_REQUIRED") return "pending"; + return null; +} + +export function parseCheckContexts( + nodes: GraphQLCheckContextNode[], +): PullRequestCheck[] { + const checks = nodes + .filter( + (node): node is NonNullable => node !== null, + ) + .map((node) => { + if (node.__typename === "CheckRun") { + return { + name: node.name, + status: mapCheckRunStatus(node.status, node.conclusion), + url: node.detailsUrl, + recency: getCheckRunRecency(node), + }; + } + + return { + name: node.context, + status: mapStatusContextState(node.state), + url: node.targetUrl, + recency: getStatusContextRecency(node), + }; + }); + + const dedupedChecks = new Map< + string, + PullRequestCheck & { + recency: number; + } + >(); + for (const check of checks) { + const existing = dedupedChecks.get(check.name); + if (!existing || check.recency > existing.recency) { + dedupedChecks.set(check.name, check); + } + } + + return [...dedupedChecks.values()].map( + ({ recency: _recency, ...check }) => check, + ); +} + +export function computeChecksStatus(checks: PullRequestCheck[]): ChecksStatus { + if (checks.length === 0) return "none"; + if (checks.some((check) => check.status === "failure")) return "failure"; + if (checks.some((check) => check.status === "pending")) return "pending"; + return "success"; +} + +export function coercePullRequestState(value: string | null): PullRequestState { + if (value === "merged" || value === "closed" || value === "draft") { + return value; + } + return "open"; +} + +export function coerceReviewDecision(value: string | null): ReviewDecision { + if ( + value === "approved" || + value === "changes_requested" || + value === "pending" + ) { + return value; + } + return null; +} + +export function coerceChecksStatus(value: string | null): ChecksStatus { + if (value === "success" || value === "failure" || value === "pending") { + return value; + } + return "none"; +} + +export function parseChecksJson(value: string | null): PullRequestCheck[] { + if (!value) return []; + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + + return parsed.filter( + (item): item is PullRequestCheck => + typeof item === "object" && + item !== null && + typeof item.name === "string" && + typeof item.status === "string" && + (item.url === null || typeof item.url === "string"), + ); + } catch { + return []; + } +} + +function mapCheckRunStatus( + status: string, + conclusion: string | null, +): CheckStatus { + if (status !== "COMPLETED") return "pending"; + + switch (conclusion) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "TIMED_OUT": + case "ACTION_REQUIRED": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + case "NEUTRAL": + return "skipped"; + default: + return "pending"; + } +} + +function mapStatusContextState(state: string): CheckStatus { + switch (state) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "ERROR": + return "failure"; + case "EXPECTED": + case "PENDING": + return "pending"; + default: + return "pending"; + } +} + +function getCheckRunRecency( + node: Extract, +): number { + const workflowRunId = node.checkSuite?.workflowRun?.databaseId; + if (typeof workflowRunId === "number") { + return workflowRunId; + } + + const timestamp = node.completedAt ?? node.startedAt; + if (!timestamp) { + return 0; + } + + const time = Date.parse(timestamp); + return Number.isNaN(time) ? 0 : time; +} + +function getStatusContextRecency( + node: Extract, +): number { + if (!node.createdAt) { + return 0; + } + + const time = Date.parse(node.createdAt); + return Number.isNaN(time) ? 0 : time; +} diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index 924a354f4d0..bf44a8114f4 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -2,9 +2,10 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app"; const dbPath = process.env.HOST_DB_PATH?.trim() || undefined; -const app = createApp({ dbPath }); +const { app, injectWebSocket } = createApp({ dbPath }); const port = Number(process.env.PORT) || 4879; -serve({ fetch: app.fetch, port }, (info) => { +const server = serve({ fetch: app.fetch, port }, (info) => { console.log(`[host-service] listening on http://localhost:${info.port}`); }); +injectWebSocket(server); diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts new file mode 100644 index 00000000000..5804d60e4b4 --- /dev/null +++ b/packages/host-service/src/terminal/terminal.ts @@ -0,0 +1,178 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import type { NodeWebSocket } from "@hono/node-ws"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { type IPty, spawn } from "node-pty"; +import type { HostDb } from "../db"; +import { workspaces } from "../db/schema"; + +interface RegisterWorkspaceTerminalRouteOptions { + app: Hono; + db: HostDb; + upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; +} + +type TerminalClientMessage = + | { + type: "input"; + data: string; + } + | { + type: "resize"; + cols: number; + rows: number; + }; + +type TerminalServerMessage = + | { + type: "data"; + data: string; + } + | { + type: "error"; + message: string; + } + | { + type: "exit"; + exitCode: number; + signal: number; + }; + +function sendMessage( + socket: { + send: (data: string) => void; + readyState: number; + }, + message: TerminalServerMessage, +) { + if (socket.readyState !== 1) { + return; + } + socket.send(JSON.stringify(message)); +} + +function resolveShell(): string { + if (process.platform === "win32") { + return process.env.COMSPEC || "cmd.exe"; + } + + return process.env.SHELL || "/bin/zsh"; +} + +export function registerWorkspaceTerminalRoute({ + app, + db, + upgradeWebSocket, +}: RegisterWorkspaceTerminalRouteOptions) { + app.get( + "/terminal/:workspaceId", + upgradeWebSocket((c) => { + const workspaceId = c.req.param("workspaceId"); + const workspace = db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + + let terminal: IPty | null = null; + let disposed = false; + + const disposeTerminal = () => { + if (disposed) { + return; + } + disposed = true; + terminal?.kill(); + terminal = null; + }; + + return { + onOpen: (_event, ws) => { + if (!workspace || !existsSync(workspace.worktreePath)) { + sendMessage(ws, { + type: "error", + message: "Workspace worktree not found", + }); + ws.close(1011, "Workspace worktree not found"); + return; + } + + try { + terminal = spawn(resolveShell(), [], { + name: "xterm-256color", + cwd: workspace.worktreePath, + cols: 120, + rows: 32, + env: { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + HOME: process.env.HOME || homedir(), + PWD: workspace.worktreePath, + }, + }); + } catch (error) { + sendMessage(ws, { + type: "error", + message: + error instanceof Error + ? error.message + : "Failed to start terminal", + }); + ws.close(1011, "Failed to start terminal"); + return; + } + + terminal.onData((data) => { + sendMessage(ws, { + type: "data", + data, + }); + }); + + terminal.onExit(({ exitCode, signal }) => { + sendMessage(ws, { + type: "exit", + exitCode: exitCode ?? 0, + signal: signal ?? 0, + }); + ws.close(1000, "Terminal exited"); + disposeTerminal(); + }); + }, + onMessage: (event, ws) => { + if (!terminal) { + return; + } + + let message: TerminalClientMessage; + try { + message = JSON.parse(String(event.data)) as TerminalClientMessage; + } catch { + sendMessage(ws, { + type: "error", + message: "Invalid terminal message payload", + }); + return; + } + + if (message.type === "input") { + terminal.write(message.data); + return; + } + + if (message.type === "resize") { + const cols = Math.max(20, Math.floor(message.cols)); + const rows = Math.max(5, Math.floor(message.rows)); + terminal.resize(cols, rows); + } + }, + onClose: () => { + disposeTerminal(); + }, + onError: () => { + disposeTerminal(); + }, + }; + }), + ); +} diff --git a/packages/host-service/src/trpc/context/context.ts b/packages/host-service/src/trpc/context/context.ts deleted file mode 100644 index 4ad7f2956db..00000000000 --- a/packages/host-service/src/trpc/context/context.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Octokit } from "@octokit/rest"; -import type { HostDb } from "../../db"; -import { createGitFactory } from "../../git/createGitFactory"; -import type { CredentialProvider } from "../../git/types"; -import type { ApiClient, HostServiceContext } from "../../types"; - -export function createContextFactory(opts: { - credentials: CredentialProvider; - api: ApiClient | null; - db: HostDb; - deviceClientId?: string; - deviceName?: string; -}): () => Promise { - return async () => ({ - git: createGitFactory(opts.credentials), - github: async () => { - const token = await opts.credentials.getToken("github.com"); - if (!token) { - throw new Error( - "No GitHub token available. Set GITHUB_TOKEN/GH_TOKEN or authenticate via git credential manager.", - ); - } - return new Octokit({ auth: token }); - }, - api: opts.api, - db: opts.db, - deviceClientId: opts.deviceClientId ?? null, - deviceName: opts.deviceName ?? null, - }); -} diff --git a/packages/host-service/src/trpc/context/index.ts b/packages/host-service/src/trpc/context/index.ts deleted file mode 100644 index 2890f795872..00000000000 --- a/packages/host-service/src/trpc/context/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createContextFactory } from "./context"; diff --git a/packages/host-service/src/trpc/router/pull-requests/index.ts b/packages/host-service/src/trpc/router/pull-requests/index.ts new file mode 100644 index 00000000000..3204b3126a9 --- /dev/null +++ b/packages/host-service/src/trpc/router/pull-requests/index.ts @@ -0,0 +1 @@ +export { pullRequestsRouter } from "./pull-requests"; diff --git a/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts b/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts new file mode 100644 index 00000000000..aebcf8eb34c --- /dev/null +++ b/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import { publicProcedure, router } from "../../index"; + +export const pullRequestsRouter = router({ + getByWorkspaces: publicProcedure + .input( + z.object({ + workspaceIds: z.array(z.string()), + }), + ) + .query(async ({ ctx, input }) => { + const workspaces = + await ctx.runtime.pullRequests.getPullRequestsByWorkspaces( + input.workspaceIds, + ); + return { workspaces }; + }), + refreshByWorkspaces: publicProcedure + .input( + z.object({ + workspaceIds: z.array(z.string()), + }), + ) + .mutation(async ({ ctx, input }) => { + await ctx.runtime.pullRequests.refreshPullRequestsByWorkspaces( + input.workspaceIds, + ); + return { ok: true }; + }), +}); diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index ffe1c4ffdc1..7caa77ec38d 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -4,6 +4,7 @@ import { gitRouter } from "./git"; import { githubRouter } from "./github"; import { healthRouter } from "./health"; import { projectRouter } from "./project"; +import { pullRequestsRouter } from "./pull-requests"; import { workspaceRouter } from "./workspace"; export const appRouter = router({ @@ -11,6 +12,7 @@ export const appRouter = router({ git: gitRouter, github: githubRouter, cloud: cloudRouter, + pullRequests: pullRequestsRouter, project: projectRouter, workspace: workspaceRouter, }); diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index b5a78df4869..fdafeba42df 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -3,14 +3,20 @@ import type { AppRouter } from "@superset/trpc"; import type { TRPCClient } from "@trpc/client"; import type { HostDb } from "./db"; import type { GitFactory } from "./git/types"; +import type { PullRequestRuntimeManager } from "./runtime/pull-requests"; export type ApiClient = TRPCClient; +export interface HostServiceRuntime { + pullRequests: PullRequestRuntimeManager; +} + export interface HostServiceContext { git: GitFactory; github: () => Promise; api: ApiClient | null; db: HostDb; + runtime: HostServiceRuntime; deviceClientId: string | null; deviceName: string | null; }