diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts index 01f74ab0350..05b7a4685bd 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts @@ -1,4 +1,4 @@ -import { workspaces } from "@superset/local-db"; +import { projects, workspaces } from "@superset/local-db"; import { and, eq, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; @@ -8,6 +8,7 @@ import { setLastActiveWorkspace, touchWorkspace, } from "../utils/db-helpers"; +import { getOriginRemoteUrl, parseGitRemoteUrl } from "../utils/git"; export const createStatusProcedures = () => { return router({ @@ -100,6 +101,82 @@ export const createStatusProcedures = () => { return { success: true, isUnread: input.isUnread }; }), + linkToCloud: publicProcedure + .input(z.object({ id: z.string(), cloudWorkspaceId: z.string().uuid() })) + .mutation(({ input }) => { + const workspace = getWorkspaceNotDeleting(input.id); + if (!workspace) { + throw new Error( + `Workspace ${input.id} not found or is being deleted`, + ); + } + + localDb + .update(workspaces) + .set({ cloudWorkspaceId: input.cloudWorkspaceId }) + .where(eq(workspaces.id, input.id)) + .run(); + + return { success: true, cloudWorkspaceId: input.cloudWorkspaceId }; + }), + + unlinkFromCloud: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => { + const workspace = getWorkspaceNotDeleting(input.id); + if (!workspace) { + throw new Error( + `Workspace ${input.id} not found or is being deleted`, + ); + } + + localDb + .update(workspaces) + .set({ cloudWorkspaceId: null }) + .where(eq(workspaces.id, input.id)) + .run(); + + return { success: true }; + }), + + getRepoInfo: publicProcedure + .input(z.object({ id: z.string() })) + .query(async ({ input }) => { + const workspace = getWorkspaceNotDeleting(input.id); + if (!workspace) { + throw new Error( + `Workspace ${input.id} not found or is being deleted`, + ); + } + + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); + + if (!project) { + throw new Error(`Project not found for workspace ${input.id}`); + } + + const remoteUrl = await getOriginRemoteUrl(project.mainRepoPath); + if (!remoteUrl) { + return { hasRemote: false as const }; + } + + const parsed = parseGitRemoteUrl(remoteUrl); + if (!parsed) { + return { hasRemote: false as const }; + } + + return { + hasRemote: true as const, + repoOwner: parsed.owner, + repoName: parsed.repo, + repoUrl: parsed.repoUrl, + }; + }), + setActive: publicProcedure .input(z.object({ workspaceId: z.string() })) .mutation(({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index e871e4bdaa6..cbac037b402 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -698,6 +698,87 @@ export async function hasOriginRemote(mainRepoPath: string): Promise { } } +/** + * Gets the origin remote URL for a repository. + * @param repoPath - Path to the repository + * @returns The origin remote URL, or null if not found + */ +export async function getOriginRemoteUrl( + repoPath: string, +): Promise { + try { + const git = simpleGit(repoPath); + const remotes = await git.getRemotes(true); + const origin = remotes.find((r) => r.name === "origin"); + return origin?.refs?.fetch ?? origin?.refs?.push ?? null; + } catch { + return null; + } +} + +/** + * Parses a git remote URL to extract owner and repo name. + * Supports formats: + * - https://github.com/owner/repo.git + * - https://github.com/owner/repo + * - git@github.com:owner/repo.git + * - git@github.com:owner/repo + * - ssh://git@github.com/owner/repo.git + */ +export function parseGitRemoteUrl(url: string): { + owner: string; + repo: string; + repoUrl: string; +} | null { + // Normalize the URL + let normalized = url.trim(); + + // Remove .git suffix if present + if (normalized.endsWith(".git")) { + normalized = normalized.slice(0, -4); + } + + // Handle SSH format: git@github.com:owner/repo + const sshMatch = normalized.match(/^git@([^:]+):(.+)\/(.+)$/); + if (sshMatch) { + const [, host, owner, repo] = sshMatch; + return { + owner, + repo, + repoUrl: `https://${host}/${owner}/${repo}`, + }; + } + + // Handle SSH URL format: ssh://git@github.com/owner/repo + const sshUrlMatch = normalized.match(/^ssh:\/\/git@([^/]+)\/(.+)\/(.+)$/); + if (sshUrlMatch) { + const [, host, owner, repo] = sshUrlMatch; + return { + owner, + repo, + repoUrl: `https://${host}/${owner}/${repo}`, + }; + } + + // Handle HTTPS format: https://github.com/owner/repo + try { + const urlObj = new URL(normalized); + const pathParts = urlObj.pathname.split("/").filter(Boolean); + if (pathParts.length >= 2) { + const [owner, repo] = pathParts; + return { + owner, + repo, + repoUrl: `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}`, + }; + } + } catch { + // Not a valid URL + } + + return null; +} + export async function getDefaultBranch(mainRepoPath: string): Promise { const git = simpleGit(mainRepoPath); diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 6093b04da8d..b2662f3d25e 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -40,6 +40,7 @@ import { useNewWorkspaceModalOpen, usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; +import { ENABLE_CLOUD_WORKSPACES } from "shared/constants"; import { sanitizeBranchName, sanitizeSegment } from "shared/utils/branch"; import { ExistingWorktreesList } from "./components/ExistingWorktreesList"; @@ -300,17 +301,19 @@ export function NewWorkspaceModal() { > Existing - + {ENABLE_CLOUD_WORKSPACES && ( + + )} @@ -481,7 +484,7 @@ export function NewWorkspaceModal() { onOpenSuccess={handleClose} /> )} - {mode === "cloud" && ( + {ENABLE_CLOUD_WORKSPACES && mode === "cloud" && (
Cloud Workspaces diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/CloudWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/CloudWorkspaceButton.tsx new file mode 100644 index 00000000000..e044acc444a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/CloudWorkspaceButton.tsx @@ -0,0 +1,299 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { + HiOutlineCloud, + HiOutlineCloudArrowUp, + HiOutlineExclamationTriangle, + HiOutlineGlobeAlt, + HiOutlineLink, + HiOutlinePlus, +} from "react-icons/hi2"; +import { LuGitBranch, LuLoader, LuUnlink } from "react-icons/lu"; +import { env } from "renderer/env.renderer"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +interface CloudWorkspaceButtonProps { + workspaceId: string; + workspaceName: string; + branch: string; + cloudWorkspaceId: string | null; +} + +export function CloudWorkspaceButton({ + workspaceId, + workspaceName, + branch, + cloudWorkspaceId, +}: CloudWorkspaceButtonProps) { + const [isLinking, setIsLinking] = useState(false); + const [error, setError] = useState(null); + + const { data: session } = authClient.useSession(); + const utils = electronTrpc.useUtils(); + const queryClient = useQueryClient(); + const organizationId = session?.session?.activeOrganizationId; + + const { data: repoInfo } = electronTrpc.workspaces.getRepoInfo.useQuery( + { id: workspaceId }, + { enabled: !cloudWorkspaceId }, + ); + + const { data: cloudWorkspace, isLoading: isLoadingCloudStatus } = useQuery({ + queryKey: ["cloudWorkspace", cloudWorkspaceId], + queryFn: () => + apiTrpcClient.cloudWorkspace.byId.query(cloudWorkspaceId as string), + enabled: !!cloudWorkspaceId, + staleTime: 30_000, + }); + + const isCloudDeleted = cloudWorkspace?.deletedAt != null; + + const { data: matchingWorkspaces } = useQuery({ + queryKey: [ + "cloudWorkspace", + "matching", + organizationId, + repoInfo?.repoOwner, + repoInfo?.repoName, + ], + queryFn: () => + apiTrpcClient.cloudWorkspace.findMatching.query({ + organizationId: organizationId ?? "", + repoOwner: repoInfo?.repoOwner ?? "", + repoName: repoInfo?.repoName ?? "", + }), + enabled: + !!organizationId && + !!repoInfo?.hasRemote && + !!repoInfo.repoOwner && + !!repoInfo.repoName && + !cloudWorkspaceId, + staleTime: 30_000, + }); + + const linkToCloudMutation = electronTrpc.workspaces.linkToCloud.useMutation({ + onSuccess: () => { + utils.workspaces.get.invalidate({ id: workspaceId }); + utils.workspaces.getAllGrouped.invalidate(); + }, + }); + + const unlinkFromCloudMutation = + electronTrpc.workspaces.unlinkFromCloud.useMutation({ + onSuccess: () => { + utils.workspaces.get.invalidate({ id: workspaceId }); + utils.workspaces.getAllGrouped.invalidate(); + }, + }); + + const handleCreateAndLink = async () => { + if (!organizationId || !repoInfo?.hasRemote) { + return; + } + + setIsLinking(true); + setError(null); + + try { + const result = await apiTrpcClient.cloudWorkspace.create.mutate({ + organizationId, + repoOwner: repoInfo.repoOwner, + repoName: repoInfo.repoName, + repoUrl: repoInfo.repoUrl, + name: workspaceName, + branch, + }); + + await linkToCloudMutation.mutateAsync({ + id: workspaceId, + cloudWorkspaceId: result.cloudWorkspace.id, + }); + + queryClient.invalidateQueries({ + queryKey: ["cloudWorkspace", "matching"], + }); + } catch (err) { + console.error("[cloud-workspace] Failed to create and link:", err); + setError(err instanceof Error ? err.message : "Failed to link to cloud"); + } finally { + setIsLinking(false); + } + }; + + const handleLinkToExisting = async (existingCloudWorkspaceId: string) => { + setIsLinking(true); + setError(null); + + try { + await linkToCloudMutation.mutateAsync({ + id: workspaceId, + cloudWorkspaceId: existingCloudWorkspaceId, + }); + } catch (err) { + console.error("[cloud-workspace] Failed to link:", err); + setError(err instanceof Error ? err.message : "Failed to link to cloud"); + } finally { + setIsLinking(false); + } + }; + + const handleOpenInWeb = () => { + if (cloudWorkspaceId) { + window.open( + `${env.NEXT_PUBLIC_WEB_URL}/cloud/workspace/${cloudWorkspaceId}`, + "_blank", + ); + } + }; + + const handleUnlink = () => { + unlinkFromCloudMutation.mutate({ id: workspaceId }); + }; + + if (cloudWorkspaceId) { + if (isLoadingCloudStatus) { + return ( + + ); + } + + if (isCloudDeleted) { + return ( + + + + + +
+ Cloud workspace was deleted +
+ + + + Unlink + +
+
+ ); + } + + return ( + + + + + + + + Open in web + + + + + Unlink + + + + ); + } + + if (!repoInfo?.hasRemote) { + return null; + } + + const hasMatchingWorkspaces = + matchingWorkspaces && matchingWorkspaces.length > 0; + + return ( + + + + + + {hasMatchingWorkspaces && ( + <> + + Existing workspaces + + {matchingWorkspaces.map((ws) => ( + handleLinkToExisting(ws.id)} + disabled={isLinking || !organizationId} + > + +
+ {ws.name} + + + {ws.branch} + +
+
+ ))} + + + )} + + +
+ Create new workspace + + {repoInfo.repoOwner}/{repoInfo.repoName} ยท {branch} + +
+
+ {error && ( +
{error}
+ )} + {!organizationId && ( +
+ Please select an organization first +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index 9e36080cd8c..e6bf6674af6 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,5 +1,7 @@ import { useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { ENABLE_CLOUD_WORKSPACES } from "shared/constants"; +import { CloudWorkspaceButton } from "./CloudWorkspaceButton"; import { OpenInMenuButton } from "./OpenInMenuButton"; import { OrganizationDropdown } from "./OrganizationDropdown"; import { WindowControls } from "./WindowControls"; @@ -26,6 +28,14 @@ export function TopBar() {
+ {ENABLE_CLOUD_WORKSPACES && workspace && ( + + )} {workspace?.worktreePath && ( ))}
@@ -209,6 +211,7 @@ export function ProjectSection({ isUnread={workspace.isUnread} index={wsIndex} shortcutIndex={shortcutBaseIndex + wsIndex} + cloudWorkspaceId={workspace.cloudWorkspaceId} /> ))}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 0ffbff2c9f0..1be439430b9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -14,10 +14,15 @@ import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { useQuery } from "@tanstack/react-query"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; -import { HiMiniXMark } from "react-icons/hi2"; +import { + HiMiniXMark, + HiOutlineCloud, + HiOutlineExclamationTriangle, +} from "react-icons/hi2"; import { LuCopy, LuEye, @@ -28,6 +33,7 @@ import { LuPencil, LuX, } from "react-icons/lu"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useReorderWorkspaces, @@ -39,6 +45,7 @@ import { StatusIndicator } from "renderer/screens/main/components/StatusIndicato import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; +import { ENABLE_CLOUD_WORKSPACES } from "shared/constants"; import { getHighestPriorityStatus } from "shared/tabs-types"; import { STROKE_WIDTH } from "../constants"; import { @@ -69,6 +76,8 @@ interface WorkspaceListItemProps { shortcutIndex?: number; /** Whether the sidebar is in collapsed mode (icon-only view) */ isCollapsed?: boolean; + /** Cloud workspace ID if linked to cloud */ + cloudWorkspaceId?: string | null; } export function WorkspaceListItem({ @@ -82,8 +91,20 @@ export function WorkspaceListItem({ index, shortcutIndex, isCollapsed = false, + cloudWorkspaceId, }: WorkspaceListItemProps) { const isBranchWorkspace = type === "branch"; + + const { data: cloudWorkspace } = useQuery({ + queryKey: ["cloudWorkspace", cloudWorkspaceId], + queryFn: () => + apiTrpcClient.cloudWorkspace.byId.query(cloudWorkspaceId as string), + enabled: ENABLE_CLOUD_WORKSPACES && !!cloudWorkspaceId, + staleTime: 30_000, + }); + + const isCloudWorkspace = ENABLE_CLOUD_WORKSPACES && !!cloudWorkspaceId; + const isCloudDeleted = cloudWorkspace?.deletedAt != null; const navigate = useNavigate(); const matchRoute = useMatchRoute(); const reorderWorkspaces = useReorderWorkspaces(); @@ -96,7 +117,6 @@ export function WorkspaceListItem({ ); const utils = electronTrpc.useUtils(); - // Derive isActive from route const isActive = !!matchRoute({ to: "/workspace/$workspaceId", params: { workspaceId: id }, @@ -112,11 +132,9 @@ export function WorkspaceListItem({ toast.error(`Failed to update unread status: ${error.message}`), }); - // Shared delete logic const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); - // Lazy-load GitHub status on hover to avoid N+1 queries const { data: githubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( { workspaceId: id }, @@ -126,7 +144,6 @@ export function WorkspaceListItem({ }, ); - // Lazy-load local git changes on hover const { data: localChanges } = electronTrpc.changes.getStatus.useQuery( { worktreePath }, { @@ -135,7 +152,6 @@ export function WorkspaceListItem({ }, ); - // Calculate total local changes (staged + unstaged + untracked) const localDiffStats = useMemo(() => { if (!localChanges) return null; const allFiles = [ @@ -149,7 +165,6 @@ export function WorkspaceListItem({ return { additions, deletions }; }, [localChanges]); - // Memoize workspace pane IDs to avoid recalculating on every render const workspacePaneIds = useMemo(() => { const workspaceTabs = tabs.filter((t) => t.workspaceId === id); return new Set( @@ -157,9 +172,7 @@ export function WorkspaceListItem({ ); }, [tabs, id]); - // Compute aggregate status for workspace using shared priority logic const workspaceStatus = useMemo(() => { - // Generator avoids array allocation function* paneStatuses() { for (const paneId of workspacePaneIds) { yield panes[paneId]?.status; @@ -202,7 +215,6 @@ export function WorkspaceListItem({ } }; - // Drag and drop const [{ isDragging }, drag] = useDrag( () => ({ type: WORKSPACE_TYPE, @@ -235,18 +247,14 @@ export function WorkspaceListItem({ }); const pr = githubStatus?.pr; - // Show diff stats from PR if available, otherwise from local changes const diffStats = localDiffStats || (pr && (pr.additions > 0 || pr.deletions > 0) ? { additions: pr.additions, deletions: pr.deletions } : null); const showDiffStats = !!diffStats; - - // Determine if we should show the branch subtitle const showBranchSubtitle = !isBranchWorkspace; - // Collapsed sidebar: show just the icon with hover card (worktree) or tooltip (branch) if (isCollapsed) { const collapsedButton = ( ); - // Branch workspaces get a simple tooltip - if (isBranchWorkspace) { + if (isBranchWorkspace || isCloudWorkspace) { return ( {collapsedButton} {name || branch} - - Local workspace + + {isCloudDeleted + ? "Cloud workspace deleted" + : isCloudWorkspace + ? "Cloud workspace" + : "Local workspace"} ); } - // Worktree workspaces get the full hover card with context menu return ( <> - {/* Active indicator - left border */} {isActive && (
)} - {/* Icon with status indicator */}
{workspaceStatus === "working" ? ( + ) : isCloudWorkspace && isCloudDeleted ? ( + + ) : isCloudWorkspace ? ( + ) : isBranchWorkspace ? ( - {isBranchWorkspace ? ( + {isCloudWorkspace && isCloudDeleted ? ( + <> +

+ Cloud workspace deleted +

+

+ The linked cloud workspace was deleted +

+ + ) : isCloudWorkspace ? ( + <> +

Cloud workspace

+

+ Linked to cloud for remote access +

+ + ) : isBranchWorkspace ? ( <>

Local workspace

@@ -432,7 +481,6 @@ export function WorkspaceListItem({ - {/* Content area */}

{rename.isRenaming ? ( ) : (
- {/* Row 1: Title + actions */}
- {/* Keyboard shortcut */} {shortcutIndex !== undefined && shortcutIndex < MAX_KEYBOARD_SHORTCUT_INDEX && ( @@ -472,12 +518,10 @@ export function WorkspaceListItem({ )} - {/* Branch switcher for branch workspaces */} {isBranchWorkspace && ( )} - {/* Diff stats (transforms to X on hover) or close button for worktree workspaces */} {!isBranchWorkspace && (showDiffStats && diffStats ? ( - {/* Row 2: Git info (branch + PR badge) */} {(showBranchSubtitle || pr) && (
{showBranchSubtitle && ( @@ -551,7 +594,6 @@ export function WorkspaceListItem({ ); - // Wrap with context menu and hover card if (isBranchWorkspace) { return ( <> diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 7d4d80c53b9..76090a3ba6c 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -59,3 +59,6 @@ export const DEFAULT_AUTO_APPLY_DEFAULT_PRESET = true; export const EXTERNAL_LINKS = { SETUP_TEARDOWN_SCRIPTS: `${process.env.NEXT_PUBLIC_DOCS_URL}/setup-teardown-scripts`, } as const; + +// Feature toggles (flip to true when ready to ship) +export const ENABLE_CLOUD_WORKSPACES = false; diff --git a/apps/web/src/app/(dashboard)/cloud/components/WorkspaceList/WorkspaceList.tsx b/apps/web/src/app/(dashboard)/cloud/components/WorkspaceList/WorkspaceList.tsx new file mode 100644 index 00000000000..0eaf29c085a --- /dev/null +++ b/apps/web/src/app/(dashboard)/cloud/components/WorkspaceList/WorkspaceList.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Badge } from "@superset/ui/badge"; +import { useQuery } from "@tanstack/react-query"; +import { Cloud, GitBranch } from "lucide-react"; +import Link from "next/link"; +import { useTRPC } from "@/trpc/react"; + +export function WorkspaceList() { + const trpc = useTRPC(); + + const { + data: workspaces, + isLoading, + isError, + } = useQuery(trpc.cloudWorkspace.all.queryOptions()); + + if (isLoading) { + return ( +
+ Loading workspaces... +
+ ); + } + + if (isError) { + return ( +
+ Failed to load workspaces. Please try again. +
+ ); + } + + if (!workspaces || workspaces.length === 0) { + return ( +
+ No cloud workspaces found. +
+ ); + } + + return ( +
+ {workspaces.map((workspace) => ( + +
+ +
+

{workspace.name}

+
+ + {workspace.branch} + {workspace.repository && ( + in {workspace.repository.name} + )} +
+
+
+ Active + + ))} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/cloud/components/WorkspaceList/index.ts b/apps/web/src/app/(dashboard)/cloud/components/WorkspaceList/index.ts new file mode 100644 index 00000000000..271c7477c89 --- /dev/null +++ b/apps/web/src/app/(dashboard)/cloud/components/WorkspaceList/index.ts @@ -0,0 +1 @@ +export { WorkspaceList } from "./WorkspaceList"; diff --git a/apps/web/src/app/(dashboard)/cloud/new/page.tsx b/apps/web/src/app/(dashboard)/cloud/new/page.tsx new file mode 100644 index 00000000000..e48ba55f2be --- /dev/null +++ b/apps/web/src/app/(dashboard)/cloud/new/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { Skeleton } from "@superset/ui/skeleton"; +import { toast } from "@superset/ui/sonner"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Settings } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useTRPC } from "@/trpc/react"; + +export default function NewCloudWorkspacePage() { + const [selectedRepoId, setSelectedRepoId] = useState(""); + const [name, setName] = useState(""); + const [branch, setBranch] = useState(""); + + const router = useRouter(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { data: organization, isLoading: isLoadingOrg } = useQuery( + trpc.user.myOrganization.queryOptions(), + ); + + const { data: repositories, isLoading: isLoadingRepos } = useQuery({ + ...trpc.integration.github.listRepositories.queryOptions({ + organizationId: organization?.id ?? "", + }), + enabled: !!organization?.id, + }); + + const selectedRepo = repositories?.find((r) => r.id === selectedRepoId); + + const createMutation = useMutation( + trpc.cloudWorkspace.create.mutationOptions({ + onSuccess: () => { + toast.success("Workspace created", { + description: "Your cloud workspace has been created successfully.", + }); + queryClient.invalidateQueries({ + queryKey: trpc.cloudWorkspace.all.queryKey(), + }); + router.push("/cloud"); + }, + onError: (error) => { + toast.error("Failed to create workspace", { + description: error.message, + }); + }, + }), + ); + + const handleRepoChange = (repoId: string) => { + setSelectedRepoId(repoId); + const repo = repositories?.find((r) => r.id === repoId); + if (repo) { + setBranch(repo.defaultBranch); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!organization) { + toast.error("No organization found"); + return; + } + + if (!selectedRepo) { + toast.error("Please select a repository"); + return; + } + + createMutation.mutate({ + organizationId: organization.id, + repoOwner: selectedRepo.owner, + repoName: selectedRepo.name, + repoUrl: `https://github.com/${selectedRepo.fullName}`, + name: name || `${selectedRepo.name}-workspace`, + branch: branch || selectedRepo.defaultBranch, + }); + }; + + if (isLoadingOrg) { + return ( +
Loading...
+ ); + } + + if (!organization) { + return ( +
+

+ You need to be part of an organization to create workspaces. +

+
+ ); + } + + return ( +
+ + + Back to Cloud Workspaces + + +
+

Create Cloud Workspace

+

+ Create a new cloud workspace from a GitHub repository. +

+
+ +
+
+ + {isLoadingRepos ? ( + + ) : repositories && repositories.length > 0 ? ( +
+ + +
+ ) : ( +

+ No repositories found. Please connect your GitHub account in{" "} + + Integrations + + . +

+ )} +
+ +
+ + setName(e.target.value)} + /> +

+ Leave empty to auto-generate from repository name. +

+
+ +
+ + setBranch(e.target.value)} + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/cloud/page.tsx b/apps/web/src/app/(dashboard)/cloud/page.tsx new file mode 100644 index 00000000000..34ca019fdd0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/cloud/page.tsx @@ -0,0 +1,31 @@ +import { Button } from "@superset/ui/button"; +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { WorkspaceList } from "./components/WorkspaceList"; + +export default function CloudPage() { + return ( +
+
+
+
+

Cloud Workspaces

+

+ Manage your cloud workspaces here. +

+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/cloud/workspace/[id]/page.tsx b/apps/web/src/app/(dashboard)/cloud/workspace/[id]/page.tsx new file mode 100644 index 00000000000..7bd2af33dd8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/cloud/workspace/[id]/page.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeft, + Cloud, + ExternalLink, + GitBranch, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useTRPC } from "@/trpc/react"; + +export default function WorkspaceDetailPage() { + const params = useParams(); + const id = params.id as string; + const router = useRouter(); + + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { + data: workspace, + isLoading, + isError, + } = useQuery(trpc.cloudWorkspace.byId.queryOptions(id)); + + const deleteMutation = useMutation( + trpc.cloudWorkspace.delete.mutationOptions({ + onSuccess: () => { + toast.success("Workspace deleted", { + description: "The workspace has been deleted successfully.", + }); + queryClient.invalidateQueries({ + queryKey: trpc.cloudWorkspace.all.queryKey(), + }); + router.push("/cloud"); + }, + onError: (error) => { + toast.error("Failed to delete workspace", { + description: error.message, + }); + }, + }), + ); + + const handleDelete = () => { + deleteMutation.mutate(id); + }; + + if (isLoading) { + return ( +
+ Loading workspace... +
+ ); + } + + if (isError || !workspace) { + return ( +
+ + + Back to Cloud Workspaces + +
+ Workspace not found. +
+
+ ); + } + + return ( +
+ + + Back to Cloud Workspaces + + +
+
+
+ +
+
+

{workspace.name}

+
+ + {workspace.branch} +
+
+
+ + + + + + + + Delete Workspace + + Are you sure you want to delete "{workspace.name}"? This action + cannot be undone. + + + + Cancel + + {deleteMutation.isPending ? "Deleting..." : "Delete"} + + + + +
+ +
+
+

Workspace Details

+
+
+
Name
+
{workspace.name}
+
+
+
Branch
+
{workspace.branch}
+
+
+
Created
+
{new Date(workspace.createdAt).toLocaleDateString()}
+
+
+
+ + {workspace.repository && ( +
+

Repository

+
+
+
Name
+
{workspace.repository.name}
+
+
+
Owner
+
{workspace.repository.repoOwner}
+
+
+
Default Branch
+
{workspace.repository.defaultBranch}
+
+
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/components/SidebarNav/SidebarNav.tsx b/apps/web/src/app/(dashboard)/components/SidebarNav/SidebarNav.tsx index bd7041e82c6..8d7fb7d90aa 100644 --- a/apps/web/src/app/(dashboard)/components/SidebarNav/SidebarNav.tsx +++ b/apps/web/src/app/(dashboard)/components/SidebarNav/SidebarNav.tsx @@ -7,6 +7,7 @@ import { usePathname } from "next/navigation"; const navItems = [ { href: "/", label: "Home" }, { href: "/integrations", label: "Integrations" }, + { href: "/cloud", label: "Cloud" }, ]; export function SidebarNav() { @@ -23,9 +24,7 @@ export function SidebarNav() { href={item.href} className={cn( "font-mono transition-opacity", - isActive - ? "underline opacity-100" - : "opacity-60 hover:opacity-80", + isActive ? "opacity-100" : "opacity-60 hover:opacity-80", )} > {item.label} diff --git a/packages/db/drizzle/0013_add_cloud_workspaces.sql b/packages/db/drizzle/0013_add_cloud_workspaces.sql new file mode 100644 index 00000000000..d489eed87f6 --- /dev/null +++ b/packages/db/drizzle/0013_add_cloud_workspaces.sql @@ -0,0 +1,14 @@ +CREATE TABLE "cloud_workspaces" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "repository_id" uuid NOT NULL, + "name" text NOT NULL, + "branch" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "cloud_workspaces" ADD CONSTRAINT "cloud_workspaces_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cloud_workspaces" ADD CONSTRAINT "cloud_workspaces_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "cloud_workspaces_organization_id_idx" ON "cloud_workspaces" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "cloud_workspaces_repository_id_idx" ON "cloud_workspaces" USING btree ("repository_id"); \ No newline at end of file diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index c66c8f9e24c..64e349391ec 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -15,6 +15,7 @@ import { } from "./github"; import { agentCommands, + cloudWorkspaces, devicePresence, integrationConnections, repositories, @@ -59,6 +60,7 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ taskStatuses: many(taskStatuses), integrations: many(integrationConnections), githubInstallations: many(githubInstallations), + cloudWorkspaces: many(cloudWorkspaces), devicePresence: many(devicePresence), agentCommands: many(agentCommands), })); @@ -100,6 +102,7 @@ export const repositoriesRelations = relations( references: [organizations.id], }), tasks: many(tasks), + cloudWorkspaces: many(cloudWorkspaces), }), ); @@ -190,6 +193,20 @@ export const githubPullRequestsRelations = relations( }), ); +export const cloudWorkspacesRelations = relations( + cloudWorkspaces, + ({ one }) => ({ + organization: one(organizations, { + fields: [cloudWorkspaces.organizationId], + references: [organizations.id], + }), + repository: one(repositories, { + fields: [cloudWorkspaces.repositoryId], + references: [repositories.id], + }), + }), +); + // Agent relations export const devicePresenceRelations = relations(devicePresence, ({ one }) => ({ user: one(users, { diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index c330c0cb477..7a263ed7373 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -71,7 +71,7 @@ export const taskStatuses = pgTable( name: text().notNull(), color: text().notNull(), - type: text().notNull(), // "backlog" | "unstarted" | "started" | "completed" | "canceled" + type: text().notNull(), position: real().notNull(), progressPercent: real("progress_percent"), @@ -139,7 +139,7 @@ export const tasks = pgTable( // External sync (null if local-only task) externalProvider: integrationProvider("external_provider"), externalId: text("external_id"), - externalKey: text("external_key"), // "SUPER-172", "#123" + externalKey: text("external_key"), externalUrl: text("external_url"), lastSyncedAt: timestamp("last_synced_at"), syncError: text("sync_error"), @@ -175,7 +175,6 @@ export const tasks = pgTable( export type InsertTask = typeof tasks.$inferInsert; export type SelectTask = typeof tasks.$inferSelect; -// Integration connections for external providers (Linear, GitHub, etc.) export const integrationConnections = pgTable( "integration_connections", { @@ -219,7 +218,6 @@ export type InsertIntegrationConnection = export type SelectIntegrationConnection = typeof integrationConnections.$inferSelect; -// Stripe subscriptions (org-based billing) export const subscriptions = pgTable( "subscriptions", { @@ -256,6 +254,34 @@ export const subscriptions = pgTable( export type InsertSubscription = typeof subscriptions.$inferInsert; export type SelectSubscription = typeof subscriptions.$inferSelect; +export const cloudWorkspaces = pgTable( + "cloud_workspaces", + { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + repositoryId: uuid("repository_id") + .notNull() + .references(() => repositories.id, { onDelete: "cascade" }), + name: text().notNull(), + branch: text().notNull(), + deletedAt: timestamp("deleted_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index("cloud_workspaces_organization_id_idx").on(table.organizationId), + index("cloud_workspaces_repository_id_idx").on(table.repositoryId), + ], +); + +export type InsertCloudWorkspace = typeof cloudWorkspaces.$inferInsert; +export type SelectCloudWorkspace = typeof cloudWorkspaces.$inferSelect; + // Device presence - tracks online devices for command routing export const devicePresence = pgTable( "device_presence", diff --git a/packages/local-db/drizzle/0013_add_cloud_workspace_id.sql b/packages/local-db/drizzle/0013_add_cloud_workspace_id.sql new file mode 100644 index 00000000000..9ef9e7148aa --- /dev/null +++ b/packages/local-db/drizzle/0013_add_cloud_workspace_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE `workspaces` ADD `cloud_workspace_id` text;--> statement-breakpoint +CREATE INDEX `workspaces_cloud_workspace_id_idx` ON `workspaces` (`cloud_workspace_id`); \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 6a968a7aefe..585a4ef98bb 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -106,11 +106,14 @@ export const workspaces = sqliteTable( // Timestamp when deletion was initiated. Non-null means deletion in progress. // Workspaces with deletingAt set should be filtered out from queries. deletingAt: integer("deleting_at"), + // Optional link to cloud workspace (UUID from cloud_workspaces table) + cloudWorkspaceId: text("cloud_workspace_id"), }, (table) => [ index("workspaces_project_id_idx").on(table.projectId), index("workspaces_worktree_id_idx").on(table.worktreeId), index("workspaces_last_opened_at_idx").on(table.lastOpenedAt), + index("workspaces_cloud_workspace_id_idx").on(table.cloudWorkspaceId), // NOTE: Migration 0006 creates an additional partial unique index: // CREATE UNIQUE INDEX workspaces_unique_branch_per_project // ON workspaces(project_id) WHERE type = 'branch' diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 7e333dee987..12f0a06d3d5 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -3,6 +3,7 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { adminRouter } from "./router/admin"; import { agentRouter } from "./router/agent"; import { analyticsRouter } from "./router/analytics"; +import { cloudWorkspaceRouter } from "./router/cloudWorkspace"; import { deviceRouter } from "./router/device"; import { integrationRouter } from "./router/integration"; import { organizationRouter } from "./router/organization"; @@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({ admin: adminRouter, agent: agentRouter, analytics: analyticsRouter, + cloudWorkspace: cloudWorkspaceRouter, device: deviceRouter, integration: integrationRouter, organization: organizationRouter, diff --git a/packages/trpc/src/router/cloudWorkspace/cloudWorkspace.ts b/packages/trpc/src/router/cloudWorkspace/cloudWorkspace.ts new file mode 100644 index 00000000000..98d8a61fb05 --- /dev/null +++ b/packages/trpc/src/router/cloudWorkspace/cloudWorkspace.ts @@ -0,0 +1,149 @@ +import { db, dbWs } from "@superset/db/client"; +import { cloudWorkspaces, repositories } from "@superset/db/schema"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { and, desc, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../trpc"; + +export const cloudWorkspaceRouter = { + all: protectedProcedure.query(() => { + return db.query.cloudWorkspaces.findMany({ + where: isNull(cloudWorkspaces.deletedAt), + orderBy: desc(cloudWorkspaces.createdAt), + with: { + organization: true, + repository: true, + }, + }); + }), + + byOrganization: protectedProcedure + .input(z.string().uuid()) + .query(({ input }) => { + return db.query.cloudWorkspaces.findMany({ + where: and( + eq(cloudWorkspaces.organizationId, input), + isNull(cloudWorkspaces.deletedAt), + ), + orderBy: desc(cloudWorkspaces.createdAt), + with: { + repository: true, + }, + }); + }), + + byId: protectedProcedure.input(z.string().uuid()).query(({ input }) => { + return db.query.cloudWorkspaces.findFirst({ + where: eq(cloudWorkspaces.id, input), + with: { + organization: true, + repository: true, + }, + }); + }), + + findMatching: protectedProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + repoOwner: z.string().min(1), + repoName: z.string().min(1), + }), + ) + .query(async ({ input }) => { + const { organizationId, repoOwner, repoName } = input; + + const repository = await db.query.repositories.findFirst({ + where: and( + eq(repositories.organizationId, organizationId), + eq(repositories.repoOwner, repoOwner), + eq(repositories.repoName, repoName), + ), + }); + + if (!repository) { + return []; + } + + return db.query.cloudWorkspaces.findMany({ + where: and( + eq(cloudWorkspaces.repositoryId, repository.id), + isNull(cloudWorkspaces.deletedAt), + ), + orderBy: desc(cloudWorkspaces.createdAt), + }); + }), + + create: protectedProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + repoOwner: z.string().min(1), + repoName: z.string().min(1), + repoUrl: z.string().url(), + name: z.string().min(1), + branch: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const { organizationId, repoOwner, repoName, repoUrl, name, branch } = + input; + + return dbWs.transaction(async (tx) => { + let repository = await tx.query.repositories.findFirst({ + where: and( + eq(repositories.organizationId, organizationId), + eq(repositories.repoOwner, repoOwner), + eq(repositories.repoName, repoName), + ), + }); + + if (!repository) { + const slug = `${repoOwner}-${repoName}`.toLowerCase(); + const [newRepo] = await tx + .insert(repositories) + .values({ + organizationId, + name: repoName, + slug, + repoUrl, + repoOwner, + repoName, + defaultBranch: "main", + }) + .returning(); + + if (!newRepo) { + throw new Error("Failed to create repository"); + } + repository = newRepo; + } + + const [cloudWorkspace] = await tx + .insert(cloudWorkspaces) + .values({ + organizationId, + repositoryId: repository.id, + name, + branch, + }) + .returning(); + + return { + cloudWorkspace, + repository, + }; + }); + }), + + delete: protectedProcedure + .input(z.string().uuid()) + .mutation(async ({ input }) => { + await dbWs + .update(cloudWorkspaces) + .set({ deletedAt: new Date() }) + .where(eq(cloudWorkspaces.id, input)); + + return { success: true }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/cloudWorkspace/index.ts b/packages/trpc/src/router/cloudWorkspace/index.ts new file mode 100644 index 00000000000..b83b0b61f78 --- /dev/null +++ b/packages/trpc/src/router/cloudWorkspace/index.ts @@ -0,0 +1 @@ +export { cloudWorkspaceRouter } from "./cloudWorkspace";