diff --git a/.env.example b/.env.example index e516015f6..a8188822f 100644 --- a/.env.example +++ b/.env.example @@ -28,9 +28,11 @@ NEXT_PUBLIC_MARKETING_URL=http://localhost:3002 NEXT_PUBLIC_DOCS_URL=http://localhost:3004 # ----------------------------------------------------------------------------- -# Better Auth +# Clerk Auth # ----------------------------------------------------------------------------- -BETTER_AUTH_SECRET= +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +CLERK_WEBHOOK_SECRET= NEXT_PUBLIC_COOKIE_DOMAIN=localhost # ----------------------------------------------------------------------------- @@ -52,6 +54,7 @@ BLOB_READ_WRITE_TOKEN= NEXT_PUBLIC_POSTHOG_KEY= POSTHOG_API_KEY= POSTHOG_PROJECT_ID= +DESKTOP_AUTH_SECRET= # ----------------------------------------------------------------------------- # Freestyle diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index f8350265f..3152585e6 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -170,7 +170,10 @@ jobs: NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_ADMIN_URL: https://${{ env.ADMIN_ALIAS }} - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} @@ -194,13 +197,16 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ + --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ + --env CLERK_WEBHOOK_SECRET=$CLERK_WEBHOOK_SECRET \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BLOB_READ_WRITE_TOKEN=$BLOB_READ_WRITE_TOKEN \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ - --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ + --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ + --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ --env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ --env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ @@ -286,10 +292,12 @@ jobs: NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} NEXT_PUBLIC_DOCS_URL: https://${{ env.DOCS_ALIAS }} - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} + DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }} NEXT_PUBLIC_SENTRY_DSN_WEB: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN_WEB }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} @@ -297,13 +305,15 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ + --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ - --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ + --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ --env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \ + --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -360,7 +370,8 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} @@ -371,9 +382,10 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ - --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ + --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ + --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -443,7 +455,8 @@ jobs: DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} @@ -456,11 +469,12 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ + --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ - --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ + --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 150188a05..47e542c92 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -73,8 +73,11 @@ jobs: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} @@ -98,13 +101,16 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \ + --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ + --env CLERK_WEBHOOK_SECRET=$CLERK_WEBHOOK_SECRET \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BLOB_READ_WRITE_TOKEN=$BLOB_READ_WRITE_TOKEN \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ - --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ + --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ + --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ --env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ --env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ @@ -162,10 +168,12 @@ jobs: NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }} NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} + DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }} NEXT_PUBLIC_SENTRY_DSN_WEB: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN_WEB }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} @@ -173,13 +181,15 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \ + --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ - --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ + --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ --env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \ + --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -220,7 +230,8 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} @@ -231,9 +242,10 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \ - --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ + --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ + --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -276,7 +288,8 @@ jobs: DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} @@ -289,11 +302,12 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \ + --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ - --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ + --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ diff --git a/.mcp.json b/.mcp.json index 6c136b8f5..659f15ea7 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,6 +6,14 @@ "env": { "NEON_API_KEY": "${NEON_API_KEY}" } + }, + "morph-warp-grep": { + "command": "npx", + "args": ["-y", "@morphllm/morphmcp"], + "env": { + "MORPH_API_KEY": "${MORPH_API_KEY}", + "ENABLED_TOOLS": "warpgrep_codebase_search" + } } } } diff --git a/README.md b/README.md index 9e7f5e2c3..4f68cc83e 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Superset monitors your running agents, notify you when changes are ready, and he Screenshot 2025-12-24 at 9 33 51 PM -Superet is designed to be a superset of your existing tools. It works for any CLI agents that runs in the terminal. You can open your superset workspace in any apps like IDE, filesystem, terminal, etc. +Superset is designed to be a superset of your existing tools. It works for any CLI agents that runs in the terminal. You can open your superset workspace in any apps like IDE, filesystem, terminal, etc. Screenshot 2025-12-24 at 9 34 04 PM diff --git a/apps/admin/package.json b/apps/admin/package.json index 51757481b..c22d46c49 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -11,8 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/nextjs": "^6.36.2", "@sentry/nextjs": "^10.32.1", - "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", @@ -23,7 +23,6 @@ "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", - "better-auth": "^1.4.9", "date-fns": "^4.1.0", "drizzle-orm": "0.45.1", "import-in-the-middle": "2.0.1", diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx index eb751b99e..1b39591c8 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx @@ -20,7 +20,7 @@ import { SidebarRail, } from "@superset/ui/sidebar"; import { usePathname } from "next/navigation"; -import { LuChevronRight, LuHouse, LuUsers } from "react-icons/lu"; +import { LuChevronRight, LuHouse, LuUsers, LuUserX } from "react-icons/lu"; import { AppSidebarHeader } from "./components/AppSidebarHeader"; import { NavUser } from "./components/NavUser"; @@ -43,6 +43,11 @@ const sections = [ url: "/users", icon: LuUsers, }, + { + title: "Deleted Users", + url: "/users/deleted", + icon: LuUserX, + }, ], }, ]; diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx index 83bb8abd3..774cc5e09 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx @@ -1,6 +1,6 @@ "use client"; -import { authClient } from "@superset/auth/client"; +import { useClerk } from "@clerk/nextjs"; import type { RouterOutputs } from "@superset/trpc"; import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; import { @@ -25,6 +25,7 @@ import { LuLogOut, LuSettings, } from "react-icons/lu"; + import { env } from "@/env"; export interface NavUserProps { @@ -33,22 +34,13 @@ export interface NavUserProps { export function NavUser({ user }: NavUserProps) { const { isMobile } = useSidebar(); + const { signOut } = useClerk(); const userInitials = user.name .split(" ") .map((name) => name[0]) .join(""); - const handleSignOut = async () => { - await authClient.signOut({ - fetchOptions: { - onSuccess: () => { - window.location.href = env.NEXT_PUBLIC_WEB_URL; - }, - }, - }); - }; - return ( @@ -59,7 +51,10 @@ export function NavUser({ user }: NavUserProps) { className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - + {userInitials} @@ -80,7 +75,10 @@ export function NavUser({ user }: NavUserProps) {
- + {userInitials} @@ -107,7 +105,9 @@ export function NavUser({ user }: NavUserProps) { - + signOut({ redirectUrl: env.NEXT_PUBLIC_WEB_URL })} + > Log out diff --git a/apps/admin/src/app/(dashboard)/components/LeaderboardTable/LeaderboardTable.tsx b/apps/admin/src/app/(dashboard)/components/LeaderboardTable/LeaderboardTable.tsx index b24cba6e3..6c7bd5b63 100644 --- a/apps/admin/src/app/(dashboard)/components/LeaderboardTable/LeaderboardTable.tsx +++ b/apps/admin/src/app/(dashboard)/components/LeaderboardTable/LeaderboardTable.tsx @@ -23,7 +23,7 @@ interface LeaderboardEntry { userId: string; name: string; email: string; - image: string | null; + avatarUrl: string | null; count: number; } @@ -97,7 +97,7 @@ export function LeaderboardTable({
- + {entry.name .split(" ") diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx index e7b05fe6a..cdf7dd2d5 100644 --- a/apps/admin/src/app/(dashboard)/layout.tsx +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -1,5 +1,3 @@ -import { auth } from "@superset/auth"; -import { COMPANY } from "@superset/shared/constants"; import { Breadcrumb, BreadcrumbItem, @@ -14,10 +12,7 @@ import { SidebarProvider, SidebarTrigger, } from "@superset/ui/sidebar"; -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; -import { env } from "@/env"; import { api } from "@/trpc/server"; import { AppSidebar } from "./components/AppSidebar"; @@ -27,23 +22,11 @@ export default async function DashboardLayout({ }: { children: React.ReactNode; }) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - redirect(env.NEXT_PUBLIC_WEB_URL); - } - - if (!session.user.email?.endsWith(COMPANY.EMAIL_DOMAIN)) { - redirect(env.NEXT_PUBLIC_WEB_URL); - } - const trpc = await api(); const user = await trpc.user.me.query(); if (!user) { - redirect(env.NEXT_PUBLIC_WEB_URL); + throw new Error("User not found"); } return ( diff --git a/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx b/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx index 0883614d7..3ccac15f6 100644 --- a/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx +++ b/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx @@ -45,7 +45,7 @@ export function UsersTable() { const trpc = useTRPC(); const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery( - trpc.admin.listUsers.queryOptions(), + trpc.admin.listActiveUsers.queryOptions(), ); const [userToDelete, setUserToDelete] = useState<{ @@ -55,12 +55,12 @@ export function UsersTable() { } | null>(null); const deleteMutation = useMutation( - trpc.admin.deleteUser.mutationOptions({ + trpc.admin.permanentlyDeleteUser.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ - queryKey: trpc.admin.listUsers.queryKey(), + queryKey: trpc.admin.listActiveUsers.queryKey(), }); - toast.success(`${userToDelete?.name} has been deleted`); + toast.success(`${userToDelete?.name} has been permanently deleted`); setUserToDelete(null); }, onError: (error) => { @@ -152,7 +152,7 @@ export function UsersTable() {
- + {user.name .split(" ") diff --git a/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/DeletedUsersTable.tsx b/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/DeletedUsersTable.tsx new file mode 100644 index 000000000..c137fbb1a --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/DeletedUsersTable.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { Button } from "@superset/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@superset/ui/card"; +import { toast } from "@superset/ui/sonner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@superset/ui/table"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { useState } from "react"; +import { LuLoaderCircle, LuRotateCcw, LuTrash2, LuUserX } from "react-icons/lu"; + +import { useTRPC } from "@/trpc/react"; + +export function DeletedUsersTable() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data, isLoading, error } = useQuery( + trpc.admin.listDeletedUsers.queryOptions(), + ); + + const [userToDelete, setUserToDelete] = useState<{ + id: string; + email: string; + name: string; + } | null>(null); + + const restoreMutation = useMutation( + trpc.admin.restoreUser.mutationOptions({ + onSuccess: (_, _variables) => { + queryClient.invalidateQueries({ + queryKey: trpc.admin.listActiveUsers.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.admin.listDeletedUsers.queryKey(), + }); + toast.success("User restored successfully"); + }, + onError: (error) => { + toast.error(`Failed to restore user: ${error.message}`); + }, + }), + ); + + const permanentDeleteMutation = useMutation( + trpc.admin.permanentlyDeleteUser.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.admin.listDeletedUsers.queryKey(), + }); + toast.success(`${userToDelete?.name} has been permanently deleted`); + setUserToDelete(null); + }, + onError: (error) => { + toast.error(`Failed to delete user: ${error.message}`); + }, + }), + ); + + const handlePermanentDelete = () => { + if (!userToDelete) return; + permanentDeleteMutation.mutate({ userId: userToDelete.id }); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + +
+ +
+

Failed to load deleted users

+

+ {error.message || "An error occurred while fetching deleted users"} +

+
+
+ ); + } + + if (!data || data.length === 0) { + return ( + + + +

No deleted users

+

+ Users that are soft-deleted will appear here +

+
+
+ ); + } + + return ( + <> + + + Deleted Users + + {data.length} user{data.length !== 1 ? "s" : ""} queued for deletion + + + + + + + User + Email + Deleted + Actions + + + + {data.map((user) => { + const deletedAt = user.deletedAt + ? new Date(user.deletedAt) + : null; + const daysSinceDeleted = deletedAt + ? Math.floor( + (Date.now() - deletedAt.getTime()) / + (1000 * 60 * 60 * 24), + ) + : 0; + const isOverdue = daysSinceDeleted > 30; + + return ( + + +
+ + + + {user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2)} + + + {user.name} +
+
+ {user.email} + +
+
+ {deletedAt + ? formatDistanceToNow(deletedAt, { + addSuffix: true, + }) + : "-"} +
+ {deletedAt && ( +
+ {daysSinceDeleted} day + {daysSinceDeleted !== 1 ? "s" : ""} ago + {isOverdue && " (overdue)"} +
+ )} +
+
+ +
+ + +
+
+
+ ); + })} +
+
+
+
+ + !open && setUserToDelete(null)} + > + + + Permanently delete user? + +
+

+ This will permanently delete{" "} + {userToDelete?.name} ({userToDelete?.email}) + and all their data including: +

+
    +
  • All user data
  • +
  • All associated records
  • +
  • Their Clerk account
  • +
+

+ This action cannot be undone. +

+
+
+
+ + Cancel + + {permanentDeleteMutation.isPending ? ( + + ) : null} + Delete Permanently + + +
+
+ + ); +} diff --git a/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/index.ts b/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/index.ts new file mode 100644 index 000000000..3b5567553 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/index.ts @@ -0,0 +1 @@ +export { DeletedUsersTable } from "./DeletedUsersTable"; diff --git a/apps/admin/src/app/(dashboard)/users/deleted/page.tsx b/apps/admin/src/app/(dashboard)/users/deleted/page.tsx new file mode 100644 index 000000000..9d188ff24 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/deleted/page.tsx @@ -0,0 +1,16 @@ +import { DeletedUsersTable } from "./components/DeletedUsersTable"; + +export default function DeletedUsersPage() { + return ( +
+
+

Deleted Users

+

+ Manage users queued for deletion. Users can be restored or permanently + deleted. +

+
+ +
+ ); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 5be8712d8..2280d3658 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,8 +1,11 @@ +import { ClerkProvider } from "@clerk/nextjs"; import { Toaster } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import type { Metadata, Viewport } from "next"; import { IBM_Plex_Mono, Inter } from "next/font/google"; +import { env } from "@/env"; + import "./globals.css"; import { Providers } from "./providers"; @@ -37,19 +40,21 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - - - {children} - - - - + + + + + {children} + + + + + ); } diff --git a/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx b/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx index e70937d4b..68f00c993 100644 --- a/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx +++ b/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx @@ -1,27 +1,27 @@ "use client"; -import { authClient } from "@superset/auth/client"; +import { useUser } from "@clerk/nextjs"; import { useQuery } from "@tanstack/react-query"; import posthog from "posthog-js"; import { useEffect } from "react"; -import { useTRPC } from "@/trpc/react"; +import { useTRPC } from "../../trpc/react"; export function PostHogUserIdentifier() { - const { data: session } = authClient.useSession(); + const { isSignedIn } = useUser(); const trpc = useTRPC(); const { data: user } = useQuery({ ...trpc.user.me.queryOptions(), - enabled: !!session?.user, + enabled: isSignedIn, }); useEffect(() => { if (user) { posthog.identify(user.id, { email: user.email, name: user.name }); - } else if (!session?.user) { + } else if (isSignedIn === false) { posthog.reset(); } - }, [user, session?.user]); + }, [user, isSignedIn]); return null; } diff --git a/apps/admin/src/env.ts b/apps/admin/src/env.ts index 5dce7ad6f..ba90201c8 100644 --- a/apps/admin/src/env.ts +++ b/apps/admin/src/env.ts @@ -13,12 +13,14 @@ export const env = createEnv({ server: { DATABASE_URL: z.string().url(), DATABASE_URL_UNPOOLED: z.string().url(), + CLERK_SECRET_KEY: z.string(), SENTRY_AUTH_TOKEN: z.string().optional(), }, client: { NEXT_PUBLIC_API_URL: z.string().url(), NEXT_PUBLIC_WEB_URL: z.string().url(), + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(), NEXT_PUBLIC_COOKIE_DOMAIN: z.string(), NEXT_PUBLIC_POSTHOG_KEY: z.string(), NEXT_PUBLIC_POSTHOG_HOST: z.string().url(), @@ -32,6 +34,8 @@ export const env = createEnv({ NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, NEXT_PUBLIC_COOKIE_DOMAIN: process.env.NEXT_PUBLIC_COOKIE_DOMAIN, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, diff --git a/apps/admin/src/proxy.ts b/apps/admin/src/proxy.ts index 566f2fc8a..087e49b26 100644 --- a/apps/admin/src/proxy.ts +++ b/apps/admin/src/proxy.ts @@ -1,29 +1,47 @@ -import { auth } from "@superset/auth"; +import { clerkMiddleware } from "@clerk/nextjs/server"; +import { db } from "@superset/db/client"; +import { users } from "@superset/db/schema"; import { COMPANY } from "@superset/shared/constants"; -import { headers } from "next/headers"; +import { eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { env } from "./env"; -export default async function proxy() { - const session = await auth.api.getSession({ - headers: await headers(), - }); +const PUBLIC_ROUTES = ["/ingest", "/monitoring"]; + +function isPublicRoute(pathname: string): boolean { + return PUBLIC_ROUTES.some( + (route) => pathname === route || pathname.startsWith(`${route}/`), + ); +} + +export default clerkMiddleware(async (auth, req) => { + const { pathname } = req.nextUrl; + + if (isPublicRoute(pathname)) { + return NextResponse.next(); + } + + const { userId: clerkId } = await auth(); - if (!session?.user) { + if (!clerkId) { return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); } - if (!session.user.email.endsWith(COMPANY.EMAIL_DOMAIN)) { + const user = await db.query.users.findFirst({ + where: eq(users.clerkId, clerkId), + }); + + if (!user?.email.endsWith(COMPANY.EMAIL_DOMAIN)) { return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); } return NextResponse.next(); -} +}); export const config = { matcher: [ - "/((?!_next|ingest|monitoring|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", ], }; diff --git a/apps/admin/src/trpc/react.tsx b/apps/admin/src/trpc/react.tsx index b654b409f..95db3b924 100644 --- a/apps/admin/src/trpc/react.tsx +++ b/apps/admin/src/trpc/react.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAuth } from "@clerk/nextjs"; import type { AppRouter } from "@superset/trpc"; import type { QueryClient } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -32,6 +33,7 @@ export type UseTRPC = typeof useTRPC; export function TRPCReactProvider(props: { children: React.ReactNode }) { const queryClient = getQueryClient(); + const { getToken } = useAuth(); const [trpcClient] = useState(() => createTRPCClient({ @@ -44,17 +46,13 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) { httpBatchStreamLink({ transformer: SuperJSON, url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, - headers() { + async headers() { + const token = await getToken(); return { "x-trpc-source": "nextjs-react", + ...(token ? { Authorization: `Bearer ${token}` } : {}), }; }, - fetch(url, options) { - return fetch(url, { - ...options, - credentials: "include", - }); - }, }), ], }), diff --git a/apps/api/next.config.ts b/apps/api/next.config.ts index 8de5ba6fc..9943f4e41 100644 --- a/apps/api/next.config.ts +++ b/apps/api/next.config.ts @@ -3,6 +3,7 @@ import { withSentryConfig } from "@sentry/nextjs"; import { config as dotenvConfig } from "dotenv"; import type { NextConfig } from "next"; +// Load .env from monorepo root during development if (process.env.NODE_ENV !== "production") { dotenvConfig({ path: join(process.cwd(), "../../.env"), override: true }); } @@ -10,6 +11,7 @@ if (process.env.NODE_ENV !== "production") { const config: NextConfig = { reactCompiler: true, typescript: { ignoreBuildErrors: true }, + // CORS is handled dynamically in the route handlers }; export default withSentryConfig(config, { diff --git a/apps/api/package.json b/apps/api/package.json index 023421e13..62ccda6d7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,10 +11,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/backend": "^2.27.0", + "@clerk/nextjs": "^6.36.2", "@electric-sql/client": "^1.3.1", "@linear/sdk": "^68.1.0", "@sentry/nextjs": "^10.32.1", - "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", @@ -22,7 +23,6 @@ "@trpc/server": "^11.7.1", "@upstash/qstash": "^2.8.4", "@vercel/blob": "^2.0.0", - "better-auth": "^1.4.9", "drizzle-orm": "0.45.1", "import-in-the-middle": "2.0.1", "jose": "^6.1.3", diff --git a/apps/api/src/app/api/auth/[...all]/route.ts b/apps/api/src/app/api/auth/[...all]/route.ts deleted file mode 100644 index 6b68abd83..000000000 --- a/apps/api/src/app/api/auth/[...all]/route.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { auth } from "@superset/auth"; -import { toNextJsHandler } from "better-auth/next-js"; - -export const { GET, POST } = toNextJsHandler(auth); diff --git a/apps/api/src/app/api/auth/desktop/connect/route.ts b/apps/api/src/app/api/auth/desktop/connect/route.ts deleted file mode 100644 index 86deb03a7..000000000 --- a/apps/api/src/app/api/auth/desktop/connect/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { auth } from "@superset/auth"; -import { NextResponse } from "next/server"; - -import { env } from "@/env"; - -export async function GET(request: Request) { - const url = new URL(request.url); - const provider = url.searchParams.get("provider"); - const state = url.searchParams.get("state"); - - if (!provider || !state) { - return new Response("Missing provider or state", { status: 400 }); - } - - if (provider !== "google" && provider !== "github") { - return new Response("Invalid provider", { status: 400 }); - } - - const successUrl = new URL(`${env.NEXT_PUBLIC_WEB_URL}/auth/desktop/success`); - successUrl.searchParams.set("desktop_state", state); - - const result = await auth.api.signInSocial({ - body: { - provider, - callbackURL: successUrl.toString(), - }, - asResponse: true, - }); - - const cookies = result.headers.getSetCookie(); - const body = (await result.json()) as { url?: string; redirect?: boolean }; - - if (!body.url) { - return new Response(`Failed to initiate OAuth: ${JSON.stringify(body)}`, { - status: 500, - }); - } - - const response = NextResponse.redirect(body.url); - for (const cookie of cookies) { - response.headers.append("set-cookie", cookie); - } - - return response; -} diff --git a/apps/api/src/app/api/auth/desktop/github/route.ts b/apps/api/src/app/api/auth/desktop/github/route.ts new file mode 100644 index 000000000..4f95b0968 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/github/route.ts @@ -0,0 +1,207 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import { env } from "@/env"; +import { generateTokens } from "../tokens"; + +/** + * GitHub OAuth token response + */ +interface GitHubTokenResponse { + access_token: string; + token_type: string; + scope: string; +} + +/** + * GitHub user response + */ +interface GitHubUser { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +} + +/** + * GitHub email response + */ +interface GitHubEmail { + email: string; + primary: boolean; + verified: boolean; + visibility: string | null; +} + +/** + * Exchange GitHub auth code for tokens and create desktop session + * + * POST /api/auth/desktop/github + * Body: { code: string, redirectUri: string } + * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const { code, redirectUri } = body as { + code: string; + redirectUri: string; + }; + + if (!code || !redirectUri) { + return Response.json( + { error: "Missing code or redirectUri" }, + { status: 400 }, + ); + } + + // Exchange code for access token with GitHub + const tokenResponse = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: env.GH_CLIENT_ID, + client_secret: env.GH_CLIENT_SECRET, + code, + redirect_uri: redirectUri, + }), + }, + ); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json().catch(() => ({})); + console.error("[auth/github] Token exchange failed:", errorData); + return Response.json({ error: "Token exchange failed" }, { status: 400 }); + } + + const tokenData: GitHubTokenResponse = await tokenResponse.json(); + + if (!tokenData.access_token) { + console.error("[auth/github] No access token in response:", tokenData); + return Response.json( + { error: "No access token received" }, + { status: 400 }, + ); + } + + // Fetch user info from GitHub + const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!userResponse.ok) { + console.error("[auth/github] Failed to fetch user info"); + return Response.json( + { error: "Failed to fetch user info" }, + { status: 400 }, + ); + } + + const githubUser: GitHubUser = await userResponse.json(); + + // Always fetch verified email from /user/emails endpoint + // Never trust githubUser.email as it could be unverified + const emailsResponse = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!emailsResponse.ok) { + console.error("[auth/github] Failed to fetch user emails"); + return Response.json( + { error: "Failed to fetch user emails" }, + { status: 400 }, + ); + } + + const emails: GitHubEmail[] = await emailsResponse.json(); + // Only trust verified emails - prefer primary+verified, fallback to any verified + const primaryVerifiedEmail = emails.find((e) => e.primary && e.verified); + const anyVerifiedEmail = emails.find((e) => e.verified); + const email = + primaryVerifiedEmail?.email || anyVerifiedEmail?.email || null; + + if (!email) { + return Response.json( + { error: "No verified email found on GitHub account" }, + { status: 400 }, + ); + } + + // Parse name into first/last + const nameParts = (githubUser.name || "").split(" "); + const firstName = nameParts[0] || undefined; + const lastName = nameParts.slice(1).join(" ") || undefined; + + // Find or create user in Clerk + const clerk = await clerkClient(); + const existingUsers = await clerk.users.getUserList({ + emailAddress: [email], + }); + + let userId: string; + const existingUser = existingUsers.data[0]; + + if (existingUser) { + userId = existingUser.id; + console.log("[auth/github] Found existing user:", userId); + } else { + // Create new user + try { + const newUser = await clerk.users.createUser({ + emailAddress: [email], + firstName, + lastName, + skipPasswordRequirement: true, + }); + userId = newUser.id; + console.log("[auth/github] Created new user:", userId); + + // Mark the email as verified since GitHub already verified it + const emailId = newUser.emailAddresses[0]?.id; + if (emailId) { + await clerk.emailAddresses.updateEmailAddress(emailId, { + verified: true, + }); + console.log("[auth/github] Marked email as verified"); + } + } catch (clerkError: unknown) { + // Log and return detailed Clerk error + const errorDetails = + clerkError && typeof clerkError === "object" && "errors" in clerkError + ? (clerkError as { errors: unknown[] }).errors + : clerkError; + console.error( + "[auth/github] Clerk createUser failed:", + JSON.stringify(errorDetails, null, 2), + ); + return Response.json( + { + error: "Failed to create user account", + details: errorDetails, + }, + { status: 400 }, + ); + } + } + + // Generate access and refresh tokens + const tokens = await generateTokens(userId, email); + + return Response.json(tokens); + } catch (error) { + console.error("[auth/github] Error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/api/src/app/api/auth/desktop/google/route.ts b/apps/api/src/app/api/auth/desktop/google/route.ts new file mode 100644 index 000000000..622408b47 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/google/route.ts @@ -0,0 +1,173 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { env } from "@/env"; +import { generateTokens } from "../tokens"; + +/** + * Google OAuth token response + */ +interface GoogleTokenResponse { + access_token: string; + expires_in: number; + token_type: string; + scope: string; + id_token: string; + refresh_token?: string; +} + +/** + * Google ID token payload (verified) + */ +interface GoogleIdTokenPayload { + iss: string; + azp: string; + aud: string; + sub: string; + email: string; + email_verified: boolean; + name?: string; + picture?: string; + given_name?: string; + family_name?: string; + iat: number; + exp: number; +} + +// Google's JWKS endpoint - jose handles caching internally +const GOOGLE_JWKS = createRemoteJWKSet( + new URL("https://www.googleapis.com/oauth2/v3/certs"), +); + +/** + * Exchange Google auth code for tokens and create desktop session + * + * POST /api/auth/desktop/google + * Body: { code: string, redirectUri: string } + * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const { code, redirectUri } = body as { + code: string; + redirectUri: string; + }; + + if (!code || !redirectUri) { + return Response.json( + { error: "Missing code or redirectUri" }, + { status: 400 }, + ); + } + + // Exchange code for tokens with Google + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json().catch(() => ({})); + console.error("[auth/google] Token exchange failed:", errorData); + return Response.json( + { error: errorData.error_description || "Token exchange failed" }, + { status: 400 }, + ); + } + + const googleTokens: GoogleTokenResponse = await tokenResponse.json(); + + // Verify the ID token signature and claims using Google's JWKS + let payload: GoogleIdTokenPayload; + try { + const { payload: verifiedPayload } = await jwtVerify( + googleTokens.id_token, + GOOGLE_JWKS, + { + issuer: ["https://accounts.google.com", "accounts.google.com"], + audience: env.GOOGLE_CLIENT_ID, + }, + ); + payload = verifiedPayload as unknown as GoogleIdTokenPayload; + } catch (jwtError) { + console.error("[auth/google] JWT verification failed:", jwtError); + return Response.json( + { error: "Invalid or expired ID token" }, + { status: 401 }, + ); + } + + if (!payload.email_verified) { + return Response.json({ error: "Email not verified" }, { status: 400 }); + } + + // Find or create user in Clerk + const clerk = await clerkClient(); + const existingUsers = await clerk.users.getUserList({ + emailAddress: [payload.email], + }); + + let userId: string; + const existingUser = existingUsers.data[0]; + + if (existingUser) { + userId = existingUser.id; + console.log("[auth/google] Found existing user:", userId); + } else { + // Create new user + try { + const newUser = await clerk.users.createUser({ + emailAddress: [payload.email], + firstName: payload.given_name, + lastName: payload.family_name, + skipPasswordRequirement: true, + }); + userId = newUser.id; + console.log("[auth/google] Created new user:", userId); + + // Mark the email as verified since Google already verified it + const emailId = newUser.emailAddresses[0]?.id; + if (emailId) { + await clerk.emailAddresses.updateEmailAddress(emailId, { + verified: true, + }); + console.log("[auth/google] Marked email as verified"); + } + } catch (clerkError: unknown) { + // Log and return detailed Clerk error + const errorDetails = + clerkError && typeof clerkError === "object" && "errors" in clerkError + ? (clerkError as { errors: unknown[] }).errors + : clerkError; + console.error( + "[auth/google] Clerk createUser failed:", + JSON.stringify(errorDetails, null, 2), + ); + return Response.json( + { + error: "Failed to create user account", + details: errorDetails, + }, + { status: 400 }, + ); + } + } + + // Generate access and refresh tokens + const tokens = await generateTokens(userId, payload.email); + + return Response.json(tokens); + } catch (error) { + console.error("[auth/google] Error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/api/src/app/api/auth/desktop/refresh/route.ts b/apps/api/src/app/api/auth/desktop/refresh/route.ts new file mode 100644 index 000000000..808cc500a --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/refresh/route.ts @@ -0,0 +1,42 @@ +import { generateTokens, verifyRefreshToken } from "../tokens"; + +/** + * Refresh access token using a valid refresh token + * + * POST /api/auth/desktop/refresh + * Body: { refreshToken: string } + * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } + * + * This endpoint allows the desktop app to get new tokens without + * requiring the user to re-authenticate through Google OAuth. + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const { refreshToken } = body as { refreshToken: string }; + + if (!refreshToken) { + return Response.json({ error: "Missing refresh token" }, { status: 400 }); + } + + // Verify the refresh token + const tokenData = await verifyRefreshToken(refreshToken); + + if (!tokenData) { + return Response.json( + { error: "Invalid or expired refresh token" }, + { status: 401 }, + ); + } + + // Generate new tokens (rotate both access and refresh tokens) + const tokens = await generateTokens(tokenData.userId, tokenData.email); + + console.log("[auth/refresh] Tokens refreshed for user:", tokenData.userId); + + return Response.json(tokens); + } catch (error) { + console.error("[auth/refresh] Error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/api/src/app/api/auth/desktop/tokens.ts b/apps/api/src/app/api/auth/desktop/tokens.ts new file mode 100644 index 000000000..f3aff4065 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/tokens.ts @@ -0,0 +1,81 @@ +import { jwtVerify, SignJWT } from "jose"; +import { env } from "@/env"; + +// Token expiration times +export const ACCESS_TOKEN_EXPIRY = 60 * 60 * 1000; // 1 hour +export const REFRESH_TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000; // 30 days + +/** + * Get the secret key for signing/verifying tokens + */ +export function getSecretKey(): Uint8Array { + return new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); +} + +/** + * Generate access and refresh tokens for a user + */ +export async function generateTokens(userId: string, email: string) { + const secretKey = getSecretKey(); + const now = Date.now(); + const accessTokenExpiresAt = now + ACCESS_TOKEN_EXPIRY; + const refreshTokenExpiresAt = now + REFRESH_TOKEN_EXPIRY; + + // Access token - short-lived, used for API calls + const accessToken = await new SignJWT({ + sub: userId, + email, + type: "access", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(Math.floor(accessTokenExpiresAt / 1000)) + .setIssuer("superset-desktop") + .sign(secretKey); + + // Refresh token - long-lived, used to get new access tokens + const refreshToken = await new SignJWT({ + sub: userId, + email, + type: "refresh", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(Math.floor(refreshTokenExpiresAt / 1000)) + .setIssuer("superset-desktop") + .sign(secretKey); + + return { + accessToken, + accessTokenExpiresAt, + refreshToken, + refreshTokenExpiresAt, + }; +} + +/** + * Verify a refresh token and return its payload + */ +export async function verifyRefreshToken(token: string): Promise<{ + userId: string; + email: string; +} | null> { + try { + const secretKey = getSecretKey(); + const { payload } = await jwtVerify(token, secretKey, { + issuer: "superset-desktop", + }); + + // Ensure it's a refresh token + if (payload.type !== "refresh") { + return null; + } + + return { + userId: payload.sub as string, + email: payload.email as string, + }; + } catch { + return null; + } +} diff --git a/apps/api/src/app/api/desktop/version/route.ts b/apps/api/src/app/api/desktop/version/route.ts index df53cf357..c87a24859 100644 --- a/apps/api/src/app/api/desktop/version/route.ts +++ b/apps/api/src/app/api/desktop/version/route.ts @@ -1,4 +1,4 @@ -const MINIMUM_DESKTOP_VERSION = "0.0.44"; +const MINIMUM_DESKTOP_VERSION = "0.0.39"; /** * Used to force the desktop app to update, in cases where we can't support @@ -7,7 +7,6 @@ const MINIMUM_DESKTOP_VERSION = "0.0.44"; export async function GET() { return Response.json({ minimumVersion: MINIMUM_DESKTOP_VERSION, - message: - "We've upgraded our authentication system. Please update to continue.", + message: "Please update to the latest version to continue.", }); } diff --git a/apps/api/src/app/api/electric/[...path]/route.ts b/apps/api/src/app/api/electric/[...path]/route.ts index 3617a72d1..90c9cb64e 100644 --- a/apps/api/src/app/api/electric/[...path]/route.ts +++ b/apps/api/src/app/api/electric/[...path]/route.ts @@ -1,22 +1,46 @@ import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"; -import { auth } from "@superset/auth"; +import { db } from "@superset/db/client"; +import { organizationMembers, users } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; import { env } from "@/env"; +import { authenticateRequest } from "@/lib/auth"; import { buildWhereClause } from "./utils"; +/** + * Electric SQL Proxy + * + * Forwards shape requests to Electric with organization-based filtering. + * @see https://electric-sql.com/docs/guides/auth#proxy-auth + */ export async function GET(request: Request): Promise { - const sessionData = await auth.api.getSession({ - headers: request.headers, - }); - if (!sessionData?.user) { + const clerkUserId = await authenticateRequest(request); + if (!clerkUserId) { return new Response("Unauthorized", { status: 401 }); } - const organizationId = sessionData.session.activeOrganizationId; - if (!organizationId) { - return new Response("No active organization", { status: 400 }); + const user = await db.query.users.findFirst({ + where: eq(users.clerkId, clerkUserId), + }); + if (!user) { + return new Response("User not found", { status: 401 }); } const url = new URL(request.url); + const organizationId = url.searchParams.get("organizationId"); + if (!organizationId) { + return new Response("Missing organizationId parameter", { status: 400 }); + } + + const membership = await db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.userId, user.id), + eq(organizationMembers.organizationId, organizationId), + ), + }); + if (!membership) { + return new Response("Not a member of this organization", { status: 403 }); + } + const originUrl = new URL(env.ELECTRIC_URL); originUrl.searchParams.set("secret", env.ELECTRIC_SECRET); @@ -44,6 +68,8 @@ export async function GET(request: Request): Promise { const response = await fetch(originUrl.toString()); + // Forward headers, but remove content-encoding/length per Electric docs + // (these can cause issues when proxying compressed responses) const headers = new Headers(); response.headers.forEach((value, key) => { const lower = key.toLowerCase(); diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts index ba2b1d05f..efd8ca2ff 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -1,8 +1,7 @@ import { db } from "@superset/db/client"; import { - members, + organizationMembers, organizations, - repositories, tasks, users, } from "@superset/db/schema"; @@ -12,10 +11,9 @@ import { QueryBuilder } from "drizzle-orm/pg-core"; export type AllowedTable = | "tasks" - | "repositories" - | "auth.members" - | "auth.organizations" - | "auth.users"; + | "organization_members" + | "organizations" + | "users"; interface WhereClause { fragment: string; @@ -42,62 +40,25 @@ export async function buildWhereClause( case "tasks": return build(tasks, tasks.organizationId, organizationId); - case "repositories": - return build(repositories, repositories.organizationId, organizationId); - - case "auth.members": - return build(members, members.organizationId, organizationId); - - case "auth.organizations": { - const userMemberships = await db.query.members.findMany({ - where: eq(members.organizationId, organizationId), - columns: { userId: true }, - }); - - if (userMemberships.length === 0) { - return { fragment: "1 = 0", params: [] }; - } - - const userId = userMemberships[0]?.userId; - if (!userId) { - return { fragment: "1 = 0", params: [] }; - } - - const allUserMemberships = await db.query.members.findMany({ - where: eq(members.userId, userId), - columns: { organizationId: true }, - }); - - if (allUserMemberships.length === 0) { - return { fragment: "1 = 0", params: [] }; - } - - const orgIds = [ - ...new Set(allUserMemberships.map((m) => m.organizationId)), - ]; - const whereExpr = inArray( - sql`${sql.identifier(organizations.id.name)}`, - orgIds, + case "organization_members": + return build( + organizationMembers, + organizationMembers.organizationId, + organizationId, ); - const qb = new QueryBuilder(); - const { sql: query, params } = qb - .select() - .from(organizations) - .where(whereExpr) - .toSQL(); - const fragment = query.replace(/^select .* from .* where\s+/i, ""); - return { fragment, params }; - } - case "auth.users": { - const orgMembers = await db.query.members.findMany({ - where: eq(members.organizationId, organizationId), + case "organizations": + return build(organizations, organizations.id, organizationId); + + case "users": { + const members = await db.query.organizationMembers.findMany({ + where: eq(organizationMembers.organizationId, organizationId), columns: { userId: true }, }); - if (orgMembers.length === 0) { + if (members.length === 0) { return { fragment: "1 = 0", params: [] }; } - const userIds = [...new Set(orgMembers.map((m) => m.userId))]; + const userIds = [...new Set(members.map((m) => m.userId))]; const whereExpr = inArray(sql`${sql.identifier(users.id.name)}`, userIds); const qb = new QueryBuilder(); const { sql: query, params } = qb diff --git a/apps/api/src/app/api/integrations/linear/connect/route.ts b/apps/api/src/app/api/integrations/linear/connect/route.ts index b359cb37c..7a76e51a0 100644 --- a/apps/api/src/app/api/integrations/linear/connect/route.ts +++ b/apps/api/src/app/api/integrations/linear/connect/route.ts @@ -1,15 +1,13 @@ -import { auth } from "@superset/auth"; +import { auth } from "@clerk/nextjs/server"; import { db } from "@superset/db/client"; -import { members } from "@superset/db/schema"; +import { organizationMembers, users } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; import { env } from "@/env"; export async function GET(request: Request) { - const session = await auth.api.getSession({ - headers: request.headers, - }); + const { userId: clerkUserId } = await auth(); - if (!session?.user) { + if (!clerkUserId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -23,10 +21,18 @@ export async function GET(request: Request) { ); } - const membership = await db.query.members.findFirst({ + const user = await db.query.users.findFirst({ + where: eq(users.clerkId, clerkUserId), + }); + + if (!user) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + const membership = await db.query.organizationMembers.findFirst({ where: and( - eq(members.organizationId, organizationId), - eq(members.userId, session.user.id), + eq(organizationMembers.organizationId, organizationId), + eq(organizationMembers.userId, user.id), ), }); @@ -38,7 +44,7 @@ export async function GET(request: Request) { } const state = Buffer.from( - JSON.stringify({ organizationId, userId: session.user.id }), + JSON.stringify({ organizationId, userId: user.id }), ).toString("base64url"); const linearAuthUrl = new URL("https://linear.app/oauth/authorize"); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 710ea1a1b..a1cfec766 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -10,9 +10,11 @@ export const env = createEnv({ server: { DATABASE_URL: z.string(), DATABASE_URL_UNPOOLED: z.string(), + CLERK_SECRET_KEY: z.string(), ELECTRIC_URL: z.string().url(), ELECTRIC_SECRET: z.string().min(16), BLOB_READ_WRITE_TOKEN: z.string(), + DESKTOP_AUTH_SECRET: z.string().min(32), GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), GH_CLIENT_ID: z.string().min(1), diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts new file mode 100644 index 000000000..e883f106c --- /dev/null +++ b/apps/api/src/lib/auth.ts @@ -0,0 +1,61 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import { jwtVerify } from "jose"; +import { env } from "@/env"; + +/** + * Verify desktop JWT access token + * Only accepts access tokens (type: "access"), not refresh tokens + */ +async function verifyDesktopToken(token: string): Promise { + try { + const secretKey = new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); + const { payload } = await jwtVerify(token, secretKey, { + issuer: "superset-desktop", + }); + + if (payload.type !== "access") { + return null; + } + + if (typeof payload.sub !== "string") { + return null; + } + + return payload.sub; + } catch { + return null; + } +} + +/** + * Authenticate a request and return the Clerk user ID + * + * Supports: + * 1. Clerk session token (from web app) + * 2. Desktop JWT token (from desktop app) + * + * Returns null if not authenticated. + */ +export async function authenticateRequest( + request: Request, +): Promise { + // Try Clerk auth first + const client = await clerkClient(); + const { isAuthenticated, toAuth } = await client.authenticateRequest(request); + + if (isAuthenticated) { + const auth = toAuth(); + if (auth.userId) { + return auth.userId; + } + } + + // Fall back to desktop JWT + const authHeader = request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + return verifyDesktopToken(token); + } + + return null; +} diff --git a/apps/api/src/proxy.ts b/apps/api/src/proxy.ts index 11ccda86f..9d58e4e26 100644 --- a/apps/api/src/proxy.ts +++ b/apps/api/src/proxy.ts @@ -1,4 +1,5 @@ -import { type NextRequest, NextResponse } from "next/server"; +import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; import { env } from "./env"; @@ -7,6 +8,7 @@ const allowedOrigins = [ env.NEXT_PUBLIC_ADMIN_URL, env.NODE_ENV === "development" && "http://localhost:5927", ].filter(Boolean); +const isPublicRoute = createRouteMatcher(["/ingest(.*)", "/monitoring(.*)"]); function getCorsHeaders(origin: string | null) { const isAllowed = origin && allowedOrigins.includes(origin); @@ -19,7 +21,12 @@ function getCorsHeaders(origin: string | null) { }; } -export default function proxy(req: NextRequest) { +export default clerkMiddleware(async (_auth, req) => { + // Allow Sentry and PostHog routes without CORS processing + if (isPublicRoute(req)) { + return NextResponse.next(); + } + const origin = req.headers.get("origin"); const corsHeaders = getCorsHeaders(origin); @@ -34,11 +41,13 @@ export default function proxy(req: NextRequest) { response.headers.set(key, value); } return response; -} +}); export const config = { matcher: [ - "/((?!_next|ingest|monitoring|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + // Skip Next.js internals and static files + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + // Always run for API routes "/(api|trpc)(.*)", ], }; diff --git a/apps/api/src/trpc/context.ts b/apps/api/src/trpc/context.ts index ccafe79f2..98bc13e3b 100644 --- a/apps/api/src/trpc/context.ts +++ b/apps/api/src/trpc/context.ts @@ -1,5 +1,5 @@ -import { auth } from "@superset/auth"; import { createTRPCContext } from "@superset/trpc"; +import { authenticateRequest } from "@/lib/auth"; export const createContext = async ({ req, @@ -7,8 +7,6 @@ export const createContext = async ({ req: Request; resHeaders: Headers; }) => { - const session = await auth.api.getSession({ - headers: req.headers, - }); - return createTRPCContext({ session }); + const userId = await authenticateRequest(req); + return createTRPCContext({ userId }); }; diff --git a/apps/desktop/docs/LOCAL_FIRST_SYNC.md b/apps/desktop/docs/LOCAL_FIRST_SYNC.md deleted file mode 100644 index e59f70cba..000000000 --- a/apps/desktop/docs/LOCAL_FIRST_SYNC.md +++ /dev/null @@ -1,60 +0,0 @@ -# Local-First Sync Plan - -## Current State - -- **Read path**: Electric SQL syncs server → PGlite (working) -- **Write path**: Direct tRPC API calls from renderer (working, but no offline support) - -## Proposed: Zustand Pending-Writes Pattern - -Simple offline queue with optimistic local updates. - -### Architecture - -``` -User action - ↓ -PGlite (optimistic write) - ↓ -Zustand pending-writes queue (persisted to localStorage) - ↓ -Sync worker (processes queue, retries on failure) - ↓ -tRPC API - ↓ -Database - ↓ -Electric syncs canonical state back to PGlite -``` - -### Implementation (~150 lines) - -1. **`stores/pending-writes/store.ts`** - Zustand store with localStorage persistence -2. **`stores/pending-writes/sync-worker.ts`** - Background processor with exponential backoff -3. **`stores/pending-writes/hooks.ts`** - `useOptimisticTask()` etc. - -### API Requirements - -- [x] Accept client-generated UUIDs for creates -- [x] Partial updates (PATCH style, only changed fields) -- [x] Idempotent operations (retries are safe) -- [ ] Optional: Client timestamps for explicit LWW conflict resolution - -### Alternatives Considered - -| Approach | Pros | Cons | -|----------|------|------| -| **Zustand pending-writes** | Simple, ~150 LOC, works with existing stack | Manual wiring per entity | -| **Legend State v3** | Built-in sync engine, retries, offline | Beta, new dependency, learning curve | -| **Through-the-DB (SQL triggers)** | Pure SQL, no JS sync code | Complex schema, harder to debug | -| **Electric Pattern 3** | Well-documented | Adds Valtio dependency | - -### Effort - -- Basic implementation: 1 day -- Add entity: ~15 min each - -## Decision - -Ship without offline sync for now. Current API writes work fine. -Revisit when offline support becomes a priority. diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 2ce99bdab..a171195b0 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -213,7 +213,6 @@ export default defineConfig({ optimizeDeps: { include: ["monaco-editor"], - exclude: ["@electric-sql/pglite"], }, publicDir: resolve(resources, "public"), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 024186298..696bb9f33 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@superset/desktop", "productName": "Superset", "description": "The last developer tool you'll ever need", - "version": "0.0.42", + "version": "0.0.43", "main": "./dist/main/index.js", "resources": "src/resources", "repository": { @@ -39,7 +39,6 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@sentry/electron": "^7.5.0", - "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/local-db": "workspace:*", "@superset/shared": "workspace:*", @@ -100,7 +99,6 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.3", - "react-hotkeys-hook": "^5.2.1", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-mosaic-component": "^6.1.1", @@ -140,7 +138,6 @@ "bun-types": "^1.3.1", "code-inspector-plugin": "^1.2.2", "cross-env": "^10.0.0", - "drizzle-kit": "0.31.8", "electron": "39.1.2", "electron-builder": "^26.0.12", "electron-extension-installer": "^2.0.0", diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 4c299e24c..579fa3403 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -58,9 +58,7 @@ export async function makeAppSetup( // Always prevent in-app navigation for external URLs if (url.startsWith("http://") || url.startsWith("https://")) { event.preventDefault(); - shell.openExternal(url).catch((error) => { - console.error("[app] Failed to open external URL:", url, error); - }); + shell.openExternal(url); } }), ); diff --git a/apps/desktop/src/lib/electron-app/factories/windows/create.ts b/apps/desktop/src/lib/electron-app/factories/windows/create.ts index d5a029311..479cb0faf 100644 --- a/apps/desktop/src/lib/electron-app/factories/windows/create.ts +++ b/apps/desktop/src/lib/electron-app/factories/windows/create.ts @@ -9,9 +9,7 @@ export function createWindow({ id, ...settings }: WindowProps) { // Open external URLs in the system browser instead of Electron window.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith("http://") || url.startsWith("https://")) { - shell.openExternal(url).catch((error) => { - console.error("[window] Failed to open external URL:", url, error); - }); + shell.openExternal(url); return { action: "deny" }; } return { action: "deny" }; diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts index 3705bad29..ca001efce 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -1,6 +1,7 @@ -import { AUTH_PROVIDERS } from "@superset/shared/constants"; import { observable } from "@trpc/server/observable"; +import type { BrowserWindow } from "electron"; import { authService } from "main/lib/auth"; +import { AUTH_PROVIDERS } from "shared/auth"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -8,16 +9,18 @@ import { publicProcedure, router } from "../.."; * Authentication router for desktop app * Handles sign in/out and state management */ -export const createAuthRouter = () => { +export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { return router({ + /** + * Get current authentication state + */ getState: publicProcedure.query(() => { return authService.getState(); }), - getAccessToken: publicProcedure.query(() => { - return authService.getAccessToken(); - }), - + /** + * Subscribe to auth state changes + */ onStateChange: publicProcedure.subscription(() => { return observable<{ isSignedIn: boolean }>((emit) => { const handler = (state: { isSignedIn: boolean }) => { @@ -38,7 +41,7 @@ export const createAuthRouter = () => { /** * Subscribe to access token (for Electric sync in renderer) - * Emits current token on subscribe and when auth state changes + * Emits current token on subscribe and again when tokens refresh */ onAccessToken: publicProcedure.subscription(() => { return observable<{ accessToken: string | null }>((emit) => { @@ -47,7 +50,6 @@ export const createAuthRouter = () => { const accessToken = await authService.getAccessToken(); emit.next({ accessToken }); } catch (err) { - console.error("[auth/onAccessToken] Error getting token:", err); emit.error(err instanceof Error ? err : new Error(String(err))); } }; @@ -58,45 +60,28 @@ export const createAuthRouter = () => { void emitToken(); + authService.on("tokens-refreshed", handler); authService.on("state-changed", handler); return () => { + authService.off("tokens-refreshed", handler); authService.off("state-changed", handler); }; }); }), - onSessionChange: publicProcedure.subscription(() => { - return observable>((emit) => { - const handler = ( - session: ReturnType, - ) => { - emit.next(session); - }; - - emit.next(authService.getSession()); - - authService.on("session-changed", handler); - - return () => { - authService.off("session-changed", handler); - }; - }); - }), - - setActiveOrganization: publicProcedure - .input(z.object({ organizationId: z.string() })) - .mutation(async ({ input }) => { - await authService.setActiveOrganization(input.organizationId); - return { success: true }; - }), - + /** + * Sign in with OAuth provider + */ signIn: publicProcedure .input(z.object({ provider: z.enum(AUTH_PROVIDERS) })) .mutation(async ({ input }) => { - return authService.signIn(input.provider); + return authService.signIn(input.provider, getWindow); }), + /** + * Sign out + */ signOut: publicProcedure.mutation(async () => { await authService.signOut(); return { success: true }; diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index bc63ab91a..bda18dccf 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -4,6 +4,11 @@ import { localDb } from "main/lib/local-db"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + assertRegisteredWorktree, + getRegisteredWorktree, + gitSwitchBranch, +} from "./security"; export const createBranchesRouter = () => { return router({ @@ -18,6 +23,8 @@ export const createBranchesRouter = () => { defaultBranch: string; checkedOutBranches: Record; }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const branchSummary = await git.branch(["-a"]); @@ -59,18 +66,8 @@ export const createBranchesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - - const worktree = localDb - .select() - .from(worktrees) - .where(eq(worktrees.path, input.worktreePath)) - .get(); - if (!worktree) { - throw new Error(`No worktree found at path "${input.worktreePath}"`); - } - - await git.checkout(input.branch); + const worktree = getRegisteredWorktree(input.worktreePath); + await gitSwitchBranch(input.worktreePath, input.branch); // Update the branch in the worktree record const gitStatus = worktree.gitStatus diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index 8af04dd68..48b01555b 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -1,10 +1,47 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FileContents } from "shared/changes-types"; +import { detectLanguage } from "shared/detect-language"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; -import { detectLanguage } from "./utils/parse-status"; +import { + assertRegisteredWorktree, + PathValidationError, + secureFs, +} from "./security"; + +/** Maximum file size for reading (2 MiB) */ +const MAX_FILE_SIZE = 2 * 1024 * 1024; + +/** Bytes to scan for binary detection */ +const BINARY_CHECK_SIZE = 8192; + +/** + * Result type for readWorkingFile procedure + */ +type ReadWorkingFileResult = + | { ok: true; content: string; truncated: boolean; byteLength: number } + | { + ok: false; + reason: + | "not-found" + | "too-large" + | "binary" + | "outside-worktree" + | "symlink-escape"; + }; + +/** + * Detects if a buffer contains binary content by checking for NUL bytes + */ +function isBinaryContent(buffer: Buffer): boolean { + const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE); + for (let i = 0; i < checkLength; i++) { + if (buffer[i] === 0) { + return true; + } + } + return false; +} export const createFileContentsRouter = () => { return router({ @@ -20,6 +57,8 @@ export const createFileContentsRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const originalPath = input.oldPath || input.filePath; @@ -50,10 +89,57 @@ export const createFileContentsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - await writeFile(fullPath, input.content, "utf-8"); + await secureFs.writeFile( + input.worktreePath, + input.filePath, + input.content, + ); return { success: true }; }), + + /** + * Read a working tree file safely with size cap and binary detection. + * Used for File Viewer raw/rendered modes. + */ + readWorkingFile: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .query(async ({ input }): Promise => { + try { + const stats = await secureFs.stat(input.worktreePath, input.filePath); + if (stats.size > MAX_FILE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + const buffer = await secureFs.readFileBuffer( + input.worktreePath, + input.filePath, + ); + + if (isBinaryContent(buffer)) { + return { ok: false, reason: "binary" }; + } + + return { + ok: true, + content: buffer.toString("utf-8"), + truncated: false, + byteLength: buffer.length, + }; + } catch (error) { + if (error instanceof PathValidationError) { + if (error.code === "SYMLINK_ESCAPE") { + return { ok: false, reason: "symlink-escape" }; + } + return { ok: false, reason: "outside-worktree" }; + } + return { ok: false, reason: "not-found" }; + } + }), }); }; @@ -91,26 +177,41 @@ async function getFileVersions( } } +/** Helper to safely get git show content with size limit and memory protection */ +async function safeGitShow( + git: ReturnType, + spec: string, +): Promise { + try { + // Preflight: check blob size before loading into memory + // This prevents memory spikes from large files in git history + try { + const sizeOutput = await git.raw(["cat-file", "-s", spec]); + const blobSize = Number.parseInt(sizeOutput.trim(), 10); + if (!Number.isNaN(blobSize) && blobSize > MAX_FILE_SIZE) { + return `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + } + } catch { + // cat-file failed (blob doesn't exist) - let git.show handle the error + } + + const content = await git.show([spec]); + return content; + } catch { + return ""; + } +} + async function getAgainstBaseVersions( git: ReturnType, filePath: string, originalPath: string, defaultBranch: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`origin/${defaultBranch}:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`HEAD:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `origin/${defaultBranch}:${originalPath}`), + safeGitShow(git, `HEAD:${filePath}`), + ]); return { original, modified }; } @@ -121,20 +222,10 @@ async function getCommittedVersions( originalPath: string, commitHash: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`${commitHash}^:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`${commitHash}:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `${commitHash}^:${originalPath}`), + safeGitShow(git, `${commitHash}:${filePath}`), + ]); return { original, modified }; } @@ -144,20 +235,10 @@ async function getStagedVersions( filePath: string, originalPath: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`HEAD:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`:0:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `HEAD:${originalPath}`), + safeGitShow(git, `:0:${filePath}`), + ]); return { original, modified }; } @@ -168,22 +249,22 @@ async function getUnstagedVersions( filePath: string, originalPath: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`:0:${originalPath}`]); - } catch { - try { - original = await git.show([`HEAD:${originalPath}`]); - } catch { - original = ""; - } + // Try staged version first, fall back to HEAD + let original = await safeGitShow(git, `:0:${originalPath}`); + if (!original) { + original = await safeGitShow(git, `HEAD:${originalPath}`); } + let modified = ""; try { - modified = await readFile(join(worktreePath, filePath), "utf-8"); + const stats = await secureFs.stat(worktreePath, filePath); + if (stats.size <= MAX_FILE_SIZE) { + modified = await secureFs.readFile(worktreePath, filePath); + } else { + modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + } } catch { + // File doesn't exist or validation failed - that's ok for diff display modified = ""; } diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index 6e6f584cf..c69364a34 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,10 +1,9 @@ -import { writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; import { shell } from "electron"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { isUpstreamMissingError } from "./git-utils"; +import { assertRegisteredWorktree } from "./security"; export { isUpstreamMissingError }; @@ -21,25 +20,8 @@ async function hasUpstreamBranch( export const createGitOperationsRouter = () => { return router({ - saveFile: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - content: z.string(), - }), - ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const resolvedWorktree = resolve(input.worktreePath); - const fullPath = resolve(resolvedWorktree, input.filePath); - - if (!fullPath.startsWith(`${resolvedWorktree}/`)) { - throw new Error("Invalid file path: path traversal detected"); - } - - await writeFile(fullPath, input.content, "utf-8"); - return { success: true }; - }), + // NOTE: saveFile is defined in file-contents.ts with hardened path validation + // Do NOT add saveFile here - it would overwrite the secure version commit: publicProcedure .input( @@ -50,6 +32,8 @@ export const createGitOperationsRouter = () => { ) .mutation( async ({ input }): Promise<{ success: boolean; hash: string }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const result = await git.commit(input.message); return { success: true, hash: result.commit }; @@ -64,6 +48,8 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const hasUpstream = await hasUpstreamBranch(git); @@ -84,6 +70,8 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); try { await git.pull(["--rebase"]); @@ -107,6 +95,8 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); try { await git.pull(["--rebase"]); @@ -134,6 +124,8 @@ export const createGitOperationsRouter = () => { ) .mutation( async ({ input }): Promise<{ success: boolean; url: string }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); const hasUpstream = await hasUpstreamBranch(git); diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts new file mode 100644 index 000000000..2877176b8 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -0,0 +1,137 @@ +import simpleGit from "simple-git"; +import { + assertRegisteredWorktree, + assertValidGitPath, +} from "./path-validation"; + +/** + * Git command helpers with semantic naming. + * + * Design principle: Different functions for different git semantics. + * You can't accidentally use file checkout syntax for branch switching. + * + * Each function: + * 1. Validates worktree is registered + * 2. Validates paths/refs as appropriate + * 3. Uses the correct git command syntax + */ + +/** + * Switch to a branch. + * + * Uses `git switch` (unambiguous branch operation, git 2.23+). + * Falls back to `git checkout ` for older git versions. + * + * Note: `git checkout -- ` is WRONG - that's file checkout syntax. + */ +export async function gitSwitchBranch( + worktreePath: string, + branch: string, +): Promise { + assertRegisteredWorktree(worktreePath); + + // Validate: reject anything that looks like a flag + if (branch.startsWith("-")) { + throw new Error("Invalid branch name: cannot start with -"); + } + + // Validate: reject empty branch names + if (!branch.trim()) { + throw new Error("Invalid branch name: cannot be empty"); + } + + const git = simpleGit(worktreePath); + + try { + // Prefer `git switch` - unambiguous branch operation (git 2.23+) + await git.raw(["switch", branch]); + } catch (switchError) { + // Check if it's because `switch` command doesn't exist (old git < 2.23) + // Git outputs: "git: 'switch' is not a git command. See 'git --help'." + const errorMessage = String(switchError); + if (errorMessage.includes("is not a git command")) { + // Fallback for older git versions + // Note: checkout WITHOUT -- is correct for branches + await git.checkout(branch); + } else { + throw switchError; + } + } +} + +/** + * Checkout (restore) a file path, discarding local changes. + * + * Uses `git checkout -- ` - the `--` is REQUIRED here + * to indicate path mode (not branch mode). + */ +export async function gitCheckoutFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + // `--` is correct here - we want path semantics + await git.checkout(["--", filePath]); +} + +/** + * Stage a file for commit. + * + * Uses `git add -- ` - the `--` prevents paths starting + * with `-` from being interpreted as flags. + */ +export async function gitStageFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + await git.add(["--", filePath]); +} + +/** + * Stage all changes for commit. + * + * Uses `git add -A` to stage all changes (new, modified, deleted). + */ +export async function gitStageAll(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.add("-A"); +} + +/** + * Unstage a file (remove from staging area). + * + * Uses `git reset HEAD -- ` to unstage without + * discarding changes. + */ +export async function gitUnstageFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + await git.reset(["HEAD", "--", filePath]); +} + +/** + * Unstage all files. + * + * Uses `git reset HEAD` to unstage all changes without + * discarding them. + */ +export async function gitUnstageAll(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.reset(["HEAD"]); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts new file mode 100644 index 000000000..8fdb09c9e --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts @@ -0,0 +1,31 @@ +/** + * Security module for changes routers. + * + * Security model: + * - PRIMARY: Worktree must be registered in localDb + * - SECONDARY: Paths validated for traversal attempts + * + * See path-validation.ts header for full threat model. + */ + +export { + gitCheckoutFile, + gitStageAll, + gitStageFile, + gitSwitchBranch, + gitUnstageAll, + gitUnstageFile, +} from "./git-commands"; + +export { + assertRegisteredWorktree, + assertValidGitPath, + getRegisteredWorktree, + PathValidationError, + type PathValidationErrorCode, + resolvePathInWorktree, + type ValidatePathOptions, + validateRelativePath, +} from "./path-validation"; + +export { secureFs } from "./secure-fs"; diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts new file mode 100644 index 000000000..317994323 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts @@ -0,0 +1,194 @@ +import { isAbsolute, normalize, resolve, sep } from "node:path"; +import { projects, worktrees } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; + +/** + * Security model for desktop app filesystem access: + * + * THREAT MODEL: + * While a compromised renderer can execute commands via terminal panes, + * the File Viewer presents a distinct threat: malicious repositories can + * contain symlinks that trick users into reading/writing sensitive files + * (e.g., `docs/config.yml` → `~/.bashrc`). Users clicking these links + * don't know they're accessing files outside the repo. + * + * PRIMARY BOUNDARY: assertRegisteredWorktree() + * - Only worktree paths registered in localDb are accessible via tRPC + * - Prevents direct filesystem access to unregistered paths + * + * SECONDARY: validateRelativePath() + * - Rejects absolute paths and ".." traversal segments + * - Defense in depth against path manipulation + * + * SYMLINK PROTECTION (secure-fs.ts): + * - Writes: Block if realpath escapes worktree (prevents accidental overwrites) + * - Reads: Caller can check isSymlinkEscaping() to warn users + */ + +/** + * Security error codes for path validation failures. + */ +export type PathValidationErrorCode = + | "ABSOLUTE_PATH" + | "PATH_TRAVERSAL" + | "UNREGISTERED_WORKTREE" + | "INVALID_TARGET" + | "SYMLINK_ESCAPE"; + +/** + * Error thrown when path validation fails. + * Includes a code for programmatic handling. + */ +export class PathValidationError extends Error { + constructor( + message: string, + public readonly code: PathValidationErrorCode, + ) { + super(message); + this.name = "PathValidationError"; + } +} + +/** + * Validates that a workspace path is registered in localDb. + * This is THE critical security boundary. + * + * Accepts: + * - Worktree paths (from worktrees table) + * - Project mainRepoPath (for branch workspaces that work on the main repo) + * + * @throws PathValidationError if path is not registered + */ +export function assertRegisteredWorktree(workspacePath: string): void { + // Check worktrees table first (most common case) + const worktreeExists = localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, workspacePath)) + .get(); + + if (worktreeExists) { + return; + } + + // Check projects.mainRepoPath for branch workspaces + const projectExists = localDb + .select() + .from(projects) + .where(eq(projects.mainRepoPath, workspacePath)) + .get(); + + if (projectExists) { + return; + } + + throw new PathValidationError( + "Workspace path not registered in database", + "UNREGISTERED_WORKTREE", + ); +} + +/** + * Gets the worktree record if registered. Returns record for updates. + * Only works for actual worktrees, not project mainRepoPath. + * + * @throws PathValidationError if worktree is not registered + */ +export function getRegisteredWorktree( + worktreePath: string, +): typeof worktrees.$inferSelect { + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, worktreePath)) + .get(); + + if (!worktree) { + throw new PathValidationError( + "Worktree not registered in database", + "UNREGISTERED_WORKTREE", + ); + } + + return worktree; +} + +/** + * Options for path validation. + */ +export interface ValidatePathOptions { + /** + * Allow empty/root path (resolves to worktree itself). + * Default: false (prevents accidental worktree deletion) + */ + allowRoot?: boolean; +} + +/** + * Validates a relative file path for safety. + * Rejects absolute paths and path traversal attempts. + * + * @throws PathValidationError if path is invalid + */ +export function validateRelativePath( + filePath: string, + options: ValidatePathOptions = {}, +): void { + const { allowRoot = false } = options; + + // Reject absolute paths + if (isAbsolute(filePath)) { + throw new PathValidationError( + "Absolute paths are not allowed", + "ABSOLUTE_PATH", + ); + } + + const normalized = normalize(filePath); + const segments = normalized.split(sep); + + // Reject ".." as a path segment (allows "..foo" directories) + if (segments.includes("..")) { + throw new PathValidationError( + "Path traversal not allowed", + "PATH_TRAVERSAL", + ); + } + + // Reject root path unless explicitly allowed + if (!allowRoot && (normalized === "" || normalized === ".")) { + throw new PathValidationError( + "Cannot target worktree root", + "INVALID_TARGET", + ); + } +} + +/** + * Validates and resolves a path within a worktree. Sync, simple. + * + * @param worktreePath - The worktree base path + * @param filePath - The relative file path to validate + * @param options - Validation options + * @returns The resolved full path + * @throws PathValidationError if path is invalid + */ +export function resolvePathInWorktree( + worktreePath: string, + filePath: string, + options: ValidatePathOptions = {}, +): string { + validateRelativePath(filePath, options); + // Use resolve to handle any worktreePath (relative or absolute) + return resolve(worktreePath, normalize(filePath)); +} + +/** + * Validates a path for git commands. Lighter check that allows root. + * + * @throws PathValidationError if path is invalid + */ +export function assertValidGitPath(filePath: string): void { + validateRelativePath(filePath, { allowRoot: true }); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts new file mode 100644 index 000000000..9a931f0f8 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -0,0 +1,469 @@ +import type { Stats } from "node:fs"; +import { + lstat, + readFile, + readlink, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { + assertRegisteredWorktree, + PathValidationError, + resolvePathInWorktree, +} from "./path-validation"; + +/** + * Secure filesystem operations with built-in validation. + * + * Each operation: + * 1. Validates worktree is registered (security boundary) + * 2. Validates path doesn't escape worktree (defense in depth) + * 3. For writes: validates target is not a symlink escaping worktree + * 4. Performs the filesystem operation + * + * See path-validation.ts for the full security model and threat assumptions. + */ + +/** + * Check if a resolved path is within the worktree boundary using path.relative(). + * This is safer than string prefix matching which can have boundary bugs. + */ +function isPathWithinWorktree( + worktreeReal: string, + targetReal: string, +): boolean { + if (targetReal === worktreeReal) { + return true; + } + const relativePath = relative(worktreeReal, targetReal); + // Check if path escapes worktree: + // - ".." means direct parent + // - "../" prefix means ancestor escape (use sep for cross-platform) + // - Absolute path means completely outside + // Note: Don't use startsWith("..") as it incorrectly catches "..config" directories + // Note: Empty relativePath ("") case is already handled by the equality check above + const escapesWorktree = + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath); + + return !escapesWorktree; +} + +/** + * Validate that the parent directory chain stays within the worktree. + * Handles the case where the target file doesn't exist yet (ENOENT). + * + * This function walks up the directory tree to find the first existing + * ancestor and validates it. It also detects dangling symlinks by checking + * if any component is a symlink pointing outside the worktree. + * + * @throws PathValidationError if any ancestor escapes the worktree + */ +async function assertParentInWorktree( + worktreePath: string, + fullPath: string, +): Promise { + const worktreeReal = await realpath(worktreePath); + let currentPath = dirname(fullPath); + + // Walk up the directory tree until we find an existing directory + while (currentPath !== dirname(currentPath)) { + // Stop at filesystem root + try { + // First check if this path component is a symlink (even if target doesn't exist) + const stats = await lstat(currentPath); + + if (stats.isSymbolicLink()) { + // This is a symlink - validate its target even if it doesn't exist + const linkTarget = await readlink(currentPath); + // Resolve the link target relative to the symlink's parent + const resolvedTarget = isAbsolute(linkTarget) + ? linkTarget + : resolve(dirname(currentPath), linkTarget); + + // Try to get the realpath of the resolved target + try { + const targetReal = await realpath(resolvedTarget); + if (!isPathWithinWorktree(worktreeReal, targetReal)) { + throw new PathValidationError( + "Symlink in path resolves outside the worktree", + "SYMLINK_ESCAPE", + ); + } + } catch (error) { + // Target doesn't exist - check if the resolved target path + // would be within worktree if it existed + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + // For dangling symlinks, validate the target path itself + // We need to check if the target, when resolved, would be in worktree + // This is conservative: if we can't determine, fail closed + const targetRelative = relative(worktreeReal, resolvedTarget); + // Use sep-aware check to avoid false positives on "..config" dirs + if ( + targetRelative === ".." || + targetRelative.startsWith(`..${sep}`) || + isAbsolute(targetRelative) + ) { + throw new PathValidationError( + "Dangling symlink points outside the worktree", + "SYMLINK_ESCAPE", + ); + } + // Target would be within worktree if it existed - continue + return; + } + if (error instanceof PathValidationError) { + throw error; + } + // Other errors - fail closed for security + throw new PathValidationError( + "Cannot validate symlink target", + "SYMLINK_ESCAPE", + ); + } + return; // Symlink validated successfully + } + + // Not a symlink - get realpath and validate + const parentReal = await realpath(currentPath); + if (!isPathWithinWorktree(worktreeReal, parentReal)) { + throw new PathValidationError( + "Parent directory resolves outside the worktree", + "SYMLINK_ESCAPE", + ); + } + return; // Found valid ancestor + } catch (error) { + if (error instanceof PathValidationError) { + throw error; + } + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + // This ancestor doesn't exist either, keep walking up + currentPath = dirname(currentPath); + continue; + } + // Other errors (EACCES, ENOTDIR, etc.) - fail closed for security + throw new PathValidationError( + "Cannot validate path ancestry", + "SYMLINK_ESCAPE", + ); + } + } + + // Reached filesystem root without finding valid ancestor + throw new PathValidationError( + "Could not validate path ancestry within worktree", + "SYMLINK_ESCAPE", + ); +} + +/** + * Check if the resolved realpath stays within the worktree boundary. + * Prevents symlink escape attacks where a symlink points outside the worktree. + * + * @throws PathValidationError if realpath escapes worktree + */ +async function assertRealpathInWorktree( + worktreePath: string, + fullPath: string, +): Promise { + try { + const real = await realpath(fullPath); + const worktreeReal = await realpath(worktreePath); + + // Use path.relative for safer boundary checking + if (!isPathWithinWorktree(worktreeReal, real)) { + throw new PathValidationError( + "File is a symlink pointing outside the worktree", + "SYMLINK_ESCAPE", + ); + } + } catch (error) { + // If realpath fails with ENOENT, the target doesn't exist + // But the path itself might be a dangling symlink - check that first! + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + await assertDanglingSymlinkSafe(worktreePath, fullPath); + return; + } + // Re-throw PathValidationError + if (error instanceof PathValidationError) { + throw error; + } + // Other errors (permission denied, etc.) - fail closed for security + throw new PathValidationError( + "Cannot validate file path", + "SYMLINK_ESCAPE", + ); + } +} + +/** + * Handle the ENOENT case: check if fullPath is a dangling symlink pointing outside + * the worktree, or if it truly doesn't exist (in which case validate parent chain). + * + * Attack scenario this prevents: + * - Repo contains `docs/config.yml` → symlink to `~/.ssh/some_new_file` (doesn't exist) + * - realpath() fails with ENOENT (target missing) + * - Without this check, we'd only validate parent (`docs/`) which is valid + * - Write would follow symlink and create `~/.ssh/some_new_file` + * + * @throws PathValidationError if symlink escapes worktree + */ +async function assertDanglingSymlinkSafe( + worktreePath: string, + fullPath: string, +): Promise { + const worktreeReal = await realpath(worktreePath); + + try { + // Check if the path itself exists (as a symlink or otherwise) + const stats = await lstat(fullPath); + + if (stats.isSymbolicLink()) { + // It's a dangling symlink - validate where it points + const linkTarget = await readlink(fullPath); + const resolvedTarget = isAbsolute(linkTarget) + ? linkTarget + : resolve(dirname(fullPath), linkTarget); + + // Check if the resolved target would be within worktree + // For dangling symlinks, we can't use realpath on the target, + // so we check the literal resolved path + const targetRelative = relative(worktreeReal, resolvedTarget); + if ( + targetRelative === ".." || + targetRelative.startsWith(`..${sep}`) || + isAbsolute(targetRelative) + ) { + throw new PathValidationError( + "Dangling symlink points outside the worktree", + "SYMLINK_ESCAPE", + ); + } + // Dangling symlink points within worktree - allow the operation + return; + } + + // Not a symlink but lstat succeeded - weird state, but validate parent chain + await assertParentInWorktree(worktreePath, fullPath); + } catch (error) { + if (error instanceof PathValidationError) { + throw error; + } + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + // Path truly doesn't exist (not even as a symlink) - validate parent chain + await assertParentInWorktree(worktreePath, fullPath); + return; + } + // Other errors - fail closed + throw new PathValidationError("Cannot validate path", "SYMLINK_ESCAPE"); + } +} +export const secureFs = { + /** + * Read a file within a worktree. + * + * SECURITY: Enforces symlink-escape check. If the file is a symlink + * pointing outside the worktree, this will throw PathValidationError. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if file escapes worktree + */ + async readFile( + worktreePath: string, + filePath: string, + encoding: BufferEncoding = "utf-8", + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block reads through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + + return readFile(fullPath, encoding); + }, + + /** + * Read a file as a Buffer within a worktree. + * + * SECURITY: Enforces symlink-escape check. If the file is a symlink + * pointing outside the worktree, this will throw PathValidationError. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if file escapes worktree + */ + async readFileBuffer( + worktreePath: string, + filePath: string, + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block reads through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + + return readFile(fullPath); + }, + + /** + * Write content to a file within a worktree. + * + * SECURITY: Blocks writes if the file is a symlink pointing outside + * the worktree. This prevents malicious repos from tricking users + * into overwriting sensitive files like ~/.bashrc. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if target escapes worktree + */ + async writeFile( + worktreePath: string, + filePath: string, + content: string, + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block writes through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + + await writeFile(fullPath, content, "utf-8"); + }, + + /** + * Delete a file or directory within a worktree. + * + * SECURITY: Validates the real path is within worktree before deletion. + * - Symlinks: Deletes the link itself (safe - link lives in worktree) + * - Files/dirs: Validates realpath then deletes + * + * This prevents symlink escape attacks where a malicious repo contains + * `docs -> /Users/victim` and a delete of `docs/file` would delete + * `/Users/victim/file`. + */ + async delete(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + // allowRoot: false prevents deleting the worktree itself + const fullPath = resolvePathInWorktree(worktreePath, filePath, { + allowRoot: false, + }); + + let stats: Stats; + try { + stats = await lstat(fullPath); + } catch (error) { + // File doesn't exist - idempotent delete, nothing to do + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + return; + } + throw error; + } + + if (stats.isSymbolicLink()) { + // Symlink - safe to delete the link itself (it lives in the worktree). + // Don't use recursive as we're just removing the symlink file. + await rm(fullPath); + return; + } + + // Regular file or directory - validate realpath is within worktree. + // This catches path traversal via symlinked parent components: + // e.g., `docs -> /victim`, delete `docs/file` → realpath is `/victim/file` + await assertRealpathInWorktree(worktreePath, fullPath); + + // Safe to delete - realpath confirmed within worktree. + // Note: Symlinks INSIDE a directory are safe - rm deletes the links, not targets. + await rm(fullPath, { recursive: true, force: true }); + }, + + /** + * Get file stats within a worktree. + * + * Uses `stat` (follows symlinks) to get the real file size. + * Validates that the resolved path stays within the worktree boundary. + */ + async stat(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + await assertRealpathInWorktree(worktreePath, fullPath); + return stat(fullPath); + }, + + /** + * Get file stats without following symlinks. + * + * Use this when you need to know if something IS a symlink. + * For size checks, prefer `stat` instead. + */ + async lstat(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + return lstat(fullPath); + }, + + /** + * Check if a file exists within a worktree. + * + * Returns false for non-existent files, symlink escapes, and validation failures. + */ + async exists(worktreePath: string, filePath: string): Promise { + try { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + await assertRealpathInWorktree(worktreePath, fullPath); + await stat(fullPath); + return true; + } catch { + return false; + } + }, + + /** + * Check if a file is a symlink that points outside the worktree. + * + * WARNING: This is a best-effort helper for UI warnings only. + * It returns `false` on errors, so it is NOT suitable as a security gate. + * For security enforcement, use the read/write methods which call + * assertRealpathInWorktree internally. + * + * @returns true if the file is definitely a symlink escaping the worktree, + * false if not escaping OR if we can't determine (errors) + */ + async isSymlinkEscaping( + worktreePath: string, + filePath: string, + ): Promise { + try { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Check if it's a symlink first + const stats = await lstat(fullPath); + if (!stats.isSymbolicLink()) { + return false; + } + + // Check if realpath escapes worktree + const real = await realpath(fullPath); + const worktreeReal = await realpath(worktreePath); + + return !isPathWithinWorktree(worktreeReal, real); + } catch { + // If we can't determine, assume not escaping (file may not exist) + // NOTE: This makes this method unsuitable as a security gate + return false; + } + }, +}; diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts index 1d3109a65..678e1304c 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -1,8 +1,13 @@ -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + gitCheckoutFile, + gitStageAll, + gitStageFile, + gitUnstageAll, + gitUnstageFile, + secureFs, +} from "./security"; export const createStagingRouter = () => { return router({ @@ -14,8 +19,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.add(input.filePath); + await gitStageFile(input.worktreePath, input.filePath); return { success: true }; }), @@ -27,8 +31,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.reset(["HEAD", "--", input.filePath]); + await gitUnstageFile(input.worktreePath, input.filePath); return { success: true }; }), @@ -40,24 +43,21 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.checkout(["--", input.filePath]); + await gitCheckoutFile(input.worktreePath, input.filePath); return { success: true }; }), stageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.add("-A"); + await gitStageAll(input.worktreePath); return { success: true }; }), unstageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.reset(["HEAD"]); + await gitUnstageAll(input.worktreePath); return { success: true }; }), @@ -69,8 +69,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - await rm(fullPath, { recursive: true, force: true }); + await secureFs.delete(input.worktreePath, input.filePath); return { success: true }; }), }); diff --git a/apps/desktop/src/lib/trpc/routers/changes/status.ts b/apps/desktop/src/lib/trpc/routers/changes/status.ts index c547b9855..2916e0609 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts @@ -1,9 +1,8 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import type { ChangedFile, GitChangesStatus } from "shared/changes-types"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { assertRegisteredWorktree, secureFs } from "./security"; import { applyNumstatToFiles } from "./utils/apply-numstat"; import { parseGitLog, @@ -21,6 +20,8 @@ export const createStatusRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; @@ -64,6 +65,8 @@ export const createStatusRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const nameStatus = await git.raw([ @@ -141,18 +144,25 @@ async function getBranchComparison( return { commits, againstBase, ahead, behind }; } +/** Max file size for line counting (1 MiB) - skip larger files to avoid OOM */ +const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024; + async function applyUntrackedLineCount( worktreePath: string, untracked: ChangedFile[], ): Promise { for (const file of untracked) { try { - const fullPath = join(worktreePath, file.path); - const content = await readFile(fullPath, "utf-8"); + const stats = await secureFs.stat(worktreePath, file.path); + if (stats.size > MAX_LINE_COUNT_SIZE) continue; + + const content = await secureFs.readFile(worktreePath, file.path); const lineCount = content.split("\n").length; file.additions = lineCount; file.deletions = 0; - } catch {} + } catch { + // Skip files that fail validation or reading + } } } diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts index d05a8920b..d1c72efea 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { - detectLanguage, - parseDiffNumstat, - parseGitLog, - parseNameStatus, -} from "./parse-status"; +import { detectLanguage } from "shared/detect-language"; +import { parseDiffNumstat, parseGitLog, parseNameStatus } from "./parse-status"; describe("parseGitLog", () => { test("parses basic log output", () => { diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts index 723473efc..598f66762 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts @@ -195,65 +195,3 @@ export function parseNameStatus(nameStatusOutput: string): ChangedFile[] { return files; } - -export function detectLanguage(filePath: string): string { - const ext = filePath.split(".").pop()?.toLowerCase(); - - const languageMap: Record = { - // JavaScript/TypeScript - ts: "typescript", - tsx: "typescript", - js: "javascript", - jsx: "javascript", - mjs: "javascript", - cjs: "javascript", - - // Web - html: "html", - htm: "html", - css: "css", - scss: "scss", - less: "less", - - // Data formats - json: "json", - yaml: "yaml", - yml: "yaml", - xml: "xml", - toml: "toml", - - // Markdown/Documentation - md: "markdown", - mdx: "markdown", - - // Shell - sh: "shell", - bash: "shell", - zsh: "shell", - fish: "shell", - - // Config - dockerfile: "dockerfile", - makefile: "makefile", - - // Other languages - py: "python", - rb: "ruby", - go: "go", - rs: "rust", - java: "java", - kt: "kotlin", - swift: "swift", - c: "c", - cpp: "cpp", - h: "c", - hpp: "cpp", - cs: "csharp", - php: "php", - sql: "sql", - graphql: "graphql", - gql: "graphql", - }; - - return languageMap[ext || ""] || "plaintext"; -} diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 66ad3766f..9fd7455f7 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -23,7 +23,7 @@ import { createWorkspacesRouter } from "./workspaces"; export const createAppRouter = (getWindow: () => BrowserWindow | null) => { return router({ analytics: createAnalyticsRouter(), - auth: createAuthRouter(), + auth: createAuthRouter(getWindow), autoUpdate: createAutoUpdateRouter(), user: createUserRouter(), window: createWindowRouter(getWindow), diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index ce6245816..8c2e5ed6e 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1,6 +1,13 @@ -import { settings, type TerminalPreset } from "@superset/local-db"; +import { + settings, + TERMINAL_LINK_BEHAVIORS, + type TerminalPreset, +} from "@superset/local-db"; import { localDb } from "main/lib/local-db"; -import { DEFAULT_CONFIRM_ON_QUIT } from "shared/constants"; +import { + DEFAULT_CONFIRM_ON_QUIT, + DEFAULT_TERMINAL_LINK_BEHAVIOR, +} from "shared/constants"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -180,5 +187,25 @@ export const createSettingsRouter = () => { return { success: true }; }), + + getTerminalLinkBehavior: publicProcedure.query(() => { + const row = getSettings(); + return row.terminalLinkBehavior ?? DEFAULT_TERMINAL_LINK_BEHAVIOR; + }), + + setTerminalLinkBehavior: publicProcedure + .input(z.object({ behavior: z.enum(TERMINAL_LINK_BEHAVIORS) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, terminalLinkBehavior: input.behavior }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalLinkBehavior: input.behavior }, + }) + .run(); + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index d23ca0637..2d70d1eec 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -7,6 +7,7 @@ import { localDb } from "main/lib/local-db"; import { terminalManager } from "main/lib/terminal"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { assertWorkspaceUsable } from "../workspaces/utils/usability"; import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveCwd } from "./utils"; @@ -58,6 +59,14 @@ export const createTerminalRouter = () => { const workspacePath = workspace ? (getWorkspacePath(workspace) ?? undefined) : undefined; + + // Guard: For worktree workspaces, ensure the workspace is ready + // (not still initializing or failed). Branch workspaces use the main + // repo path which always exists, so no guard needed. + if (workspace?.type === "worktree") { + assertWorkspaceUsable(workspaceId, workspacePath); + } + const cwd = resolveCwd(cwdOverride, workspacePath); // Get project info for environment variables @@ -182,11 +191,11 @@ export const createTerminalRouter = () => { .where(eq(workspaces.id, workspaceId)) .get(); if (!workspace) { - return null; + return undefined; } if (!workspace.worktreeId) { - return null; + return undefined; } const worktree = localDb @@ -194,7 +203,7 @@ export const createTerminalRouter = () => { .from(worktrees) .where(eq(worktrees.id, workspace.worktreeId)) .get(); - return worktree?.path ?? null; + return worktree?.path; }), /** diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 0d2b8e87f..afbff9fc9 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -10,19 +10,39 @@ import { import { z } from "zod"; import { publicProcedure, router } from "../.."; +/** + * Zod schema for FileViewerState persistence. + * Note: initialLine/initialColumn from shared/tabs-types.ts are intentionally + * omitted as they are transient (applied once on open, not persisted). + */ +const fileViewerStateSchema = z.object({ + filePath: z.string(), + viewMode: z.enum(["rendered", "raw", "diff"]), + isLocked: z.boolean(), + diffLayout: z.enum(["inline", "side-by-side"]), + diffCategory: z + .enum(["against-base", "committed", "staged", "unstaged"]) + .optional(), + commitHash: z.string().optional(), + oldPath: z.string().optional(), +}); + /** * Zod schema for Pane */ const paneSchema = z.object({ id: z.string(), tabId: z.string(), - type: z.enum(["terminal", "webview"]), + type: z.enum(["terminal", "webview", "file-viewer"]), name: z.string(), isNew: z.boolean().optional(), needsAttention: z.boolean().optional(), initialCommands: z.array(z.string()).optional(), initialCwd: z.string().optional(), url: z.string().optional(), + cwd: z.string().nullable().optional(), + cwdConfirmed: z.boolean().optional(), + fileViewer: fileViewerStateSchema.optional(), }); /** 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 258a223fc..2226de978 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -13,6 +13,26 @@ import { checkGitLfsAvailable, getShellEnvironment } from "./shell-env"; const execFileAsync = promisify(execFile); +/** + * Error thrown by execFile when the command fails. + * `code` can be a number (exit code) or string (spawn error like "ENOENT"). + */ +interface ExecFileException extends Error { + code?: number | string; + killed?: boolean; + signal?: NodeJS.Signals; + cmd?: string; + stdout?: string; + stderr?: string; +} + +function isExecFileException(error: unknown): error is ExecFileException { + return ( + error instanceof Error && + ("code" in error || "signal" in error || "killed" in error) + ); +} + async function getGitEnv(): Promise> { const shellEnv = await getShellEnvironment(); const result: Record = {}; @@ -413,25 +433,158 @@ export async function hasUnpushedCommits( } } +export type BranchExistsResult = + | { status: "exists" } + | { status: "not_found" } + | { status: "error"; message: string }; + +/** + * Git exit codes for ls-remote --exit-code: + * - 0: Refs found (branch exists) + * - 2: No matching refs (branch doesn't exist) + * - 128: Fatal error (auth, network, invalid repo, etc.) + */ +const GIT_EXIT_CODES = { + SUCCESS: 0, + NO_MATCHING_REFS: 2, + FATAL_ERROR: 128, +} as const; + +/** + * Patterns for categorizing git fatal errors (exit code 128). + * These are checked against lowercase error messages/stderr. + */ +const GIT_ERROR_PATTERNS = { + network: [ + "could not resolve host", + "unable to access", + "connection refused", + "network is unreachable", + "timed out", + "ssl", + "could not read from remote", + ], + auth: [ + "authentication", + "permission denied", + "403", + "401", + // SSH-specific auth failures + "permission denied (publickey)", + "host key verification failed", + ], + remoteNotConfigured: [ + "does not appear to be a git repository", + "no such remote", + "repository not found", + "remote origin not found", + ], +} as const; + +function categorizeGitError(errorMessage: string): BranchExistsResult { + const lowerMessage = errorMessage.toLowerCase(); + + if (GIT_ERROR_PATTERNS.network.some((p) => lowerMessage.includes(p))) { + return { + status: "error", + message: "Cannot connect to remote. Check your network connection.", + }; + } + + if (GIT_ERROR_PATTERNS.auth.some((p) => lowerMessage.includes(p))) { + return { + status: "error", + message: "Authentication failed. Check your Git credentials.", + }; + } + + if ( + GIT_ERROR_PATTERNS.remoteNotConfigured.some((p) => lowerMessage.includes(p)) + ) { + return { + status: "error", + message: + "Remote 'origin' is not configured or the repository was not found.", + }; + } + + return { + status: "error", + message: `Failed to verify branch: ${errorMessage}`, + }; +} + export async function branchExistsOnRemote( worktreePath: string, branchName: string, -): Promise { - const git = simpleGit(worktreePath); +): Promise { + const env = await getGitEnv(); + try { - // Use ls-remote to check actual remote state (not just local refs) - const result = await git.raw([ - "ls-remote", - "--exit-code", - "--heads", - "origin", - branchName, - ]); - // If we get output, the branch exists - return result.trim().length > 0; - } catch { - // --exit-code makes git return non-zero if no matching refs found - return false; + // Use execFileAsync directly to get reliable exit codes + // simple-git doesn't expose exit codes in a predictable way + await execFileAsync( + "git", + [ + "-C", + worktreePath, + "ls-remote", + "--exit-code", + "--heads", + "origin", + branchName, + ], + { env, timeout: 30_000 }, + ); + // Exit code 0 = branch exists (--exit-code flag ensures this) + return { status: "exists" }; + } catch (error) { + // Use type guard to safely access ExecFileException properties + if (!isExecFileException(error)) { + return { + status: "error", + message: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + // Handle spawn/system errors first (code is a string like "ENOENT") + if (typeof error.code === "string") { + if (error.code === "ENOENT") { + return { + status: "error", + message: "Git is not installed or not found in PATH.", + }; + } + if (error.code === "ETIMEDOUT") { + return { + status: "error", + message: "Git command timed out. Check your network connection.", + }; + } + // Other system errors + return { + status: "error", + message: `System error: ${error.code}`, + }; + } + + // Handle killed/timed out processes (timeout option triggers this) + if (error.killed || error.signal) { + return { + status: "error", + message: "Git command timed out. Check your network connection.", + }; + } + + // Now code is numeric - it's a git exit code + if (error.code === GIT_EXIT_CODES.NO_MATCHING_REFS) { + return { status: "not_found" }; + } + + // For fatal errors (128) or other codes, categorize using stderr (preferred) or message + // stderr contains the actual git error; message may include wrapper text + const errorText = error.stderr || error.message || ""; + return categorizeGitError(errorText); } } @@ -656,6 +809,42 @@ export async function checkoutBranch( * @param branch - Branch to checkout * @throws Error if safety checks fail or checkout fails */ +/** + * Checks if a git ref exists locally (without network access). + * Uses --verify --quiet to only check exit code without output. + * @param repoPath - Path to the repository + * @param ref - The ref to check (e.g., "main", "origin/main") + * @returns true if the ref exists locally, false otherwise + */ +export async function refExistsLocally( + repoPath: string, + ref: string, +): Promise { + const git = simpleGit(repoPath); + try { + // Use --verify --quiet to check if ref exists without output + // Append ^{commit} to ensure it resolves to a commit-ish + await git.raw(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +/** + * Sanitizes git error messages for user display. + * Strips "fatal:" prefixes, excessive newlines, and other git plumbing text. + * @param message - Raw git error message + * @returns Cleaned message suitable for UI display + */ +export function sanitizeGitError(message: string): string { + return message + .replace(/^fatal:\s*/i, "") + .replace(/^error:\s*/i, "") + .replace(/\n+/g, " ") + .trim(); +} + export async function safeCheckoutBranch( repoPath: string, branch: string, diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index 556c89d6d..48868425e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { CheckItem, GitHubStatus } from "@superset/local-db"; import { branchExistsOnRemote } from "../git"; +import { execWithShellEnv } from "../shell-env"; import { type GHPRResponse, GHPRResponseSchema, @@ -44,11 +45,15 @@ export async function fetchGitHubPRStatus( const branchName = branchOutput.trim(); // Check if branch exists on remote and get PR info in parallel - const [existsOnRemote, prInfo] = await Promise.all([ + const [branchCheck, prInfo] = await Promise.all([ branchExistsOnRemote(worktreePath, branchName), getPRForBranch(worktreePath, branchName), ]); + // Convert result to boolean - only "exists" is true + // "not_found" and "error" both mean we can't confirm it exists + const existsOnRemote = branchCheck.status === "exists"; + const result: GitHubStatus = { pr: prInfo, repoUrl, @@ -68,12 +73,10 @@ export async function fetchGitHubPRStatus( async function getRepoUrl(worktreePath: string): Promise { try { - const { stdout } = await execFileAsync( + const { stdout } = await execWithShellEnv( "gh", ["repo", "view", "--json", "url"], - { - cwd: worktreePath, - }, + { cwd: worktreePath }, ); const raw = JSON.parse(stdout); const result = GHRepoResponseSchema.safeParse(raw); @@ -93,8 +96,8 @@ async function getPRForBranch( branch: string, ): Promise { try { - // Use execFile with args array to prevent command injection - const { stdout } = await execFileAsync( + // Use execWithShellEnv to handle macOS GUI app PATH issues + const { stdout } = await execWithShellEnv( "gh", [ "pr", diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts index 396504588..c59efa72d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts @@ -1,4 +1,7 @@ -import { execFile } from "node:child_process"; +import { + type ExecFileOptionsWithStringEncoding, + execFile, +} from "node:child_process"; import os from "node:os"; import { promisify } from "node:util"; @@ -11,6 +14,10 @@ let isFallbackCache = false; const CACHE_TTL_MS = 60_000; // 1 minute cache const FALLBACK_CACHE_TTL_MS = 10_000; // 10 second cache for fallback (retry sooner) +// Track PATH fix state for macOS GUI app PATH fix +let pathFixAttempted = false; +let pathFixSucceeded = false; + /** * Gets the full shell environment by spawning a login shell. * This captures PATH and other environment variables set in shell profiles @@ -29,7 +36,9 @@ export async function getShellEnvironment(): Promise> { return { ...cachedEnv }; } - const shell = process.env.SHELL || "/bin/bash"; + const shell = + process.env.SHELL || + (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"); try { // Use -lc flags (not -ilc): @@ -103,3 +112,62 @@ export function clearShellEnvCache(): void { cacheTime = 0; isFallbackCache = false; } + +/** + * Execute a command, retrying once with shell environment if it fails with ENOENT. + * On macOS, GUI apps launched from Finder/Dock get minimal PATH that excludes + * homebrew and other user-installed tools. This lazily derives the user's + * shell environment only when needed, then persists the fix to process.env.PATH. + */ +export async function execWithShellEnv( + cmd: string, + args: string[], + options?: Omit, +): Promise<{ stdout: string; stderr: string }> { + try { + return await execFileAsync(cmd, args, { ...options, encoding: "utf8" }); + } catch (error) { + // Only retry on ENOENT (command not found), only on macOS + // Skip if we've already successfully fixed PATH, or if a fix attempt is in progress + if ( + process.platform !== "darwin" || + pathFixSucceeded || + pathFixAttempted || + !(error instanceof Error) || + !("code" in error) || + error.code !== "ENOENT" + ) { + throw error; + } + + pathFixAttempted = true; + console.log("[shell-env] Command not found, deriving shell environment"); + + try { + const shellEnv = await getShellEnvironment(); + + // Persist the fix to process.env so all subsequent calls benefit + if (shellEnv.PATH) { + process.env.PATH = shellEnv.PATH; + pathFixSucceeded = true; + console.log("[shell-env] Fixed process.env.PATH for GUI app"); + } + + // Retry with fixed env (respect caller's other env vars, force PATH if present) + const retryEnv = shellEnv.PATH + ? { ...shellEnv, ...options?.env, PATH: shellEnv.PATH } + : { ...shellEnv, ...options?.env }; + + return await execFileAsync(cmd, args, { + ...options, + encoding: "utf8", + env: retryEnv, + }); + } catch (retryError) { + // Shell env derivation or retry failed - allow future retries + pathFixAttempted = false; + console.error("[shell-env] Retry failed:", retryError); + throw retryError; + } + } +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/usability.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/usability.ts new file mode 100644 index 000000000..5c261e716 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/usability.ts @@ -0,0 +1,122 @@ +import { existsSync } from "node:fs"; +import { TRPCError } from "@trpc/server"; +import { workspaceInitManager } from "main/lib/workspace-init-manager"; +import type { WorkspaceInitProgress } from "shared/types/workspace-init"; + +export type WorkspaceUsabilityReason = + | "initializing" + | "failed" + | "path_missing" + | "not_found"; + +export interface WorkspaceUsabilityCheck { + usable: boolean; + reason?: WorkspaceUsabilityReason; + progress?: WorkspaceInitProgress; +} + +/** + * Check if a workspace is usable for operations requiring the worktree path. + * Returns detailed status for UI to display appropriate state. + * + * A workspace is NOT usable if: + * - It is currently initializing (git operations in progress) + * - Its initialization failed (needs retry or delete) + * - The worktree path doesn't exist on disk + */ +export function checkWorkspaceUsability( + workspaceId: string, + worktreePath: string | null | undefined, +): WorkspaceUsabilityCheck { + if (workspaceInitManager.isInitializing(workspaceId)) { + return { + usable: false, + reason: "initializing", + progress: workspaceInitManager.getProgress(workspaceId), + }; + } + + if (workspaceInitManager.hasFailed(workspaceId)) { + return { + usable: false, + reason: "failed", + progress: workspaceInitManager.getProgress(workspaceId), + }; + } + + if (!worktreePath) { + return { usable: false, reason: "path_missing" }; + } + + if (!existsSync(worktreePath)) { + return { usable: false, reason: "path_missing" }; + } + + return { usable: true }; +} + +/** + * Throws TRPCError if workspace is not usable. + * Use this as a guard in tRPC procedures that require the worktree to exist. + * + * The error includes a `cause` object with details that the frontend can use + * to display appropriate UI (e.g., progress view for initializing, error for failed). + */ +export function assertWorkspaceUsable( + workspaceId: string, + worktreePath: string | null | undefined, +): void { + const check = checkWorkspaceUsability(workspaceId, worktreePath); + + if (!check.usable) { + switch (check.reason) { + case "initializing": + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace is still initializing", + cause: { reason: "initializing", progress: check.progress }, + }); + case "failed": + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace initialization failed", + cause: { reason: "failed", progress: check.progress }, + }); + case "path_missing": + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace path does not exist", + cause: { reason: "path_missing" }, + }); + default: + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Workspace is not usable", + }); + } + } +} + +/** + * Check if a workspace usability error indicates the workspace is initializing. + * Useful for frontend to determine whether to show progress UI. + */ +export function isInitializingError(error: unknown): boolean { + if (error instanceof TRPCError) { + const cause = error.cause as { reason?: string } | undefined; + return cause?.reason === "initializing"; + } + return false; +} + +/** + * Check if a workspace usability error indicates the workspace failed to initialize. + * Useful for frontend to determine whether to show error UI with retry option. + */ +export function isFailedError(error: unknown): boolean { + if (error instanceof TRPCError) { + const cause = error.cause as { reason?: string } | undefined; + return cause?.reason === "failed"; + } + return false; +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 670b8e41b..5afb68f0f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -7,11 +7,14 @@ import { workspaces, worktrees, } from "@superset/local-db"; -import { and, desc, eq, isNotNull } from "drizzle-orm"; +import { observable } from "@trpc/server/observable"; +import { and, desc, eq, isNotNull, not } from "drizzle-orm"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; import { terminalManager } from "main/lib/terminal"; +import { workspaceInitManager } from "main/lib/workspace-init-manager"; import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants"; +import type { WorkspaceInitProgress } from "shared/types/workspace-init"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { @@ -27,9 +30,11 @@ import { hasUncommittedChanges, hasUnpushedCommits, listBranches, + refExistsLocally, refreshDefaultBranch, removeWorktree, safeCheckoutBranch, + sanitizeGitError, worktreeExists, } from "./utils/git"; import { fetchGitHubPRStatus } from "./utils/github"; @@ -37,6 +42,302 @@ import { copySupersetConfigToWorktree, loadSetupConfig } from "./utils/setup"; import { runTeardown } from "./utils/teardown"; import { getWorkspacePath } from "./utils/worktree"; +/** + * Background initialization for workspace worktree. + * This runs after the fast-path mutation returns, streaming progress to the renderer. + * + * Does NOT throw - errors are communicated via progress events. + */ +async function initializeWorkspaceWorktree({ + workspaceId, + projectId, + worktreeId, + worktreePath, + branch, + baseBranch, + baseBranchWasExplicit, + mainRepoPath, +}: { + workspaceId: string; + projectId: string; + worktreeId: string; + worktreePath: string; + branch: string; + baseBranch: string; + /** If true, user explicitly specified baseBranch - don't auto-update it */ + baseBranchWasExplicit: boolean; + mainRepoPath: string; +}): Promise { + const manager = workspaceInitManager; + + try { + // Acquire per-project lock to prevent concurrent git operations + await manager.acquireProjectLock(projectId); + + // Check cancellation before starting (use durable cancellation check) + if (manager.isCancellationRequested(workspaceId)) { + manager.updateProgress(workspaceId, "failed", "Cancelled"); + return; + } + + // Step 1: Sync with remote + manager.updateProgress(workspaceId, "syncing", "Syncing with remote..."); + const remoteDefaultBranch = await refreshDefaultBranch(mainRepoPath); + + // Track the effective baseBranch - may be updated if auto-derived and remote differs + let effectiveBaseBranch = baseBranch; + + // Update project's default branch if it changed + if (remoteDefaultBranch) { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (project && remoteDefaultBranch !== project.defaultBranch) { + localDb + .update(projects) + .set({ defaultBranch: remoteDefaultBranch }) + .where(eq(projects.id, projectId)) + .run(); + } + + // If baseBranch was auto-derived and differs from remote, + // update the worktree record so retries use the correct branch + if (!baseBranchWasExplicit && remoteDefaultBranch !== baseBranch) { + console.log( + `[workspace-init] Auto-updating baseBranch from "${baseBranch}" to "${remoteDefaultBranch}" for workspace ${workspaceId}`, + ); + effectiveBaseBranch = remoteDefaultBranch; + localDb + .update(worktrees) + .set({ baseBranch: remoteDefaultBranch }) + .where(eq(worktrees.id, worktreeId)) + .run(); + } + } + + if (manager.isCancellationRequested(workspaceId)) { + manager.updateProgress(workspaceId, "failed", "Cancelled"); + return; + } + + // Step 2: Verify remote and branch + manager.updateProgress( + workspaceId, + "verifying", + "Verifying base branch...", + ); + const hasRemote = await hasOriginRemote(mainRepoPath); + + // Helper to resolve local ref with proper fallback order + const resolveLocalStartPoint = async ( + reason: string, + ): Promise => { + // Fallback order: origin/ (local tracking) > local branch > fail + const originRef = `origin/${effectiveBaseBranch}`; + if (await refExistsLocally(mainRepoPath, originRef)) { + console.log( + `[workspace-init] ${reason}. Using local tracking ref: ${originRef}`, + ); + return originRef; + } + if (await refExistsLocally(mainRepoPath, effectiveBaseBranch)) { + console.log( + `[workspace-init] ${reason}. Using local branch: ${effectiveBaseBranch}`, + ); + return effectiveBaseBranch; + } + return null; + }; + + let startPoint: string; + if (hasRemote) { + const branchCheck = await branchExistsOnRemote( + mainRepoPath, + effectiveBaseBranch, + ); + + if (branchCheck.status === "error") { + // Network/auth error - can't verify, surface to user and try local fallback + const sanitizedError = sanitizeGitError(branchCheck.message); + console.warn( + `[workspace-init] Cannot verify remote branch: ${sanitizedError}. Falling back to local ref.`, + ); + + // Update progress to inform user about the network issue + manager.updateProgress( + workspaceId, + "verifying", + "Using local reference (remote unavailable)", + sanitizedError, + ); + + const localRef = await resolveLocalStartPoint("Remote unavailable"); + if (!localRef) { + manager.updateProgress( + workspaceId, + "failed", + "No local reference available", + `Cannot reach remote and no local ref for "${effectiveBaseBranch}" exists. Please check your network connection and try again.`, + ); + return; + } + startPoint = localRef; + } else if (branchCheck.status === "not_found") { + manager.updateProgress( + workspaceId, + "failed", + "Branch does not exist on remote", + `Branch "${effectiveBaseBranch}" does not exist on origin. Please delete this workspace and try again with a different base branch.`, + ); + return; + } else { + // Branch exists on remote - use remote tracking ref + startPoint = `origin/${effectiveBaseBranch}`; + } + } else { + // No remote configured - use local fallback logic + const localRef = await resolveLocalStartPoint("No remote configured"); + if (!localRef) { + manager.updateProgress( + workspaceId, + "failed", + "No local reference available", + `No remote configured and no local ref for "${effectiveBaseBranch}" exists.`, + ); + return; + } + startPoint = localRef; + } + + if (manager.isCancellationRequested(workspaceId)) { + manager.updateProgress(workspaceId, "failed", "Cancelled"); + return; + } + + // Step 3: Fetch latest + manager.updateProgress( + workspaceId, + "fetching", + "Fetching latest changes...", + ); + if (hasRemote) { + try { + await fetchDefaultBranch(mainRepoPath, effectiveBaseBranch); + } catch { + // Silently continue - branch exists on remote, just couldn't fetch + } + } + + if (manager.isCancellationRequested(workspaceId)) { + manager.updateProgress(workspaceId, "failed", "Cancelled"); + return; + } + + // Step 4: Create worktree (SLOW) + manager.updateProgress( + workspaceId, + "creating_worktree", + "Creating git worktree...", + ); + await createWorktree(mainRepoPath, branch, worktreePath, startPoint); + manager.markWorktreeCreated(workspaceId); + + if (manager.isCancellationRequested(workspaceId)) { + // Cleanup: remove the worktree we just created + try { + await removeWorktree(mainRepoPath, worktreePath); + } catch (e) { + console.error( + "[workspace-init] Failed to cleanup worktree after cancel:", + e, + ); + } + manager.updateProgress(workspaceId, "failed", "Cancelled"); + return; + } + + // Step 5: Copy config + manager.updateProgress( + workspaceId, + "copying_config", + "Copying configuration...", + ); + copySupersetConfigToWorktree(mainRepoPath, worktreePath); + + if (manager.isCancellationRequested(workspaceId)) { + try { + await removeWorktree(mainRepoPath, worktreePath); + } catch (e) { + console.error( + "[workspace-init] Failed to cleanup worktree after cancel:", + e, + ); + } + manager.updateProgress(workspaceId, "failed", "Cancelled"); + return; + } + + // Step 6: Finalize + manager.updateProgress(workspaceId, "finalizing", "Finalizing setup..."); + + // Update worktree record with git status + localDb + .update(worktrees) + .set({ + gitStatus: { + branch, + needsRebase: false, + lastRefreshed: Date.now(), + }, + }) + .where(eq(worktrees.id, worktreeId)) + .run(); + + manager.updateProgress(workspaceId, "ready", "Ready"); + + track("workspace_initialized", { + workspace_id: workspaceId, + project_id: projectId, + branch, + base_branch: effectiveBaseBranch, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `[workspace-init] Failed to initialize ${workspaceId}:`, + errorMessage, + ); + + // Best-effort cleanup if worktree was created + if (manager.wasWorktreeCreated(workspaceId)) { + try { + await removeWorktree(mainRepoPath, worktreePath); + console.log( + `[workspace-init] Cleaned up partial worktree at ${worktreePath}`, + ); + } catch (cleanupError) { + console.error( + "[workspace-init] Failed to cleanup partial worktree:", + cleanupError, + ); + } + } + + manager.updateProgress( + workspaceId, + "failed", + "Initialization failed", + errorMessage, + ); + } finally { + // Always finalize the job to unblock waitForInit() callers (e.g., delete mutation) + manager.finalizeJob(workspaceId); + manager.releaseProjectLock(projectId); + } +} + export const createWorkspacesRouter = () => { return router({ create: publicProcedure @@ -68,67 +369,13 @@ export const createWorkspacesRouter = () => { branch, ); - // Sync with remote in case the default branch changed (e.g. master -> main) - const remoteDefaultBranch = await refreshDefaultBranch( - project.mainRepoPath, - ); - - const defaultBranch = - remoteDefaultBranch || - project.defaultBranch || - (await getDefaultBranch(project.mainRepoPath)); - - if (defaultBranch !== project.defaultBranch) { - localDb - .update(projects) - .set({ defaultBranch }) - .where(eq(projects.id, project.id)) - .run(); - } - + // Use cached defaultBranch for fast path, will refresh in background + // If no cached value exists, use "main" as fallback (background will verify) + const defaultBranch = project.defaultBranch || "main"; const targetBranch = input.baseBranch || defaultBranch; - // Check if this repo has a remote origin - const hasRemote = await hasOriginRemote(project.mainRepoPath); - - // Determine the start point for the worktree - let startPoint: string; - if (hasRemote) { - // Verify the branch exists on remote before attempting to use it - const existsOnRemote = await branchExistsOnRemote( - project.mainRepoPath, - targetBranch, - ); - if (!existsOnRemote) { - throw new Error( - `Branch "${targetBranch}" does not exist on origin. Please select a different base branch.`, - ); - } - - // Fetch the target branch to ensure we're branching from latest (best-effort) - try { - await fetchDefaultBranch(project.mainRepoPath, targetBranch); - } catch { - // Silently continue - branch exists on remote, just couldn't fetch - } - startPoint = `origin/${targetBranch}`; - } else { - // For local-only repos, use the local branch - startPoint = targetBranch; - } - - await createWorktree( - project.mainRepoPath, - branch, - worktreePath, - startPoint, - ); - - // Copy .superset directory to worktree if it's gitignored (not present in worktree) - // This ensures setup scripts like "./.superset/setup.sh" work even when gitignored - copySupersetConfigToWorktree(project.mainRepoPath, worktreePath); - - // Insert worktree + // Insert worktree record immediately (before git operations) + // gitStatus will be updated when initialization completes const worktree = localDb .insert(worktrees) .values({ @@ -136,11 +383,7 @@ export const createWorkspacesRouter = () => { path: worktreePath, branch, baseBranch: targetBranch, - gitStatus: { - branch, - needsRebase: false, - lastRefreshed: Date.now(), - }, + gitStatus: null, // Will be set when init completes }) .returning() .get(); @@ -156,7 +399,6 @@ export const createWorkspacesRouter = () => { ? Math.max(...projectWorkspaces.map((w) => w.tabOrder)) : -1; - // Insert workspace const workspace = localDb .insert(workspaces) .values({ @@ -170,7 +412,6 @@ export const createWorkspacesRouter = () => { .returning() .get(); - // Update settings localDb .insert(settings) .values({ id: 1, lastActiveWorkspaceId: workspace.id }) @@ -180,7 +421,6 @@ export const createWorkspacesRouter = () => { }) .run(); - // Update project const activeProjects = localDb .select() .from(projects) @@ -203,9 +443,7 @@ export const createWorkspacesRouter = () => { .where(eq(projects.id, input.projectId)) .run(); - // Load setup configuration from the main repo (where .superset/config.json lives) - const setupConfig = loadSetupConfig(project.mainRepoPath); - + // Track workspace creation (not initialization - that's tracked when it completes) track("workspace_created", { workspace_id: workspace.id, project_id: project.id, @@ -213,11 +451,29 @@ export const createWorkspacesRouter = () => { base_branch: targetBranch, }); + workspaceInitManager.startJob(workspace.id, input.projectId); + + // Start background initialization (DO NOT await - return immediately) + initializeWorkspaceWorktree({ + workspaceId: workspace.id, + projectId: input.projectId, + worktreeId: worktree.id, + worktreePath, + branch, + baseBranch: targetBranch, + baseBranchWasExplicit: !!input.baseBranch, + mainRepoPath: project.mainRepoPath, + }); + + // Load setup configuration (fast operation, can return with response) + const setupConfig = loadSetupConfig(project.mainRepoPath); + return { workspace, initialCommands: setupConfig?.setup || null, worktreePath, projectId: project.id, + isInitializing: true, }; }), @@ -305,22 +561,11 @@ export const createWorkspacesRouter = () => { }; } - // Shift existing workspaces to make room at front - const projectWorkspaces = localDb - .select() - .from(workspaces) - .where(eq(workspaces.projectId, input.projectId)) - .all(); - for (const ws of projectWorkspaces) { - localDb - .update(workspaces) - .set({ tabOrder: ws.tabOrder + 1 }) - .where(eq(workspaces.id, ws.id)) - .run(); - } - - // Insert new workspace - const workspace = localDb + // Insert new workspace first with conflict handling for race conditions + // The unique partial index (projectId WHERE type='branch') prevents duplicates + // We insert first, then shift - this prevents race conditions where + // concurrent calls both shift before either inserts (causing double shifts) + const insertResult = localDb .insert(workspaces) .values({ projectId: input.projectId, @@ -329,8 +574,54 @@ export const createWorkspacesRouter = () => { name: branch, tabOrder: 0, }) + .onConflictDoNothing() .returning() - .get(); + .all(); + + const wasExisting = insertResult.length === 0; + + // Only shift existing workspaces if we successfully inserted + // Losers of the race should NOT shift (they didn't create anything) + if (!wasExisting) { + const newWorkspaceId = insertResult[0].id; + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + // Exclude the workspace we just inserted + not(eq(workspaces.id, newWorkspaceId)), + ), + ) + .all(); + for (const ws of projectWorkspaces) { + localDb + .update(workspaces) + .set({ tabOrder: ws.tabOrder + 1 }) + .where(eq(workspaces.id, ws.id)) + .run(); + } + } + + // If insert returned nothing, another concurrent call won the race + // Fetch the existing workspace instead + const workspace = + insertResult[0] ?? + localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.type, "branch"), + ), + ) + .get(); + + if (!workspace) { + throw new Error("Failed to create or find branch workspace"); + } // Update settings localDb @@ -342,41 +633,43 @@ export const createWorkspacesRouter = () => { }) .run(); - // Update project - const activeProjects = localDb - .select() - .from(projects) - .where(isNotNull(projects.tabOrder)) - .all(); - const maxProjectTabOrder = - activeProjects.length > 0 - ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) - : -1; + // Update project (only if we actually inserted a new workspace) + if (!wasExisting) { + const activeProjects = localDb + .select() + .from(projects) + .where(isNotNull(projects.tabOrder)) + .all(); + const maxProjectTabOrder = + activeProjects.length > 0 + ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) + : -1; - localDb - .update(projects) - .set({ - lastOpenedAt: Date.now(), - tabOrder: - project.tabOrder === null - ? maxProjectTabOrder + 1 - : project.tabOrder, - }) - .where(eq(projects.id, input.projectId)) - .run(); + localDb + .update(projects) + .set({ + lastOpenedAt: Date.now(), + tabOrder: + project.tabOrder === null + ? maxProjectTabOrder + 1 + : project.tabOrder, + }) + .where(eq(projects.id, input.projectId)) + .run(); - track("workspace_opened", { - workspace_id: workspace.id, - project_id: project.id, - type: "branch", - was_existing: false, - }); + track("workspace_opened", { + workspace_id: workspace.id, + project_id: project.id, + type: "branch", + was_existing: false, + }); + } return { workspace, worktreePath: project.mainRepoPath, projectId: project.id, - wasExisting: false, + wasExisting, }; }), @@ -533,6 +826,7 @@ export const createWorkspacesRouter = () => { name: string; color: string; tabOrder: number; + mainRepoPath: string; }; workspaces: Array<{ id: string; @@ -546,6 +840,7 @@ export const createWorkspacesRouter = () => { createdAt: number; updatedAt: number; lastOpenedAt: number; + isUnread: boolean; }>; } >(); @@ -558,6 +853,7 @@ export const createWorkspacesRouter = () => { color: project.color, // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null tabOrder: project.tabOrder!, + mainRepoPath: project.mainRepoPath, }, workspaces: [], }); @@ -575,6 +871,7 @@ export const createWorkspacesRouter = () => { ...workspace, type: workspace.type as "worktree" | "branch", worktreePath: getWorkspacePath(workspace) ?? "", + isUnread: workspace.isUnread ?? false, }); } } @@ -672,7 +969,8 @@ export const createWorkspacesRouter = () => { ? { branch: worktree.branch, baseBranch, - gitStatus: worktree.gitStatus, + // Normalize to null to ensure consistent "incomplete init" detection in UI + gitStatus: worktree.gitStatus ?? null, } : null, }; @@ -851,6 +1149,17 @@ export const createWorkspacesRouter = () => { return { success: false, error: "Workspace not found" }; } + // Cancel any ongoing initialization and wait for it to complete + // This ensures we don't race with init's git operations + if (workspaceInitManager.isInitializing(input.id)) { + console.log( + `[workspace/delete] Cancelling init for ${input.id}, waiting for completion...`, + ); + workspaceInitManager.cancel(input.id); + // Wait for init to finish (up to 30s) - it will see cancellation and exit + await workspaceInitManager.waitForInit(input.id, 30000); + } + // Kill all terminal processes in this workspace first const terminalResult = await terminalManager.killByWorkspaceId( input.id, @@ -874,43 +1183,51 @@ export const createWorkspacesRouter = () => { .get() ?? undefined; if (worktree && project) { - // Run teardown scripts before removing worktree - const exists = await worktreeExists( - project.mainRepoPath, - worktree.path, - ); + // Acquire project lock before any git operations + // This prevents racing with any concurrent init operations + await workspaceInitManager.acquireProjectLock(project.id); - if (exists) { - runTeardown( + try { + // Run teardown scripts before removing worktree + const exists = await worktreeExists( project.mainRepoPath, worktree.path, - workspace.name, - ).then((result) => { - if (!result.success) { - console.error( - `Teardown failed for workspace ${workspace.name}:`, - result.error, - ); - } - }); - } + ); - try { if (exists) { - await removeWorktree(project.mainRepoPath, worktree.path); - } else { - console.warn( - `Worktree ${worktree.path} not found in git, skipping removal`, - ); + runTeardown( + project.mainRepoPath, + worktree.path, + workspace.name, + ).then((result) => { + if (!result.success) { + console.error( + `Teardown failed for workspace ${workspace.name}:`, + result.error, + ); + } + }); } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - console.error("Failed to remove worktree:", errorMessage); - return { - success: false, - error: `Failed to remove worktree: ${errorMessage}`, - }; + + try { + if (exists) { + await removeWorktree(project.mainRepoPath, worktree.path); + } else { + console.warn( + `Worktree ${worktree.path} not found in git, skipping removal`, + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error("Failed to remove worktree:", errorMessage); + return { + success: false, + error: `Failed to remove worktree: ${errorMessage}`, + }; + } + } finally { + workspaceInitManager.releaseProjectLock(project.id); } } } @@ -962,6 +1279,10 @@ export const createWorkspacesRouter = () => { track("workspace_deleted", { workspace_id: input.id }); + // Clear init job state only after all cleanup is complete + // This ensures cancellation signals remain visible during cleanup + workspaceInitManager.clearJob(input.id); + return { success: true, terminalWarning }; }), @@ -977,10 +1298,18 @@ export const createWorkspacesRouter = () => { throw new Error(`Workspace ${input.id} not found`); } + // Track if workspace was unread before clearing + const wasUnread = workspace.isUnread ?? false; + const now = Date.now(); localDb .update(workspaces) - .set({ lastOpenedAt: now, updatedAt: now }) + .set({ + lastOpenedAt: now, + updatedAt: now, + // Auto-clear unread state when switching to workspace + isUnread: false, + }) .where(eq(workspaces.id, input.id)) .run(); @@ -993,7 +1322,7 @@ export const createWorkspacesRouter = () => { }) .run(); - return { success: true }; + return { success: true, wasUnread }; }), reorder: publicProcedure @@ -1331,6 +1660,27 @@ export const createWorkspacesRouter = () => { }; }), + setUnread: publicProcedure + .input(z.object({ id: z.string(), isUnread: z.boolean() })) + .mutation(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } + + localDb + .update(workspaces) + .set({ isUnread: input.isUnread }) + .where(eq(workspaces.id, input.id)) + .run(); + + return { success: true, isUnread: input.isUnread }; + }), + close: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { @@ -1393,6 +1743,154 @@ export const createWorkspacesRouter = () => { return { success: true, terminalWarning }; }), + + /** + * Subscribe to workspace initialization progress events. + * Streams progress updates for workspaces that are currently initializing. + */ + onInitProgress: publicProcedure + .input( + z.object({ workspaceIds: z.array(z.string()).optional() }).optional(), + ) + .subscription(({ input }) => { + return observable((emit) => { + const handler = (progress: WorkspaceInitProgress) => { + // If specific workspaces requested, filter + if ( + input?.workspaceIds && + !input.workspaceIds.includes(progress.workspaceId) + ) { + return; + } + emit.next(progress); + }; + + // Send current state for initializing/failed workspaces + for (const progress of workspaceInitManager.getAllProgress()) { + if ( + !input?.workspaceIds || + input.workspaceIds.includes(progress.workspaceId) + ) { + emit.next(progress); + } + } + + workspaceInitManager.on("progress", handler); + + return () => { + workspaceInitManager.off("progress", handler); + }; + }); + }), + + /** + * Retry initialization for a failed workspace. + * Clears the failed state and restarts the initialization process. + */ + retryInit: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .mutation(async ({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); + + if (!workspace) { + throw new Error("Workspace not found"); + } + + const worktree = workspace.worktreeId + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() + : null; + + if (!worktree) { + throw new Error("Worktree not found"); + } + + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); + + if (!project) { + throw new Error("Project not found"); + } + + // Clear the failed state + workspaceInitManager.clearJob(input.workspaceId); + + // Start fresh initialization + workspaceInitManager.startJob(input.workspaceId, workspace.projectId); + + // Run initialization in background (DO NOT await) + // On retry, the worktree.baseBranch is already correct (either originally explicit + // or auto-corrected by P1 fix), so we treat it as explicit to prevent further updates + initializeWorkspaceWorktree({ + workspaceId: input.workspaceId, + projectId: workspace.projectId, + worktreeId: worktree.id, + worktreePath: worktree.path, + branch: worktree.branch, + baseBranch: worktree.baseBranch ?? project.defaultBranch ?? "main", + baseBranchWasExplicit: true, + mainRepoPath: project.mainRepoPath, + }); + + return { success: true }; + }), + + /** + * Get current initialization progress for a workspace. + * Returns null if the workspace is not initializing. + */ + getInitProgress: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => { + return workspaceInitManager.getProgress(input.workspaceId) ?? null; + }), + + /** + * Get setup commands for a workspace. + * Used as a fallback when pending terminal setup data is lost (e.g., after retry or app restart). + * Re-reads the project config to get fresh commands. + */ + getSetupCommands: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); + + if (!workspace) { + return null; + } + + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); + + if (!project) { + return null; + } + + // Re-read config from project to get fresh commands + const setupConfig = loadSetupConfig(project.mainRepoPath); + + return { + projectId: project.id, + initialCommands: setupConfig?.setup ?? null, + }; + }), }); }; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a9d1e6c56..6afdda383 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -10,7 +10,7 @@ import { DEFAULT_CONFIRM_ON_QUIT, PROTOCOL_SCHEME } from "shared/constants"; import { setupAgentHooks } from "./lib/agent-setup"; import { posthog } from "./lib/analytics"; import { initAppState } from "./lib/app-state"; -import { authService, parseAuthDeepLink } from "./lib/auth"; +import { authService, handleAuthDeepLink, isAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; import { localDb } from "./lib/local-db"; import { terminalManager } from "./lib/terminal"; @@ -36,15 +36,31 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME); } +/** + * Process a deep link URL for auth + */ async function processDeepLink(url: string): Promise { - const authParams = parseAuthDeepLink(url); - if (!authParams) return; - - const result = await authService.handleAuthCallback(authParams); - if (result.success) { - focusMainWindow(); - } else { - console.error("[main] Auth deep link failed:", result.error); + if (isAuthDeepLink(url)) { + const result = await handleAuthDeepLink(url); + if ( + result.success && + result.accessToken && + result.accessTokenExpiresAt && + result.refreshToken && + result.refreshTokenExpiresAt && + result.state + ) { + await authService.handleAuthCallback({ + accessToken: result.accessToken, + accessTokenExpiresAt: result.accessTokenExpiresAt, + refreshToken: result.refreshToken, + refreshTokenExpiresAt: result.refreshTokenExpiresAt, + state: result.state, + }); + focusMainWindow(); + } else { + console.error("[main] Auth deep link failed:", result.error); + } } } diff --git a/apps/desktop/src/main/lib/api-client.ts b/apps/desktop/src/main/lib/api-client.ts index 457c43733..c8b24b647 100644 --- a/apps/desktop/src/main/lib/api-client.ts +++ b/apps/desktop/src/main/lib/api-client.ts @@ -7,9 +7,6 @@ import { authService } from "./auth"; /** * tRPC client for calling the Superset API * Automatically includes the access token in requests - * Handles auth errors vs network errors: - * - 401/403: Token invalid/revoked, clears session - * - Network errors: Preserves session for offline work */ export const apiClient = createTRPCClient({ links: [ @@ -17,7 +14,7 @@ export const apiClient = createTRPCClient({ url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, transformer: superjson, async headers() { - const token = authService.getAccessToken(); + const token = await authService.getAccessToken(); if (token) { return { Authorization: `Bearer ${token}`, @@ -25,23 +22,6 @@ export const apiClient = createTRPCClient({ } return {}; }, - async fetch(url, options) { - try { - const response = await globalThis.fetch(url, options); - - // Handle auth errors - token was revoked/invalid on server - if (response.status === 401 || response.status === 403) { - console.log("[api-client] Auth error, clearing session"); - await authService.signOut(); - } - - return response; - } catch (error) { - // Network errors - preserve session for offline work - console.log("[api-client] Network error, preserving session", error); - throw error; - } - }, }), ], }); diff --git a/apps/desktop/src/main/lib/app-environment.ts b/apps/desktop/src/main/lib/app-environment.ts index b332b1f4b..5881e38d0 100644 --- a/apps/desktop/src/main/lib/app-environment.ts +++ b/apps/desktop/src/main/lib/app-environment.ts @@ -6,3 +6,6 @@ export const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); // For lowdb - use our own path instead of app.getPath("userData") export const APP_STATE_PATH = join(SUPERSET_HOME_DIR, "app-state.json"); + +// Window geometry state (separate from UI state - main process only, sync I/O) +export const WINDOW_STATE_PATH = join(SUPERSET_HOME_DIR, "window-state.json"); diff --git a/apps/desktop/src/main/lib/auth/auth-service.ts b/apps/desktop/src/main/lib/auth/auth-service.ts new file mode 100644 index 000000000..87465c97f --- /dev/null +++ b/apps/desktop/src/main/lib/auth/auth-service.ts @@ -0,0 +1,361 @@ +import crypto from "node:crypto"; +import { EventEmitter } from "node:events"; +import { type BrowserWindow, shell } from "electron"; +import { env } from "main/env.main"; +import type { AuthProvider, AuthSession, SignInResult } from "shared/auth"; +import { tokenStorage } from "./token-storage"; + +/** + * Store for state parameter (CSRF protection) + */ +const stateStore = new Map(); // state -> timestamp + +/** + * Generate random state for CSRF protection + */ +function generateState(): string { + const state = crypto.randomBytes(32).toString("base64url"); + stateStore.set(state, Date.now()); + // Clean up old states (older than 10 minutes) + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + for (const [s, timestamp] of stateStore) { + if (timestamp < tenMinutesAgo) { + stateStore.delete(s); + } + } + return state; +} + +/** + * Verify and consume state + */ +function verifyState(state: string): boolean { + if (!stateStore.has(state)) { + return false; + } + stateStore.delete(state); + return true; +} + +interface TokenResponse { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; +} + +/** + * Type guard to validate token response shape at runtime + */ +function isValidTokenResponse(data: unknown): data is TokenResponse { + if (typeof data !== "object" || data === null) { + return false; + } + const obj = data as Record; + return ( + typeof obj.accessToken === "string" && + obj.accessToken.length > 0 && + typeof obj.accessTokenExpiresAt === "number" && + obj.accessTokenExpiresAt > 0 && + typeof obj.refreshToken === "string" && + obj.refreshToken.length > 0 && + typeof obj.refreshTokenExpiresAt === "number" && + obj.refreshTokenExpiresAt > 0 + ); +} + +/** + * Main authentication service + * Handles direct Google OAuth flow, with token exchange via API + */ +class AuthService extends EventEmitter { + private session: AuthSession | null = null; + + /** + * Initialize auth service - load persisted session + */ + async initialize(): Promise { + const session = await tokenStorage.load(); + + if (!session) { + return; + } + + // Check if access token is expired + if (session.accessTokenExpiresAt < Date.now()) { + console.log("[auth] Access token expired on startup"); + + // Check if refresh token is still valid + if (session.refreshToken && session.refreshTokenExpiresAt > Date.now()) { + console.log("[auth] Attempting to refresh tokens on startup"); + this.session = session; // Temporarily set to allow refresh + const result = await this.refreshTokens(); + + if (result === "success") { + console.log("[auth] Session restored via token refresh"); + return; + } + + if (result === "network_error") { + // Offline - keep session with expired access token + // User can work offline, and we'll refresh when online + console.log("[auth] Offline - keeping session for offline use"); + this.session = session; + return; + } + + // result === "invalid" - tokens are revoked, must clear + } + + // Refresh token invalid/expired or no refresh token + console.log("[auth] Session fully expired, clearing"); + await this.clearSession(); + return; + } + + // Restore session + this.session = session; + console.log("[auth] Session restored"); + } + + /** + * Get current authentication state + */ + getState() { + return { + isSignedIn: !!this.session, + }; + } + + /** + * Get access token for API calls + * Automatically refreshes if access token is expired but refresh token is valid + * Returns null if offline or tokens invalid (caller should handle gracefully) + */ + async getAccessToken(): Promise { + if (!this.session) { + return null; + } + + // Check if access token is expired + if (this.session.accessTokenExpiresAt < Date.now()) { + console.log("[auth] Access token expired, attempting refresh"); + + // Check if refresh token is still valid + if ( + this.session.refreshToken && + this.session.refreshTokenExpiresAt > Date.now() + ) { + const result = await this.refreshTokens(); + if (result === "success") { + return this.session.accessToken; + } + if (result === "network_error") { + // Offline - return null but don't clear session + // User stays signed in, but can't make API calls until online + console.log("[auth] Offline - cannot refresh token"); + return null; + } + // result === "invalid" - fall through to clear session + } + + // Refresh failed or no valid refresh token + console.log("[auth] Session fully expired, clearing"); + await this.clearSession(); + return null; + } + + return this.session.accessToken; + } + + /** + * Refresh tokens using the refresh token + * Returns: 'success' | 'invalid' | 'network_error' + * - 'success': Tokens refreshed successfully + * - 'invalid': Tokens are invalid/revoked (should clear session) + * - 'network_error': Network unavailable (should keep session for offline use) + */ + private async refreshTokens(): Promise< + "success" | "invalid" | "network_error" + > { + if (!this.session?.refreshToken) { + return "invalid"; + } + + try { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/refresh`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refreshToken: this.session.refreshToken, + }), + }, + ); + + if (!response.ok) { + console.error("[auth] Token refresh failed:", response.status); + // 401/403 means tokens are actually invalid, not a network issue + if (response.status === 401 || response.status === 403) { + return "invalid"; + } + // Other errors (500, etc) - treat as temporary, keep session + return "network_error"; + } + + let data: unknown; + try { + data = await response.json(); + } catch (parseErr) { + console.error( + "[auth] Token refresh JSON parse error:", + parseErr instanceof Error ? parseErr.message : parseErr, + ); + return "invalid"; + } + + // Validate response shape before persisting + if (!isValidTokenResponse(data)) { + console.error( + "[auth] Token refresh response missing required fields:", + data, + ); + return "invalid"; + } + + // Update session with validated tokens + this.session = { + accessToken: data.accessToken, + accessTokenExpiresAt: data.accessTokenExpiresAt, + refreshToken: data.refreshToken, + refreshTokenExpiresAt: data.refreshTokenExpiresAt, + }; + + await tokenStorage.save(this.session); + console.log("[auth] Tokens refreshed successfully"); + this.emit("tokens-refreshed"); + return "success"; + } catch (err) { + // Network errors (offline, DNS failure, etc) - keep session for offline use + const errType = err instanceof Error ? err.constructor.name : typeof err; + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`[auth] Token refresh network error (${errType}):`, errMsg); + return "network_error"; + } + } + + /** + * Sign in with OAuth provider + * Opens system browser directly to provider's OAuth (bypasses Clerk UI) + */ + async signIn( + provider: AuthProvider, + _getWindow: () => BrowserWindow | null, + ): Promise { + try { + // Generate state for CSRF protection + const state = generateState(); + + let authUrl: URL; + + if (provider === "github") { + // Build GitHub OAuth URL + authUrl = new URL("https://github.com/login/oauth/authorize"); + authUrl.searchParams.set("client_id", env.GH_CLIENT_ID); + authUrl.searchParams.set( + "redirect_uri", + `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/github`, + ); + authUrl.searchParams.set("scope", "user:email"); + authUrl.searchParams.set("state", state); + } else { + // Build Google OAuth URL (default) + authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + authUrl.searchParams.set("client_id", env.GOOGLE_CLIENT_ID); + authUrl.searchParams.set( + "redirect_uri", + `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/google`, + ); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid email profile"); + authUrl.searchParams.set("state", state); + // Force account selection every time + authUrl.searchParams.set("prompt", "select_account"); + authUrl.searchParams.set("access_type", "online"); + } + + // Open OAuth flow in system browser + await shell.openExternal(authUrl.toString()); + + console.log("[auth] Opened OAuth flow in browser for:", provider); + return { success: true }; + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to open browser"; + console.error("[auth] Sign in failed:", message); + return { success: false, error: message }; + } + } + + /** + * Handle auth callback with all tokens from web + */ + async handleAuthCallback(params: { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + state: string; + }): Promise { + try { + // Verify state for CSRF protection + if (!verifyState(params.state)) { + return { success: false, error: "Invalid or expired auth session" }; + } + + // Create session with both access and refresh tokens + const session: AuthSession = { + accessToken: params.accessToken, + accessTokenExpiresAt: params.accessTokenExpiresAt, + refreshToken: params.refreshToken, + refreshTokenExpiresAt: params.refreshTokenExpiresAt, + }; + + this.session = session; + await tokenStorage.save(session); + this.emitStateChange(); + + console.log("[auth] Signed in via Google OAuth with refresh token"); + return { success: true }; + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to complete sign in"; + console.error("[auth] Auth callback handling failed:", message); + await this.clearSession(); + return { success: false, error: message }; + } + } + + /** + * Sign out - clear session + */ + async signOut(): Promise { + await this.clearSession(); + console.log("[auth] Signed out"); + } + + private async clearSession(): Promise { + this.session = null; + await tokenStorage.clear(); + this.emitStateChange(); + } + + private emitStateChange(): void { + this.emit("state-changed", this.getState()); + } +} + +export const authService = new AuthService(); diff --git a/apps/desktop/src/main/lib/auth/auth.ts b/apps/desktop/src/main/lib/auth/auth.ts deleted file mode 100644 index f56471603..000000000 --- a/apps/desktop/src/main/lib/auth/auth.ts +++ /dev/null @@ -1,219 +0,0 @@ -import crypto from "node:crypto"; -import { EventEmitter } from "node:events"; -import fs from "node:fs/promises"; -import { join } from "node:path"; -import { auth } from "@superset/auth"; -import type { AuthProvider } from "@superset/shared/constants"; -import { PROTOCOL_SCHEMES } from "@superset/shared/constants"; -import { shell } from "electron"; -import { env } from "main/env.main"; -import { SUPERSET_HOME_DIR } from "../app-environment"; -import { decrypt, encrypt } from "./crypto-storage"; - -export interface SignInResult { - success: boolean; - error?: string; -} - -const TOKEN_FILE = join(SUPERSET_HOME_DIR, "auth-token.enc"); -const stateStore = new Map(); - -export function parseAuthDeepLink( - url: string, -): { token: string; expiresAt: string; state: string } | null { - try { - const parsed = new URL(url); - const validProtocols = [ - `${PROTOCOL_SCHEMES.PROD}:`, - `${PROTOCOL_SCHEMES.DEV}:`, - ]; - if (!validProtocols.includes(parsed.protocol)) return null; - if (parsed.host !== "auth" || parsed.pathname !== "/callback") return null; - - const token = parsed.searchParams.get("token"); - const expiresAt = parsed.searchParams.get("expiresAt"); - const state = parsed.searchParams.get("state"); - if (!token || !expiresAt || !state) return null; - return { token, expiresAt, state }; - } catch { - return null; - } -} - -interface StoredAuth { - token: string; - expiresAt: string; -} - -type Session = Awaited>; - -class AuthService extends EventEmitter { - private token: string | null = null; - private expiresAt: Date | null = null; - private session: Session | null = null; - - async initialize(): Promise { - try { - const data = decrypt(await fs.readFile(TOKEN_FILE)); - const parsed: StoredAuth = JSON.parse(data); - this.token = parsed.token; - this.expiresAt = new Date(parsed.expiresAt); - - if (this.isExpired()) { - console.log("[auth] Session expired, clearing"); - await this.signOut(); - } else { - console.log("[auth] Session restored"); - // Fetch session data from API - await this.refreshSession(); - } - } catch { - this.token = null; - this.expiresAt = null; - } - } - - private isExpired(): boolean { - if (!this.expiresAt) return true; - // Consider expired 5 minutes before actual expiry for safety - const bufferMs = 5 * 60 * 1000; - return Date.now() > this.expiresAt.getTime() - bufferMs; - } - - getState() { - const state = { - isSignedIn: !!this.token && !this.isExpired(), - expiresAt: this.expiresAt?.toISOString() ?? null, - }; - console.log("[auth] getState called:", { - hasToken: !!this.token, - isExpired: this.isExpired(), - isSignedIn: state.isSignedIn, - }); - return state; - } - - getAccessToken(): string | null { - const expired = this.isExpired(); - const token = expired ? null : this.token; - console.log("[auth] getAccessToken called:", { - hasToken: !!this.token, - isExpired: expired, - returning: token ? "token" : "null", - }); - return token; - } - - getSession(): Session | null { - return this.session; - } - - private async refreshSession(): Promise { - const token = this.getAccessToken(); - if (!token) { - this.session = null; - return; - } - - try { - const session = await auth.api.getSession({ - headers: new Headers({ - Authorization: `Bearer ${token}`, - }), - }); - - this.session = session; - this.emit("session-changed", this.session); - } catch (error) { - console.error("[auth] Failed to refresh session:", error); - this.session = null; - } - } - - async setActiveOrganization(organizationId: string): Promise { - const token = this.getAccessToken(); - if (!token) throw new Error("Not authenticated"); - - await auth.api.setActiveOrganization({ - headers: new Headers({ - Authorization: `Bearer ${token}`, - }), - body: { organizationId }, - }); - - // Refresh session to get updated activeOrganizationId - await this.refreshSession(); - } - - async signIn(provider: AuthProvider): Promise { - try { - const state = crypto.randomBytes(32).toString("base64url"); - stateStore.set(state, Date.now()); - - const tenMinutesAgo = Date.now() - 10 * 60 * 1000; - for (const [s, ts] of stateStore) { - if (ts < tenMinutesAgo) stateStore.delete(s); - } - - const connectUrl = new URL( - `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/connect`, - ); - connectUrl.searchParams.set("provider", provider); - connectUrl.searchParams.set("state", state); - await shell.openExternal(connectUrl.toString()); - return { success: true }; - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : "Failed to open browser", - }; - } - } - - async handleAuthCallback(params: { - token: string; - expiresAt: string; - state: string; - }): Promise { - if (!stateStore.has(params.state)) { - return { success: false, error: "Invalid or expired auth session" }; - } - stateStore.delete(params.state); - - this.token = params.token; - this.expiresAt = new Date(params.expiresAt); - - const storedAuth: StoredAuth = { - token: this.token, - expiresAt: params.expiresAt, - }; - await fs.writeFile(TOKEN_FILE, encrypt(JSON.stringify(storedAuth))); - - console.log("[auth] Token saved, fetching session..."); - - // Fetch session data from API before emitting state change - await this.refreshSession(); - - console.log("[auth] Session fetched, emitting state change"); - const state = this.getState(); - console.log("[auth] EMIT state-changed from handleAuthCallback:", state); - this.emit("state-changed", state); - - return { success: true }; - } - - async signOut(): Promise { - console.log("[auth] signOut called"); - this.token = null; - this.expiresAt = null; - this.session = null; - try { - await fs.unlink(TOKEN_FILE); - } catch {} - const state = this.getState(); - console.log("[auth] EMIT state-changed from signOut:", state); - this.emit("state-changed", state); - } -} - -export const authService = new AuthService(); diff --git a/apps/desktop/src/main/lib/auth/crypto-storage.ts b/apps/desktop/src/main/lib/auth/crypto-storage.ts index faeba16f7..faf50a98b 100644 --- a/apps/desktop/src/main/lib/auth/crypto-storage.ts +++ b/apps/desktop/src/main/lib/auth/crypto-storage.ts @@ -16,6 +16,8 @@ const AUTH_TAG_LENGTH = 16; /** * Gets a stable machine identifier for key derivation. + * This provides "good enough" protection for local credential storage + * without requiring OS keychain access. */ function getMachineId(): string { try { diff --git a/apps/desktop/src/main/lib/auth/deep-link-handler.ts b/apps/desktop/src/main/lib/auth/deep-link-handler.ts new file mode 100644 index 000000000..5f6d864e1 --- /dev/null +++ b/apps/desktop/src/main/lib/auth/deep-link-handler.ts @@ -0,0 +1,128 @@ +import { PROTOCOL_SCHEMES } from "@superset/shared/constants"; + +/** + * Result of handling an auth deep link + */ +export interface AuthDeepLinkResult { + success: boolean; + accessToken?: string; + accessTokenExpiresAt?: number; + refreshToken?: string; + refreshTokenExpiresAt?: number; + state?: string; + error?: string; +} + +/** + * Handle authentication deep links from web callback + * Format: superset-dev://auth/callback?accessToken=XXX&accessTokenExpiresAt=YYY&refreshToken=ZZZ&refreshTokenExpiresAt=WWW&state=SSS + */ +export async function handleAuthDeepLink( + url: string, +): Promise { + try { + const parsedUrl = new URL(url); + + // Check if this is an auth callback + const isAuthCallback = + parsedUrl.host === "auth" && parsedUrl.pathname === "/callback"; + + if (!isAuthCallback) { + return { success: false, error: "Not an auth callback URL" }; + } + + // Check for error response + const error = parsedUrl.searchParams.get("error"); + if (error) { + const errorDescription = parsedUrl.searchParams.get("error_description"); + return { success: false, error: errorDescription || error }; + } + + // Get all tokens and metadata + const accessToken = parsedUrl.searchParams.get("accessToken"); + const accessTokenExpiresAtStr = parsedUrl.searchParams.get( + "accessTokenExpiresAt", + ); + const refreshToken = parsedUrl.searchParams.get("refreshToken"); + const refreshTokenExpiresAtStr = parsedUrl.searchParams.get( + "refreshTokenExpiresAt", + ); + const state = parsedUrl.searchParams.get("state"); + + if (!accessToken) { + return { success: false, error: "No access token in callback" }; + } + + if (!accessTokenExpiresAtStr) { + return { + success: false, + error: "No access token expiration in callback", + }; + } + + if (!refreshToken) { + return { success: false, error: "No refresh token in callback" }; + } + + if (!refreshTokenExpiresAtStr) { + return { + success: false, + error: "No refresh token expiration in callback", + }; + } + + if (!state) { + return { success: false, error: "No state in callback" }; + } + + const accessTokenExpiresAt = Number.parseInt(accessTokenExpiresAtStr, 10); + if (Number.isNaN(accessTokenExpiresAt)) { + return { + success: false, + error: "Invalid access token expiration in callback", + }; + } + + const refreshTokenExpiresAt = Number.parseInt(refreshTokenExpiresAtStr, 10); + if (Number.isNaN(refreshTokenExpiresAt)) { + return { + success: false, + error: "Invalid refresh token expiration in callback", + }; + } + + return { + success: true, + accessToken, + accessTokenExpiresAt, + refreshToken, + refreshTokenExpiresAt, + state, + }; + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to process auth callback"; + console.error("[auth] Deep link handling failed:", message); + return { success: false, error: message }; + } +} + +/** + * Check if a URL is an auth-related deep link + */ +export function isAuthDeepLink(url: string): boolean { + try { + const parsedUrl = new URL(url); + // Accept both production and dev protocols + const validProtocols = [ + `${PROTOCOL_SCHEMES.PROD}:`, + `${PROTOCOL_SCHEMES.DEV}:`, + ]; + // Accept "auth" host for callbacks + return ( + validProtocols.includes(parsedUrl.protocol) && parsedUrl.host === "auth" + ); + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/lib/auth/index.ts b/apps/desktop/src/main/lib/auth/index.ts index 91e69aedf..1749da60e 100644 --- a/apps/desktop/src/main/lib/auth/index.ts +++ b/apps/desktop/src/main/lib/auth/index.ts @@ -1,2 +1,4 @@ -export type { SignInResult } from "./auth"; -export { authService, parseAuthDeepLink } from "./auth"; +export { authService } from "./auth-service"; +export type { AuthDeepLinkResult } from "./deep-link-handler"; +export { handleAuthDeepLink, isAuthDeepLink } from "./deep-link-handler"; +export { tokenStorage } from "./token-storage"; diff --git a/apps/desktop/src/main/lib/auth/pkce.ts b/apps/desktop/src/main/lib/auth/pkce.ts new file mode 100644 index 000000000..dd0c6a0ae --- /dev/null +++ b/apps/desktop/src/main/lib/auth/pkce.ts @@ -0,0 +1,104 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** + * PKCE (Proof Key for Code Exchange) utilities + * Provides security for OAuth flows by preventing authorization code interception attacks + */ + +/** + * Generate a cryptographically random code verifier + * Must be 43-128 characters, using unreserved URI characters + */ +export function generateCodeVerifier(): string { + // 32 bytes = 43 characters when base64url encoded + return randomBytes(32).toString("base64url"); +} + +/** + * Generate code challenge from code verifier using SHA256 + * This is the S256 method as recommended by RFC 7636 + */ +export function generateCodeChallenge(codeVerifier: string): string { + const hash = createHash("sha256").update(codeVerifier).digest(); + return hash.toString("base64url"); +} + +/** + * Generate a random state value for CSRF protection + */ +export function generateState(): string { + return randomBytes(16).toString("base64url"); +} + +interface PkceData { + codeVerifier: string; + state: string; + createdAt: number; +} + +/** + * PKCE + state storage + * Stores code verifier and state temporarily during OAuth flow + */ +class PkceStore { + private data: PkceData | null = null; + + // Expires after 10 minutes + private readonly EXPIRY_MS = 10 * 60 * 1000; + + /** + * Generate and store a new PKCE pair + state + * Returns the code challenge and state to send to the authorization server + */ + createChallenge(): { codeChallenge: string; state: string } { + const codeVerifier = generateCodeVerifier(); + const state = generateState(); + + this.data = { + codeVerifier, + state, + createdAt: Date.now(), + }; + + return { + codeChallenge: generateCodeChallenge(codeVerifier), + state, + }; + } + + /** + * Retrieve and consume the stored verifier if state matches + * Returns null if expired, not found, or state mismatch + */ + consumeVerifier(state: string): string | null { + if (!this.data) { + return null; + } + + // Check expiry + if (Date.now() - this.data.createdAt > this.EXPIRY_MS) { + this.clear(); + return null; + } + + // Verify state matches (CSRF protection) + if (this.data.state !== state) { + console.warn("[auth] State mismatch - possible CSRF attack"); + this.clear(); + return null; + } + + const verifier = this.data.codeVerifier; + this.clear(); + return verifier; + } + + /** + * Clear stored PKCE state + */ + clear(): void { + this.data = null; + } +} + +export const pkceStore = new PkceStore(); diff --git a/apps/desktop/src/main/lib/auth/token-storage.ts b/apps/desktop/src/main/lib/auth/token-storage.ts new file mode 100644 index 000000000..4364278dc --- /dev/null +++ b/apps/desktop/src/main/lib/auth/token-storage.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import { join } from "node:path"; +import type { AuthSession } from "shared/auth"; +import { SUPERSET_HOME_DIR } from "../app-environment"; +import { decrypt, encrypt } from "./crypto-storage"; + +const SESSION_FILE_NAME = "auth-session.enc"; + +/** + * Securely stores authentication session using machine-derived encryption. + * Session data is encrypted at rest using AES-256-GCM with a key derived + * from the machine's hardware identifier. + */ +class TokenStorage { + private readonly filePath: string; + + constructor() { + this.filePath = join(SUPERSET_HOME_DIR, SESSION_FILE_NAME); + } + + async save(session: AuthSession): Promise { + const encrypted = encrypt(JSON.stringify(session)); + await fs.writeFile(this.filePath, encrypted); + } + + async load(): Promise { + try { + const encrypted = await fs.readFile(this.filePath); + const decrypted = decrypt(encrypted); + return JSON.parse(decrypted) as AuthSession; + } catch { + // File doesn't exist or can't be decrypted + return null; + } + } + + async clear(): Promise { + try { + await fs.unlink(this.filePath); + } catch { + // File doesn't exist, that's fine + } + } +} + +export const tokenStorage = new TokenStorage(); diff --git a/apps/desktop/src/main/lib/terminal/env.test.ts b/apps/desktop/src/main/lib/terminal/env.test.ts index f8fb5b1e5..6ed3c03a1 100644 --- a/apps/desktop/src/main/lib/terminal/env.test.ts +++ b/apps/desktop/src/main/lib/terminal/env.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { + buildSafeEnv, buildTerminalEnv, FALLBACK_SHELL, getLocale, + removeAppEnvVars, SHELL_CRASH_THRESHOLD_MS, sanitizeEnv, } from "./env"; @@ -102,6 +104,432 @@ describe("env", () => { }); }); + describe("buildSafeEnv", () => { + describe("excludes unknown/dangerous vars (allowlist approach)", () => { + it("should exclude NODE_ENV (not in allowlist)", () => { + const env = { NODE_ENV: "production", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.NODE_ENV).toBeUndefined(); + expect(result.PATH).toBe("/usr/bin"); + }); + + it("should exclude NODE_OPTIONS (not in allowlist)", () => { + const env = { + NODE_OPTIONS: "--max-old-space-size=4096", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.NODE_OPTIONS).toBeUndefined(); + expect(result.PATH).toBe("/usr/bin"); + }); + + it("should exclude NODE_PATH (not in allowlist)", () => { + const env = { NODE_PATH: "/custom/modules", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.NODE_PATH).toBeUndefined(); + expect(result.PATH).toBe("/usr/bin"); + }); + + it("should exclude ELECTRON_RUN_AS_NODE (not in allowlist)", () => { + const env = { ELECTRON_RUN_AS_NODE: "1", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(result.PATH).toBe("/usr/bin"); + }); + }); + + describe("excludes secrets (not in allowlist)", () => { + it("should exclude GOOGLE_API_KEY", () => { + const env = { GOOGLE_API_KEY: "secret", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.GOOGLE_API_KEY).toBeUndefined(); + }); + + it("should exclude DATABASE_URL", () => { + const env = { + DATABASE_URL: "postgres://user:pass@host/db", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.DATABASE_URL).toBeUndefined(); + }); + + it("should exclude CLERK_SECRET_KEY", () => { + const env = { CLERK_SECRET_KEY: "sk_test_xxx", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.CLERK_SECRET_KEY).toBeUndefined(); + }); + + it("should exclude NEON_API_KEY", () => { + const env = { NEON_API_KEY: "neon-api-key", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.NEON_API_KEY).toBeUndefined(); + }); + + it("should exclude SENTRY_AUTH_TOKEN", () => { + const env = { SENTRY_AUTH_TOKEN: "sentry-token", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.SENTRY_AUTH_TOKEN).toBeUndefined(); + }); + + it("should exclude GH_CLIENT_SECRET", () => { + const env = { GH_CLIENT_SECRET: "gh-secret", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.GH_CLIENT_SECRET).toBeUndefined(); + }); + }); + + describe("excludes app/build-time vars (not in allowlist)", () => { + it("should exclude VITE_* vars", () => { + const env = { + VITE_API_URL: "http://localhost", + VITE_DEBUG: "true", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.VITE_API_URL).toBeUndefined(); + expect(result.VITE_DEBUG).toBeUndefined(); + expect(result.PATH).toBe("/usr/bin"); + }); + + it("should exclude NEXT_PUBLIC_* vars", () => { + const env = { + NEXT_PUBLIC_API_URL: "https://api.example.com", + NEXT_PUBLIC_POSTHOG_KEY: "phkey", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.NEXT_PUBLIC_API_URL).toBeUndefined(); + expect(result.NEXT_PUBLIC_POSTHOG_KEY).toBeUndefined(); + }); + + it("should exclude TURBO_* vars", () => { + const env = { + TURBO_TEAM: "team", + TURBO_TOKEN: "token", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.TURBO_TEAM).toBeUndefined(); + expect(result.TURBO_TOKEN).toBeUndefined(); + }); + }); + + describe("includes allowlisted shell environment vars", () => { + it("should include PATH, HOME, SHELL, USER", () => { + const env = { + PATH: "/usr/bin:/usr/local/bin", + HOME: "/Users/test", + SHELL: "/bin/zsh", + USER: "testuser", + NODE_ENV: "production", // Should be excluded + }; + const result = buildSafeEnv(env); + expect(result.PATH).toBe("/usr/bin:/usr/local/bin"); + expect(result.HOME).toBe("/Users/test"); + expect(result.SHELL).toBe("/bin/zsh"); + expect(result.USER).toBe("testuser"); + expect(result.NODE_ENV).toBeUndefined(); + }); + + it("should include SSH_AUTH_SOCK (critical for git)", () => { + const env = { SSH_AUTH_SOCK: "/tmp/ssh-agent.sock", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.SSH_AUTH_SOCK).toBe("/tmp/ssh-agent.sock"); + }); + + it("should include SSH_AGENT_PID", () => { + const env = { SSH_AGENT_PID: "12345", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(result.SSH_AGENT_PID).toBe("12345"); + }); + + it("should include language manager vars (NVM, PYENV, etc.)", () => { + const env = { + NVM_DIR: "/Users/test/.nvm", + PYENV_ROOT: "/Users/test/.pyenv", + RBENV_ROOT: "/Users/test/.rbenv", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.NVM_DIR).toBe("/Users/test/.nvm"); + expect(result.PYENV_ROOT).toBe("/Users/test/.pyenv"); + expect(result.RBENV_ROOT).toBe("/Users/test/.rbenv"); + }); + + it("should include proxy vars (both cases)", () => { + const env = { + HTTP_PROXY: "http://proxy:8080", + HTTPS_PROXY: "http://proxy:8080", + http_proxy: "http://proxy:8080", + https_proxy: "http://proxy:8080", + NO_PROXY: "localhost,127.0.0.1", + no_proxy: "localhost", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.HTTP_PROXY).toBe("http://proxy:8080"); + expect(result.HTTPS_PROXY).toBe("http://proxy:8080"); + expect(result.http_proxy).toBe("http://proxy:8080"); + expect(result.https_proxy).toBe("http://proxy:8080"); + expect(result.NO_PROXY).toBe("localhost,127.0.0.1"); + expect(result.no_proxy).toBe("localhost"); + }); + + it("should include locale vars", () => { + const env = { + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "UTF-8", + TZ: "America/New_York", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.LANG).toBe("en_US.UTF-8"); + expect(result.LC_ALL).toBe("en_US.UTF-8"); + expect(result.LC_CTYPE).toBe("UTF-8"); + expect(result.TZ).toBe("America/New_York"); + }); + + it("should include XDG directories", () => { + const env = { + XDG_CONFIG_HOME: "/home/user/.config", + XDG_DATA_HOME: "/home/user/.local/share", + XDG_CACHE_HOME: "/home/user/.cache", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.XDG_CONFIG_HOME).toBe("/home/user/.config"); + expect(result.XDG_DATA_HOME).toBe("/home/user/.local/share"); + expect(result.XDG_CACHE_HOME).toBe("/home/user/.cache"); + }); + + it("should include editor vars", () => { + const env = { + EDITOR: "vim", + VISUAL: "code", + PAGER: "less", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.EDITOR).toBe("vim"); + expect(result.VISUAL).toBe("code"); + expect(result.PAGER).toBe("less"); + }); + + it("should include Homebrew vars", () => { + const env = { + HOMEBREW_PREFIX: "/opt/homebrew", + HOMEBREW_CELLAR: "/opt/homebrew/Cellar", + HOMEBREW_REPOSITORY: "/opt/homebrew", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.HOMEBREW_PREFIX).toBe("/opt/homebrew"); + expect(result.HOMEBREW_CELLAR).toBe("/opt/homebrew/Cellar"); + expect(result.HOMEBREW_REPOSITORY).toBe("/opt/homebrew"); + }); + + it("should include Go/Rust/Deno/Bun paths", () => { + const env = { + GOPATH: "/Users/test/go", + GOROOT: "/usr/local/go", + CARGO_HOME: "/Users/test/.cargo", + RUSTUP_HOME: "/Users/test/.rustup", + DENO_DIR: "/Users/test/.deno", + BUN_INSTALL: "/Users/test/.bun", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.GOPATH).toBe("/Users/test/go"); + expect(result.GOROOT).toBe("/usr/local/go"); + expect(result.CARGO_HOME).toBe("/Users/test/.cargo"); + expect(result.RUSTUP_HOME).toBe("/Users/test/.rustup"); + expect(result.DENO_DIR).toBe("/Users/test/.deno"); + expect(result.BUN_INSTALL).toBe("/Users/test/.bun"); + }); + }); + + describe("includes developer tool config vars (non-secrets)", () => { + it("should include SSL/TLS config vars", () => { + const env = { + SSL_CERT_FILE: "/etc/ssl/certs/ca-certificates.crt", + SSL_CERT_DIR: "/etc/ssl/certs", + NODE_EXTRA_CA_CERTS: "/path/to/custom-ca.crt", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.SSL_CERT_FILE).toBe("/etc/ssl/certs/ca-certificates.crt"); + expect(result.SSL_CERT_DIR).toBe("/etc/ssl/certs"); + expect(result.NODE_EXTRA_CA_CERTS).toBe("/path/to/custom-ca.crt"); + }); + + it("should include Git config vars (not credentials)", () => { + const env = { + GIT_SSH_COMMAND: "ssh -i ~/.ssh/custom_key", + GIT_AUTHOR_NAME: "Test User", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_EDITOR: "vim", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.GIT_SSH_COMMAND).toBe("ssh -i ~/.ssh/custom_key"); + expect(result.GIT_AUTHOR_NAME).toBe("Test User"); + expect(result.GIT_AUTHOR_EMAIL).toBe("test@example.com"); + expect(result.GIT_EDITOR).toBe("vim"); + }); + + it("should include AWS profile config (not credentials)", () => { + const env = { + AWS_PROFILE: "production", + AWS_DEFAULT_REGION: "us-east-1", + AWS_REGION: "us-west-2", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.AWS_PROFILE).toBe("production"); + expect(result.AWS_DEFAULT_REGION).toBe("us-east-1"); + expect(result.AWS_REGION).toBe("us-west-2"); + }); + + it("should include Docker/K8s config vars", () => { + const env = { + DOCKER_HOST: "unix:///var/run/docker.sock", + DOCKER_CONFIG: "/home/user/.docker", + KUBECONFIG: "/home/user/.kube/config", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.DOCKER_HOST).toBe("unix:///var/run/docker.sock"); + expect(result.DOCKER_CONFIG).toBe("/home/user/.docker"); + expect(result.KUBECONFIG).toBe("/home/user/.kube/config"); + }); + + it("should include SDK path vars", () => { + const env = { + JAVA_HOME: "/usr/lib/jvm/java-17", + ANDROID_HOME: "/home/user/Android/Sdk", + ANDROID_SDK_ROOT: "/home/user/Android/Sdk", + FLUTTER_ROOT: "/home/user/flutter", + DOTNET_ROOT: "/usr/share/dotnet", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.JAVA_HOME).toBe("/usr/lib/jvm/java-17"); + expect(result.ANDROID_HOME).toBe("/home/user/Android/Sdk"); + expect(result.ANDROID_SDK_ROOT).toBe("/home/user/Android/Sdk"); + expect(result.FLUTTER_ROOT).toBe("/home/user/flutter"); + expect(result.DOTNET_ROOT).toBe("/usr/share/dotnet"); + }); + }); + + describe("includes SUPERSET_* prefix vars", () => { + it("should include SUPERSET_* vars (our metadata)", () => { + const env = { + SUPERSET_PANE_ID: "pane-1", + SUPERSET_TAB_ID: "tab-1", + SUPERSET_WORKSPACE_ID: "ws-1", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env); + expect(result.SUPERSET_PANE_ID).toBe("pane-1"); + expect(result.SUPERSET_TAB_ID).toBe("tab-1"); + expect(result.SUPERSET_WORKSPACE_ID).toBe("ws-1"); + }); + }); + + it("should not mutate the original env object", () => { + const env = { NODE_ENV: "production", PATH: "/usr/bin" }; + const result = buildSafeEnv(env); + expect(env.NODE_ENV).toBe("production"); // Original unchanged + expect(result.NODE_ENV).toBeUndefined(); // Result excludes it + }); + + it("should return empty object for env with no allowlisted vars", () => { + const env = { + SECRET_KEY: "secret", + DATABASE_URL: "postgres://...", + API_TOKEN: "token", + }; + const result = buildSafeEnv(env); + expect(Object.keys(result).length).toBe(0); + }); + + describe("Windows platform case-insensitivity", () => { + it("should include Path (Windows casing) when platform is win32", () => { + const env = { Path: "C:\\Windows\\System32", HOME: "/home/user" }; + const result = buildSafeEnv(env, { platform: "win32" }); + expect(result.Path).toBe("C:\\Windows\\System32"); + }); + + it("should NOT include Path on non-Windows (case-sensitive)", () => { + const env = { Path: "C:\\Windows\\System32", HOME: "/home/user" }; + const result = buildSafeEnv(env, { platform: "darwin" }); + expect(result.Path).toBeUndefined(); + expect(result.HOME).toBe("/home/user"); + }); + + it("should include SystemRoot (Windows casing) when platform is win32", () => { + const env = { SystemRoot: "C:\\Windows", PATH: "/usr/bin" }; + const result = buildSafeEnv(env, { platform: "win32" }); + expect(result.SystemRoot).toBe("C:\\Windows"); + }); + + it("should include TEMP and TMP on Windows", () => { + const env = { + Temp: "C:\\Users\\test\\AppData\\Local\\Temp", + TMP: "C:\\Users\\test\\AppData\\Local\\Temp", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env, { platform: "win32" }); + expect(result.Temp).toBe("C:\\Users\\test\\AppData\\Local\\Temp"); + expect(result.TMP).toBe("C:\\Users\\test\\AppData\\Local\\Temp"); + }); + + it("should include PATHEXT on Windows", () => { + const env = { + PATHEXT: ".COM;.EXE;.BAT;.CMD", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env, { platform: "win32" }); + expect(result.PATHEXT).toBe(".COM;.EXE;.BAT;.CMD"); + }); + + it("should include Superset_* prefix vars case-insensitively on Windows", () => { + const env = { + Superset_Pane_Id: "pane-1", + SUPERSET_TAB_ID: "tab-1", + PATH: "/usr/bin", + }; + const result = buildSafeEnv(env, { platform: "win32" }); + expect(result.Superset_Pane_Id).toBe("pane-1"); + expect(result.SUPERSET_TAB_ID).toBe("tab-1"); + }); + + it("should preserve original key casing in output", () => { + const env = { + Path: "C:\\Windows\\System32", + systemroot: "C:\\Windows", + HOME: "/home/user", + }; + const result = buildSafeEnv(env, { platform: "win32" }); + // Keys should preserve their original casing + expect(result.Path).toBe("C:\\Windows\\System32"); + expect(result.systemroot).toBe("C:\\Windows"); + expect(result.HOME).toBe("/home/user"); + }); + }); + }); + + describe("removeAppEnvVars (deprecated wrapper)", () => { + it("should delegate to buildSafeEnv", () => { + const env = { NODE_ENV: "production", PATH: "/usr/bin" }; + const result = removeAppEnvVars(env); + expect(result.NODE_ENV).toBeUndefined(); + expect(result.PATH).toBe("/usr/bin"); + }); + }); + describe("buildTerminalEnv", () => { const baseParams = { shell: "/bin/zsh", @@ -110,72 +538,132 @@ describe("env", () => { workspaceId: "ws-1", }; - it("should set TERM_PROGRAM to Superset", () => { - const result = buildTerminalEnv(baseParams); - expect(result.TERM_PROGRAM).toBe("Superset"); + // Store original env vars to restore after tests + const originalEnvVars: Record = {}; + const varsToTrack = [ + "NODE_ENV", + "NODE_OPTIONS", + "NODE_PATH", + "ELECTRON_RUN_AS_NODE", + "GOOGLE_API_KEY", + "VITE_TEST_VAR", + "NEXT_PUBLIC_TEST", + "DATABASE_URL", + "CLERK_SECRET_KEY", + ]; + + beforeEach(() => { + // Save original values + for (const key of varsToTrack) { + originalEnvVars[key] = process.env[key]; + } }); - it("should set COLORTERM to truecolor", () => { - const result = buildTerminalEnv(baseParams); - expect(result.COLORTERM).toBe("truecolor"); + afterEach(() => { + // Restore original values + for (const key of varsToTrack) { + if (originalEnvVars[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnvVars[key]; + } + } }); - it("should set Superset-specific env vars", () => { - const result = buildTerminalEnv(baseParams); + describe("excludes non-allowlisted vars from terminals", () => { + it("should exclude NODE_ENV from Electron's process.env", () => { + process.env.NODE_ENV = "production"; + const result = buildTerminalEnv(baseParams); + expect(result.NODE_ENV).toBeUndefined(); + }); - expect(result.SUPERSET_PANE_ID).toBe("pane-1"); - expect(result.SUPERSET_TAB_ID).toBe("tab-1"); - expect(result.SUPERSET_WORKSPACE_ID).toBe("ws-1"); - }); + it("should exclude NODE_OPTIONS from Electron's process.env", () => { + process.env.NODE_OPTIONS = "--inspect"; + const result = buildTerminalEnv(baseParams); + expect(result.NODE_OPTIONS).toBeUndefined(); + }); - it("should handle optional workspace params", () => { - const result = buildTerminalEnv({ - ...baseParams, - workspaceName: "my-workspace", - workspacePath: "/path/to/workspace", - rootPath: "/root/path", + it("should exclude VITE_* vars from Electron's process.env", () => { + process.env.VITE_TEST_VAR = "test-value"; + const result = buildTerminalEnv(baseParams); + expect(result.VITE_TEST_VAR).toBeUndefined(); }); - expect(result.SUPERSET_WORKSPACE_NAME).toBe("my-workspace"); - expect(result.SUPERSET_WORKSPACE_PATH).toBe("/path/to/workspace"); - expect(result.SUPERSET_ROOT_PATH).toBe("/root/path"); - }); + it("should exclude NEXT_PUBLIC_* vars from Electron's process.env", () => { + process.env.NEXT_PUBLIC_TEST = "test-value"; + const result = buildTerminalEnv(baseParams); + expect(result.NEXT_PUBLIC_TEST).toBeUndefined(); + }); - it("should default optional params to empty string", () => { - const result = buildTerminalEnv(baseParams); + it("should exclude GOOGLE_API_KEY from Electron's process.env", () => { + process.env.GOOGLE_API_KEY = "secret-key"; + const result = buildTerminalEnv(baseParams); + expect(result.GOOGLE_API_KEY).toBeUndefined(); + }); - expect(result.SUPERSET_WORKSPACE_NAME).toBe(""); - expect(result.SUPERSET_WORKSPACE_PATH).toBe(""); - expect(result.SUPERSET_ROOT_PATH).toBe(""); + it("should exclude DATABASE_URL from Electron's process.env", () => { + process.env.DATABASE_URL = "postgres://user:pass@host/db"; + const result = buildTerminalEnv(baseParams); + expect(result.DATABASE_URL).toBeUndefined(); + }); + + it("should exclude CLERK_SECRET_KEY from Electron's process.env", () => { + process.env.CLERK_SECRET_KEY = "sk_test_xxx"; + const result = buildTerminalEnv(baseParams); + expect(result.CLERK_SECRET_KEY).toBeUndefined(); + }); }); - it("should remove GOOGLE_API_KEY for security", () => { - // Temporarily set GOOGLE_API_KEY - const originalKey = process.env.GOOGLE_API_KEY; - process.env.GOOGLE_API_KEY = "secret-key"; + describe("terminal metadata", () => { + it("should set TERM_PROGRAM to Superset", () => { + const result = buildTerminalEnv(baseParams); + expect(result.TERM_PROGRAM).toBe("Superset"); + }); - try { + it("should set COLORTERM to truecolor", () => { const result = buildTerminalEnv(baseParams); - expect(result.GOOGLE_API_KEY).toBeUndefined(); - } finally { - // Restore original value - if (originalKey === undefined) { - delete process.env.GOOGLE_API_KEY; - } else { - process.env.GOOGLE_API_KEY = originalKey; - } - } - }); + expect(result.COLORTERM).toBe("truecolor"); + }); - it("should set LANG to a UTF-8 locale", () => { - const result = buildTerminalEnv(baseParams); - expect(result.LANG).toContain("UTF-8"); - }); + it("should set Superset-specific env vars", () => { + const result = buildTerminalEnv(baseParams); + + expect(result.SUPERSET_PANE_ID).toBe("pane-1"); + expect(result.SUPERSET_TAB_ID).toBe("tab-1"); + expect(result.SUPERSET_WORKSPACE_ID).toBe("ws-1"); + }); - it("should include SUPERSET_PORT", () => { - const result = buildTerminalEnv(baseParams); - expect(result.SUPERSET_PORT).toBeDefined(); - expect(typeof result.SUPERSET_PORT).toBe("string"); + it("should handle optional workspace params", () => { + const result = buildTerminalEnv({ + ...baseParams, + workspaceName: "my-workspace", + workspacePath: "/path/to/workspace", + rootPath: "/root/path", + }); + + expect(result.SUPERSET_WORKSPACE_NAME).toBe("my-workspace"); + expect(result.SUPERSET_WORKSPACE_PATH).toBe("/path/to/workspace"); + expect(result.SUPERSET_ROOT_PATH).toBe("/root/path"); + }); + + it("should default optional params to empty string", () => { + const result = buildTerminalEnv(baseParams); + + expect(result.SUPERSET_WORKSPACE_NAME).toBe(""); + expect(result.SUPERSET_WORKSPACE_PATH).toBe(""); + expect(result.SUPERSET_ROOT_PATH).toBe(""); + }); + + it("should set LANG to a UTF-8 locale", () => { + const result = buildTerminalEnv(baseParams); + expect(result.LANG).toContain("UTF-8"); + }); + + it("should include SUPERSET_PORT", () => { + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_PORT).toBeDefined(); + expect(typeof result.SUPERSET_PORT).toBe("string"); + }); }); }); }); diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index d5931a908..7709d6686 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -63,6 +63,254 @@ export function sanitizeEnv( return Object.keys(sanitized).length > 0 ? sanitized : undefined; } +/** + * Allowlist of environment variable names safe to pass to terminals. + * Using an allowlist (vs denylist) ensures unknown vars (including secrets) are excluded by default. + * + * IMPORTANT: On Windows, env var keys are case-insensitive. The system may store + * "Path" instead of "PATH", "SystemRoot" instead of "SYSTEMROOT", etc. + * We store uppercase versions here and do case-insensitive matching on Windows. + */ +const ALLOWED_ENV_VARS = new Set([ + // Core shell environment + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TERM", + "TMPDIR", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "LC_COLLATE", + "LC_MONETARY", + "LC_NUMERIC", + "LC_TIME", + "TZ", + + // Terminal/display + "DISPLAY", + "COLORTERM", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "COLUMNS", + "LINES", + + // SSH (critical for git operations) + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + + // Proxy configuration (user may need for network access) + // Note: proxy vars are case-sensitive on Unix, so we include both cases + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "NO_PROXY", + "no_proxy", + "ALL_PROXY", + "all_proxy", + "FTP_PROXY", + "ftp_proxy", + + // Language version managers (users expect these to work) + "NVM_DIR", + "NVM_BIN", + "NVM_INC", + "NVM_CD_FLAGS", + "NVM_RC_VERSION", + "PYENV_ROOT", + "PYENV_SHELL", + "PYENV_VERSION", + "RBENV_ROOT", + "RBENV_SHELL", + "RBENV_VERSION", + "GOPATH", + "GOROOT", + "GOBIN", + "CARGO_HOME", + "RUSTUP_HOME", + "DENO_DIR", + "DENO_INSTALL", + "BUN_INSTALL", + "PNPM_HOME", + "VOLTA_HOME", + "ASDF_DIR", + "ASDF_DATA_DIR", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "FNM_NODE_DIST_MIRROR", + "SDKMAN_DIR", + + // Homebrew + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + + // XDG directories (Linux/macOS standards) + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "XDG_RUNTIME_DIR", + + // Editor (user preference, safe) + "EDITOR", + "VISUAL", + "PAGER", + + // macOS specific + "__CF_USER_TEXT_ENCODING", + "Apple_PubSub_Socket_Render", + + // Windows specific (for cross-platform compatibility) + // Note: Windows stores these with various casings (Path, SystemRoot, etc.) + // but we match case-insensitively on win32 + "COMSPEC", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "SYSTEMROOT", + "WINDIR", + "TEMP", + "TMP", + "PATHEXT", // Required for command resolution on Windows + + // SSL/TLS configuration (custom certs, not secrets) + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", // Python requests library + + // Git configuration (not credentials) + "GIT_SSH_COMMAND", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_EDITOR", + "GIT_PAGER", + + // AWS configuration (profile selection, not credentials) + // Actual secrets are in ~/.aws/credentials, not env vars + "AWS_PROFILE", + "AWS_DEFAULT_REGION", + "AWS_REGION", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + + // Docker configuration (not credentials) + "DOCKER_HOST", + "DOCKER_CONFIG", + "DOCKER_CERT_PATH", + "DOCKER_TLS_VERIFY", + "COMPOSE_PROJECT_NAME", + + // Kubernetes configuration (not credentials) + "KUBECONFIG", + "KUBE_CONFIG_PATH", + + // Cloud CLI tools (not credentials) + "CLOUDSDK_CONFIG", // Google Cloud SDK + "AZURE_CONFIG_DIR", // Azure CLI + + // SDK paths (not secrets) + "JAVA_HOME", + "ANDROID_HOME", + "ANDROID_SDK_ROOT", + "FLUTTER_ROOT", + "DOTNET_ROOT", +]); + +/** + * Prefixes for environment variables that are safe to pass through. + * These are checked after exact matches fail. + */ +const ALLOWED_PREFIXES = [ + "SUPERSET_", // Our own metadata vars + "LC_", // Locale settings +]; + +/** + * Check if a key is in the allowlist, handling Windows case-insensitivity. + * @param key - The environment variable key + * @param isWindows - Whether running on Windows (for case-insensitive matching) + */ +function isAllowedVar(key: string, isWindows: boolean): boolean { + // On Windows, env vars are case-insensitive + // The system may store "Path" instead of "PATH" + if (isWindows) { + return ALLOWED_ENV_VARS.has(key.toUpperCase()); + } + return ALLOWED_ENV_VARS.has(key); +} + +/** + * Check if a key matches an allowed prefix, handling Windows case-insensitivity. + * @param key - The environment variable key + * @param isWindows - Whether running on Windows (for case-insensitive matching) + */ +function hasAllowedPrefix(key: string, isWindows: boolean): boolean { + const keyToCheck = isWindows ? key.toUpperCase() : key; + return ALLOWED_PREFIXES.some((prefix) => keyToCheck.startsWith(prefix)); +} + +/** + * Build a safe environment by only including allowlisted variables. + * This prevents Superset app secrets and build-time config from leaking to terminals. + * + * Threat model: Prevent app secrets (DATABASE_URL, API keys from .env) from leaking. + * User shell config vars (proxy, tool paths) are intentionally allowed so terminals + * behave like the user's normal environment. + * + * Allowlist approach rationale: + * - Unknown vars excluded by default (prevents app secrets like DATABASE_URL from leaking) + * - Only infrastructure vars (PATH, HOME, etc.) pass through from Electron + * - Shell initialization vars (ZDOTDIR, BASH_ENV) are added separately via shellEnv + * + * Note: Allowlisted vars like HTTP_PROXY may contain user-configured credentials. + * + * @param env - The environment variables to filter + * @param options - Optional configuration + * @param options.platform - Override platform detection (for testing) + */ +export function buildSafeEnv( + env: Record, + options?: { platform?: NodeJS.Platform }, +): Record { + const platform = options?.platform ?? os.platform(); + const isWindows = platform === "win32"; + const safe: Record = {}; + + for (const [key, value] of Object.entries(env)) { + // Check exact match (case-insensitive on Windows) + if (isAllowedVar(key, isWindows)) { + safe[key] = value; + continue; + } + + // Check prefix match (case-insensitive on Windows) + if (hasAllowedPrefix(key, isWindows)) { + safe[key] = value; + } + } + + return safe; +} + +/** + * @deprecated Use buildSafeEnv instead. Kept for backward compatibility. + */ +export function removeAppEnvVars( + env: Record, +): Record { + return buildSafeEnv(env); +} + export function buildTerminalEnv(params: { shell: string; paneId: string; @@ -82,9 +330,15 @@ export function buildTerminalEnv(params: { rootPath, } = params; - const baseEnv = sanitizeEnv(process.env) || {}; + // Get Electron's process.env and filter to only allowlisted safe vars + // This prevents secrets and app config from leaking to user terminals + const rawBaseEnv = sanitizeEnv(process.env) || {}; + const baseEnv = buildSafeEnv(rawBaseEnv); + + // shellEnv provides shell wrapper control variables (ZDOTDIR, BASH_ENV, etc.) + // These configure how the shell initializes, not the user's actual environment const shellEnv = getShellEnv(shell); - const locale = getLocale(baseEnv); + const locale = getLocale(rawBaseEnv); const env: Record = { ...baseEnv, @@ -102,7 +356,5 @@ export function buildTerminalEnv(params: { SUPERSET_PORT: String(PORTS.NOTIFICATIONS), }; - delete env.GOOGLE_API_KEY; - return env; } diff --git a/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts b/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts new file mode 100644 index 000000000..8b89325ac --- /dev/null +++ b/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts @@ -0,0 +1,377 @@ +import { beforeEach, describe, expect, it, type mock } from "bun:test"; +import { screen } from "electron"; +import { + getInitialWindowBounds, + isVisibleOnAnyDisplay, +} from "./bounds-validation"; + +const MIN_VISIBLE_OVERLAP = 50; +const MIN_WINDOW_SIZE = 400; + +describe("isVisibleOnAnyDisplay", () => { + describe("single display setup", () => { + beforeEach(() => { + (screen.getAllDisplays as ReturnType).mockReturnValue([ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + ]); + }); + + it("should return true for window fully within display", () => { + expect( + isVisibleOnAnyDisplay({ x: 100, y: 100, width: 800, height: 600 }), + ).toBe(true); + }); + + it("should return true for window covering entire display", () => { + expect( + isVisibleOnAnyDisplay({ x: 0, y: 0, width: 1920, height: 1080 }), + ).toBe(true); + }); + + it("should return true for window with more than MIN_VISIBLE_OVERLAP on right edge", () => { + expect( + isVisibleOnAnyDisplay({ + x: 1920 - MIN_VISIBLE_OVERLAP - 1, + y: 100, + width: 800, + height: 600, + }), + ).toBe(true); + }); + + it("should return true for window with more than MIN_VISIBLE_OVERLAP on bottom edge", () => { + expect( + isVisibleOnAnyDisplay({ + x: 100, + y: 1080 - MIN_VISIBLE_OVERLAP - 1, + width: 800, + height: 600, + }), + ).toBe(true); + }); + + it("should return false for window at exactly MIN_VISIBLE_OVERLAP boundary (strict inequality)", () => { + expect( + isVisibleOnAnyDisplay({ + x: 1920 - MIN_VISIBLE_OVERLAP, + y: 100, + width: 800, + height: 600, + }), + ).toBe(false); + }); + + it("should return false for window completely off-screen (right)", () => { + expect( + isVisibleOnAnyDisplay({ x: 2000, y: 100, width: 800, height: 600 }), + ).toBe(false); + }); + + it("should return false for window completely off-screen (left)", () => { + expect( + isVisibleOnAnyDisplay({ x: -900, y: 100, width: 800, height: 600 }), + ).toBe(false); + }); + + it("should return false for window completely off-screen (bottom)", () => { + expect( + isVisibleOnAnyDisplay({ x: 100, y: 1200, width: 800, height: 600 }), + ).toBe(false); + }); + + it("should return false for window completely off-screen (top)", () => { + expect( + isVisibleOnAnyDisplay({ x: 100, y: -700, width: 800, height: 600 }), + ).toBe(false); + }); + + it("should return false for window with insufficient overlap (49px < 50px threshold)", () => { + expect( + isVisibleOnAnyDisplay({ + x: 1920 - MIN_VISIBLE_OVERLAP + 1, + y: 100, + width: 800, + height: 600, + }), + ).toBe(false); + }); + }); + + describe("multi-display setup", () => { + beforeEach(() => { + (screen.getAllDisplays as ReturnType).mockReturnValue([ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + { bounds: { x: 1920, y: 0, width: 1920, height: 1080 } }, + ]); + }); + + it("should return true for window on secondary display", () => { + expect( + isVisibleOnAnyDisplay({ x: 2000, y: 100, width: 800, height: 600 }), + ).toBe(true); + }); + + it("should return true for window spanning both displays", () => { + expect( + isVisibleOnAnyDisplay({ x: 1500, y: 100, width: 1000, height: 600 }), + ).toBe(true); + }); + + it("should return false for window off-screen to the right of secondary", () => { + expect( + isVisibleOnAnyDisplay({ x: 4000, y: 100, width: 800, height: 600 }), + ).toBe(false); + }); + }); + + describe("secondary display with offset", () => { + beforeEach(() => { + (screen.getAllDisplays as ReturnType).mockReturnValue([ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + { bounds: { x: 960, y: 1080, width: 1920, height: 1080 } }, + ]); + }); + + it("should return true for window on offset secondary display", () => { + expect( + isVisibleOnAnyDisplay({ x: 1000, y: 1200, width: 800, height: 600 }), + ).toBe(true); + }); + + it("should return false for window in gap between displays", () => { + expect( + isVisibleOnAnyDisplay({ x: 0, y: 1100, width: 800, height: 600 }), + ).toBe(false); + }); + }); + + describe("display to the left (negative coordinates)", () => { + beforeEach(() => { + (screen.getAllDisplays as ReturnType).mockReturnValue([ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + { bounds: { x: -1920, y: 0, width: 1920, height: 1080 } }, + ]); + }); + + it("should return true for window on display with negative coordinates", () => { + expect( + isVisibleOnAnyDisplay({ x: -1000, y: 100, width: 800, height: 600 }), + ).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should return false when no displays connected", () => { + (screen.getAllDisplays as ReturnType).mockReturnValue([]); + expect( + isVisibleOnAnyDisplay({ x: 100, y: 100, width: 800, height: 600 }), + ).toBe(false); + }); + + it("should return true for zero-size window if position is valid (size validation is separate)", () => { + (screen.getAllDisplays as ReturnType).mockReturnValue([ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + ]); + expect( + isVisibleOnAnyDisplay({ x: 100, y: 100, width: 0, height: 0 }), + ).toBe(true); + }); + }); +}); + +describe("getInitialWindowBounds", () => { + beforeEach(() => { + (screen.getPrimaryDisplay as ReturnType).mockReturnValue({ + workAreaSize: { width: 1920, height: 1080 }, + }); + (screen.getAllDisplays as ReturnType).mockReturnValue([ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + ]); + }); + + describe("no saved state", () => { + it("should return primary display size when no saved state", () => { + const result = getInitialWindowBounds(null); + expect(result).toEqual({ + width: 1920, + height: 1080, + center: true, + isMaximized: false, + }); + }); + + it("should not include x/y when centering", () => { + const result = getInitialWindowBounds(null); + expect(result.x).toBeUndefined(); + expect(result.y).toBeUndefined(); + }); + }); + + describe("saved state on visible display", () => { + it("should restore exact position when visible on display", () => { + const result = getInitialWindowBounds({ + x: 100, + y: 200, + width: 800, + height: 600, + isMaximized: false, + }); + expect(result).toEqual({ + x: 100, + y: 200, + width: 800, + height: 600, + center: false, + isMaximized: false, + }); + }); + + it("should preserve isMaximized when restoring position", () => { + const result = getInitialWindowBounds({ + x: 0, + y: 0, + width: 1920, + height: 1080, + isMaximized: true, + }); + expect(result.isMaximized).toBe(true); + expect(result.center).toBe(false); + }); + }); + + describe("saved state on disconnected display", () => { + it("should center window but keep dimensions when display disconnected", () => { + const result = getInitialWindowBounds({ + x: 2000, + y: 100, + width: 800, + height: 600, + isMaximized: false, + }); + expect(result).toEqual({ + width: 800, + height: 600, + center: true, + isMaximized: false, + }); + expect(result.x).toBeUndefined(); + expect(result.y).toBeUndefined(); + }); + + it("should preserve isMaximized when centering", () => { + const result = getInitialWindowBounds({ + x: 2000, + y: 100, + width: 800, + height: 600, + isMaximized: true, + }); + expect(result.isMaximized).toBe(true); + expect(result.center).toBe(true); + }); + }); + + describe("dimension clamping", () => { + it("should clamp width to work area size", () => { + const result = getInitialWindowBounds({ + x: 0, + y: 0, + width: 3000, + height: 600, + isMaximized: false, + }); + expect(result.width).toBe(1920); + }); + + it("should clamp height to work area size", () => { + const result = getInitialWindowBounds({ + x: 0, + y: 0, + width: 800, + height: 2000, + isMaximized: false, + }); + expect(result.height).toBe(1080); + }); + + it("should enforce minimum window size for width", () => { + const result = getInitialWindowBounds({ + x: 0, + y: 0, + width: 100, + height: 600, + isMaximized: false, + }); + expect(result.width).toBe(MIN_WINDOW_SIZE); + }); + + it("should enforce minimum window size for height", () => { + const result = getInitialWindowBounds({ + x: 0, + y: 0, + width: 800, + height: 100, + isMaximized: false, + }); + expect(result.height).toBe(MIN_WINDOW_SIZE); + }); + }); + + describe("DPI/resolution changes", () => { + it("should handle resolution decrease gracefully", () => { + (screen.getPrimaryDisplay as ReturnType).mockReturnValue({ + workAreaSize: { width: 1280, height: 720 }, + }); + + const result = getInitialWindowBounds({ + x: 0, + y: 0, + width: 1920, + height: 1080, + isMaximized: false, + }); + + expect(result.width).toBe(1280); + expect(result.height).toBe(720); + }); + + it("should clamp to work area even if smaller than MIN_WINDOW_SIZE", () => { + (screen.getPrimaryDisplay as ReturnType).mockReturnValue({ + workAreaSize: { width: 300, height: 200 }, + }); + + const result = getInitialWindowBounds({ + x: 0, + y: 0, + width: 800, + height: 600, + isMaximized: false, + }); + + expect(result.width).toBe(300); + expect(result.height).toBe(200); + }); + }); + + describe("multi-monitor scenarios", () => { + beforeEach(() => { + (screen.getAllDisplays as ReturnType).mockReturnValue([ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + { bounds: { x: 1920, y: 0, width: 1920, height: 1080 } }, + ]); + }); + + it("should restore position on secondary display", () => { + const result = getInitialWindowBounds({ + x: 2000, + y: 100, + width: 800, + height: 600, + isMaximized: false, + }); + expect(result.x).toBe(2000); + expect(result.y).toBe(100); + expect(result.center).toBe(false); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/window-state/bounds-validation.ts b/apps/desktop/src/main/lib/window-state/bounds-validation.ts new file mode 100644 index 000000000..039847731 --- /dev/null +++ b/apps/desktop/src/main/lib/window-state/bounds-validation.ts @@ -0,0 +1,103 @@ +import type { Rectangle } from "electron"; +import { screen } from "electron"; +import type { WindowState } from "./window-state"; + +const MIN_VISIBLE_OVERLAP = 50; +const MIN_WINDOW_SIZE = 400; + +/** + * Checks if bounds overlap at least MIN_VISIBLE_OVERLAP pixels with any display. + * Returns false if window would be completely off-screen (e.g., monitor disconnected). + */ +export function isVisibleOnAnyDisplay(bounds: Rectangle): boolean { + const displays = screen.getAllDisplays(); + + return displays.some((display) => { + const db = display.bounds; + return ( + bounds.x < db.x + db.width - MIN_VISIBLE_OVERLAP && + bounds.x + bounds.width > db.x + MIN_VISIBLE_OVERLAP && + bounds.y < db.y + db.height - MIN_VISIBLE_OVERLAP && + bounds.y + bounds.height > db.y + MIN_VISIBLE_OVERLAP + ); + }); +} + +/** + * Clamps dimensions to not exceed the primary display work area. + * Handles DPI/resolution changes since last save. + */ +function clampToWorkArea( + width: number, + height: number, +): { width: number; height: number } { + const { workAreaSize } = screen.getPrimaryDisplay(); + return { + width: Math.min(Math.max(width, MIN_WINDOW_SIZE), workAreaSize.width), + height: Math.min(Math.max(height, MIN_WINDOW_SIZE), workAreaSize.height), + }; +} + +export interface InitialWindowBounds { + x?: number; + y?: number; + width: number; + height: number; + center: boolean; + isMaximized: boolean; +} + +/** + * Computes initial window bounds from saved state, with fallbacks. + * + * - No saved state → default to primary display size, centered + * - Saved position visible → restore exactly + * - Saved position not visible (monitor disconnected) → use saved size, but center + */ +export function getInitialWindowBounds( + savedState: WindowState | null, +): InitialWindowBounds { + const { workAreaSize } = screen.getPrimaryDisplay(); + + // No saved state → default to primary display size, centered + if (!savedState) { + return { + width: workAreaSize.width, + height: workAreaSize.height, + center: true, + isMaximized: false, + }; + } + + const { width, height } = clampToWorkArea( + savedState.width, + savedState.height, + ); + + const savedBounds: Rectangle = { + x: savedState.x, + y: savedState.y, + width, + height, + }; + + // Saved position visible on a connected display → restore exactly + if (isVisibleOnAnyDisplay(savedBounds)) { + return { + x: savedState.x, + y: savedState.y, + width, + height, + center: false, + isMaximized: savedState.isMaximized, + }; + } + + // Position not visible (monitor disconnected) → use saved size, but center + return { + width, + height, + center: true, + isMaximized: savedState.isMaximized, + }; +} diff --git a/apps/desktop/src/main/lib/window-state/index.ts b/apps/desktop/src/main/lib/window-state/index.ts new file mode 100644 index 000000000..dabdb477f --- /dev/null +++ b/apps/desktop/src/main/lib/window-state/index.ts @@ -0,0 +1,11 @@ +export { + getInitialWindowBounds, + type InitialWindowBounds, + isVisibleOnAnyDisplay, +} from "./bounds-validation"; +export { + isValidWindowState, + loadWindowState, + saveWindowState, + type WindowState, +} from "./window-state"; diff --git a/apps/desktop/src/main/lib/window-state/window-state.test.ts b/apps/desktop/src/main/lib/window-state/window-state.test.ts new file mode 100644 index 000000000..688464272 --- /dev/null +++ b/apps/desktop/src/main/lib/window-state/window-state.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "bun:test"; +import { isValidWindowState } from "./window-state"; + +describe("isValidWindowState", () => { + describe("valid window states", () => { + it("should accept valid window state with positive coordinates", () => { + expect( + isValidWindowState({ + x: 100, + y: 200, + width: 800, + height: 600, + isMaximized: false, + }), + ).toBe(true); + }); + + it("should accept valid window state at origin", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 1920, + height: 1080, + isMaximized: false, + }), + ).toBe(true); + }); + + it("should accept valid window state with negative coordinates (multi-monitor)", () => { + expect( + isValidWindowState({ + x: -1920, + y: 0, + width: 1920, + height: 1080, + isMaximized: false, + }), + ).toBe(true); + }); + + it("should accept valid window state when maximized", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 1920, + height: 1080, + isMaximized: true, + }), + ).toBe(true); + }); + + it("should accept valid window state with decimal coordinates", () => { + expect( + isValidWindowState({ + x: 100.5, + y: 200.75, + width: 800.25, + height: 600.5, + isMaximized: false, + }), + ).toBe(true); + }); + + it("should accept state with extra properties (forward compatibility)", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 800, + height: 600, + isMaximized: false, + futureProperty: "ignored", + }), + ).toBe(true); + }); + + it("should accept MAX_SAFE_INTEGER dimensions", () => { + expect( + isValidWindowState({ + x: Number.MAX_SAFE_INTEGER, + y: Number.MAX_SAFE_INTEGER, + width: Number.MAX_SAFE_INTEGER, + height: Number.MAX_SAFE_INTEGER, + isMaximized: false, + }), + ).toBe(true); + }); + }); + + describe("invalid dimensions", () => { + it("should reject zero width", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 0, + height: 600, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject zero height", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 800, + height: 0, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject negative width", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: -800, + height: 600, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject negative height", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 800, + height: -600, + isMaximized: false, + }), + ).toBe(false); + }); + }); + + describe("invalid number values", () => { + it("should reject Infinity", () => { + expect( + isValidWindowState({ + x: Number.POSITIVE_INFINITY, + y: 0, + width: 800, + height: 600, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject NaN", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: Number.NaN, + height: 600, + isMaximized: false, + }), + ).toBe(false); + }); + }); + + describe("missing properties", () => { + it("should reject missing x", () => { + expect( + isValidWindowState({ + y: 0, + width: 800, + height: 600, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject missing y", () => { + expect( + isValidWindowState({ + x: 0, + width: 800, + height: 600, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject missing width", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + height: 600, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject missing height", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 800, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject missing isMaximized", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 800, + height: 600, + }), + ).toBe(false); + }); + }); + + describe("wrong types", () => { + it("should reject string for x", () => { + expect( + isValidWindowState({ + x: "100", + y: 0, + width: 800, + height: 600, + isMaximized: false, + }), + ).toBe(false); + }); + + it("should reject string for isMaximized", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 800, + height: 600, + isMaximized: "false", + }), + ).toBe(false); + }); + + it("should reject number for isMaximized", () => { + expect( + isValidWindowState({ + x: 0, + y: 0, + width: 800, + height: 600, + isMaximized: 1, + }), + ).toBe(false); + }); + }); + + describe("non-object values", () => { + it("should reject null", () => { + expect(isValidWindowState(null)).toBe(false); + }); + + it("should reject undefined", () => { + expect(isValidWindowState(undefined)).toBe(false); + }); + + it("should reject string", () => { + expect(isValidWindowState("not an object")).toBe(false); + }); + + it("should reject number", () => { + expect(isValidWindowState(123)).toBe(false); + }); + + it("should reject array", () => { + expect(isValidWindowState([0, 0, 800, 600, false])).toBe(false); + }); + + it("should reject empty object", () => { + expect(isValidWindowState({})).toBe(false); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/window-state/window-state.ts b/apps/desktop/src/main/lib/window-state/window-state.ts new file mode 100644 index 000000000..7c749458f --- /dev/null +++ b/apps/desktop/src/main/lib/window-state/window-state.ts @@ -0,0 +1,73 @@ +import { + existsSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { WINDOW_STATE_PATH } from "../app-environment"; + +export interface WindowState { + x: number; + y: number; + width: number; + height: number; + isMaximized: boolean; +} + +/** + * Loads window state from disk. + * Returns null if file doesn't exist, is corrupted, or has invalid shape. + */ +export function loadWindowState(): WindowState | null { + try { + if (!existsSync(WINDOW_STATE_PATH)) return null; + + const raw = readFileSync(WINDOW_STATE_PATH, "utf-8"); + const parsed = JSON.parse(raw); + + if (!isValidWindowState(parsed)) return null; + + return parsed; + } catch { + // Parse error or read error → treat as no saved state + return null; + } +} + +/** + * Saves window state to disk atomically (temp file + rename). + * Corruption-safe: partial writes won't corrupt existing state. + */ +export function saveWindowState(state: WindowState): void { + const tempPath = join( + dirname(WINDOW_STATE_PATH), + `.window-state.${Date.now()}.tmp`, + ); + + try { + writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf-8"); + renameSync(tempPath, WINDOW_STATE_PATH); // Atomic replace + } catch (error) { + // Clean up temp file if rename failed + try { + unlinkSync(tempPath); + } catch {} + console.error("[window-state] Failed to save:", error); + } +} + +export function isValidWindowState(value: unknown): value is WindowState { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + Number.isFinite(v.x) && + Number.isFinite(v.y) && + Number.isFinite(v.width) && + (v.width as number) > 0 && + Number.isFinite(v.height) && + (v.height as number) > 0 && + typeof v.isMaximized === "boolean" + ); +} diff --git a/apps/desktop/src/main/lib/workspace-init-manager.ts b/apps/desktop/src/main/lib/workspace-init-manager.ts new file mode 100644 index 000000000..99245eb01 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-init-manager.ts @@ -0,0 +1,302 @@ +import { EventEmitter } from "node:events"; +import type { + WorkspaceInitProgress, + WorkspaceInitStep, +} from "shared/types/workspace-init"; + +interface InitJob { + workspaceId: string; + projectId: string; + progress: WorkspaceInitProgress; + cancelled: boolean; + worktreeCreated: boolean; // Track for cleanup on failure +} + +/** + * Manages workspace initialization jobs with: + * - Progress tracking and streaming via EventEmitter + * - Cancellation support + * - Per-project mutex to prevent concurrent git operations + * + * This is an in-memory manager - state is NOT persisted across app restarts. + * If the app restarts during initialization, the workspace may be left in + * an incomplete state requiring manual cleanup (documented limitation). + */ +class WorkspaceInitManager extends EventEmitter { + private jobs = new Map(); + private projectLocks = new Map>(); + private projectLockResolvers = new Map void>(); + + // Coordination state that persists even after job progress is cleared + private donePromises = new Map>(); + private doneResolvers = new Map void>(); + private cancellations = new Set(); + + /** + * Check if a workspace is currently initializing + */ + isInitializing(workspaceId: string): boolean { + const job = this.jobs.get(workspaceId); + return ( + job !== undefined && + job.progress.step !== "ready" && + job.progress.step !== "failed" + ); + } + + /** + * Check if a workspace has failed initialization + */ + hasFailed(workspaceId: string): boolean { + const job = this.jobs.get(workspaceId); + return job?.progress.step === "failed"; + } + + /** + * Get current progress for a workspace + */ + getProgress(workspaceId: string): WorkspaceInitProgress | undefined { + return this.jobs.get(workspaceId)?.progress; + } + + /** + * Get all workspaces currently initializing or failed + */ + getAllProgress(): WorkspaceInitProgress[] { + return Array.from(this.jobs.values()).map((job) => job.progress); + } + + /** + * Start tracking a new initialization job + */ + startJob(workspaceId: string, projectId: string): void { + if (this.jobs.has(workspaceId)) { + console.warn( + `[workspace-init] Job already exists for ${workspaceId}, clearing old job`, + ); + this.jobs.delete(workspaceId); + } + + // Clear any stale cancellation state from previous attempt + this.cancellations.delete(workspaceId); + + // Create done promise for coordination (allows delete to wait for init completion) + let resolve: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + this.donePromises.set(workspaceId, promise); + // biome-ignore lint/style/noNonNullAssertion: resolve is assigned in Promise constructor + this.doneResolvers.set(workspaceId, resolve!); + + const progress: WorkspaceInitProgress = { + workspaceId, + projectId, + step: "pending", + message: "Preparing...", + }; + + this.jobs.set(workspaceId, { + workspaceId, + projectId, + progress, + cancelled: false, + worktreeCreated: false, + }); + + this.emit("progress", progress); + } + + /** + * Update progress for an initialization job + */ + updateProgress( + workspaceId: string, + step: WorkspaceInitStep, + message: string, + error?: string, + ): void { + const job = this.jobs.get(workspaceId); + if (!job) { + console.warn(`[workspace-init] No job found for ${workspaceId}`); + return; + } + + job.progress = { + ...job.progress, + step, + message, + error, + }; + + this.emit("progress", job.progress); + + // Clean up ready jobs after a delay + if (step === "ready") { + setTimeout(() => { + if (this.jobs.get(workspaceId)?.progress.step === "ready") { + this.jobs.delete(workspaceId); + } + }, 2000); + } + } + + /** + * Mark that the worktree has been created (for cleanup tracking) + */ + markWorktreeCreated(workspaceId: string): void { + const job = this.jobs.get(workspaceId); + if (job) { + job.worktreeCreated = true; + } + } + + /** + * Check if worktree was created (for cleanup decisions) + */ + wasWorktreeCreated(workspaceId: string): boolean { + return this.jobs.get(workspaceId)?.worktreeCreated ?? false; + } + + /** + * Cancel an initialization job. + * Sets cancellation flag on job (if exists) AND adds to cancellations Set. + * The Set persists even after job is cleared, preventing the race where + * clearJob() removes the cancellation signal before init can observe it. + */ + cancel(workspaceId: string): void { + // Add to durable cancellations set (survives clearJob) + this.cancellations.add(workspaceId); + + const job = this.jobs.get(workspaceId); + if (job) { + job.cancelled = true; + } + console.log(`[workspace-init] Cancelled job for ${workspaceId}`); + } + + /** + * Check if a job has been cancelled (legacy - checks job record only). + * @deprecated Use isCancellationRequested() for race-safe cancellation checks. + */ + isCancelled(workspaceId: string): boolean { + return this.jobs.get(workspaceId)?.cancelled ?? false; + } + + /** + * Check if cancellation has been requested for a workspace. + * This checks the durable cancellations Set, which persists even after + * the job record is cleared. Use this in init flow for race-safe checks. + */ + isCancellationRequested(workspaceId: string): boolean { + return this.cancellations.has(workspaceId); + } + + /** + * Clear a job (called before retry or after delete). + * Also cleans up coordination state (done promise, cancellation). + */ + clearJob(workspaceId: string): void { + this.jobs.delete(workspaceId); + this.donePromises.delete(workspaceId); + this.doneResolvers.delete(workspaceId); + this.cancellations.delete(workspaceId); + } + + /** + * Finalize a job, resolving the done promise and cleaning up coordination state. + * MUST be called in all init exit paths (success, failure, cancellation). + * This allows waitForInit() to unblock. + */ + finalizeJob(workspaceId: string): void { + const resolve = this.doneResolvers.get(workspaceId); + if (resolve) { + resolve(); + console.log(`[workspace-init] Finalized job for ${workspaceId}`); + } + + // Clean up coordination state to prevent memory leaks + // This is safe because waitForInit() either: + // 1. Already resolved (promise completed) + // 2. Will return immediately (promise no longer in map) + this.donePromises.delete(workspaceId); + this.doneResolvers.delete(workspaceId); + // Note: Don't clear cancellations here - clearJob handles that + // to allow cancellation signal to persist through async cleanup + } + + /** + * Wait for an init job to complete (success, failure, or cancellation). + * Returns immediately if no init is in progress. + * + * @param workspaceId - The workspace to wait for + * @param timeoutMs - Maximum time to wait (default 30s). On timeout, returns without error. + */ + async waitForInit(workspaceId: string, timeoutMs = 30000): Promise { + const promise = this.donePromises.get(workspaceId); + if (!promise) { + // No init in progress or already completed + return; + } + + console.log( + `[workspace-init] Waiting for init to complete: ${workspaceId}`, + ); + + await Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => { + console.warn( + `[workspace-init] Wait timed out after ${timeoutMs}ms for ${workspaceId}`, + ); + resolve(); + }, timeoutMs); + }), + ]); + } + + /** + * Acquire per-project lock for git operations. + * Only one git operation per project at a time. + * This prevents race conditions and git lock conflicts. + */ + async acquireProjectLock(projectId: string): Promise { + // Wait for any existing lock to be released + while (this.projectLocks.has(projectId)) { + await this.projectLocks.get(projectId); + } + + // Create a new lock + let resolve: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + + this.projectLocks.set(projectId, promise); + // biome-ignore lint/style/noNonNullAssertion: resolve is assigned in Promise constructor + this.projectLockResolvers.set(projectId, resolve!); + } + + /** + * Release per-project lock + */ + releaseProjectLock(projectId: string): void { + const resolve = this.projectLockResolvers.get(projectId); + if (resolve) { + this.projectLocks.delete(projectId); + this.projectLockResolvers.delete(projectId); + resolve(); + } + } + + /** + * Check if a project has an active lock + */ + hasProjectLock(projectId: string): boolean { + return this.projectLocks.has(projectId); + } +} + +/** Singleton workspace initialization manager instance */ +export const workspaceInitManager = new WorkspaceInitManager(); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index c3c2c4e97..0ba5fc33b 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; -import { Notification, screen } from "electron"; +import { Notification } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { localDb } from "main/lib/local-db"; @@ -18,6 +18,11 @@ import { notificationsEmitter, } from "../lib/notifications/server"; import { terminalManager } from "../lib/terminal"; +import { + getInitialWindowBounds, + loadWindowState, + saveWindowState, +} from "../lib/window-state"; // Singleton IPC handler to prevent duplicate handlers on window reopen (macOS) let ipcHandler: ReturnType | null = null; @@ -29,17 +34,20 @@ let currentWindow: BrowserWindow | null = null; const getWindow = () => currentWindow; export async function MainWindow() { - const { width, height } = screen.getPrimaryDisplay().workAreaSize; + const savedWindowState = loadWindowState(); + const initialBounds = getInitialWindowBounds(savedWindowState); const window = createWindow({ id: "main", title: productName, - width, - height, + width: initialBounds.width, + height: initialBounds.height, + x: initialBounds.x, + y: initialBounds.y, minWidth: 400, minHeight: 400, show: false, - center: true, + center: initialBounds.center, movable: true, resizable: true, alwaysOnTop: false, @@ -50,9 +58,6 @@ export async function MainWindow() { webPreferences: { preload: join(__dirname, "../preload/index.js"), webviewTag: true, - // Isolate Electron session from system browser cookies - // This ensures desktop uses bearer token auth, not web cookies - partition: "persist:superset", }, }); @@ -160,10 +165,25 @@ export async function MainWindow() { ); window.webContents.on("did-finish-load", async () => { + // Restore maximized state if it was saved + if (initialBounds.isMaximized) { + window.maximize(); + } window.show(); }); window.on("close", () => { + // Save window state first, before any cleanup + const isMaximized = window.isMaximized(); + const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); + saveWindowState({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }); + server.close(); notificationsEmitter.removeAllListeners(); // Remove terminal listeners to prevent duplicates when window reopens on macOS diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/collections/index.ts similarity index 55% rename from apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts rename to apps/desktop/src/renderer/collections/index.ts index 7cbff9ec9..c94b4b1f9 100644 --- a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/collections/index.ts @@ -1,68 +1,83 @@ import { snakeCamelMapper } from "@electric-sql/client"; import type { - SelectMember, - SelectOrganization, + SelectOrganizationMember, SelectRepository, SelectTask, SelectUser, } from "@superset/db/schema"; import type { AppRouter } from "@superset/trpc"; +import { localStorageCollectionOptions } from "@tanstack/db"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; import { createCollection } from "@tanstack/react-db"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import superjson from "superjson"; -import { env } from "../../env.renderer"; const columnMapper = snakeCamelMapper(); -const electricUrl = `${env.NEXT_PUBLIC_API_URL}/api/electric/v1/shape`; -interface CreateCollectionsParams { - token: string; - activeOrgId: string; -} - -export function createCollections({ - token, - activeOrgId, -}: CreateCollectionsParams) { - const headers = { Authorization: `Bearer ${token}` }; - - const apiClient = createTRPCProxyClient({ +const createHttpTrpcClient = ({ + apiUrl, + getHeaders, +}: { + apiUrl: string; + getHeaders: () => Record; +}) => { + return createTRPCProxyClient({ links: [ httpBatchLink({ - url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, - headers, + url: `${apiUrl}/api/trpc`, + headers: getHeaders, transformer: superjson, }), ], }); +}; + +export interface DeviceSetting { + key: string; + value: unknown; +} + +export const createCollections = ({ + orgId, + electricUrl, + apiUrl, + getHeaders, +}: { + orgId: string; + electricUrl: string; + apiUrl: string; + getHeaders: () => Record; +}) => { + const httpTrpcClient = createHttpTrpcClient({ apiUrl, getHeaders }); const tasks = createCollection( electricCollectionOptions({ - id: `tasks-${activeOrgId}`, + id: `tasks-${orgId}`, shapeOptions: { url: electricUrl, params: { table: "tasks", - org: activeOrgId, }, - headers, + headers: getHeaders(), columnMapper, }, getKey: (item) => item.id, + onInsert: async ({ transaction }) => { const item = transaction.mutations[0].modified; - const result = await apiClient.task.create.mutate(item); + const result = await httpTrpcClient.task.create.mutate(item); return { txid: result.txid }; }, + onUpdate: async ({ transaction }) => { const { modified } = transaction.mutations[0]; - const result = await apiClient.task.update.mutate(modified); + const result = await httpTrpcClient.task.update.mutate(modified); return { txid: result.txid }; }, + onDelete: async ({ transaction }) => { const item = transaction.mutations[0].original; - const result = await apiClient.task.delete.mutate(item.id); + const result = await httpTrpcClient.task.delete.mutate(item.id); return { txid: result.txid }; }, }), @@ -70,40 +85,40 @@ export function createCollections({ const repositories = createCollection( electricCollectionOptions({ - id: `repositories-${activeOrgId}`, + id: `repositories-${orgId}`, shapeOptions: { url: electricUrl, params: { table: "repositories", - org: activeOrgId, }, - headers, + headers: getHeaders(), columnMapper, }, getKey: (item) => item.id, + onInsert: async ({ transaction }) => { const item = transaction.mutations[0].modified; - const result = await apiClient.repository.create.mutate(item); + const result = await httpTrpcClient.repository.create.mutate(item); return { txid: result.txid }; }, + onUpdate: async ({ transaction }) => { const { modified } = transaction.mutations[0]; - const result = await apiClient.repository.update.mutate(modified); + const result = await httpTrpcClient.repository.update.mutate(modified); return { txid: result.txid }; }, }), ); const members = createCollection( - electricCollectionOptions({ - id: `members-${activeOrgId}`, + electricCollectionOptions({ + id: `members-${orgId}`, shapeOptions: { url: electricUrl, params: { - table: "auth.members", - org: activeOrgId, + table: "organization_members", }, - headers, + headers: getHeaders(), columnMapper, }, getKey: (item) => item.id, @@ -112,32 +127,34 @@ export function createCollections({ const users = createCollection( electricCollectionOptions({ - id: `users-${activeOrgId}`, + id: `users-${orgId}`, shapeOptions: { url: electricUrl, params: { - table: "auth.users", - org: activeOrgId, + table: "users", }, - headers, + headers: getHeaders(), columnMapper, }, getKey: (item) => item.id, }), ); - const organizations = createCollection( - electricCollectionOptions({ - id: "organizations", - shapeOptions: { - url: electricUrl, - params: { table: "auth.organizations" }, - headers, - columnMapper, - }, - getKey: (item) => item.id, + const deviceSettings = createCollection( + localStorageCollectionOptions({ + storageKey: "device-settings", + getKey: (item) => item.key, + storage: localStorage, }), ); - return { tasks, repositories, members, users, organizations }; -} + return { + tasks, + repositories, + members, + users, + deviceSettings, + }; +}; + +export type Collections = ReturnType; diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx new file mode 100644 index 000000000..91295eaa9 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx @@ -0,0 +1,71 @@ +import { LuImageOff } from "react-icons/lu"; + +/** + * Check if an image source is safe to load. + * + * Uses strict ALLOWLIST approach - only data: URLs are safe. + * + * ALLOWED: + * - data: URLs (embedded base64 images) + * + * BLOCKED (everything else): + * - http://, https:// (tracking pixels, privacy leak) + * - file:// URLs (arbitrary local file access) + * - Absolute paths /... or \... (become file:// in Electron) + * - Relative paths with .. (can escape repo boundary) + * - UNC paths //server/share (Windows NTLM credential leak) + * - Empty or malformed sources + * + * Security context: In Electron production, renderer loads via file:// + * protocol. Any non-data: image src could access local filesystem or + * trigger network requests to attacker-controlled servers. + */ +function isSafeImageSrc(src: string | undefined): boolean { + if (!src) return false; + const trimmed = src.trim(); + if (trimmed.length === 0) return false; + + // Only allow data: URLs (embedded images) + // These are self-contained and can't access external resources + return trimmed.toLowerCase().startsWith("data:"); +} + +interface SafeImageProps { + src?: string; + alt?: string; + className?: string; +} + +/** + * Safe image component for untrusted markdown content. + * + * Only renders embedded data: URLs. All other sources are blocked + * to prevent local file access, network requests, and path traversal + * attacks from malicious repository content. + * + * Future: Could add opt-in support for repo-relative images via a + * secure loader that validates paths through secureFs and serves + * as blob: URLs. + */ +export function SafeImage({ src, alt, className }: SafeImageProps) { + if (!isSafeImageSrc(src)) { + return ( +
+ + Image blocked +
+ ); + } + + // Safe to render - embedded data: URL + return ( + {alt} + ); +} diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts new file mode 100644 index 000000000..3a608bf50 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts @@ -0,0 +1 @@ +export { SafeImage } from "./SafeImage"; diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts index b5cd6b0e8..d20884501 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts @@ -1,2 +1,3 @@ export { CodeBlock } from "./CodeBlock"; +export { SafeImage } from "./SafeImage"; export { SelectionContextMenu } from "./SelectionContextMenu"; diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx index 61ef8cdd6..c19cc5e1b 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx @@ -1,5 +1,5 @@ import { cn } from "@superset/ui/utils"; -import { CodeBlock } from "../../components"; +import { CodeBlock, SafeImage } from "../../components"; import type { MarkdownStyleConfig } from "../types"; import "./default.css"; @@ -41,7 +41,11 @@ export const defaultConfig: MarkdownStyleConfig = { ), img: ({ src, alt }) => ( - {alt} + ), hr: () =>
, li: ({ children, className }) => { diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx index 077f8d913..5173f7e45 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx @@ -1,4 +1,4 @@ -import { CodeBlock } from "../../components"; +import { CodeBlock, SafeImage } from "../../components"; import type { MarkdownStyleConfig } from "../types"; import "./tufte.css"; @@ -12,5 +12,7 @@ export const tufteConfig: MarkdownStyleConfig = { {children} ), + // Block external images for privacy (tracking pixels, etc.) + img: ({ src, alt }) => , }, }; diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 98d08f1af..b2df6edd9 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -36,6 +36,7 @@ import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, + usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; import { ExistingWorktreesList } from "./components/ExistingWorktreesList"; @@ -57,6 +58,7 @@ type Mode = "existing" | "new"; export function NewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); + const preSelectedProjectId = usePreSelectedProjectId(); const [selectedProjectId, setSelectedProjectId] = useState( null, ); @@ -94,12 +96,15 @@ export function NewWorkspaceModal() { ); }, [branchData?.branches, branchSearch]); - // Auto-select current project when modal opens + // Auto-select project when modal opens (prioritize pre-selected, then current) useEffect(() => { - if (isOpen && currentProjectId && !selectedProjectId) { - setSelectedProjectId(currentProjectId); + if (isOpen && !selectedProjectId) { + const projectToSelect = preSelectedProjectId ?? currentProjectId; + if (projectToSelect) { + setSelectedProjectId(projectToSelect); + } } - }, [isOpen, currentProjectId, selectedProjectId]); + }, [isOpen, currentProjectId, selectedProjectId, preSelectedProjectId]); // Effective base branch - use explicit selection or fall back to default const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? null; @@ -167,23 +172,30 @@ export function NewWorkspaceModal() { const workspaceName = title.trim() || undefined; const customBranchName = branchName.trim() || undefined; - toast.promise( - createWorkspace.mutateAsync({ + try { + const result = await createWorkspace.mutateAsync({ projectId: selectedProjectId, name: workspaceName, branchName: customBranchName, baseBranch: effectiveBaseBranch || undefined, - }), - { - loading: "Creating workspace...", - success: () => { - handleClose(); - return "Workspace created"; - }, - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); + }); + + // Close modal immediately - workspace appears in sidebar + handleClose(); + + // Show appropriate toast based on initialization state + if (result.isInitializing) { + toast.success("Workspace created", { + description: "Setting up in the background...", + }); + } else { + toast.success("Workspace created"); + } + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to create workspace", + ); + } }; return ( @@ -205,11 +217,13 @@ export function NewWorkspaceModal() { - {recentProjects.map((project) => ( - - {project.name} - - ))} + {recentProjects + .filter((project) => project.id) + .map((project) => ( + + {project.name} + + ))}
diff --git a/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx b/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx new file mode 100644 index 000000000..9a407e597 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx @@ -0,0 +1,63 @@ +import { createContext, type ReactNode, useContext, useState } from "react"; +import { useOrganizations } from "./OrganizationsProvider"; + +const ACTIVE_ORG_KEY = "superset_active_organization_id"; + +interface ActiveOrganizationContextValue { + activeOrganizationId: string; + activeOrganization: ReturnType[number]; + switchOrganization: (orgId: string) => void; +} + +const ActiveOrganizationContext = + createContext(null); + +export function ActiveOrganizationProvider({ + children, +}: { + children: ReactNode; +}) { + const organizations = useOrganizations(); + + const [activeOrganizationId, setActiveOrganizationId] = useState( + () => { + const stored = localStorage.getItem(ACTIVE_ORG_KEY); + const valid = organizations.find((o) => o.id === stored); + return valid?.id ?? organizations[0].id; + }, + ); + + const activeOrganization = organizations.find( + (o) => o.id === activeOrganizationId, + ); + if (!activeOrganization) { + throw new Error(`Active organization not found: ${activeOrganizationId}.`); + } + + const switchOrganization = (newOrgId: string) => { + localStorage.setItem(ACTIVE_ORG_KEY, newOrgId); + setActiveOrganizationId(newOrgId); + }; + + const value: ActiveOrganizationContextValue = { + activeOrganizationId, + activeOrganization, + switchOrganization, + }; + + return ( + + {children} + + ); +} + +export const useActiveOrganization = () => { + const context = useContext(ActiveOrganizationContext); + if (!context) { + throw new Error( + "useActiveOrganization must be used within ActiveOrganizationProvider", + ); + } + return context; +}; diff --git a/apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx b/apps/desktop/src/renderer/contexts/AppProviders.tsx similarity index 59% rename from apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx rename to apps/desktop/src/renderer/contexts/AppProviders.tsx index e74933632..784f7afb0 100644 --- a/apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx +++ b/apps/desktop/src/renderer/contexts/AppProviders.tsx @@ -1,9 +1,9 @@ import type React from "react"; import { PostHogUserIdentifier } from "renderer/components/PostHogUserIdentifier"; -import { MonacoProvider } from "../MonacoProvider"; -import { OrganizationsProvider } from "../OrganizationsProvider"; -import { PostHogProvider } from "../PostHogProvider"; -import { TRPCProvider } from "../TRPCProvider"; +import { AuthProvider } from "./AuthProvider"; +import { MonacoProvider } from "./MonacoProvider"; +import { PostHogProvider } from "./PostHogProvider"; +import { TRPCProvider } from "./TRPCProvider"; interface AppProvidersProps { children: React.ReactNode; @@ -13,10 +13,10 @@ export function AppProviders({ children }: AppProvidersProps) { return ( - + {children} - + ); diff --git a/apps/desktop/src/renderer/contexts/AppProviders/index.ts b/apps/desktop/src/renderer/contexts/AppProviders/index.ts deleted file mode 100644 index 9457e31d2..000000000 --- a/apps/desktop/src/renderer/contexts/AppProviders/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AppProviders } from "./AppProviders"; diff --git a/apps/desktop/src/renderer/contexts/AuthProvider.tsx b/apps/desktop/src/renderer/contexts/AuthProvider.tsx new file mode 100644 index 000000000..39badb360 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/AuthProvider.tsx @@ -0,0 +1,46 @@ +import { createContext, type ReactNode, useContext, useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { SignInScreen } from "renderer/screens/sign-in"; + +interface AuthContextValue { + accessToken: string; + isAuthenticated: true; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [accessToken, setAccessToken] = useState( + undefined, + ); + + trpc.auth.onAccessToken.useSubscription(undefined, { + onData: (data) => setAccessToken(data.accessToken), + onError: (err) => { + console.error("[AuthProvider] Token subscription error:", err); + }, + }); + + if (accessToken === undefined) { + return null; + } + + if (accessToken === null) { + return ; + } + + const value: AuthContextValue = { + accessToken, + isAuthenticated: true, + }; + + return {children}; +} + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within AuthProvider"); + } + return context; +}; diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider.tsx b/apps/desktop/src/renderer/contexts/CollectionsProvider.tsx new file mode 100644 index 000000000..98130c276 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider.tsx @@ -0,0 +1,65 @@ +import { + createContext, + type ReactNode, + useContext, + useMemo, + useRef, +} from "react"; +import { type Collections, createCollections } from "renderer/collections"; +import { env } from "renderer/env.renderer"; +import { useActiveOrganization } from "./ActiveOrganizationProvider"; +import { useAuth } from "./AuthProvider"; + +const CollectionsContext = createContext(null); + +export function CollectionsProvider({ children }: { children: ReactNode }) { + const { accessToken } = useAuth(); + const { activeOrganizationId } = useActiveOrganization(); + + // Keep ref to always get current token (handles token refresh without recreating collections) + const accessTokenRef = useRef(accessToken); + accessTokenRef.current = accessToken; + + // Stable map of collections per org (never recreate collections, just cache them) + const collectionsCache = useRef>(new Map()); + + const collections = useMemo(() => { + if (!activeOrganizationId) return null; + + const cached = collectionsCache.current.get(activeOrganizationId); + if (cached) return cached; + + const getHeaders = () => ({ + Authorization: `Bearer ${accessTokenRef.current}`, + }); + const electricUrl = `${env.NEXT_PUBLIC_API_URL}/api/electric/v1/shape?organizationId=${activeOrganizationId}`; + + const newCollections = createCollections({ + orgId: activeOrganizationId, + electricUrl, + apiUrl: env.NEXT_PUBLIC_API_URL, + getHeaders, + }); + + collectionsCache.current.set(activeOrganizationId, newCollections); + return newCollections; + }, [activeOrganizationId]); + + if (!collections) { + return null; + } + + return ( + + {children} + + ); +} + +export const useCollections = () => { + const collections = useContext(CollectionsContext); + if (!collections) { + throw new Error("useCollections must be used within CollectionsProvider"); + } + return collections; +}; diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/contexts/CollectionsProvider/CollectionsProvider.tsx deleted file mode 100644 index 1728e325f..000000000 --- a/apps/desktop/src/renderer/contexts/CollectionsProvider/CollectionsProvider.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { - createContext, - type ReactNode, - useContext, - useMemo, - useState, -} from "react"; -import { trpc } from "../../lib/trpc"; -import { createCollections } from "./collections"; - -type Collections = ReturnType; - -const CollectionsContext = createContext(null); - -export function CollectionsProvider({ children }: { children: ReactNode }) { - const { data: session } = trpc.auth.onSessionChange.useSubscription(); - const { data: tokenData } = trpc.auth.onAccessToken.useSubscription(); - const [error, setError] = useState(null); - - const activeOrgId = session?.session.activeOrganizationId; - const token = tokenData?.accessToken; - - const collections = useMemo(() => { - console.log("[CollectionsProvider] Creating collections with:", { - hasToken: !!token, - activeOrgId, - }); - - if (!token || !activeOrgId) { - console.log( - "[CollectionsProvider] Missing token or activeOrgId, returning null", - ); - return null; - } - - try { - return createCollections({ token, activeOrgId }); - } catch (err) { - console.error("[CollectionsProvider] Failed to create collections:", err); - setError(err instanceof Error ? err : new Error(String(err))); - return null; - } - }, [token, activeOrgId]); - - if (error) { - return ( -
-
- Failed to initialize collections - {error.message} -
-
- ); - } - - if (!collections) { - return ( -
-
-
- ); - } - - return ( - - {children} - - ); -} - -export function useCollections(): Collections { - const context = useContext(CollectionsContext); - if (!context) { - throw new Error("useCollections must be used within CollectionsProvider"); - } - return context; -} diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts deleted file mode 100644 index 8200e98b6..000000000 --- a/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CollectionsProvider, useCollections } from "./CollectionsProvider"; diff --git a/apps/desktop/src/renderer/contexts/MonacoProvider/MonacoProvider.tsx b/apps/desktop/src/renderer/contexts/MonacoProvider.tsx similarity index 81% rename from apps/desktop/src/renderer/contexts/MonacoProvider/MonacoProvider.tsx rename to apps/desktop/src/renderer/contexts/MonacoProvider.tsx index af44e94f7..1282cd954 100644 --- a/apps/desktop/src/renderer/contexts/MonacoProvider/MonacoProvider.tsx +++ b/apps/desktop/src/renderer/contexts/MonacoProvider.tsx @@ -104,4 +104,31 @@ export function MonacoProvider({ children }: MonacoProviderProps) { ); } +export const MONACO_EDITOR_OPTIONS = { + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "on" as const, + fontSize: 13, + lineHeight: 20, + fontFamily: + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace", + padding: { top: 8, bottom: 8 }, + scrollbar: { + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, +}; + +export function registerSaveAction( + editor: monaco.editor.IStandaloneCodeEditor, + onSave: () => void, +) { + editor.addAction({ + id: "save-file", + label: "Save File", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: onSave, + }); +} + export { monaco, SUPERSET_THEME }; diff --git a/apps/desktop/src/renderer/contexts/MonacoProvider/index.ts b/apps/desktop/src/renderer/contexts/MonacoProvider/index.ts deleted file mode 100644 index 91be94062..000000000 --- a/apps/desktop/src/renderer/contexts/MonacoProvider/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - MonacoProvider, - monaco, - SUPERSET_THEME, - useMonacoReady, -} from "./MonacoProvider"; diff --git a/apps/desktop/src/renderer/contexts/OrganizationsProvider/OrganizationsProvider.tsx b/apps/desktop/src/renderer/contexts/OrganizationsProvider.tsx similarity index 100% rename from apps/desktop/src/renderer/contexts/OrganizationsProvider/OrganizationsProvider.tsx rename to apps/desktop/src/renderer/contexts/OrganizationsProvider.tsx diff --git a/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts b/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts deleted file mode 100644 index ef167b375..000000000 --- a/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - type Organization, - OrganizationsProvider, - useOrganizations, -} from "./OrganizationsProvider"; diff --git a/apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx b/apps/desktop/src/renderer/contexts/PostHogProvider.tsx similarity index 90% rename from apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx rename to apps/desktop/src/renderer/contexts/PostHogProvider.tsx index a5cce0f13..489996944 100644 --- a/apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx +++ b/apps/desktop/src/renderer/contexts/PostHogProvider.tsx @@ -1,7 +1,8 @@ import { PostHogProvider as PHProvider } from "posthog-js/react"; import type React from "react"; import { useEffect, useState } from "react"; -import { initPostHog, posthog } from "renderer/lib/posthog"; + +import { initPostHog, posthog } from "../lib/posthog"; interface PostHogProviderProps { children: React.ReactNode; diff --git a/apps/desktop/src/renderer/contexts/PostHogProvider/index.ts b/apps/desktop/src/renderer/contexts/PostHogProvider/index.ts deleted file mode 100644 index 56caddde4..000000000 --- a/apps/desktop/src/renderer/contexts/PostHogProvider/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PostHogProvider } from "./PostHogProvider"; diff --git a/apps/desktop/src/renderer/contexts/TRPCProvider/TRPCProvider.tsx b/apps/desktop/src/renderer/contexts/TRPCProvider.tsx similarity index 100% rename from apps/desktop/src/renderer/contexts/TRPCProvider/TRPCProvider.tsx rename to apps/desktop/src/renderer/contexts/TRPCProvider.tsx diff --git a/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts b/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts deleted file mode 100644 index 080c22d76..000000000 --- a/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TRPCProvider } from "./TRPCProvider"; diff --git a/apps/desktop/src/renderer/contexts/index.ts b/apps/desktop/src/renderer/contexts/index.ts new file mode 100644 index 000000000..710daff5f --- /dev/null +++ b/apps/desktop/src/renderer/contexts/index.ts @@ -0,0 +1,18 @@ +export { + ActiveOrganizationProvider, + useActiveOrganization, +} from "./ActiveOrganizationProvider"; +export { AppProviders } from "./AppProviders"; +export { CollectionsProvider, useCollections } from "./CollectionsProvider"; +export { + MonacoProvider, + SUPERSET_THEME, + useMonacoReady, +} from "./MonacoProvider"; +export { + type Organization, + OrganizationsProvider, + useOrganizations, +} from "./OrganizationsProvider"; +export { PostHogProvider } from "./PostHogProvider"; +export { TRPCProvider } from "./TRPCProvider"; diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts new file mode 100644 index 000000000..acd914cb1 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { + useCreateBranchWorkspace, + useSetActiveWorkspace, +} from "renderer/react-query/workspaces"; +import { useAppHotkey } from "renderer/stores/hotkeys"; + +/** + * Shared hook for workspace keyboard shortcuts and auto-creation logic. + * Used by WorkspaceSidebar for navigation between workspaces. + * + * It handles: + * - ⌘1-9 workspace switching shortcuts + * - Previous/next workspace shortcuts + * - Auto-create main workspace for new projects + */ +export function useWorkspaceShortcuts() { + const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id || null; + const setActiveWorkspace = useSetActiveWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); + + // Track projects we've attempted to create workspaces for (persists across renders) + const attemptedProjectsRef = useRef>(new Set()); + const [isCreating, setIsCreating] = useState(false); + + // Auto-create main workspace for new projects (one-time per project) + useEffect(() => { + if (isCreating) return; + + for (const group of groups) { + const projectId = group.project.id; + const hasMainWorkspace = group.workspaces.some( + (w) => w.type === "branch", + ); + + // Skip if already has main workspace or we've already attempted this project + if (hasMainWorkspace || attemptedProjectsRef.current.has(projectId)) { + continue; + } + + // Mark as attempted before creating (prevents retries) + attemptedProjectsRef.current.add(projectId); + setIsCreating(true); + + // Auto-create fails silently - this is a background convenience feature + createBranchWorkspace.mutate( + { projectId }, + { + onSettled: () => { + setIsCreating(false); + }, + }, + ); + // Only create one at a time + break; + } + }, [groups, isCreating, createBranchWorkspace]); + + // Flatten workspaces for keyboard navigation + const allWorkspaces = groups.flatMap((group) => group.workspaces); + + const switchToWorkspace = useCallback( + (index: number) => { + const workspace = allWorkspaces[index]; + if (workspace) { + setActiveWorkspace.mutate({ id: workspace.id }); + } + }, + [allWorkspaces, setActiveWorkspace], + ); + + useAppHotkey("JUMP_TO_WORKSPACE_1", () => switchToWorkspace(0), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_2", () => switchToWorkspace(1), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_3", () => switchToWorkspace(2), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_4", () => switchToWorkspace(3), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_5", () => switchToWorkspace(4), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_6", () => switchToWorkspace(5), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_7", () => switchToWorkspace(6), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_8", () => switchToWorkspace(7), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_9", () => switchToWorkspace(8), undefined, [ + switchToWorkspace, + ]); + + useAppHotkey( + "PREV_WORKSPACE", + () => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex > 0) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); + } + }, + undefined, + [activeWorkspaceId, allWorkspaces, setActiveWorkspace], + ); + + useAppHotkey( + "NEXT_WORKSPACE", + () => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex < allWorkspaces.length - 1) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); + } + }, + undefined, + [activeWorkspaceId, allWorkspaces, setActiveWorkspace], + ); + + return { + groups, + allWorkspaces, + activeWorkspaceId, + setActiveWorkspace, + }; +} diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index 3d45899fe..f532f964a 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -2,20 +2,17 @@ import { initSentry } from "./lib/sentry"; initSentry(); -import React from "react"; import ReactDom from "react-dom/client"; import { ThemedToaster } from "./components/ThemedToaster"; -import { AppProviders } from "./contexts/AppProviders"; +import { AppProviders } from "./contexts"; import { AppRoutes } from "./routes"; import "./globals.css"; ReactDom.createRoot(document.querySelector("app") as HTMLElement).render( - - - - - - , + + + + , ); diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 60a9c29b7..438ba8495 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -6,3 +6,4 @@ export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; +export { useWorkspaceDeleteHandler } from "./useWorkspaceDeleteHandler"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index 0b2c78c5a..42070b42c 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -1,23 +1,27 @@ -import { toast } from "@superset/ui/sonner"; import { trpc } from "renderer/lib/trpc"; -import { useOpenConfigModal } from "renderer/stores/config-modal"; -import { useTabsStore } from "renderer/stores/tabs/store"; +import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; /** * Mutation hook for creating a new workspace * Automatically invalidates all workspace queries on success - * Creates a terminal tab with setup commands if present - * Shows config toast if no setup commands are configured + * + * For worktree workspaces with async initialization: + * - Returns immediately after workspace record is created + * - Terminal tab is created by WorkspaceInitEffects when initialization completes + * + * For branch workspaces (no async init): + * - Terminal setup is triggered immediately via WorkspaceInitEffects + * + * Note: Terminal creation is handled by WorkspaceInitEffects (always mounted in MainScreen) + * to survive dialog unmounts. This hook just adds to the global pending store. */ export function useCreateWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); - const addTab = useTabsStore((state) => state.addTab); - const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); - const createOrAttach = trpc.terminal.createOrAttach.useMutation(); - const openConfigModal = useOpenConfigModal(); - const dismissConfigToast = trpc.config.dismissConfigToast.useMutation(); + const addPendingTerminalSetup = useWorkspaceInitStore( + (s) => s.addPendingTerminalSetup, + ); return trpc.workspaces.create.useMutation({ ...options, @@ -25,34 +29,17 @@ export function useCreateWorkspace( // Auto-invalidate all workspace queries await utils.workspaces.invalidate(); - // Create terminal tab with setup commands if present - if ( - Array.isArray(data.initialCommands) && - data.initialCommands.length > 0 - ) { - const { tabId, paneId } = addTab(data.workspace.id); - setTabAutoTitle(tabId, "Workspace Setup"); - // Pre-create terminal session with initial commands - // Terminal component will attach to this session when it mounts - createOrAttach.mutate({ - paneId, - tabId, - workspaceId: data.workspace.id, - initialCommands: data.initialCommands, - }); - } else { - // Show config toast if no setup commands - toast.info("No setup script configured", { - description: "Automate workspace setup with a config.json file", - action: { - label: "Configure", - onClick: () => openConfigModal(data.projectId), - }, - onDismiss: () => { - dismissConfigToast.mutate({ projectId: data.projectId }); - }, - }); - } + // Add to global pending store (WorkspaceInitEffects will handle terminal creation) + // This survives dialog unmounts since it's stored in Zustand, not a hook-local ref + addPendingTerminalSetup({ + workspaceId: data.workspace.id, + projectId: data.projectId, + initialCommands: data.initialCommands, + }); + + // Handle race condition: if init already completed before we added to pending, + // WorkspaceInitEffects will process it on next render when it sees the progress + // is already "ready" and there's a matching pending setup. // Call user's onSuccess if provided await options?.onSuccess?.(data, ...rest); diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts index 7dc43e36b..debb3a08b 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts @@ -1,23 +1,62 @@ +import { toast } from "@superset/ui/sonner"; import { trpc } from "renderer/lib/trpc"; /** * Mutation hook for setting the active workspace * Automatically invalidates getActive and getAll queries on success + * Shows undo toast if workspace was marked as unread (auto-cleared on switch) */ export function useSetActiveWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); + const setUnread = trpc.workspaces.setUnread.useMutation({ + onSuccess: () => { + utils.workspaces.getAllGrouped.invalidate(); + }, + onError: (error) => { + console.error("[workspace/setUnread] Failed to update unread status:", { + error: error.message, + }); + toast.error(`Failed to undo: ${error.message}`); + }, + }); return trpc.workspaces.setActive.useMutation({ ...options, - onSuccess: async (...args) => { + onError: (error, variables, context, meta) => { + console.error("[workspace/setActive] Failed to set active workspace:", { + workspaceId: variables.id, + error: error.message, + }); + toast.error(`Failed to switch workspace: ${error.message}`); + options?.onError?.(error, variables, context, meta); + }, + onSuccess: async (data, variables, ...rest) => { // Auto-invalidate active workspace and all workspaces queries - await utils.workspaces.getActive.invalidate(); - await utils.workspaces.getAll.invalidate(); + await Promise.all([ + utils.workspaces.getActive.invalidate(), + utils.workspaces.getAll.invalidate(), + utils.workspaces.getAllGrouped.invalidate(), + ]); + + // Show undo toast if workspace was marked as unread + if (data.wasUnread) { + toast("Marked as read", { + description: "Workspace unread marker cleared", + action: { + label: "Undo", + onClick: () => { + setUnread.mutate({ id: variables.id, isUnread: true }); + }, + }, + duration: 5000, + }); + } // Call user's onSuccess if provided - await options?.onSuccess?.(...args); + // biome-ignore lint/suspicious/noExplicitAny: spread args for compatibility + await (options?.onSuccess as any)?.(data, variables, ...rest); }, }); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts new file mode 100644 index 000000000..cdd2075e1 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts @@ -0,0 +1,29 @@ +import { useState } from "react"; + +interface UseWorkspaceDeleteHandlerResult { + /** Whether the delete dialog should be shown */ + showDeleteDialog: boolean; + /** Set whether the delete dialog should be shown */ + setShowDeleteDialog: (show: boolean) => void; + /** Handle delete click - always shows the dialog to let user choose close or delete */ + handleDeleteClick: (e?: React.MouseEvent) => void; +} + +/** + * Shared hook for workspace delete/close dialog state. + * Always shows the confirmation dialog to let user choose between closing or deleting. + */ +export function useWorkspaceDeleteHandler(): UseWorkspaceDeleteHandlerResult { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleDeleteClick = (e?: React.MouseEvent) => { + e?.stopPropagation(); + setShowDeleteDialog(true); + }; + + return { + showDeleteDialog, + setShowDeleteDialog, + handleDeleteClick, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx b/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx index 49f6433bd..1ac9dacb7 100644 --- a/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx +++ b/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx @@ -87,7 +87,7 @@ export function AvatarDropdown() { aria-label="User menu" > - + {initials || "?"} diff --git a/apps/desktop/src/renderer/screens/main/components/PRIcon/PRIcon.tsx b/apps/desktop/src/renderer/screens/main/components/PRIcon/PRIcon.tsx new file mode 100644 index 000000000..0e26e6ef7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/PRIcon/PRIcon.tsx @@ -0,0 +1,38 @@ +import { cn } from "@superset/ui/utils"; +import { LuCircleDot, LuGitMerge, LuGitPullRequest } from "react-icons/lu"; + +export type PRState = "open" | "merged" | "closed" | "draft"; + +interface PRIconProps { + state: PRState; + className?: string; +} + +const stateStyles: Record = { + open: "text-emerald-500", + merged: "text-violet-500", + closed: "text-red-500", + draft: "text-muted-foreground", +}; + +/** + * Renders a PR icon with color based on state. + * - open: green pull request icon + * - merged: purple/violet merge icon + * - closed: red dot icon + * - draft: muted pull request icon + */ +export function PRIcon({ state, className }: PRIconProps) { + const baseClass = cn(stateStyles[state], className); + + if (state === "merged") { + return ; + } + + if (state === "closed") { + return ; + } + + // open or draft + return ; +} diff --git a/apps/desktop/src/renderer/screens/main/components/PRIcon/index.ts b/apps/desktop/src/renderer/screens/main/components/PRIcon/index.ts new file mode 100644 index 000000000..3c6b8a069 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/PRIcon/index.ts @@ -0,0 +1 @@ +export { PRIcon, type PRState } from "./PRIcon"; diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx index b782d982a..9f16c773f 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx @@ -44,7 +44,7 @@ export function AccountSettings() { ) : user ? ( <> - + {initials || "?"} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx index f21aff0a3..7bbac096c 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx @@ -1,37 +1,73 @@ +import type { TerminalLinkBehavior } from "@superset/local-db"; import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { Switch } from "@superset/ui/switch"; import { trpc } from "renderer/lib/trpc"; export function BehaviorSettings() { const utils = trpc.useUtils(); - const { data: confirmOnQuit, isLoading } = + + // Confirm on quit setting + const { data: confirmOnQuit, isLoading: isConfirmLoading } = trpc.settings.getConfirmOnQuit.useQuery(); const setConfirmOnQuit = trpc.settings.setConfirmOnQuit.useMutation({ onMutate: async ({ enabled }) => { - // Cancel outgoing fetches await utils.settings.getConfirmOnQuit.cancel(); - // Snapshot previous value const previous = utils.settings.getConfirmOnQuit.getData(); - // Optimistically update utils.settings.getConfirmOnQuit.setData(undefined, enabled); return { previous }; }, onError: (_err, _vars, context) => { - // Rollback on error if (context?.previous !== undefined) { utils.settings.getConfirmOnQuit.setData(undefined, context.previous); } }, onSettled: () => { - // Refetch to ensure sync with server utils.settings.getConfirmOnQuit.invalidate(); }, }); - const handleToggle = (enabled: boolean) => { + const handleConfirmToggle = (enabled: boolean) => { setConfirmOnQuit.mutate({ enabled }); }; + // Terminal link behavior setting + const { data: terminalLinkBehavior, isLoading: isLoadingLinkBehavior } = + trpc.settings.getTerminalLinkBehavior.useQuery(); + + const setTerminalLinkBehavior = + trpc.settings.setTerminalLinkBehavior.useMutation({ + onMutate: async ({ behavior }) => { + await utils.settings.getTerminalLinkBehavior.cancel(); + const previous = utils.settings.getTerminalLinkBehavior.getData(); + utils.settings.getTerminalLinkBehavior.setData(undefined, behavior); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getTerminalLinkBehavior.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + utils.settings.getTerminalLinkBehavior.invalidate(); + }, + }); + + const handleLinkBehaviorChange = (value: string) => { + setTerminalLinkBehavior.mutate({ + behavior: value as TerminalLinkBehavior, + }); + }; + return (
@@ -42,6 +78,7 @@ export function BehaviorSettings() {
+ {/* Confirm on Quit */}
+ +
+
+ +

+ Choose how to open file paths when Cmd+clicking in the terminal +

+
+ +
); diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx index 1a3a7ac95..74285a1a9 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx @@ -2,7 +2,7 @@ import { Input } from "@superset/ui/input"; import { HiOutlineFolder, HiOutlinePencilSquare } from "react-icons/hi2"; import { LuGitBranch } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; -import { useWorkspaceRename } from "renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename"; +import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; export function WorkspaceSettings() { const { data: activeWorkspace, isLoading } = diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx similarity index 56% rename from apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx rename to apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx index d2337d7ba..7952400cc 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx @@ -1,6 +1,7 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { HiMiniBars3, HiMiniBars3BottomLeft } from "react-icons/hi2"; +import { cn } from "@superset/ui/utils"; +import { VscSourceControl } from "react-icons/vsc"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { useSidebarStore } from "renderer/stores"; @@ -12,21 +13,26 @@ export function SidebarControl() { diff --git a/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts b/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts new file mode 100644 index 000000000..c4a177ae7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts @@ -0,0 +1 @@ +export { SidebarControl } from "./SidebarControl"; diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx index c695e48a0..5c1c006d8 100644 --- a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx @@ -33,10 +33,12 @@ import { HiUser, } from "react-icons/hi2"; import { + ActiveOrganizationProvider, CollectionsProvider, + OrganizationsProvider, + useActiveOrganization, useCollections, -} from "renderer/contexts/CollectionsProvider"; -import { OrganizationsProvider } from "renderer/contexts/OrganizationsProvider"; +} from "renderer/contexts"; import { OrganizationSwitcher } from "./components/OrganizationSwitcher"; interface TaskEditDialogProps { @@ -46,16 +48,18 @@ interface TaskEditDialogProps { } function TaskEditDialog({ task, open, onOpenChange }: TaskEditDialogProps) { - const collections = useCollections(); const [title, setTitle] = useState(task.title); const [description, setDescription] = useState(task.description || ""); const [priority, setPriority] = useState(task.priority); const [isSaving, setIsSaving] = useState(false); + const { tasks: tasksCollection } = useCollections(); const handleSave = async () => { setIsSaving(true); try { - await collections.tasks.update(task.id, (draft: SelectTask) => { + // Use collection's update method - this triggers onUpdate handler + // which sends the mutation to the API + await tasksCollection.update(task.id, (draft) => { draft.title = title; draft.description = description || null; draft.priority = priority as @@ -260,12 +264,15 @@ function TaskCard({ } function TasksList() { - const collections = useCollections(); const [editingTask, setEditingTask] = useState(null); + const { tasks: tasksCollection } = useCollections(); + const { activeOrganizationId } = useActiveOrganization(); + // Query all task objects from collection + // Include tasksCollection and activeOrganizationId in deps to force re-query when they change const { data: allTasks, isLoading } = useLiveQuery( - (q) => q.from({ tasks: collections.tasks }), - [collections], + (q) => q.from({ tasks: tasksCollection }), + [tasksCollection, activeOrganizationId], ); // Filter out deleted tasks in JavaScript @@ -347,9 +354,11 @@ function TasksViewContent() { export function TasksView() { return ( - - - + + + + + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/OrganizationSwitcher/OrganizationSwitcher.tsx index 21543ad0e..8a5f1088c 100644 --- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -5,42 +5,20 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -import { useLiveQuery } from "@tanstack/react-db"; import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; -import { useCollections } from "renderer/contexts/CollectionsProvider"; -import { trpc } from "renderer/lib/trpc"; +import { useActiveOrganization, useOrganizations } from "renderer/contexts"; export function OrganizationSwitcher() { - const collections = useCollections(); - const { data: session } = trpc.auth.onSessionChange.useSubscription(); - const setActiveOrg = trpc.auth.setActiveOrganization.useMutation(); - - const activeOrganizationId = session?.session.activeOrganizationId; - - const { data: organizations } = useLiveQuery( - (q) => q.from({ organizations: collections.organizations }), - [collections], - ); - - const activeOrganization = organizations?.find( - (o) => o.id === activeOrganizationId, - ); - - if (!activeOrganization) { - return null; - } + const organizations = useOrganizations(); + const { activeOrganization, switchOrganization } = useActiveOrganization(); const initials = activeOrganization.name ?.split(" ") - .map((n: string) => n[0]) + .map((n) => n[0]) .join("") .toUpperCase() .slice(0, 2); - const switchOrganization = async (newOrgId: string) => { - await setActiveOrg.mutateAsync({ organizationId: newOrgId }); - }; - return ( @@ -49,7 +27,7 @@ export function OrganizationSwitcher() { className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-muted transition-colors text-left" > - + {initials || "?"} @@ -61,14 +39,14 @@ export function OrganizationSwitcher() { - {organizations?.map((organization) => ( + {organizations.map((organization) => ( switchOrganization(organization.id)} className="gap-2" > - + {organization.name?.[0]?.toUpperCase() || "?"} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/OpenInMenuButton.tsx similarity index 50% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/OpenInMenuButton.tsx index 222cade48..698e7d366 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/OpenInMenuButton.tsx @@ -10,9 +10,12 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { memo, useCallback, useMemo } from "react"; import { HiChevronDown } from "react-icons/hi2"; -import { LuArrowUpRight, LuCopy } from "react-icons/lu"; +import { LuCopy } from "react-icons/lu"; import jetbrainsIcon from "renderer/assets/app-icons/jetbrains.svg"; import vscodeIcon from "renderer/assets/app-icons/vscode.svg"; import { @@ -21,121 +24,127 @@ import { JETBRAINS_OPTIONS, VSCODE_OPTIONS, } from "renderer/components/OpenInButton"; -import { shortenHomePath } from "renderer/lib/formatPath"; import { trpc } from "renderer/lib/trpc"; import { useHotkeyText } from "renderer/stores/hotkeys"; -interface FormattedPath { - prefix: string; - worktreeName: string; -} - -function formatWorktreePath( - path: string, - homeDir: string | undefined, -): FormattedPath { - const shortenedPath = shortenHomePath(path, homeDir); - - // Split into prefix and worktree name (last segment) - const lastSlashIndex = shortenedPath.lastIndexOf("/"); - if (lastSlashIndex !== -1) { - return { - prefix: shortenedPath.slice(0, lastSlashIndex + 1), - worktreeName: shortenedPath.slice(lastSlashIndex + 1), - }; - } - - return { prefix: "", worktreeName: shortenedPath }; -} - -interface WorkspaceActionBarRightProps { +interface OpenInMenuButtonProps { worktreePath: string; + branch?: string; } -export function WorkspaceActionBarRight({ +export const OpenInMenuButton = memo(function OpenInMenuButton({ worktreePath, -}: WorkspaceActionBarRightProps) { - const { data: homeDir } = trpc.window.getHomeDir.useQuery(); + branch, +}: OpenInMenuButtonProps) { const utils = trpc.useUtils(); const { data: lastUsedApp = "cursor" } = - trpc.settings.getLastUsedApp.useQuery(); + trpc.settings.getLastUsedApp.useQuery(undefined, { + staleTime: 30000, + }); const openInApp = trpc.external.openInApp.useMutation({ onSuccess: () => utils.settings.getLastUsedApp.invalidate(), + onError: (error) => toast.error(`Failed to open: ${error.message}`), + }); + const copyPath = trpc.external.copyPath.useMutation({ + onSuccess: () => toast.success("Path copied to clipboard"), + onError: (error) => toast.error(`Failed to copy path: ${error.message}`), }); - const copyPath = trpc.external.copyPath.useMutation(); - const formattedPath = formatWorktreePath(worktreePath, homeDir); - const currentApp = getAppOption(lastUsedApp); + const currentApp = useMemo(() => getAppOption(lastUsedApp), [lastUsedApp]); const openInShortcut = useHotkeyText("OPEN_IN_APP"); const copyPathShortcut = useHotkeyText("COPY_PATH"); const showOpenInShortcut = openInShortcut !== "Unassigned"; const showCopyPathShortcut = copyPathShortcut !== "Unassigned"; + const isLoading = openInApp.isPending || copyPath.isPending; - const handleOpenInEditor = () => { + const handleOpenInEditor = useCallback(() => { + if (openInApp.isPending || copyPath.isPending) return; openInApp.mutate({ path: worktreePath, app: lastUsedApp }); - }; + }, [worktreePath, lastUsedApp, openInApp, copyPath.isPending]); - const handleOpenInOtherApp = (appId: ExternalApp) => { - openInApp.mutate({ path: worktreePath, app: appId }); - }; + const handleOpenInOtherApp = useCallback( + (appId: ExternalApp) => { + if (openInApp.isPending || copyPath.isPending) return; + openInApp.mutate({ path: worktreePath, app: appId }); + }, + [worktreePath, openInApp, copyPath.isPending], + ); - const handleCopyPath = () => { + const handleCopyPath = useCallback(() => { + if (openInApp.isPending || copyPath.isPending) return; copyPath.mutate(worktreePath); - }; - - const BUTTON_HEIGHT = 24; + }, [worktreePath, copyPath, openInApp.isPending]); return ( - <> - {/* Path - clickable to open */} +
+ {/* Main button - opens in last used app */} - - - Open in {currentApp.displayLabel ?? currentApp.label} - - {showOpenInShortcut ? openInShortcut : "—"} - - + +
+ + Open in {currentApp.displayLabel ?? currentApp.label} + {showOpenInShortcut && ( + + {openInShortcut} + + )} + + {branch && ( + + /{branch} + + )} +
- {/* Open dropdown button */} + {/* Dropdown trigger */} - + {APP_OPTIONS.map((app) => ( {app.label} {app.label} @@ -156,12 +165,12 @@ export function WorkspaceActionBarRight({ VS Code VS Code - + {VSCODE_OPTIONS.map((app) => ( {app.label} {app.label} - {app.id === lastUsedApp && ( - ⌘O + {app.id === lastUsedApp && showOpenInShortcut && ( + + {openInShortcut} + )} ))} @@ -184,12 +195,12 @@ export function WorkspaceActionBarRight({ JetBrains JetBrains - + {JETBRAINS_OPTIONS.map((app) => ( {app.label} {app.label} @@ -215,6 +226,6 @@ export function WorkspaceActionBarRight({ - +
); -} +}); diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx new file mode 100644 index 000000000..a5a517901 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx @@ -0,0 +1,47 @@ +import { Button } from "@superset/ui/button"; +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuPanelLeft, LuPanelLeftClose } from "react-icons/lu"; +import { useWorkspaceSidebarStore } from "renderer/stores"; +import { + formatHotkeyDisplay, + getCurrentPlatform, + getHotkey, +} from "shared/hotkeys"; + +export function WorkspaceSidebarControl() { + const { isOpen, toggleOpen } = useWorkspaceSidebarStore(); + + return ( + + + + + + + Toggle Workspaces + + {formatHotkeyDisplay( + getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), + getCurrentPlatform(), + ).map((key) => ( + {key} + ))} + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx deleted file mode 100644 index b63425907..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useCallback, useState } from "react"; -import { - HiChevronDown, - HiFolderOpen, - HiMiniPlus, - HiOutlineBolt, -} from "react-icons/hi2"; -import { trpc } from "renderer/lib/trpc"; -import { useOpenNew } from "renderer/react-query/projects"; -import { - useCreateBranchWorkspace, - useCreateWorkspace, -} from "renderer/react-query/workspaces"; -import { useAppHotkey, useHotkeyText } from "renderer/stores/hotkeys"; -import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; -import { InitGitDialog } from "../../StartView/InitGitDialog"; - -export interface CreateWorkspaceButtonProps { - className?: string; -} - -export function CreateWorkspaceButton({ - className, -}: CreateWorkspaceButtonProps) { - const [open, setOpen] = useState(false); - const [initGitDialog, setInitGitDialog] = useState<{ - isOpen: boolean; - selectedPath: string; - }>({ isOpen: false, selectedPath: "" }); - - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); - const createWorkspace = useCreateWorkspace(); - const createBranchWorkspace = useCreateBranchWorkspace(); - const openNew = useOpenNew(); - const openModal = useOpenNewWorkspaceModal(); - - const currentProject = recentProjects.find( - (p) => p.id === activeWorkspace?.projectId, - ); - - const isLoading = - createWorkspace.isPending || - createBranchWorkspace.isPending || - openNew.isPending; - - const handleModalCreate = useCallback(() => { - setOpen(false); - openModal(); - }, [openModal]); - - const handleOpenNewProject = useCallback(async () => { - setOpen(false); - try { - const result = await openNew.mutateAsync(undefined); - if (result.canceled) { - return; - } - if ("error" in result) { - toast.error("Failed to open project", { - description: result.error, - }); - return; - } - if ("needsGitInit" in result) { - setInitGitDialog({ - isOpen: true, - selectedPath: result.selectedPath, - }); - return; - } - // Create a main workspace on the current branch for the new project - toast.promise( - createBranchWorkspace.mutateAsync({ projectId: result.project.id }), - { - loading: "Opening project...", - success: "Project opened", - error: (err) => - err instanceof Error ? err.message : "Failed to open project", - }, - ); - } catch (error) { - toast.error("Failed to open project", { - description: - error instanceof Error ? error.message : "An unknown error occurred", - }); - } - }, [openNew, createBranchWorkspace]); - - const handleQuickCreate = useCallback(() => { - setOpen(false); - if (currentProject) { - toast.promise( - createWorkspace.mutateAsync({ projectId: currentProject.id }), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - } else { - handleOpenNewProject(); - } - }, [currentProject, createWorkspace, handleOpenNewProject]); - - // Keyboard shortcuts - const handleQuickCreateHotkey = useCallback(() => { - if (!isLoading) handleQuickCreate(); - }, [isLoading, handleQuickCreate]); - - const handleOpenProjectHotkey = useCallback(() => { - if (!isLoading) handleOpenNewProject(); - }, [isLoading, handleOpenNewProject]); - - useAppHotkey("NEW_WORKSPACE", handleModalCreate, undefined, [ - handleModalCreate, - ]); - useAppHotkey("QUICK_CREATE_WORKSPACE", handleQuickCreateHotkey, undefined, [ - handleQuickCreateHotkey, - ]); - useAppHotkey("OPEN_PROJECT", handleOpenProjectHotkey, undefined, [ - handleOpenProjectHotkey, - ]); - - const newWorkspaceShortcut = useHotkeyText("NEW_WORKSPACE"); - const quickCreateShortcut = useHotkeyText("QUICK_CREATE_WORKSPACE"); - const openProjectShortcut = useHotkeyText("OPEN_PROJECT"); - const showNewWorkspaceShortcut = newWorkspaceShortcut !== "Unassigned"; - const showQuickCreateShortcut = quickCreateShortcut !== "Unassigned"; - const showOpenProjectShortcut = openProjectShortcut !== "Unassigned"; - - return ( -
- - - - - - New workspace - - - - - - - - - - - - More options - - - - - - New Workspace - {showNewWorkspaceShortcut && ( - - {newWorkspaceShortcut} - - )} - - - - Quick Create - {showQuickCreateShortcut && ( - - {quickCreateShortcut} - - )} - - - - - Open Project - {showOpenProjectShortcut && ( - - {openProjectShortcut} - - )} - - - - - setInitGitDialog({ isOpen: false, selectedPath: "" })} - onError={(error) => - toast.error("Failed to initialize git", { description: error }) - } - /> -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx deleted file mode 100644 index 1b567dddd..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { cn } from "@superset/ui/utils"; -import { HiMiniXMark, HiOutlineCog6Tooth } from "react-icons/hi2"; -import { - useCloseSettingsTab, - useOpenSettings, -} from "renderer/stores/app-state"; - -interface SettingsTabProps { - width: number; - isActive: boolean; -} - -export function SettingsTab({ width, isActive }: SettingsTabProps) { - const openSettings = useOpenSettings(); - const closeSettingsTab = useCloseSettingsTab(); - - return ( -
- - - -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx deleted file mode 100644 index 0b042f2ef..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { useState } from "react"; -import { WorkspaceGroupHeader } from "./WorkspaceGroupHeader"; -import { WorkspaceItem } from "./WorkspaceItem"; - -interface Workspace { - id: string; - projectId: string; - worktreePath: string; - type: "worktree" | "branch"; - branch: string; - name: string; - tabOrder: number; -} - -interface WorkspaceGroupProps { - projectId: string; - projectName: string; - projectColor: string; - projectIndex: number; - workspaces: Workspace[]; - activeWorkspaceId: string | null; - workspaceWidth: number; - hoveredWorkspaceId: string | null; - onWorkspaceHover: (id: string | null) => void; -} - -export function WorkspaceGroup({ - projectId, - projectName, - projectColor, - projectIndex, - workspaces, - activeWorkspaceId, - workspaceWidth, - hoveredWorkspaceId: _hoveredWorkspaceId, - onWorkspaceHover, -}: WorkspaceGroupProps) { - const [isCollapsed, setIsCollapsed] = useState(false); - - return ( -
- {/* Project group badge */} - setIsCollapsed(!isCollapsed)} - /> - - {/* Workspaces with colored line (collapsed shows only active tab) */} -
- - {(isCollapsed - ? workspaces.filter((w) => w.id === activeWorkspaceId) - : workspaces - ).map((workspace, index) => ( - - onWorkspaceHover(workspace.id)} - onMouseLeave={() => onWorkspaceHover(null)} - /> - - ))} - -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx deleted file mode 100644 index 15f87c6e9..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import type { KeyboardEvent, ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; -import { - useCloseProject, - useUpdateProject, -} from "renderer/react-query/projects"; -import { PROJECT_COLORS } from "shared/constants/project-colors"; - -interface WorkspaceGroupContextMenuProps { - projectId: string; - projectName: string; - projectColor: string; - children: ReactNode; -} - -export function WorkspaceGroupContextMenu({ - projectId, - projectName, - projectColor, - children, -}: WorkspaceGroupContextMenuProps) { - const [name, setName] = useState(projectName); - const inputRef = useRef(null); - const skipBlurSubmit = useRef(false); - const updateProject = useUpdateProject(); - const closeProject = useCloseProject(); - - useEffect(() => { - setName(projectName); - }, [projectName]); - - const handleOpenChange = (open: boolean) => { - if (open) { - // Small delay to ensure the menu is fully rendered - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 0); - } - }; - - const submitName = () => { - const trimmed = name.trim(); - - if (!trimmed) { - setName(projectName); - return; - } - - if (trimmed !== name) { - setName(trimmed); - } - - if (trimmed !== projectName) { - updateProject.mutate({ - id: projectId, - patch: { name: trimmed }, - }); - } - }; - - const handleNameKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - skipBlurSubmit.current = true; - submitName(); - inputRef.current?.blur(); - } else if (event.key === "Escape") { - event.preventDefault(); - setName(projectName); - skipBlurSubmit.current = true; - inputRef.current?.blur(); - } - }; - - const handleBlur = () => { - if (skipBlurSubmit.current) { - skipBlurSubmit.current = false; - return; - } - - submitName(); - }; - - const handleColorChange = (color: string) => { - if (color === projectColor) { - return; - } - - updateProject.mutate({ - id: projectId, - patch: { color }, - }); - }; - - return ( - - {children} - -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > -

Workspace group name

- setName(event.target.value)} - onBlur={handleBlur} - onKeyDown={handleNameKeyDown} - className="w-full rounded-md border border-border bg-muted/50 px-2 py-1 text-sm text-foreground outline-none focus:border-primary focus:bg-background" - placeholder="Workspace group" - /> -
- - - -
- {PROJECT_COLORS.map((color) => ( - - ))} -
- - - - -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx deleted file mode 100644 index f7bfbfbe8..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useDrag, useDrop } from "react-dnd"; -import { useReorderProjects } from "renderer/react-query/projects"; -import { WorkspaceGroupContextMenu } from "./WorkspaceGroupContextMenu"; - -const PROJECT_GROUP_TYPE = "PROJECT_GROUP"; - -interface WorkspaceGroupHeaderProps { - projectId: string; - projectName: string; - projectColor: string; - isCollapsed: boolean; - index: number; - onToggleCollapse: () => void; -} - -export function WorkspaceGroupHeader({ - projectId, - projectName, - projectColor, - isCollapsed, - index, - onToggleCollapse, -}: WorkspaceGroupHeaderProps) { - const reorderProjects = useReorderProjects(); - - const [{ isDragging }, drag] = useDrag( - () => ({ - type: PROJECT_GROUP_TYPE, - item: { projectId, index }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }), - [projectId, index], - ); - - const [{ isOver }, drop] = useDrop( - () => ({ - accept: PROJECT_GROUP_TYPE, - hover: (item: { projectId: string; index: number }) => { - if (item.index !== index) { - reorderProjects.mutate({ - fromIndex: item.index, - toIndex: index, - }); - item.index = index; - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }), - [index, reorderProjects], - ); - - return ( - -
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx deleted file mode 100644 index b0069c3f1..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import { Button } from "@superset/ui/button"; -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 { useState } from "react"; -import { useDrag, useDrop } from "react-dnd"; -import { HiMiniXMark } from "react-icons/hi2"; -import { LuGitBranch } from "react-icons/lu"; -import { trpc } from "renderer/lib/trpc"; -import { - useDeleteWorkspace, - useReorderWorkspaces, - useSetActiveWorkspace, -} from "renderer/react-query/workspaces"; -import { useCloseSettings } from "renderer/stores/app-state"; -import { useTabsStore } from "renderer/stores/tabs/store"; -import { BranchSwitcher } from "./BranchSwitcher"; -import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; -import { useWorkspaceRename } from "./useWorkspaceRename"; -import { WorkspaceItemContextMenu } from "./WorkspaceItemContextMenu"; - -const WORKSPACE_TYPE = "WORKSPACE"; - -interface WorkspaceItemProps { - id: string; - projectId: string; - worktreePath: string; - workspaceType?: "worktree" | "branch"; - branch?: string; - title: string; - isActive: boolean; - index: number; - width: number; - onMouseEnter?: () => void; - onMouseLeave?: () => void; -} - -export function WorkspaceItem({ - id, - projectId, - worktreePath, - workspaceType = "worktree", - branch, - title, - isActive, - index, - width, - onMouseEnter, - onMouseLeave, -}: WorkspaceItemProps) { - const isBranchWorkspace = workspaceType === "branch"; - const setActive = useSetActiveWorkspace(); - const reorderWorkspaces = useReorderWorkspaces(); - const deleteWorkspace = useDeleteWorkspace(); - const closeSettings = useCloseSettings(); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const tabs = useTabsStore((s) => s.tabs); - const panes = useTabsStore((s) => s.panes); - const rename = useWorkspaceRename(id, title); - - // Query to check if workspace is empty - only enabled when needed - const canDeleteQuery = trpc.workspaces.canDelete.useQuery( - { id }, - { enabled: false }, - ); - - const handleDeleteClick = async () => { - // Prevent double-clicks and race conditions - if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; - - try { - // Always fetch fresh data before deciding - const { data: canDeleteData } = await canDeleteQuery.refetch(); - - // For branch workspaces, only show dialog if there are active terminals - // (no destructive action - branch stays in repo) - if (isBranchWorkspace) { - if ( - canDeleteData?.activeTerminalCount && - canDeleteData.activeTerminalCount > 0 - ) { - setShowDeleteDialog(true); - } else { - // Close directly without confirmation - toast.promise(deleteWorkspace.mutateAsync({ id }), { - loading: `Closing "${title}"...`, - success: `Workspace "${title}" closed`, - error: (error) => - error instanceof Error - ? `Failed to close workspace: ${error.message}` - : "Failed to close workspace", - }); - } - return; - } - - // For worktree workspaces, check all conditions - const isEmpty = - canDeleteData?.canDelete && - canDeleteData.activeTerminalCount === 0 && - !canDeleteData.warning && - !canDeleteData.hasChanges && - !canDeleteData.hasUnpushedCommits; - - if (isEmpty) { - // Delete directly without confirmation - toast.promise(deleteWorkspace.mutateAsync({ id }), { - loading: `Deleting "${title}"...`, - success: `Workspace "${title}" deleted`, - error: (error) => - error instanceof Error - ? `Failed to delete workspace: ${error.message}` - : "Failed to delete workspace", - }); - } else { - // Show confirmation dialog - setShowDeleteDialog(true); - } - } catch { - // On error checking status, show dialog for user to decide - setShowDeleteDialog(true); - } - }; - - // Check if any pane in tabs belonging to this workspace needs attention - const workspaceTabs = tabs.filter((t) => t.workspaceId === id); - const workspacePaneIds = new Set( - workspaceTabs.flatMap((t) => { - // Extract pane IDs from the layout (which is a MosaicNode) - const collectPaneIds = (node: unknown): string[] => { - if (typeof node === "string") return [node]; - if ( - node && - typeof node === "object" && - "first" in node && - "second" in node - ) { - const branch = node as { first: unknown; second: unknown }; - return [ - ...collectPaneIds(branch.first), - ...collectPaneIds(branch.second), - ]; - } - return []; - }; - return collectPaneIds(t.layout); - }), - ); - const needsAttention = Object.values(panes) - .filter((p) => workspacePaneIds.has(p.id)) - .some((p) => p.needsAttention); - - const [{ isDragging }, drag] = useDrag( - () => ({ - type: WORKSPACE_TYPE, - item: { id, projectId, index }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }), - [id, projectId, index], - ); - - const [, drop] = useDrop({ - accept: WORKSPACE_TYPE, - hover: (item: { id: string; projectId: string; index: number }) => { - // Only allow reordering within the same project - if (item.projectId === projectId && item.index !== index) { - reorderWorkspaces.mutate({ - projectId, - fromIndex: item.index, - toIndex: index, - }); - item.index = index; - } - }, - }); - - return ( - <> - -
- {/* Main workspace button */} - - - {/* Branch switcher - positioned absolutely as sibling */} - {isBranchWorkspace && branch && ( -
- -
- )} - - {/* Only show close button for worktree workspaces */} - {!isBranchWorkspace && ( - - - - - - Delete workspace - - - )} -
-
- - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx deleted file mode 100644 index 142b12651..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@superset/ui/hover-card"; -import type { ReactNode } from "react"; -import { trpc } from "renderer/lib/trpc"; -import { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; - -interface WorkspaceItemContextMenuProps { - children: ReactNode; - workspaceId: string; - worktreePath: string; - workspaceAlias?: string; - onRename: () => void; - canRename?: boolean; - showHoverCard?: boolean; -} - -export function WorkspaceItemContextMenu({ - children, - workspaceId, - worktreePath, - workspaceAlias, - onRename, - canRename = true, - showHoverCard = true, -}: WorkspaceItemContextMenuProps) { - const openInFinder = trpc.external.openInFinder.useMutation(); - - const handleOpenInFinder = () => { - if (worktreePath) { - openInFinder.mutate(worktreePath); - } - }; - - // For branch workspaces, just show context menu without hover card - if (!showHoverCard) { - return ( - - {children} - - {canRename && ( - <> - Rename - - - )} - - Open in Finder - - - - ); - } - - return ( - - - - {children} - - - {canRename && ( - <> - Rename - - - )} - - Open in Finder - - - - - - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx deleted file mode 100644 index 27c585512..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { trpc } from "renderer/lib/trpc"; -import { - useCreateBranchWorkspace, - useSetActiveWorkspace, -} from "renderer/react-query/workspaces"; -import { - useCurrentView, - useIsSettingsTabOpen, -} from "renderer/stores/app-state"; -import { useAppHotkey } from "renderer/stores/hotkeys"; -import { CreateWorkspaceButton } from "./CreateWorkspaceButton"; -import { SettingsTab } from "./SettingsTab"; -import { WorkspaceGroup } from "./WorkspaceGroup"; - -const MIN_WORKSPACE_WIDTH = 60; -const MAX_WORKSPACE_WIDTH = 160; -const ADD_BUTTON_WIDTH = 40; - -export function WorkspacesTabs() { - const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id || null; - const setActiveWorkspace = useSetActiveWorkspace(); - const createBranchWorkspace = useCreateBranchWorkspace(); - const currentView = useCurrentView(); - const isSettingsTabOpen = useIsSettingsTabOpen(); - const isSettingsActive = currentView === "settings"; - const containerRef = useRef(null); - const scrollRef = useRef(null); - const [showStartFade, setShowStartFade] = useState(false); - const [showEndFade, setShowEndFade] = useState(false); - const [workspaceWidth, setWorkspaceWidth] = useState(MAX_WORKSPACE_WIDTH); - const [hoveredWorkspaceId, setHoveredWorkspaceId] = useState( - null, - ); - - // Track projects we've attempted to create workspaces for (persists across renders) - // Using ref to avoid re-triggering the effect - const attemptedProjectsRef = useRef>(new Set()); - const [isCreating, setIsCreating] = useState(false); - - // Auto-create main workspace for new projects (one-time per project) - // This only runs for projects we haven't attempted yet - useEffect(() => { - if (isCreating) return; - - for (const group of groups) { - const projectId = group.project.id; - const hasMainWorkspace = group.workspaces.some( - (w) => w.type === "branch", - ); - - // Skip if already has main workspace or we've already attempted this project - if (hasMainWorkspace || attemptedProjectsRef.current.has(projectId)) { - continue; - } - - // Mark as attempted before creating (prevents retries) - attemptedProjectsRef.current.add(projectId); - setIsCreating(true); - - // Auto-create fails silently - this is a background convenience feature - // Users can manually create the workspace via the dropdown if needed - createBranchWorkspace.mutate( - { projectId }, - { - onSettled: () => { - setIsCreating(false); - }, - }, - ); - // Only create one at a time - break; - } - }, [groups, isCreating, createBranchWorkspace]); - - // Flatten workspaces for keyboard navigation - const allWorkspaces = groups.flatMap((group) => group.workspaces); - - const handleWorkspaceSwitch = useCallback( - (index: number) => { - const workspace = allWorkspaces[index]; - if (workspace) { - setActiveWorkspace.mutate({ id: workspace.id }); - } - }, - [allWorkspaces, setActiveWorkspace], - ); - - const handlePrevWorkspace = useCallback(() => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex > 0) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); - } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); - - const handleNextWorkspace = useCallback(() => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex < allWorkspaces.length - 1) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); - } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); - - useAppHotkey( - "JUMP_TO_WORKSPACE_1", - () => handleWorkspaceSwitch(0), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_2", - () => handleWorkspaceSwitch(1), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_3", - () => handleWorkspaceSwitch(2), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_4", - () => handleWorkspaceSwitch(3), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_5", - () => handleWorkspaceSwitch(4), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_6", - () => handleWorkspaceSwitch(5), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_7", - () => handleWorkspaceSwitch(6), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_8", - () => handleWorkspaceSwitch(7), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_9", - () => handleWorkspaceSwitch(8), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey("PREV_WORKSPACE", handlePrevWorkspace, undefined, [ - handlePrevWorkspace, - ]); - useAppHotkey("NEXT_WORKSPACE", handleNextWorkspace, undefined, [ - handleNextWorkspace, - ]); - - useEffect(() => { - const checkScroll = () => { - if (!scrollRef.current) return; - - const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; - setShowStartFade(scrollLeft > 0); - setShowEndFade(scrollLeft < scrollWidth - clientWidth - 1); - }; - - const updateWorkspaceWidth = () => { - if (!containerRef.current) return; - - const containerWidth = containerRef.current.offsetWidth; - const availableWidth = containerWidth - ADD_BUTTON_WIDTH; - - // Calculate width: fill available space but respect min/max - const calculatedWidth = Math.max( - MIN_WORKSPACE_WIDTH, - Math.min(MAX_WORKSPACE_WIDTH, availableWidth / allWorkspaces.length), - ); - setWorkspaceWidth(calculatedWidth); - }; - - checkScroll(); - updateWorkspaceWidth(); - - const scrollElement = scrollRef.current; - if (scrollElement) { - scrollElement.addEventListener("scroll", checkScroll); - } - - window.addEventListener("resize", updateWorkspaceWidth); - - return () => { - if (scrollElement) { - scrollElement.removeEventListener("scroll", checkScroll); - } - window.removeEventListener("resize", updateWorkspaceWidth); - }; - }, [allWorkspaces]); - - return ( -
-
-
- {groups.map((group, groupIndex) => ( - - - {groupIndex < groups.length - 1 && ( -
-
-
- )} - - ))} - {isSettingsTabOpen && ( - <> - {groups.length > 0 && ( -
-
-
- )} - - - )} -
- - {/* Left fade for scroll indication */} - {showStartFade && ( -
- )} - - {/* Right side: gradient fade + button container */} -
- {/* Gradient fade - only show when content overflows */} - {showEndFade && ( -
- )} - {/* Button with solid background */} -
- -
-
-
-
- ); -} 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 2875da88b..0743fe71d 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,26 +1,35 @@ import { trpc } from "renderer/lib/trpc"; import { AvatarDropdown } from "../AvatarDropdown"; -import { SidebarControl } from "./SidebarControl"; +import { OpenInMenuButton } from "./OpenInMenuButton"; import { WindowControls } from "./WindowControls"; -import { WorkspacesTabs } from "./WorkspaceTabs"; +import { WorkspaceSidebarControl } from "./WorkspaceSidebarControl"; export function TopBar() { const { data: platform } = trpc.window.getPlatform.useQuery(); - const isMac = platform === "darwin"; + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + // Default to Mac layout while loading to avoid overlap with traffic lights + const isMac = platform === undefined || platform === "darwin"; + return ( -
+
- -
-
- +
-
+ +
+ +
+ {activeWorkspace?.worktreePath && ( + + )} {!isMac && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx new file mode 100644 index 000000000..3cce23d23 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx @@ -0,0 +1,206 @@ +import { toast } from "@superset/ui/sonner"; +import { useCallback, useEffect, useRef } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useOpenConfigModal } from "renderer/stores/config-modal"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { + type PendingTerminalSetup, + useWorkspaceInitStore, +} from "renderer/stores/workspace-init"; + +/** + * Renderless component that handles terminal setup when workspaces become ready. + * + * This is mounted at the app root (MainScreen) so it survives dialog unmounts. + * When a workspace creation is initiated from a dialog (e.g., InitGitDialog, + * CloneRepoDialog), the dialog may close before initialization completes. + * This component ensures the terminal is still created when the workspace + * becomes ready. + * + * Also handles the case where pending setup data is lost (e.g., after retry + * or app restart) by fetching setup commands from the backend on demand. + */ +export function WorkspaceInitEffects() { + const initProgress = useWorkspaceInitStore((s) => s.initProgress); + const pendingTerminalSetups = useWorkspaceInitStore( + (s) => s.pendingTerminalSetups, + ); + const removePendingTerminalSetup = useWorkspaceInitStore( + (s) => s.removePendingTerminalSetup, + ); + const clearProgress = useWorkspaceInitStore((s) => s.clearProgress); + + // Track which setups are currently being processed to prevent duplicate handling + const processingRef = useRef>(new Set()); + + const addTab = useTabsStore((state) => state.addTab); + const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); + const createOrAttach = trpc.terminal.createOrAttach.useMutation(); + const openConfigModal = useOpenConfigModal(); + const dismissConfigToast = trpc.config.dismissConfigToast.useMutation(); + const utils = trpc.useUtils(); + + // Helper to create terminal with setup commands + const handleTerminalSetup = useCallback( + (setup: PendingTerminalSetup, onComplete: () => void) => { + if ( + Array.isArray(setup.initialCommands) && + setup.initialCommands.length > 0 + ) { + const { tabId, paneId } = addTab(setup.workspaceId); + setTabAutoTitle(tabId, "Workspace Setup"); + createOrAttach.mutate( + { + paneId, + tabId, + workspaceId: setup.workspaceId, + initialCommands: setup.initialCommands, + }, + { + onSuccess: () => { + onComplete(); + }, + onError: (error) => { + console.error( + "[WorkspaceInitEffects] Failed to create terminal:", + error, + ); + toast.error("Failed to create terminal", { + description: + error.message || "Terminal setup failed. Please try again.", + action: { + label: "Open Terminal", + onClick: () => { + // Allow user to manually trigger terminal creation + const { tabId: newTabId, paneId: newPaneId } = addTab( + setup.workspaceId, + ); + createOrAttach.mutate({ + paneId: newPaneId, + tabId: newTabId, + workspaceId: setup.workspaceId, + initialCommands: setup.initialCommands ?? undefined, + }); + }, + }, + }); + // Still complete to prevent infinite retries + onComplete(); + }, + }, + ); + } else { + // Show config toast if no setup commands + toast.info("No setup script configured", { + description: "Automate workspace setup with a config.json file", + action: { + label: "Configure", + onClick: () => openConfigModal(setup.projectId), + }, + onDismiss: () => { + dismissConfigToast.mutate({ projectId: setup.projectId }); + }, + }); + onComplete(); + } + }, + [ + addTab, + setTabAutoTitle, + createOrAttach, + openConfigModal, + dismissConfigToast, + ], + ); + + useEffect(() => { + // Process pending setups that have reached ready state + for (const [workspaceId, setup] of Object.entries(pendingTerminalSetups)) { + const progress = initProgress[workspaceId]; + + // Skip if already being processed + if (processingRef.current.has(workspaceId)) { + continue; + } + + // Create terminal when workspace becomes ready + if (progress?.step === "ready") { + // Mark as processing to prevent duplicate handling + processingRef.current.add(workspaceId); + + handleTerminalSetup(setup, () => { + // Only remove from pending after successful handling + removePendingTerminalSetup(workspaceId); + clearProgress(workspaceId); + processingRef.current.delete(workspaceId); + }); + } + + // Clean up pending if failed (user will use retry or delete) + // Note: losing pending data is OK now - we fetch on demand when ready + if (progress?.step === "failed") { + removePendingTerminalSetup(workspaceId); + } + } + + // Handle workspaces that became ready without pending setup data + // (e.g., after retry or app restart during init) + for (const [workspaceId, progress] of Object.entries(initProgress)) { + // Only process ready workspaces that don't have pending setup + if (progress.step !== "ready") { + continue; + } + if (pendingTerminalSetups[workspaceId]) { + continue; // Already handled above + } + if (processingRef.current.has(workspaceId)) { + continue; + } + + // Mark as processing and fetch setup commands from backend + processingRef.current.add(workspaceId); + + utils.workspaces.getSetupCommands + .fetch({ workspaceId }) + .then((setupData) => { + if (!setupData) { + // Workspace not found or no project - just clear progress + clearProgress(workspaceId); + processingRef.current.delete(workspaceId); + return; + } + + // Create a pending setup from fetched data and handle it + const fetchedSetup: PendingTerminalSetup = { + workspaceId, + projectId: setupData.projectId, + initialCommands: setupData.initialCommands, + }; + + handleTerminalSetup(fetchedSetup, () => { + clearProgress(workspaceId); + processingRef.current.delete(workspaceId); + }); + }) + .catch((error) => { + console.error( + "[WorkspaceInitEffects] Failed to fetch setup commands:", + error, + ); + // Still clear progress to avoid being stuck + clearProgress(workspaceId); + processingRef.current.delete(workspaceId); + }); + } + }, [ + initProgress, + pendingTerminalSetups, + removePendingTerminalSetup, + clearProgress, + handleTerminalSetup, + utils.workspaces.getSetupCommands, + ]); + + // Renderless component + return null; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PortsList.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/index.ts new file mode 100644 index 000000000..7f04b50ce --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/index.ts @@ -0,0 +1 @@ +export { PortsList } from "./PortsList"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx new file mode 100644 index 000000000..64bfb4671 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -0,0 +1,104 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { LuFolderOpen, LuSettings, LuX } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useOpenSettings } from "renderer/stores/app-state"; + +interface ProjectHeaderProps { + projectId: string; + projectName: string; + mainRepoPath: string; + isCollapsed: boolean; + onToggleCollapse: () => void; + workspaceCount: number; +} + +export function ProjectHeader({ + projectId, + projectName, + mainRepoPath, + isCollapsed, + onToggleCollapse, + workspaceCount, +}: ProjectHeaderProps) { + const utils = trpc.useUtils(); + const openSettings = useOpenSettings(); + + const closeProject = trpc.projects.close.useMutation({ + onSuccess: (data) => { + utils.workspaces.getAllGrouped.invalidate(); + utils.workspaces.getActive.invalidate(); + utils.projects.getRecents.invalidate(); + if (data.terminalWarning) { + toast.warning(data.terminalWarning); + } + }, + onError: (error) => { + toast.error(`Failed to close project: ${error.message}`); + }, + }); + + const openInFinder = trpc.external.openInFinder.useMutation({ + onError: (error) => toast.error(`Failed to open: ${error.message}`), + }); + + const handleCloseProject = () => { + closeProject.mutate({ id: projectId }); + }; + + const handleOpenInFinder = () => { + openInFinder.mutate(mainRepoPath); + }; + + const handleOpenSettings = () => { + openSettings("project"); + }; + + return ( + + + + + + + + Open in Finder + + + + Project Settings + + + + + {closeProject.isPending ? "Closing..." : "Close Project"} + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx new file mode 100644 index 000000000..a0e4152f9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -0,0 +1,144 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { AnimatePresence, motion } from "framer-motion"; +import { useState } from "react"; +import { HiMiniPlus, HiOutlineBolt } from "react-icons/hi2"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { useWorkspaceSidebarStore } from "renderer/stores"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { WorkspaceListItem } from "../WorkspaceListItem"; +import { ProjectHeader } from "./ProjectHeader"; + +interface Workspace { + id: string; + projectId: string; + worktreePath: string; + type: "worktree" | "branch"; + branch: string; + name: string; + tabOrder: number; + isUnread: boolean; +} + +interface ProjectSectionProps { + projectId: string; + projectName: string; + mainRepoPath: string; + workspaces: Workspace[]; + activeWorkspaceId: string | null; + /** Base index for keyboard shortcuts (0-based) */ + shortcutBaseIndex: number; +} + +export function ProjectSection({ + projectId, + projectName, + mainRepoPath, + workspaces, + activeWorkspaceId, + shortcutBaseIndex, +}: ProjectSectionProps) { + const [dropdownOpen, setDropdownOpen] = useState(false); + const { isProjectCollapsed, toggleProjectCollapsed } = + useWorkspaceSidebarStore(); + const createWorkspace = useCreateWorkspace(); + const openModal = useOpenNewWorkspaceModal(); + + const isCollapsed = isProjectCollapsed(projectId); + + const handleQuickCreate = () => { + setDropdownOpen(false); + toast.promise(createWorkspace.mutateAsync({ projectId }), { + loading: "Creating workspace...", + success: "Workspace created", + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }); + }; + + const handleNewWorkspace = () => { + setDropdownOpen(false); + openModal(projectId); + }; + + return ( +
+ toggleProjectCollapsed(projectId)} + workspaceCount={workspaces.length} + /> + + + {!isCollapsed && ( + +
+ {workspaces.map((workspace, index) => ( + + ))} + + + + + + + + New Workspace + + + + Quick Create + + + +
+
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts new file mode 100644 index 000000000..2111af01d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts @@ -0,0 +1,2 @@ +export { ProjectHeader } from "./ProjectHeader"; +export { ProjectSection } from "./ProjectSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx new file mode 100644 index 000000000..526fa283d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx @@ -0,0 +1,94 @@ +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useRef } from "react"; +import { + MAX_WORKSPACE_SIDEBAR_WIDTH, + MIN_WORKSPACE_SIDEBAR_WIDTH, + useWorkspaceSidebarStore, +} from "renderer/stores"; +import { WorkspaceSidebar } from "./WorkspaceSidebar"; + +export function ResizableWorkspaceSidebar() { + const { isOpen, width, setWidth, isResizing, setIsResizing } = + useWorkspaceSidebarStore(); + + const startXRef = useRef(0); + const startWidthRef = useRef(0); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + startXRef.current = e.clientX; + startWidthRef.current = width; + setIsResizing(true); + }, + [width, setIsResizing], + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + const delta = e.clientX - startXRef.current; + const newWidth = startWidthRef.current + delta; + const clampedWidth = Math.max( + MIN_WORKSPACE_SIDEBAR_WIDTH, + Math.min(MAX_WORKSPACE_SIDEBAR_WIDTH, newWidth), + ); + setWidth(clampedWidth); + }, + [isResizing, setWidth], + ); + + const handleMouseUp = useCallback(() => { + if (isResizing) { + setIsResizing(false); + } + }, [isResizing, setIsResizing]); + + useEffect(() => { + if (isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + if (!isOpen) { + return null; + } + + return ( +
+ + + {/* Resize handle */} + {/* biome-ignore lint/a11y/useSemanticElements:
is not appropriate for interactive resize handles */} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx new file mode 100644 index 000000000..a2758d40b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx @@ -0,0 +1,16 @@ +interface WorkspaceDiffStatsProps { + additions: number; + deletions: number; +} + +export function WorkspaceDiffStats({ + additions, + deletions, +}: WorkspaceDiffStatsProps) { + return ( +
+ +{additions} + -{deletions} +
+ ); +} 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 new file mode 100644 index 000000000..81340b17e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -0,0 +1,380 @@ +import { Button } from "@superset/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@superset/ui/hover-card"; +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 { useState } from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { HiMiniXMark } from "react-icons/hi2"; +import { LuEye, LuEyeOff, LuGitBranch } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { + useReorderWorkspaces, + useSetActiveWorkspace, + useWorkspaceDeleteHandler, +} from "renderer/react-query/workspaces"; +import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; +import { useCloseWorkspacesList } from "renderer/stores/app-state"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; +import { + BranchSwitcher, + DeleteWorkspaceDialog, + WorkspaceHoverCardContent, +} from "./components"; +import { + GITHUB_STATUS_STALE_TIME, + HOVER_CARD_CLOSE_DELAY, + HOVER_CARD_OPEN_DELAY, + MAX_KEYBOARD_SHORTCUT_INDEX, +} from "./constants"; +import { WorkspaceDiffStats } from "./WorkspaceDiffStats"; +import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; + +const WORKSPACE_TYPE = "WORKSPACE"; + +interface WorkspaceListItemProps { + id: string; + projectId: string; + worktreePath: string; + name: string; + branch: string; + type: "worktree" | "branch"; + isActive: boolean; + isUnread?: boolean; + index: number; + shortcutIndex?: number; +} + +export function WorkspaceListItem({ + id, + projectId, + worktreePath, + name, + branch, + type, + isActive, + isUnread = false, + index, + shortcutIndex, +}: WorkspaceListItemProps) { + const isBranchWorkspace = type === "branch"; + const setActiveWorkspace = useSetActiveWorkspace(); + const reorderWorkspaces = useReorderWorkspaces(); + const closeWorkspacesList = useCloseWorkspacesList(); + const [hasHovered, setHasHovered] = useState(false); + const rename = useWorkspaceRename(id, name); + const tabs = useTabsStore((s) => s.tabs); + const panes = useTabsStore((s) => s.panes); + const clearWorkspaceAttention = useTabsStore( + (s) => s.clearWorkspaceAttention, + ); + const utils = trpc.useUtils(); + const openInFinder = trpc.external.openInFinder.useMutation({ + onError: (error) => toast.error(`Failed to open: ${error.message}`), + }); + const setUnread = trpc.workspaces.setUnread.useMutation({ + onSuccess: () => { + utils.workspaces.getAllGrouped.invalidate(); + }, + onError: (error) => + 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 } = trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId: id }, + { + enabled: hasHovered && type === "worktree", + staleTime: GITHUB_STATUS_STALE_TIME, + }, + ); + + // Check if any pane in tabs belonging to this workspace needs attention (agent notifications) + const workspaceTabs = tabs.filter((t) => t.workspaceId === id); + const workspacePaneIds = new Set( + workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), + ); + const hasPaneAttention = Object.values(panes) + .filter((p) => p != null && workspacePaneIds.has(p.id)) + .some((p) => p.needsAttention); + + // Show indicator if workspace is manually marked as unread OR has pane-level attention + const needsAttention = isUnread || hasPaneAttention; + + const handleClick = () => { + if (!rename.isRenaming) { + setActiveWorkspace.mutate({ id }); + clearWorkspaceAttention(id); + // Close workspaces list view if open, to show the workspace's terminal view + closeWorkspacesList(); + } + }; + + const handleMouseEnter = () => { + if (!hasHovered) { + setHasHovered(true); + } + }; + + const handleOpenInFinder = () => { + if (worktreePath) { + openInFinder.mutate(worktreePath); + } + }; + + const handleToggleUnread = () => { + setUnread.mutate({ id, isUnread: !isUnread }); + }; + + // Drag and drop + const [{ isDragging }, drag] = useDrag( + () => ({ + type: WORKSPACE_TYPE, + item: { id, projectId, index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [id, projectId, index], + ); + + const [, drop] = useDrop({ + accept: WORKSPACE_TYPE, + hover: (item: { id: string; projectId: string; index: number }) => { + if (item.projectId === projectId && item.index !== index) { + reorderWorkspaces.mutate( + { + projectId, + fromIndex: item.index, + toIndex: index, + }, + { + onError: (error) => + toast.error(`Failed to reorder workspace: ${error.message}`), + }, + ); + item.index = index; + } + }, + }); + + const pr = githubStatus?.pr; + const showDiffStats = pr && (pr.additions > 0 || pr.deletions > 0); + + const content = ( + + + + Close or delete + + + )} + + ); + + const unreadMenuItem = ( + + {isUnread ? ( + <> + + Mark as Read + + ) : ( + <> + + Mark as Unread + + )} + + ); + + // Wrap with context menu and hover card + if (isBranchWorkspace) { + return ( + <> + + {content} + + + Open in Finder + + + {unreadMenuItem} + + + + + ); + } + + return ( + <> + + + + {content} + + + + Rename + + + + Open in Finder + + + {unreadMenuItem} + + + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx new file mode 100644 index 000000000..d6eb509d7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx @@ -0,0 +1,53 @@ +import { cn } from "@superset/ui/utils"; +import { LuCircleDot, LuGitMerge, LuGitPullRequest } from "react-icons/lu"; + +type PRState = "open" | "merged" | "closed" | "draft"; + +interface WorkspaceStatusBadgeProps { + state: PRState; + prNumber?: number; +} + +export function WorkspaceStatusBadge({ + state, + prNumber, +}: WorkspaceStatusBadgeProps) { + const iconClass = "w-3 h-3"; + + const config = { + open: { + icon: , + bgColor: "bg-emerald-500/10", + }, + merged: { + icon: , + bgColor: "bg-purple-500/10", + }, + closed: { + icon: , + bgColor: "bg-destructive/10", + }, + draft: { + icon: ( + + ), + bgColor: "bg-muted", + }, + }; + + const { icon, bgColor } = config[state]; + + return ( +
+ {icon} + {prNumber && ( + #{prNumber} + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts new file mode 100644 index 000000000..369ca7198 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts @@ -0,0 +1 @@ +export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx similarity index 92% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx index 9d6874045..88f57988d 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -7,6 +7,7 @@ import { LuTriangleAlert, } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; +import { usePRStatus } from "renderer/screens/main/hooks"; import { ChecksList } from "./components/ChecksList"; import { ChecksSummary } from "./components/ChecksSummary"; import { PRStatusBadge } from "./components/PRStatusBadge"; @@ -26,13 +27,13 @@ export function WorkspaceHoverCardContent({ { enabled: !!workspaceId }, ); - const { data: githubStatus, isLoading: isLoadingGithub } = - trpc.workspaces.getGitHubStatus.useQuery( - { workspaceId }, - { enabled: !!workspaceId }, - ); + const { + pr, + repoUrl, + branchExistsOnRemote, + isLoading: isLoadingGithub, + } = usePRStatus({ workspaceId }); - const pr = githubStatus?.pr; const needsRebase = worktreeInfo?.gitStatus?.needsRebase; const worktreeName = worktreeInfo?.worktreeName; @@ -51,9 +52,9 @@ export function WorkspaceHoverCardContent({ Branch - {githubStatus?.repoUrl && githubStatus.branchExistsOnRemote ? ( + {repoUrl && branchExistsOnRemote ? (
- ) : githubStatus ? ( + ) : repoUrl ? (
No PR for this branch
diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts new file mode 100644 index 000000000..282bf9f9a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts @@ -0,0 +1,3 @@ +export { BranchSwitcher } from "./BranchSwitcher"; +export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; +export { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts new file mode 100644 index 000000000..b6768dfb7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts @@ -0,0 +1,15 @@ +/** + * Constants for workspace list item behavior + */ + +/** Maximum index for keyboard shortcuts (Cmd+1 through Cmd+9) */ +export const MAX_KEYBOARD_SHORTCUT_INDEX = 9; + +/** Stale time for GitHub status queries (30 seconds) */ +export const GITHUB_STATUS_STALE_TIME = 30_000; + +/** Delay before showing hover card (ms) */ +export const HOVER_CARD_OPEN_DELAY = 400; + +/** Delay before hiding hover card (ms) */ +export const HOVER_CARD_CLOSE_DELAY = 100; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts new file mode 100644 index 000000000..4dd9ef18a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts @@ -0,0 +1,3 @@ +export { WorkspaceDiffStats } from "./WorkspaceDiffStats"; +export { WorkspaceListItem } from "./WorkspaceListItem"; +export { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx new file mode 100644 index 000000000..4ffc0650a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; +import { PortsList } from "./PortsList"; +import { ProjectSection } from "./ProjectSection"; +import { WorkspaceSidebarFooter } from "./WorkspaceSidebarFooter"; +import { WorkspaceSidebarHeader } from "./WorkspaceSidebarHeader"; + +export function WorkspaceSidebar() { + const { groups, activeWorkspaceId } = useWorkspaceShortcuts(); + + // Calculate shortcut base indices for each project group using cumulative offsets + const projectShortcutIndices = useMemo( + () => + groups.reduce<{ indices: number[]; cumulative: number }>( + (acc, group) => ({ + indices: [...acc.indices, acc.cumulative], + cumulative: acc.cumulative + group.workspaces.length, + }), + { indices: [], cumulative: 0 }, + ).indices, + [groups], + ); + + return ( +
+ + +
+ {groups.map((group, index) => ( + + ))} + + {groups.length === 0 && ( +
+ No workspaces yet + Add a project to get started +
+ )} +
+ + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx new file mode 100644 index 000000000..3a0b6d1c7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx @@ -0,0 +1,62 @@ +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { LuFolderOpen } from "react-icons/lu"; +import { useOpenNew } from "renderer/react-query/projects"; +import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; + +export function WorkspaceSidebarFooter() { + const openNew = useOpenNew(); + const createBranchWorkspace = useCreateBranchWorkspace(); + + const handleOpenNewProject = async () => { + try { + const result = await openNew.mutateAsync(undefined); + if (result.canceled) { + return; + } + if ("error" in result) { + toast.error("Failed to open project", { + description: result.error, + }); + return; + } + if ("needsGitInit" in result) { + toast.error("Selected folder is not a git repository", { + description: + "Please use 'Open project' from the start view to initialize git.", + }); + return; + } + // Create a main workspace on the current branch for the new project + toast.promise( + createBranchWorkspace.mutateAsync({ projectId: result.project.id }), + { + loading: "Opening project...", + success: "Project opened", + error: (err) => + err instanceof Error ? err.message : "Failed to open project", + }, + ); + } catch (error) { + toast.error("Failed to open project", { + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx new file mode 100644 index 000000000..0f392fe62 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx @@ -0,0 +1,30 @@ +import { LuPlus } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; + +export function NewWorkspaceButton() { + const openModal = useOpenNewWorkspaceModal(); + const { data: activeWorkspace, isLoading } = + trpc.workspaces.getActive.useQuery(); + + const handleClick = () => { + // projectId may be undefined if no workspace is active or query failed + // openModal handles undefined by opening without a pre-selected project + const projectId = activeWorkspace?.projectId; + openModal(projectId); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx new file mode 100644 index 000000000..9103919cf --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx @@ -0,0 +1,45 @@ +import { cn } from "@superset/ui/utils"; +import { LuLayers } from "react-icons/lu"; +import { + useCloseWorkspacesList, + useCurrentView, + useOpenWorkspacesList, +} from "renderer/stores/app-state"; +import { NewWorkspaceButton } from "./NewWorkspaceButton"; + +export function WorkspaceSidebarHeader() { + const currentView = useCurrentView(); + const openWorkspacesList = useOpenWorkspacesList(); + const closeWorkspacesList = useCloseWorkspacesList(); + + const isWorkspacesListOpen = currentView === "workspaces-list"; + + const handleClick = () => { + if (isWorkspacesListOpen) { + closeWorkspacesList(); + } else { + openWorkspacesList(); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/index.ts new file mode 100644 index 000000000..844ddc41e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/index.ts @@ -0,0 +1 @@ +export { WorkspaceSidebarHeader } from "./WorkspaceSidebarHeader"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts new file mode 100644 index 000000000..d8dc22673 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts @@ -0,0 +1,2 @@ +export { ResizableWorkspaceSidebar } from "./ResizableWorkspaceSidebar"; +export { WorkspaceSidebar } from "./WorkspaceSidebar"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx index abcc51d51..355d70a1c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx @@ -1,16 +1,15 @@ import { DiffEditor, type DiffOnMount } from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { LuLoader } from "react-icons/lu"; import { + MONACO_EDITOR_OPTIONS, + registerSaveAction, SUPERSET_THEME, useMonacoReady, } from "renderer/contexts/MonacoProvider"; import type { DiffViewMode, FileContents } from "shared/changes-types"; -import { - registerCopyPathLineAction, - registerSaveCommand, -} from "./editor-actions"; +import { registerCopyPathLineAction } from "./editor-actions"; interface DiffViewerProps { contents: FileContents; @@ -18,6 +17,7 @@ interface DiffViewerProps { filePath: string; editable?: boolean; onSave?: (content: string) => void; + onChange?: (content: string) => void; } export function DiffViewer({ @@ -26,17 +26,23 @@ export function DiffViewer({ filePath, editable = false, onSave, + onChange, }: DiffViewerProps) { const isMonacoReady = useMonacoReady(); const modifiedEditorRef = useRef( null, ); + // Track when editor is mounted to trigger effects at the right time + const [isEditorMounted, setIsEditorMounted] = useState(false); const handleSave = useCallback(() => { if (!editable || !onSave || !modifiedEditorRef.current) return; onSave(modifiedEditorRef.current.getValue()); }, [editable, onSave]); + // Store disposable for content change listener cleanup + const changeListenerRef = useRef(null); + const handleMount: DiffOnMount = useCallback( (editor) => { const originalEditor = editor.getOriginalEditor(); @@ -46,13 +52,43 @@ export function DiffViewer({ registerCopyPathLineAction(originalEditor, filePath); registerCopyPathLineAction(modifiedEditor, filePath); - if (editable) { - registerSaveCommand(modifiedEditor, handleSave); - } + setIsEditorMounted(true); }, - [editable, handleSave, filePath], + [filePath], ); + // Update readOnly and register save action when editable changes or editor mounts + // Using addAction with an ID allows replacing the action on subsequent calls + useEffect(() => { + if (!isEditorMounted || !modifiedEditorRef.current) return; + + modifiedEditorRef.current.updateOptions({ readOnly: !editable }); + + if (editable) { + registerSaveAction(modifiedEditorRef.current, handleSave); + } + }, [isEditorMounted, editable, handleSave]); + + // Set up content change listener for dirty tracking + useEffect(() => { + if (!isEditorMounted || !modifiedEditorRef.current || !onChange) return; + + // Clean up previous listener + changeListenerRef.current?.dispose(); + + changeListenerRef.current = + modifiedEditorRef.current.onDidChangeModelContent(() => { + if (modifiedEditorRef.current) { + onChange(modifiedEditorRef.current.getValue()); + } + }); + + return () => { + changeListenerRef.current?.dispose(); + changeListenerRef.current = null; + }; + }, [isEditorMounted, onChange]); + if (!isMonacoReady) { return (
@@ -78,23 +114,12 @@ export function DiffViewer({
} options={{ + ...MONACO_EDITOR_OPTIONS, renderSideBySide: viewMode === "side-by-side", readOnly: !editable, originalEditable: false, - minimap: { enabled: false }, - scrollBeyondLastLine: false, renderOverviewRuler: false, - wordWrap: "on", diffWordWrap: "on", - fontSize: 13, - lineHeight: 20, - fontFamily: - "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace", - padding: { top: 8, bottom: 8 }, - scrollbar: { - verticalScrollbarSize: 8, - horizontalScrollbarSize: 8, - }, }} />
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts index 7a5bb9c59..447ffe35f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts @@ -27,10 +27,3 @@ export function registerCopyPathLineAction( }, }); } - -export function registerSaveCommand( - editor: Monaco.editor.IStandaloneCodeEditor, - onSave: () => void, -) { - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, onSave); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx new file mode 100644 index 000000000..cbc1deac5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; + +interface ContentHeaderProps { + /** Optional leading action */ + leadingAction?: ReactNode; + /** Mode-specific header content (e.g., GroupStrip or file info) */ + children: ReactNode; + /** Optional trailing action (e.g., SidebarControl) */ + trailingAction?: ReactNode; +} + +export function ContentHeader({ + leadingAction, + children, + trailingAction, +}: ContentHeaderProps) { + return ( +
+ {leadingAction && ( +
{leadingAction}
+ )} +
{children}
+ {trailingAction && ( +
{trailingAction}
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts new file mode 100644 index 000000000..26fb6ccbc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts @@ -0,0 +1 @@ +export { ContentHeader } from "./ContentHeader"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index 0f9a435a8..3d984e3aa 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -3,16 +3,16 @@ import { HiMiniCommandLine } from "react-icons/hi2"; import { useHotkeyDisplay } from "renderer/stores/hotkeys"; export function EmptyTabView() { - const newTerminalDisplay = useHotkeyDisplay("NEW_TERMINAL"); + const newGroupDisplay = useHotkeyDisplay("NEW_GROUP"); const openInAppDisplay = useHotkeyDisplay("OPEN_IN_APP"); const shortcuts = [ - { label: "New Terminal", display: newTerminalDisplay }, + { label: "New Tab", display: newGroupDisplay }, { label: "Open in App", display: openInAppDisplay }, ]; return ( -
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx new file mode 100644 index 000000000..5154b0d01 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -0,0 +1,79 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { HiMiniXMark } from "react-icons/hi2"; +import type { Tab } from "renderer/stores/tabs/types"; +import { getTabDisplayName } from "renderer/stores/tabs/utils"; + +interface GroupItemProps { + tab: Tab; + isActive: boolean; + needsAttention: boolean; + onSelect: () => void; + onClose: () => void; +} + +export function GroupItem({ + tab, + isActive, + needsAttention, + onSelect, + onClose, +}: GroupItemProps) { + const displayName = getTabDisplayName(tab); + + return ( +
+ + + + + + {displayName} + + + + + + + + Close group + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx new file mode 100644 index 000000000..54863a005 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -0,0 +1,220 @@ +import type { TerminalPreset } from "@superset/local-db"; +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { + HiMiniChevronDown, + HiMiniCog6Tooth, + HiMiniCommandLine, + HiMiniPlus, +} from "react-icons/hi2"; +import { + getPresetIcon, + useIsDarkTheme, +} from "renderer/assets/app-icons/preset-icons"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import { trpc } from "renderer/lib/trpc"; +import { usePresets } from "renderer/react-query/presets"; +import { useOpenSettings } from "renderer/stores"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { GroupItem } from "./GroupItem"; + +export function GroupStrip() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id; + + const allTabs = useTabsStore((s) => s.tabs); + const panes = useTabsStore((s) => s.panes); + const activeTabIds = useTabsStore((s) => s.activeTabIds); + const addTab = useTabsStore((s) => s.addTab); + const renameTab = useTabsStore((s) => s.renameTab); + const removeTab = useTabsStore((s) => s.removeTab); + const setActiveTab = useTabsStore((s) => s.setActiveTab); + + const { presets } = usePresets(); + const isDark = useIsDarkTheme(); + const openSettings = useOpenSettings(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const hoverTimeoutRef = useRef | null>(null); + + const handleDropdownMouseEnter = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + hoverTimeoutRef.current = setTimeout(() => { + setDropdownOpen(true); + }, 150); + }, []); + + const handleDropdownMouseLeave = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + hoverTimeoutRef.current = setTimeout(() => { + setDropdownOpen(false); + }, 150); + }, []); + + const tabs = useMemo( + () => + activeWorkspaceId + ? allTabs.filter((tab) => tab.workspaceId === activeWorkspaceId) + : [], + [activeWorkspaceId, allTabs], + ); + + const activeTabId = activeWorkspaceId + ? activeTabIds[activeWorkspaceId] + : null; + + // Check which tabs have panes that need attention + const tabsWithAttention = useMemo(() => { + const result = new Set(); + for (const pane of Object.values(panes)) { + if (pane.needsAttention) { + result.add(pane.tabId); + } + } + return result; + }, [panes]); + + const handleAddGroup = () => { + if (activeWorkspaceId) { + addTab(activeWorkspaceId); + } + }; + + const handleSelectPreset = (preset: TerminalPreset) => { + if (!activeWorkspaceId) return; + + const { tabId } = addTab(activeWorkspaceId, { + initialCommands: preset.commands, + initialCwd: preset.cwd || undefined, + }); + + if (preset.name) { + renameTab(tabId, preset.name); + } + + setDropdownOpen(false); + }; + + const handleOpenPresetsSettings = () => { + openSettings("presets"); + setDropdownOpen(false); + }; + + const handleSelectGroup = (tabId: string) => { + if (activeWorkspaceId) { + setActiveTab(activeWorkspaceId, tabId); + } + }; + + const handleCloseGroup = (tabId: string) => { + removeTab(tabId); + }; + + return ( +
+ {tabs.length > 0 && ( +
+ {tabs.map((tab) => ( +
+ handleSelectGroup(tab.id)} + onClose={() => handleCloseGroup(tab.id)} + /> +
+ ))} +
+ )} + +
+ + + + + + + + + + + +
+ + {presets.length > 0 && ( + <> + {presets.map((preset) => { + const presetIcon = getPresetIcon(preset.name, isDark); + return ( + handleSelectPreset(preset)} + className="gap-2" + > + {presetIcon ? ( + + ) : ( + + )} + {preset.name || "default"} + + ); + })} + + + )} + + + Configure Presets + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts new file mode 100644 index 000000000..e905a6c8b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts @@ -0,0 +1 @@ +export { GroupStrip } from "./GroupStrip"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx new file mode 100644 index 000000000..146be2b36 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -0,0 +1,304 @@ +import type * as Monaco from "monaco-editor"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { MosaicBranch } from "react-mosaic-component"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Pane } from "renderer/stores/tabs/types"; +import type { FileViewerMode } from "shared/tabs-types"; +import { BasePaneWindow } from "../components"; +import { FileViewerContent } from "./components/FileViewerContent"; +import { FileViewerToolbar } from "./components/FileViewerToolbar"; +import { useFileContent } from "./hooks/useFileContent"; +import { useFileSave } from "./hooks/useFileSave"; +import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; + +interface FileViewerPaneProps { + paneId: string; + path: MosaicBranch[]; + pane: Pane; + isActive: boolean; + tabId: string; + worktreePath: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; +} + +export function FileViewerPane({ + paneId, + path, + pane, + isActive, + tabId, + worktreePath, + splitPaneAuto, + removePane, + setFocusedPane, +}: FileViewerPaneProps) { + const editorRef = useRef(null); + const [isDirty, setIsDirty] = useState(false); + const originalContentRef = useRef(""); + const draftContentRef = useRef(null); + const originalDiffContentRef = useRef(""); + const currentDiffContentRef = useRef(""); + const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); + const [isSavingAndSwitching, setIsSavingAndSwitching] = useState(false); + const pendingModeRef = useRef(null); + + const fileViewer = pane.fileViewer; + const filePath = fileViewer?.filePath ?? ""; + const viewMode = fileViewer?.viewMode ?? "raw"; + const isLocked = fileViewer?.isLocked ?? false; + const diffCategory = fileViewer?.diffCategory; + const commitHash = fileViewer?.commitHash; + const oldPath = fileViewer?.oldPath; + const initialLine = fileViewer?.initialLine; + const initialColumn = fileViewer?.initialColumn; + + const { handleSaveRaw, handleSaveDiff, isSaving } = useFileSave({ + worktreePath, + filePath, + paneId, + diffCategory, + editorRef, + originalContentRef, + originalDiffContentRef, + draftContentRef, + setIsDirty, + }); + + const { rawFileData, isLoadingRaw, diffData, isLoadingDiff } = useFileContent( + { + worktreePath, + filePath, + viewMode, + diffCategory, + commitHash, + oldPath, + isDirty, + originalContentRef, + originalDiffContentRef, + }, + ); + + const handleEditorChange = useCallback((value: string | undefined) => { + if (value === undefined) return; + if (originalContentRef.current === "") { + originalContentRef.current = value; + return; + } + setIsDirty(value !== originalContentRef.current); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on file change only + useEffect(() => { + setIsDirty(false); + originalContentRef.current = ""; + draftContentRef.current = null; + }, [filePath]); + + const handleDiffChange = useCallback((content: string) => { + currentDiffContentRef.current = content; + if (originalDiffContentRef.current === "") { + originalDiffContentRef.current = content; + return; + } + setIsDirty(content !== originalDiffContentRef.current); + }, []); + + if (!fileViewer) { + return ( +
} + > +
+ No file viewer state +
+ + ); + } + + const handleToggleLock = () => { + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + isLocked: !currentPane.fileViewer.isLocked, + }, + }, + }, + }); + } + }; + + const switchToMode = (newMode: FileViewerMode) => { + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + viewMode: newMode, + }, + }, + }, + }); + } + }; + + const handleViewModeChange = (value: string) => { + if (!value) return; + const newMode = value as FileViewerMode; + + if (isDirty && newMode !== viewMode) { + pendingModeRef.current = newMode; + setShowUnsavedDialog(true); + return; + } + + switchToMode(newMode); + }; + + const handleSaveAndSwitch = async () => { + if (!pendingModeRef.current) return; + + setIsSavingAndSwitching(true); + try { + if (viewMode === "raw" && editorRef.current) { + const savedContent = editorRef.current.getValue(); + await handleSaveRaw(); + originalContentRef.current = savedContent; + originalDiffContentRef.current = ""; + } else if ( + viewMode === "diff" && + currentDiffContentRef.current !== undefined + ) { + const savedContent = currentDiffContentRef.current; + await handleSaveDiff(savedContent); + originalDiffContentRef.current = savedContent; + originalContentRef.current = ""; + } + + setIsDirty(false); + draftContentRef.current = null; + currentDiffContentRef.current = ""; + + switchToMode(pendingModeRef.current); + pendingModeRef.current = null; + setShowUnsavedDialog(false); + } catch (error) { + console.error("[FileViewerPane] Save failed:", error); + } finally { + setIsSavingAndSwitching(false); + } + }; + + const handleDiscardAndSwitch = () => { + if (!pendingModeRef.current) return; + + if (viewMode === "raw" && editorRef.current) { + editorRef.current.setValue(originalContentRef.current); + } + + setIsDirty(false); + draftContentRef.current = null; + currentDiffContentRef.current = ""; + + switchToMode(pendingModeRef.current); + pendingModeRef.current = null; + }; + + const fileName = filePath.split("/").pop() || filePath; + const isMarkdown = + filePath.endsWith(".md") || + filePath.endsWith(".markdown") || + filePath.endsWith(".mdx"); + const hasDiff = !!diffCategory; + const hasDraft = draftContentRef.current !== null; + const isDiffEditable = + (diffCategory === "staged" || diffCategory === "unstaged") && !hasDraft; + const showEditableBadge = + viewMode === "raw" || (viewMode === "diff" && isDiffEditable); + + return ( + <> + ( +
+ +
+ )} + > + +
+ + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx new file mode 100644 index 000000000..98c47db44 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx @@ -0,0 +1,74 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { LuLoader } from "react-icons/lu"; + +interface UnsavedChangesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaveAndSwitch: () => void; + onDiscardAndSwitch: () => void; + isSaving?: boolean; +} + +export function UnsavedChangesDialog({ + open, + onOpenChange, + onSaveAndSwitch, + onDiscardAndSwitch, + isSaving = false, +}: UnsavedChangesDialogProps) { + const handleSaveAndSwitch = (e: React.MouseEvent) => { + e.preventDefault(); + onSaveAndSwitch(); + // Don't close dialog - parent will close on success + }; + + const handleDiscardAndSwitch = (e: React.MouseEvent) => { + e.preventDefault(); + onDiscardAndSwitch(); + onOpenChange(false); + }; + + return ( + + + + Unsaved Changes + + You have unsaved changes. What would you like to do? + + + + Cancel + + + {isSaving ? ( + <> + + Saving... + + ) : ( + "Save & Switch" + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx new file mode 100644 index 000000000..f6945203d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx @@ -0,0 +1,232 @@ +import Editor, { type OnMount } from "@monaco-editor/react"; +import type * as Monaco from "monaco-editor"; +import { type MutableRefObject, useCallback, useEffect, useRef } from "react"; +import { LuLoader } from "react-icons/lu"; +import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; +import { + MONACO_EDITOR_OPTIONS, + registerSaveAction, + SUPERSET_THEME, + useMonacoReady, +} from "renderer/contexts/MonacoProvider"; +import { detectLanguage } from "shared/detect-language"; +import type { FileViewerMode } from "shared/tabs-types"; +import { DiffViewer } from "../../../../../ChangesContent/components/DiffViewer"; + +interface RawFileData { + ok: true; + content: string; +} + +interface RawFileError { + ok: false; + reason: + | "too-large" + | "binary" + | "outside-worktree" + | "symlink-escape" + | "not-found"; +} + +type RawFileResult = RawFileData | RawFileError | undefined; + +interface DiffData { + original: string; + modified: string; + language: string; +} + +interface FileViewerContentProps { + viewMode: FileViewerMode; + filePath: string; + isLoadingRaw: boolean; + isLoadingDiff: boolean; + rawFileData: RawFileResult; + diffData: DiffData | undefined; + isDiffEditable: boolean; + editorRef: MutableRefObject; + originalContentRef: MutableRefObject; + draftContentRef: MutableRefObject; + initialLine?: number; + initialColumn?: number; + onSaveRaw: () => Promise; + onSaveDiff?: (content: string) => Promise; + onEditorChange: (value: string | undefined) => void; + onDiffChange?: (content: string) => void; + setIsDirty: (dirty: boolean) => void; +} + +export function FileViewerContent({ + viewMode, + filePath, + isLoadingRaw, + isLoadingDiff, + rawFileData, + diffData, + isDiffEditable, + editorRef, + originalContentRef, + draftContentRef, + initialLine, + initialColumn, + onSaveRaw, + onSaveDiff, + onEditorChange, + onDiffChange, + setIsDirty, +}: FileViewerContentProps) { + const isMonacoReady = useMonacoReady(); + const hasAppliedInitialLocationRef = useRef(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on file change only + useEffect(() => { + hasAppliedInitialLocationRef.current = false; + }, [filePath]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Only reset when coordinates change + useEffect(() => { + hasAppliedInitialLocationRef.current = false; + }, [initialLine, initialColumn]); + + const handleEditorMount: OnMount = useCallback( + (editor) => { + editorRef.current = editor; + if (!draftContentRef.current) { + originalContentRef.current = editor.getValue(); + } + setIsDirty(editor.getValue() !== originalContentRef.current); + registerSaveAction(editor, onSaveRaw); + }, + [onSaveRaw, editorRef, originalContentRef, draftContentRef, setIsDirty], + ); + + useEffect(() => { + if ( + viewMode !== "raw" || + !editorRef.current || + !initialLine || + hasAppliedInitialLocationRef.current || + isLoadingRaw || + !rawFileData?.ok + ) { + return; + } + + const editor = editorRef.current; + const model = editor.getModel(); + if (!model) return; + + const lineCount = model.getLineCount(); + const safeLine = Math.max(1, Math.min(initialLine, lineCount)); + const maxColumn = model.getLineMaxColumn(safeLine); + const safeColumn = Math.max(1, Math.min(initialColumn ?? 1, maxColumn)); + + const position = { lineNumber: safeLine, column: safeColumn }; + editor.setPosition(position); + editor.revealPositionInCenter(position); + editor.focus(); + + hasAppliedInitialLocationRef.current = true; + }, [ + viewMode, + initialLine, + initialColumn, + isLoadingRaw, + rawFileData, + editorRef, + ]); + + if (viewMode === "diff") { + if (isLoadingDiff) { + return ( +
+ Loading diff... +
+ ); + } + if (!diffData) { + return ( +
+ No diff available +
+ ); + } + return ( + + ); + } + + if (isLoadingRaw) { + return ( +
+ Loading... +
+ ); + } + + if (!rawFileData?.ok) { + const errorMessage = + rawFileData?.reason === "too-large" + ? "File is too large to preview" + : rawFileData?.reason === "binary" + ? "Binary file preview not supported" + : rawFileData?.reason === "outside-worktree" + ? "File is outside worktree" + : rawFileData?.reason === "symlink-escape" + ? "File is a symlink pointing outside worktree" + : "File not found"; + return ( +
+ {errorMessage} +
+ ); + } + + if (viewMode === "rendered") { + return ( +
+ +
+ ); + } + + if (!isMonacoReady) { + return ( +
+ + Loading editor... +
+ ); + } + + return ( + + + Loading editor... +
+ } + options={MONACO_EDITOR_OPTIONS} + /> + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/index.ts new file mode 100644 index 000000000..15337eaf3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/index.ts @@ -0,0 +1 @@ +export { FileViewerContent } from "./FileViewerContent"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx new file mode 100644 index 000000000..7232e304b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx @@ -0,0 +1,113 @@ +import { Badge } from "@superset/ui/badge"; +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { + HiMiniLockClosed, + HiMiniLockOpen, + HiMiniPencil, +} from "react-icons/hi2"; +import type { FileViewerMode } from "shared/tabs-types"; +import { PaneToolbarActions } from "../../../components"; +import type { SplitOrientation } from "../../../hooks"; + +interface FileViewerToolbarProps { + fileName: string; + isDirty: boolean; + isSaving: boolean; + viewMode: FileViewerMode; + isLocked: boolean; + isMarkdown: boolean; + hasDiff: boolean; + showEditableBadge: boolean; + splitOrientation: SplitOrientation; + onViewModeChange: (value: string) => void; + onSplitPane: (e: React.MouseEvent) => void; + onToggleLock: () => void; + onClosePane: (e: React.MouseEvent) => void; +} + +export function FileViewerToolbar({ + fileName, + isDirty, + isSaving, + viewMode, + isLocked, + isMarkdown, + hasDiff, + showEditableBadge, + splitOrientation, + onViewModeChange, + onSplitPane, + onToggleLock, + onClosePane, +}: FileViewerToolbarProps) { + return ( +
+
+ + {isDirty && } + {fileName} + + {showEditableBadge && ( + + + {isSaving ? "Saving..." : "⌘S"} + + )} +
+
+ + {isMarkdown && ( + + Rendered + + )} + + Raw + + {hasDiff && ( + + Diff + + )} + + + + + + + {isLocked + ? "Unlock (allow file replacement)" + : "Lock (prevent file replacement)"} + + + } + /> +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/index.ts new file mode 100644 index 000000000..843916763 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/index.ts @@ -0,0 +1 @@ +export { FileViewerToolbar } from "./FileViewerToolbar"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/index.ts new file mode 100644 index 000000000..ba24b334c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/index.ts @@ -0,0 +1 @@ +export { useFileContent } from "./useFileContent"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts new file mode 100644 index 000000000..2c4bbd68f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts @@ -0,0 +1,79 @@ +import { useEffect } from "react"; +import { trpc } from "renderer/lib/trpc"; +import type { ChangeCategory } from "shared/changes-types"; + +interface UseFileContentParams { + worktreePath: string; + filePath: string; + viewMode: "raw" | "diff" | "rendered"; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; + isDirty: boolean; + originalContentRef: React.MutableRefObject; + originalDiffContentRef: React.MutableRefObject; +} + +export function useFileContent({ + worktreePath, + filePath, + viewMode, + diffCategory, + commitHash, + oldPath, + isDirty, + originalContentRef, + originalDiffContentRef, +}: UseFileContentParams) { + const { data: branchData } = trpc.changes.getBranches.useQuery( + { worktreePath }, + { enabled: !!worktreePath && diffCategory === "against-base" }, + ); + const effectiveBaseBranch = branchData?.defaultBranch ?? "main"; + + const { data: rawFileData, isLoading: isLoadingRaw } = + trpc.changes.readWorkingFile.useQuery( + { worktreePath, filePath }, + { + enabled: viewMode !== "diff" && !!filePath && !!worktreePath, + }, + ); + + const { data: diffData, isLoading: isLoadingDiff } = + trpc.changes.getFileContents.useQuery( + { + worktreePath, + filePath, + oldPath, + category: diffCategory ?? "unstaged", + commitHash, + defaultBranch: + diffCategory === "against-base" ? effectiveBaseBranch : undefined, + }, + { + enabled: + viewMode === "diff" && !!diffCategory && !!filePath && !!worktreePath, + }, + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when content loads + useEffect(() => { + if (rawFileData?.ok === true && !isDirty) { + originalContentRef.current = rawFileData.content; + } + }, [rawFileData]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when diff loads + useEffect(() => { + if (diffData?.modified && !isDirty) { + originalDiffContentRef.current = diffData.modified; + } + }, [diffData]); + + return { + rawFileData, + isLoadingRaw, + diffData, + isLoadingDiff, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/index.ts new file mode 100644 index 000000000..215fc763c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/index.ts @@ -0,0 +1 @@ +export { useFileSave } from "./useFileSave"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts new file mode 100644 index 000000000..074553ac2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts @@ -0,0 +1,103 @@ +import type * as Monaco from "monaco-editor"; +import { type MutableRefObject, useCallback, useRef } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { ChangeCategory } from "shared/changes-types"; + +interface UseFileSaveParams { + worktreePath: string; + filePath: string; + paneId: string; + diffCategory?: ChangeCategory; + editorRef: MutableRefObject; + originalContentRef: MutableRefObject; + originalDiffContentRef: MutableRefObject; + draftContentRef: MutableRefObject; + setIsDirty: (dirty: boolean) => void; +} + +export function useFileSave({ + worktreePath, + filePath, + paneId, + diffCategory, + editorRef, + originalContentRef, + originalDiffContentRef, + draftContentRef, + setIsDirty, +}: UseFileSaveParams) { + const savingFromRawRef = useRef(false); + const savingDiffContentRef = useRef(null); + const utils = trpc.useUtils(); + + const saveFileMutation = trpc.changes.saveFile.useMutation({ + onSuccess: () => { + setIsDirty(false); + if (editorRef.current) { + originalContentRef.current = editorRef.current.getValue(); + } + if (savingDiffContentRef.current !== null) { + originalDiffContentRef.current = savingDiffContentRef.current; + savingDiffContentRef.current = null; + } + if (savingFromRawRef.current) { + draftContentRef.current = null; + } + savingFromRawRef.current = false; + + utils.changes.readWorkingFile.invalidate(); + utils.changes.getFileContents.invalidate(); + utils.changes.getStatus.invalidate(); + + if (diffCategory === "staged") { + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + diffCategory: "unstaged", + }, + }, + }, + }); + } + } + }, + }); + + const handleSaveRaw = useCallback(async () => { + if (!editorRef.current || !filePath || !worktreePath) return; + savingFromRawRef.current = true; + await saveFileMutation.mutateAsync({ + worktreePath, + filePath, + content: editorRef.current.getValue(), + }); + }, [worktreePath, filePath, saveFileMutation, editorRef]); + + const handleSaveDiff = useCallback( + async (content: string) => { + if (!filePath || !worktreePath) return; + savingFromRawRef.current = false; + savingDiffContentRef.current = content; + await saveFileMutation.mutateAsync({ + worktreePath, + filePath, + content, + }); + }, + [worktreePath, filePath, saveFileMutation], + ); + + return { + handleSaveRaw, + handleSaveDiff, + isSaving: saveFileMutation.isPending, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts new file mode 100644 index 000000000..96c33fa0b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts @@ -0,0 +1 @@ +export { FileViewerPane } from "./FileViewerPane"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index 6e09a646d..a72f6e168 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -1,10 +1,5 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useEffect, useRef, useState } from "react"; -import { HiMiniXMark } from "react-icons/hi2"; -import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; +import { useEffect, useRef } from "react"; import type { MosaicBranch } from "react-mosaic-component"; -import { MosaicWindow } from "react-mosaic-component"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { registerPaneRef, unregisterPaneRef, @@ -14,8 +9,7 @@ import type { Pane, Tab } from "renderer/stores/tabs/types"; import { TabContentContextMenu } from "../TabContentContextMenu"; import { Terminal } from "../Terminal"; import { DirectoryNavigator } from "../Terminal/DirectoryNavigator"; - -type SplitOrientation = "vertical" | "horizontal"; +import { BasePaneWindow, PaneToolbarActions } from "./components"; interface TabPaneProps { paneId: string; @@ -63,12 +57,11 @@ export function TabPane({ onMoveToTab, onMoveToNewTab, }: TabPaneProps) { - const containerRef = useRef(null); - const [splitOrientation, setSplitOrientation] = - useState("vertical"); + const terminalContainerRef = useRef(null); + const getClearCallback = useTerminalCallbacksStore((s) => s.getClearCallback); useEffect(() => { - const container = containerRef.current; + const container = terminalContainerRef.current; if (container) { registerPaneRef(paneId, container); } @@ -77,61 +70,21 @@ export function TabPane({ }; }, [paneId]); - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const updateOrientation = () => { - const { width, height } = container.getBoundingClientRect(); - setSplitOrientation(width >= height ? "vertical" : "horizontal"); - }; - - updateOrientation(); - - const resizeObserver = new ResizeObserver(updateOrientation); - resizeObserver.observe(container); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - const handleFocus = () => { - setFocusedPane(tabId, paneId); - }; - - const handleClosePane = (e: React.MouseEvent) => { - e.stopPropagation(); - removePane(paneId); - }; - - const handleSplitPane = (e: React.MouseEvent) => { - e.stopPropagation(); - const container = containerRef.current; - if (!container) return; - - const { width, height } = container.getBoundingClientRect(); - splitPaneAuto(tabId, paneId, { width, height }, path); - }; - - const getClearCallback = useTerminalCallbacksStore((s) => s.getClearCallback); const handleClearTerminal = () => { getClearCallback(paneId)?.(); }; - const splitIcon = - splitOrientation === "vertical" ? ( - - ) : ( - - ); - return ( - + ( -
+ tabId={tabId} + isActive={isActive} + splitPaneAuto={splitPaneAuto} + removePane={removePane} + setFocusedPane={setFocusedPane} + renderToolbar={(handlers) => ( +
-
- - - - - - - - - - - - - - - - -
+
)} - className={isActive ? "mosaic-window-focused" : ""} > splitPaneHorizontal(tabId, paneId, path)} @@ -189,15 +111,10 @@ export function TabPane({ onMoveToTab={onMoveToTab} onMoveToNewTab={onMoveToNewTab} > - {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Terminal handles its own keyboard events and focus */} -
+
- + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx new file mode 100644 index 000000000..7e7c3d0b2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx @@ -0,0 +1,89 @@ +import { useRef } from "react"; +import type { MosaicBranch } from "react-mosaic-component"; +import { MosaicWindow } from "react-mosaic-component"; +import type { SplitOrientation } from "../../hooks"; +import { useSplitOrientation } from "../../hooks"; + +export interface PaneHandlers { + onFocus: () => void; + onClosePane: (e: React.MouseEvent) => void; + onSplitPane: (e: React.MouseEvent) => void; + splitOrientation: SplitOrientation; +} + +interface BasePaneWindowProps { + paneId: string; + path: MosaicBranch[]; + tabId: string; + isActive: boolean; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; + renderToolbar: (handlers: PaneHandlers) => React.ReactElement; + children: React.ReactNode; + contentClassName?: string; +} + +export function BasePaneWindow({ + paneId, + path, + tabId, + isActive, + splitPaneAuto, + removePane, + setFocusedPane, + renderToolbar, + children, + contentClassName = "w-full h-full overflow-hidden", +}: BasePaneWindowProps) { + const containerRef = useRef(null); + const splitOrientation = useSplitOrientation(containerRef); + + const handleFocus = () => { + setFocusedPane(tabId, paneId); + }; + + const handleClosePane = (e: React.MouseEvent) => { + e.stopPropagation(); + removePane(paneId); + }; + + const handleSplitPane = (e: React.MouseEvent) => { + e.stopPropagation(); + const container = containerRef.current; + if (!container) return; + + const { width, height } = container.getBoundingClientRect(); + splitPaneAuto(tabId, paneId, { width, height }, path); + }; + + const handlers: PaneHandlers = { + onFocus: handleFocus, + onClosePane: handleClosePane, + onSplitPane: handleSplitPane, + splitOrientation, + }; + + return ( + + path={path} + title="" + renderToolbar={() => renderToolbar(handlers)} + className={isActive ? "mosaic-window-focused" : ""} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Focus handler for pane */} +
+ {children} +
+ + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/index.ts new file mode 100644 index 000000000..b93b72278 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/index.ts @@ -0,0 +1 @@ +export { BasePaneWindow, type PaneHandlers } from "./BasePaneWindow"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx new file mode 100644 index 000000000..5d15fbd1f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx @@ -0,0 +1,64 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { HiMiniXMark } from "react-icons/hi2"; +import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import type { HotkeyId } from "shared/hotkeys"; +import type { SplitOrientation } from "../../hooks"; + +interface PaneToolbarActionsProps { + splitOrientation: SplitOrientation; + onSplitPane: (e: React.MouseEvent) => void; + onClosePane: (e: React.MouseEvent) => void; + leadingActions?: React.ReactNode; + /** Hotkey ID to display for the close action. Defaults to CLOSE_PANE. */ + closeHotkeyId?: HotkeyId; +} + +export function PaneToolbarActions({ + splitOrientation, + onSplitPane, + onClosePane, + leadingActions, + closeHotkeyId = "CLOSE_PANE", +}: PaneToolbarActionsProps) { + const splitIcon = + splitOrientation === "vertical" ? ( + + ) : ( + + ); + + return ( +
+ {leadingActions} + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/index.ts new file mode 100644 index 000000000..adc90aacc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/index.ts @@ -0,0 +1 @@ +export { PaneToolbarActions } from "./PaneToolbarActions"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/index.ts new file mode 100644 index 000000000..81c4b584b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/index.ts @@ -0,0 +1,2 @@ +export { BasePaneWindow, type PaneHandlers } from "./BasePaneWindow"; +export { PaneToolbarActions } from "./PaneToolbarActions"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/index.ts new file mode 100644 index 000000000..7ade988b9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/index.ts @@ -0,0 +1,4 @@ +export { + type SplitOrientation, + useSplitOrientation, +} from "./useSplitOrientation"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/index.ts new file mode 100644 index 000000000..7ade988b9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/index.ts @@ -0,0 +1,4 @@ +export { + type SplitOrientation, + useSplitOrientation, +} from "./useSplitOrientation"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts new file mode 100644 index 000000000..a239d6e86 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts @@ -0,0 +1,31 @@ +import { type RefObject, useEffect, useState } from "react"; + +export type SplitOrientation = "vertical" | "horizontal"; + +export function useSplitOrientation( + containerRef: RefObject, +): SplitOrientation { + const [splitOrientation, setSplitOrientation] = + useState("vertical"); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateOrientation = () => { + const { width, height } = container.getBoundingClientRect(); + setSplitOrientation(width >= height ? "vertical" : "horizontal"); + }; + + updateOrientation(); + + const resizeObserver = new ResizeObserver(updateOrientation); + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, [containerRef]); + + return splitOrientation; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index b6df3608e..766f486ae 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -8,6 +8,7 @@ import { type MosaicNode, } from "react-mosaic-component"; import { dragDropManager } from "renderer/lib/dnd"; +import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { Pane, Tab } from "renderer/stores/tabs/types"; import { @@ -15,6 +16,7 @@ import { extractPaneIdsFromLayout, getPaneIdsForTab, } from "renderer/stores/tabs/utils"; +import { FileViewerPane } from "./FileViewerPane"; import { TabPane } from "./TabPane"; interface TabViewProps { @@ -35,6 +37,10 @@ export function TabView({ tab, panes }: TabViewProps) { const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const allTabs = useTabsStore((s) => s.tabs); + // Get worktree path for file viewer panes + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const worktreePath = activeWorkspace?.worktreePath ?? ""; + // Get tabs in the same workspace for move targets const workspaceTabs = allTabs.filter( (t) => t.workspaceId === tab.workspaceId, @@ -90,6 +96,31 @@ export function TabView({ tab, panes }: TabViewProps) { ); } + // Route file-viewer panes to FileViewerPane component + if (pane.type === "file-viewer") { + if (!worktreePath) { + return ( +
+ Workspace path unavailable +
+ ); + } + return ( + + ); + } + + // Default: terminal panes return ( { const setTabAutoTitle = useTabsStore((s) => s.setTabAutoTitle); const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); const terminalTheme = useTerminalTheme(); // Ref for initial theme to avoid recreating terminal on theme change @@ -68,6 +71,76 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const { data: workspaceCwd } = trpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + // Query terminal link behavior setting + const { data: terminalLinkBehavior } = + trpc.settings.getTerminalLinkBehavior.useQuery(); + + // Handler for file link clicks - uses current setting value + const handleFileLinkClick = useCallback( + (path: string, line?: number, column?: number) => { + const behavior = terminalLinkBehavior ?? "external-editor"; + + // Helper to open in external editor + const openInExternalEditor = () => { + trpcClient.external.openFileInEditor + .mutate({ + path, + line, + column, + cwd: workspaceCwd ?? undefined, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + path, + error, + ); + toast.error("Failed to open file in editor", { + description: path, + }); + }); + }; + + if (behavior === "file-viewer") { + // If workspaceCwd is not loaded yet, fall back to external editor + // This prevents confusing errors when the workspace is still initializing + if (!workspaceCwd) { + console.warn( + "[Terminal] workspaceCwd not loaded, falling back to external editor", + ); + openInExternalEditor(); + return; + } + + // Normalize absolute paths to worktree-relative paths for file viewer + // File viewer expects relative paths, but terminal links can be absolute + let filePath = path; + // Use path boundary check to avoid incorrect prefix stripping + // e.g., /repo vs /repo-other should not match + if (path === workspaceCwd) { + filePath = "."; + } else if (path.startsWith(`${workspaceCwd}/`)) { + filePath = path.slice(workspaceCwd.length + 1); + } else if (path.startsWith("/")) { + // Absolute path outside workspace - show warning and don't attempt to open + toast.warning("File is outside the workspace", { + description: + "Switch to 'External editor' in Settings to open this file", + }); + return; + } + addFileViewerPane(workspaceId, { filePath, line, column }); + } else { + openInExternalEditor(); + } + }, + [terminalLinkBehavior, workspaceId, workspaceCwd, addFileViewerPane], + ); + + // Ref to avoid terminal recreation when callback changes + const handleFileLinkClickRef = useRef(handleFileLinkClick); + handleFileLinkClickRef.current = handleFileLinkClick; + // Seed cwd from initialCwd or workspace path (shell spawns there) // OSC-7 will override if/when the shell reports directory changes useEffect(() => { @@ -197,11 +270,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm, fitAddon, cleanup: cleanupQuerySuppression, - } = createTerminalInstance( - container, - workspaceCwd ?? undefined, - initialThemeRef.current, - ); + } = createTerminalInstance(container, { + cwd: workspaceCwd, + initialTheme: initialThemeRef.current, + onFileLinkClick: (path, line, column) => + handleFileLinkClickRef.current(path, line, column), + }); xtermRef.current = xterm; fitAddonRef.current = fitAddon; isExitedRef.current = false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 3c92f907d..fbd1b980b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -93,19 +93,26 @@ function loadRenderer(xterm: XTerm): { dispose: () => void } { }; } +export interface CreateTerminalOptions { + cwd?: string; + initialTheme?: ITheme | null; + onFileLinkClick?: (path: string, line?: number, column?: number) => void; +} + export function createTerminalInstance( container: HTMLDivElement, - cwd?: string, - initialTheme?: ITheme | null, + options: CreateTerminalOptions = {}, ): { xterm: XTerm; fitAddon: FitAddon; cleanup: () => void; } { + const { cwd, initialTheme, onFileLinkClick } = options; + // Use provided theme, or fall back to localStorage-based default to prevent flash const theme = initialTheme ?? getDefaultTerminalTheme(); - const options = { ...TERMINAL_OPTIONS, theme }; - const xterm = new XTerm(options); + const terminalOptions = { ...TERMINAL_OPTIONS, theme }; + const xterm = new XTerm(terminalOptions); const fitAddon = new FitAddon(); const clipboardAddon = new ClipboardAddon(); @@ -149,20 +156,25 @@ export function createTerminalInstance( const filePathLinkProvider = new FilePathLinkProvider( xterm, (_event, path, line, column) => { - trpcClient.external.openFileInEditor - .mutate({ - path, - line, - column, - cwd, - }) - .catch((error) => { - console.error( - "[Terminal] Failed to open file in editor:", + if (onFileLinkClick) { + onFileLinkClick(path, line, column); + } else { + // Fallback to default behavior (external editor) + trpcClient.external.openFileInEditor + .mutate({ path, - error, - ); - }); + line, + column, + cwd, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + path, + error, + ); + }); + } }, ); xterm.registerLinkProvider(filePathLinkProvider); @@ -233,7 +245,7 @@ export function setupPasteHandler( /** * Setup keyboard handling for xterm including: - * - Shortcut forwarding: App hotkeys are re-dispatched to document for react-hotkeys-hook + * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) * - Clear terminal: Uses the configured clear shortcut * diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index e04866eb6..b62872350 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { ResizableSidebar } from "../../../WorkspaceView/ResizableSidebar"; import { EmptyTabView } from "./EmptyTabView"; import { TabView } from "./TabView"; @@ -19,9 +20,16 @@ export function TabsContent() { return allTabs.find((tab) => tab.id === activeTabId) || null; }, [activeWorkspaceId, activeTabIds, allTabs]); - if (!tabToRender) { - return ; - } - - return ; + return ( +
+
+ {tabToRender ? ( + + ) : ( + + )} +
+ +
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 1a2e20bd0..ecc345fae 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,19 +1,15 @@ -import { SidebarMode, useSidebarStore } from "renderer/stores"; -import { ChangesContent } from "./ChangesContent"; +import { SidebarControl } from "../../SidebarControl"; +import { ContentHeader } from "./ContentHeader"; import { TabsContent } from "./TabsContent"; +import { GroupStrip } from "./TabsContent/GroupStrip"; export function ContentView() { - const { currentMode } = useSidebarStore(); - - if (currentMode === SidebarMode.Changes) { - return ( -
-
- -
-
- ); - } - - return ; + return ( +
+ }> + + + +
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ResizableSidebar/ResizableSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ResizableSidebar/ResizableSidebar.tsx index 05d9d7b04..f6a00b367 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ResizableSidebar/ResizableSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ResizableSidebar/ResizableSidebar.tsx @@ -33,8 +33,8 @@ export function ResizableSidebar() { (e: MouseEvent) => { if (!isResizing) return; - const delta = e.clientX - startXRef.current; - const newWidth = startWidthRef.current + delta; + const draggedLeftBy = startXRef.current - e.clientX; + const newWidth = startWidthRef.current + draggedLeftBy; const clampedWidth = Math.max( MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, newWidth), @@ -87,8 +87,8 @@ export function ResizableSidebar() { tabIndex={0} onMouseDown={handleMouseDown} className={cn( - "absolute top-0 -right-2 w-5 h-full cursor-col-resize z-10", - "after:absolute after:top-0 after:left-2 after:w-1 after:h-full after:transition-colors", + "absolute top-0 -left-2 w-5 h-full cursor-col-resize z-10", + "after:absolute after:top-0 after:right-2 after:w-1 after:h-full after:transition-colors", "hover:after:bg-border focus:outline-none focus:after:bg-border", isResizing && "after:bg-border", )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx index ee0ec6d2e..2ba11f811 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx @@ -6,13 +6,22 @@ import { HiMiniMinus, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useChangesStore } from "renderer/stores/changes"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; + import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; import { CommitItem } from "./components/CommitItem"; import { FileList } from "./components/FileList"; -export function ChangesView() { +interface ChangesViewProps { + onFileOpen?: ( + file: ChangedFile, + category: ChangeCategory, + commitHash?: string, + ) => void; +} + +export function ChangesView({ onFileOpen }: ChangesViewProps) { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const worktreePath = activeWorkspace?.worktreePath; @@ -128,11 +137,13 @@ export function ChangesView() { const handleFileSelect = (file: ChangedFile, category: ChangeCategory) => { if (!worktreePath) return; selectFile(worktreePath, file, category, null); + onFileOpen?.(file, category); }; const handleCommitFileSelect = (file: ChangedFile, commitHash: string) => { if (!worktreePath) return; selectFile(worktreePath, file, "committed", commitHash); + onFileOpen?.(file, "committed", commitHash); }; const handleCommitToggle = (hash: string) => { @@ -206,6 +217,7 @@ export function ChangesView() { viewMode={fileListViewMode} onViewModeChange={setFileListViewMode} worktreePath={worktreePath} + workspaceId={activeWorkspace?.id} /> - {availableBranches.map((branch) => ( - - {branch} - {branch === branchData.defaultBranch && ( - (default) - )} - - ))} + {availableBranches + .filter((branch) => branch) + .map((branch) => ( + + {branch} + {branch === branchData.defaultBranch && ( + (default) + )} + + ))}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 0c93c0153..e1e2b8421 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -8,7 +8,10 @@ import { } from "@superset/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { HiArrowPath } from "react-icons/hi2"; +import { LuLoaderCircle } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; +import { PRIcon } from "renderer/screens/main/components/PRIcon"; +import { usePRStatus } from "renderer/screens/main/hooks"; import { useChangesStore } from "renderer/stores/changes"; import type { ChangesViewMode } from "../../types"; import { ViewModeToggle } from "../ViewModeToggle"; @@ -21,6 +24,7 @@ interface ChangesHeaderProps { viewMode: ChangesViewMode; onViewModeChange: (mode: ChangesViewMode) => void; worktreePath: string; + workspaceId?: string; } export function ChangesHeader({ @@ -31,6 +35,7 @@ export function ChangesHeader({ viewMode, onViewModeChange, worktreePath, + workspaceId, }: ChangesHeaderProps) { const { baseBranch, setBaseBranch } = useChangesStore(); @@ -39,6 +44,11 @@ export function ChangesHeader({ { enabled: !!worktreePath }, ); + const { pr, isLoading: isPRLoading } = usePRStatus({ + workspaceId, + refetchInterval: 10000, + }); + const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? "main"; const availableBranches = branchData?.remote ?? []; @@ -74,16 +84,18 @@ export function ChangesHeader({ - {sortedBranches.map((branch) => ( - - {branch} - {branch === branchData.defaultBranch && ( - - (default) - - )} - - ))} + {sortedBranches + .filter((branch) => branch) + .map((branch) => ( + + {branch} + {branch === branchData.defaultBranch && ( + + (default) + + )} + + ))} @@ -113,6 +125,30 @@ export function ChangesHeader({ Refresh changes + + {/* PR Status Icon */} + {isPRLoading ? ( + + ) : pr ? ( + + +
+ + + #{pr.number} + + + + + View PR on GitHub + + + ) : null}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PresetContextMenu/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PresetContextMenu/index.tsx deleted file mode 100644 index a9b7009d4..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PresetContextMenu/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import type React from "react"; - -interface PresetContextMenuProps { - hasActiveTab: boolean; - tooltipText?: string; - onOpenAsNewTab: () => void; - onOpenAsPane: () => void; - children: React.ReactNode; -} - -export function PresetContextMenu({ - hasActiveTab, - tooltipText, - onOpenAsNewTab, - onOpenAsPane, - children, -}: PresetContextMenuProps) { - const contextMenuContent = ( - - - Open as New Tab - - {hasActiveTab && ( - <> - - - Open as Pane in Current Tab - - - )} - - ); - - if (!tooltipText) { - return ( - - {children} - {contextMenuContent} - - ); - } - - return ( - - - - {children} - - {contextMenuContent} - - {tooltipText} - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/TabContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/TabContextMenu.tsx deleted file mode 100644 index 0ce5492de..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/TabContextMenu.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import type React from "react"; - -interface TabContextMenuProps { - paneCount: number; - onClose: () => void; - onRename: () => void; - children: React.ReactNode; -} - -export function TabContextMenu({ - paneCount, - onClose, - onRename, - children, -}: TabContextMenuProps) { - const hasMultiplePanes = paneCount > 1; - - const handleRenameSelect = (event: Event) => { - // Prevent default to stop Radix from restoring focus to the trigger - event.preventDefault(); - onRename(); - }; - - const contextMenuContent = ( - - {children} - - - Rename Tab - - - - Close Tab - - - - ); - - if (!hasMultiplePanes) { - return contextMenuContent; - } - - return ( - - - - {children} - - - - Rename Tab - - - - Close Tab - - - - -
{paneCount} terminals
-
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx deleted file mode 100644 index 07a860bed..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useDrag, useDrop } from "react-dnd"; -import { HiMiniCommandLine, HiMiniXMark } from "react-icons/hi2"; -import { trpc } from "renderer/lib/trpc"; -import { useTabsStore } from "renderer/stores/tabs/store"; -import type { Tab } from "renderer/stores/tabs/types"; -import { getTabDisplayName } from "renderer/stores/tabs/utils"; -import { TabContextMenu } from "./TabContextMenu"; - -const DRAG_TYPE = "TAB"; - -interface DragItem { - type: typeof DRAG_TYPE; - tabId: string; - index: number; -} - -interface TabItemProps { - tab: Tab; - index: number; - isActive: boolean; -} - -export function TabItem({ tab, index, isActive }: TabItemProps) { - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id; - const removeTab = useTabsStore((s) => s.removeTab); - const setActiveTab = useTabsStore((s) => s.setActiveTab); - const renameTab = useTabsStore((s) => s.renameTab); - const panes = useTabsStore((s) => s.panes); - const needsAttention = useTabsStore((s) => - Object.values(s.panes).some((p) => p.tabId === tab.id && p.needsAttention), - ); - - const paneCount = useMemo( - () => Object.values(panes).filter((p) => p.tabId === tab.id).length, - [panes, tab.id], - ); - - const [isRenaming, setIsRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(""); - const inputRef = useRef(null); - - // Drag source for tab reordering - const [{ isDragging }, drag] = useDrag< - DragItem, - void, - { isDragging: boolean } - >({ - type: DRAG_TYPE, - item: { type: DRAG_TYPE, tabId: tab.id, index }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }); - - // Drop target (just for visual feedback, actual drop is handled by parent) - const [{ isDragOver }, drop] = useDrop< - DragItem, - void, - { isDragOver: boolean } - >({ - accept: DRAG_TYPE, - collect: (monitor) => ({ - isDragOver: monitor.isOver(), - }), - }); - - const displayName = getTabDisplayName(tab); - - const handleRemoveTab = (e?: React.MouseEvent) => { - e?.stopPropagation(); - removeTab(tab.id); - }; - - const handleTabClick = () => { - if (isRenaming) return; - if (activeWorkspaceId) { - setActiveTab(activeWorkspaceId, tab.id); - } - }; - - const startRename = () => { - setRenameValue(tab.userTitle ?? tab.name ?? displayName); - setIsRenaming(true); - }; - - // Focus input when entering rename mode - useEffect(() => { - if (isRenaming && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isRenaming]); - - const submitRename = () => { - const trimmedValue = renameValue.trim(); - const currentUserTitle = tab.userTitle?.trim() ?? ""; - if (trimmedValue !== currentUserTitle) { - renameTab(tab.id, trimmedValue); - } - setIsRenaming(false); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - submitRename(); - } else if (e.key === "Escape") { - setIsRenaming(false); - } - }; - - const attachRef = (el: HTMLElement | null) => { - drag(el); - drop(el); - }; - - // When renaming, render outside TabContextMenu to avoid Radix focus interference - if (isRenaming) { - return ( -
-
-
- -
- setRenameValue(e.target.value)} - onBlur={submitRename} - onKeyDown={handleKeyDown} - onClick={(e) => e.stopPropagation()} - className="flex-1" - /> -
-
- -
-
- ); - } - - return ( -
- -
- - -
-
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabsCommandDialog/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabsCommandDialog/index.tsx deleted file mode 100644 index 63ee4320e..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabsCommandDialog/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type { TerminalPreset } from "@superset/local-db"; -import { - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@superset/ui/command"; -import { - HiMiniCommandLine, - HiMiniPlus, - HiOutlineCog6Tooth, -} from "react-icons/hi2"; -import { - getPresetIcon, - useIsDarkTheme, -} from "renderer/assets/app-icons/preset-icons"; - -interface TabsCommandDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onAddTab: () => void; - onOpenPresetsSettings: () => void; - presets: TerminalPreset[]; - onSelectPreset: (preset: TerminalPreset) => void; -} - -export function TabsCommandDialog({ - open, - onOpenChange, - onAddTab, - onOpenPresetsSettings, - presets, - onSelectPreset, -}: TabsCommandDialogProps) { - const isDark = useIsDarkTheme(); - - return ( - - - - No results found. - - - - New Terminal - - - {presets.length > 0 && ( - - {presets.map((preset) => { - const presetIcon = getPresetIcon(preset.name, isDark); - return ( - onSelectPreset(preset)} - > - {presetIcon ? ( - - ) : ( - - )} - - {preset.name || "default"} - - {preset.description ? ( - - {preset.description} - - ) : ( - preset.cwd && ( - - {preset.cwd} - - ) - )} - - ); - })} - - )} - - - - Configure Presets - - - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx deleted file mode 100644 index 2baa29305..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import type { TerminalPreset } from "@superset/local-db"; -import { Button } from "@superset/ui/button"; -import { ButtonGroup } from "@superset/ui/button-group"; -import { LayoutGroup, motion } from "framer-motion"; -import { useMemo, useRef, useState } from "react"; -import { useDrop } from "react-dnd"; -import { - HiMiniCommandLine, - HiMiniEllipsisHorizontal, - HiMiniPlus, -} from "react-icons/hi2"; -import { - getPresetIcon, - useIsDarkTheme, -} from "renderer/assets/app-icons/preset-icons"; -import { trpc } from "renderer/lib/trpc"; -import { usePresets } from "renderer/react-query/presets"; -import { useOpenSettings, useSidebarStore } from "renderer/stores"; -import { useTabsStore } from "renderer/stores/tabs/store"; -import { PortsList } from "./PortsList"; -import { PresetContextMenu } from "./PresetContextMenu"; -import { TabItem } from "./TabItem"; -import { TabsCommandDialog } from "./TabsCommandDialog"; - -const DRAG_TYPE = "TAB"; - -interface DragItem { - type: typeof DRAG_TYPE; - tabId: string; - index: number; -} - -export function TabsView() { - const isResizing = useSidebarStore((s) => s.isResizing); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id; - const allTabs = useTabsStore((s) => s.tabs); - const addTab = useTabsStore((s) => s.addTab); - const addPane = useTabsStore((s) => s.addPane); - const renameTab = useTabsStore((s) => s.renameTab); - const reorderTabById = useTabsStore((s) => s.reorderTabById); - const activeTabIds = useTabsStore((s) => s.activeTabIds); - const [dropIndex, setDropIndex] = useState(null); - const [commandOpen, setCommandOpen] = useState(false); - const openSettings = useOpenSettings(); - const containerRef = useRef(null); - - const { presets } = usePresets(); - const isDark = useIsDarkTheme(); - - const tabs = useMemo( - () => - activeWorkspaceId - ? allTabs.filter((tab) => tab.workspaceId === activeWorkspaceId) - : [], - [activeWorkspaceId, allTabs], - ); - - const handleAddTab = () => { - if (activeWorkspaceId) { - addTab(activeWorkspaceId); - setCommandOpen(false); - } - }; - - const handleAddPane = () => { - if (!activeWorkspaceId) return; - - const activeTabId = activeTabIds[activeWorkspaceId]; - if (!activeTabId) { - // Fall back to creating a new tab if no active tab - handleAddTab(); - return; - } - - addPane(activeTabId); - setCommandOpen(false); - }; - - const handleOpenPresetsSettings = () => { - openSettings("presets"); - setCommandOpen(false); - }; - - const handleSelectPreset = (preset: TerminalPreset) => { - if (!activeWorkspaceId) return; - - // Pass preset options to addTab - Terminal component will read them from pane state - const { tabId } = addTab(activeWorkspaceId, { - initialCommands: preset.commands, - initialCwd: preset.cwd || undefined, - }); - - // Rename the tab to the preset name - if (preset.name) { - renameTab(tabId, preset.name); - } - - setCommandOpen(false); - }; - - const handleSelectPresetAsPane = (preset: TerminalPreset) => { - if (!activeWorkspaceId) return; - - const activeTabId = activeTabIds[activeWorkspaceId]; - if (!activeTabId) { - // Fall back to opening as new tab if no active tab - handleSelectPreset(preset); - return; - } - - // Add pane to current tab with preset options - addPane(activeTabId, { - initialCommands: preset.commands, - initialCwd: preset.cwd || undefined, - }); - - setCommandOpen(false); - }; - - const [{ isOver }, drop] = useDrop({ - accept: DRAG_TYPE, - hover: (item, monitor) => { - if (!containerRef.current) return; - - const clientOffset = monitor.getClientOffset(); - if (!clientOffset) return; - - const tabItems = containerRef.current.querySelectorAll("[data-tab-item]"); - let newDropIndex = tabs.length; - - tabItems.forEach((element, index) => { - const rect = element.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (clientOffset.y < midY && index < newDropIndex) { - newDropIndex = index; - } - }); - - if (newDropIndex === item.index || newDropIndex === item.index + 1) { - setDropIndex(null); - } else { - setDropIndex(newDropIndex); - } - }, - drop: (item) => { - if (dropIndex !== null && dropIndex !== item.index) { - const targetIndex = dropIndex > item.index ? dropIndex - 1 : dropIndex; - reorderTabById(item.tabId, targetIndex); - } - setDropIndex(null); - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }); - - if (!isOver && dropIndex !== null) { - setDropIndex(null); - } - - return ( - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx index 751158761..86b5df975 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx @@ -1,29 +1,28 @@ -import { useSidebarStore } from "renderer/stores"; -import { SidebarMode } from "renderer/stores/sidebar-state"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import { ChangesView } from "./ChangesView"; -import { ModeCarousel } from "./ModeCarousel"; -import { TabsView } from "./TabsView"; export function Sidebar() { - const { currentMode, setMode } = useSidebarStore(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; - const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + + const handleFileOpen = workspaceId + ? (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { + addFileViewerPane(workspaceId, { + filePath: file.path, + diffCategory: category, + commitHash, + oldPath: file.oldPath, + }); + } + : undefined; return ( ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx deleted file mode 100644 index f641fde5a..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { WorkspaceActionBarLeft } from "./components/WorkspaceActionBarLeft"; -import { WorkspaceActionBarRight } from "./components/WorkspaceActionBarRight"; - -interface WorkspaceActionBarProps { - worktreePath: string | undefined; -} - -export function WorkspaceActionBar({ worktreePath }: WorkspaceActionBarProps) { - if (!worktreePath) return null; - - return ( -
-
- -
-
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx deleted file mode 100644 index 0db773eb9..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { GoGitBranch } from "react-icons/go"; -import { trpc } from "renderer/lib/trpc"; - -export function WorkspaceActionBarLeft() { - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const currentBranch = activeWorkspace?.worktree?.branch; - const baseBranch = activeWorkspace?.worktree?.baseBranch; - return ( - <> - {currentBranch && ( - - - - - - {currentBranch} - - - - - Current branch - - - )} - {baseBranch && baseBranch !== currentBranch && ( - - - - from - {baseBranch} - - - - Based on {baseBranch} - - - )} - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts deleted file mode 100644 index 9a42acaa8..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBarLeft } from "./WorkspaceActionBarLeft"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts deleted file mode 100644 index da70bada5..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBarRight } from "./WorkspaceActionBarRight"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts deleted file mode 100644 index c8caa3fbc..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBar } from "./WorkspaceActionBar"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx new file mode 100644 index 000000000..3140858c2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx @@ -0,0 +1,328 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { HiExclamationTriangle } from "react-icons/hi2"; +import { LuCheck, LuCircle, LuGitBranch, LuLoader } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { + useHasWorkspaceFailed, + useWorkspaceInitProgress, +} from "renderer/stores/workspace-init"; +import { + INIT_STEP_MESSAGES, + INIT_STEP_ORDER, + isStepComplete, + type WorkspaceInitStep, +} from "shared/types/workspace-init"; + +interface WorkspaceInitializingViewProps { + workspaceId: string; + workspaceName: string; + /** True if init was interrupted (e.g., app restart during init) */ + isInterrupted?: boolean; +} + +// Steps to display in the progress view (skip pending and ready) +const DISPLAY_STEPS: WorkspaceInitStep[] = INIT_STEP_ORDER.filter( + (step) => step !== "pending" && step !== "ready", +); + +export function WorkspaceInitializingView({ + workspaceId, + workspaceName, + isInterrupted = false, +}: WorkspaceInitializingViewProps) { + const progress = useWorkspaceInitProgress(workspaceId); + const hasFailed = useHasWorkspaceFailed(workspaceId); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const retryMutation = trpc.workspaces.retryInit.useMutation(); + const deleteMutation = trpc.workspaces.delete.useMutation(); + const utils = trpc.useUtils(); + + const handleRetry = () => { + retryMutation.mutate( + { workspaceId }, + { + onSuccess: () => { + utils.workspaces.invalidate(); + }, + }, + ); + }; + + const handleDelete = () => { + setShowDeleteConfirm(false); + deleteMutation.mutate( + { id: workspaceId }, + { + onSuccess: () => { + utils.workspaces.invalidate(); + }, + }, + ); + }; + + const currentStep = progress?.step ?? "pending"; + + // Interrupted state (app restart during init - no in-memory progress) + if (isInterrupted && !progress) { + return ( + <> +
+
+ {/* Warning icon */} +
+ +
+ + {/* Title and description */} +
+

+ Setup was interrupted +

+

{workspaceName}

+

+ The app was closed before workspace setup completed. You can + retry the setup or delete this workspace. +

+
+ + {/* Action buttons */} +
+ + +
+
+
+ + {/* Delete confirmation dialog */} + + + + + Delete workspace "{workspaceName}"? + + +
+ This workspace was not fully set up. Deleting will clean up + any partial files that were created. +
+
+
+ + + + +
+
+ + ); + } + + // Failed state + if (hasFailed) { + return ( + <> +
+
+ {/* Error icon */} +
+ +
+ + {/* Title and description */} +
+

+ Workspace setup failed +

+

{workspaceName}

+ {progress?.error && ( +

+ {progress.error} +

+ )} +
+ + {/* Action buttons */} +
+ + +
+
+
+ + {/* Delete confirmation dialog */} + + + + + Delete workspace "{workspaceName}"? + + +
+ This workspace failed to initialize. Deleting will clean up + any partial files that were created. +
+
+
+ + + + +
+
+ + ); + } + + // Initializing state + return ( +
+
+ {/* Icon with pulse animation */} +
+
+
+ +
+
+ + {/* Title and description */} +
+

+ Setting up workspace +

+

{workspaceName}

+
+ + {/* Step list */} +
+ {DISPLAY_STEPS.map((step) => { + const isComplete = isStepComplete(step, currentStep); + const isCurrent = step === currentStep; + + return ( +
+ {/* Step icon */} + {isComplete ? ( + + ) : isCurrent ? ( + + ) : ( + + )} + + {/* Step label */} + + {INIT_STEP_MESSAGES[step]} + +
+ ); + })} +
+ + {/* Helper text */} +

+ Takes 10s to a few minutes depending on the size of your repo +

+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/index.ts new file mode 100644 index 000000000..6685f11c9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/index.ts @@ -0,0 +1 @@ +export { WorkspaceInitializingView } from "./WorkspaceInitializingView"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 661543c85..ac24a87d6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -3,13 +3,33 @@ import { trpc } from "renderer/lib/trpc"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; +import { + useHasWorkspaceFailed, + useIsWorkspaceInitializing, +} from "renderer/stores/workspace-init"; import { ContentView } from "./ContentView"; -import { ResizableSidebar } from "./ResizableSidebar"; -import { WorkspaceActionBar } from "./WorkspaceActionBar"; +import { WorkspaceInitializingView } from "./WorkspaceInitializingView"; export function WorkspaceView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; + + // Check if active workspace is initializing or failed + const isInitializing = useIsWorkspaceInitializing(activeWorkspaceId ?? ""); + const hasFailed = useHasWorkspaceFailed(activeWorkspaceId ?? ""); + + // Also check for incomplete init after app restart: + // - worktree type workspace with null/undefined gitStatus means init never completed + // - This handles the case where app restarts during init (in-memory progress lost) + // - Uses explicit check instead of == null to avoid lint issues + const gitStatus = activeWorkspace?.worktree?.gitStatus; + const hasIncompleteInit = + activeWorkspace?.type === "worktree" && + (gitStatus === null || gitStatus === undefined); + + const showInitView = + activeWorkspaceId && (isInitializing || hasFailed || hasIncompleteInit); + const allTabs = useTabsStore((s) => s.tabs); const activeTabIds = useTabsStore((s) => s.activeTabIds); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); @@ -41,7 +61,7 @@ export function WorkspaceView() { // Tab management shortcuts useAppHotkey( - "NEW_TERMINAL", + "NEW_GROUP", () => { if (activeWorkspaceId) { addTab(activeWorkspaceId); @@ -63,7 +83,7 @@ export function WorkspaceView() { [focusedPaneId, removePane], ); - // Switch between tabs (configurable shortcut) + // Switch between tabs (⌘+Up/Down) useAppHotkey( "PREV_TERMINAL", () => { @@ -90,7 +110,7 @@ export function WorkspaceView() { [activeWorkspaceId, activeTabId, tabs, setActiveTab], ); - // Switch between panes within a tab (configurable shortcut) + // Switch between panes within a tab (⌘+⌥+Left/Right) useAppHotkey( "PREV_PANE", () => { @@ -150,14 +170,16 @@ export function WorkspaceView() { return (
-
- -
- -
- -
-
+
+ {showInitView && activeWorkspaceId ? ( + + ) : ( + + )}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx new file mode 100644 index 000000000..8f06de0e4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx @@ -0,0 +1,147 @@ +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { + LuArrowRight, + LuGitBranch, + LuGitFork, + LuRotateCw, +} from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import type { WorkspaceItem } from "../types"; +import { getRelativeTime } from "../utils"; + +const GITHUB_STATUS_STALE_TIME = 5 * 60 * 1000; // 5 minutes + +interface WorkspaceRowProps { + workspace: WorkspaceItem; + isActive: boolean; + onSwitch: () => void; + onReopen: () => void; + isOpening?: boolean; +} + +export function WorkspaceRow({ + workspace, + isActive, + onSwitch, + onReopen, + isOpening, +}: WorkspaceRowProps) { + const isBranch = workspace.type === "branch"; + const [hasHovered, setHasHovered] = useState(false); + + // Lazy-load GitHub status on hover to avoid N+1 queries + const { data: githubStatus } = trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId: workspace.workspaceId ?? "" }, + { + enabled: + hasHovered && workspace.type === "worktree" && !!workspace.workspaceId, + staleTime: GITHUB_STATUS_STALE_TIME, + }, + ); + + const pr = githubStatus?.pr; + const showDiffStats = pr && (pr.additions > 0 || pr.deletions > 0); + + const timeText = workspace.isOpen + ? `Opened ${getRelativeTime(workspace.lastOpenedAt)}` + : `Created ${getRelativeTime(workspace.createdAt)}`; + + const handleClick = () => { + if (workspace.isOpen) { + onSwitch(); + } else { + onReopen(); + } + }; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/index.ts new file mode 100644 index 000000000..0a45a4a84 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/index.ts @@ -0,0 +1 @@ +export * from "./WorkspaceRow"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspacesListView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspacesListView.tsx new file mode 100644 index 000000000..8a3d50a2c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspacesListView.tsx @@ -0,0 +1,282 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { useMemo, useState } from "react"; +import { LuSearch, LuX } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; +import { useCloseWorkspacesList } from "renderer/stores/app-state"; +import type { FilterMode, ProjectGroup, WorkspaceItem } from "./types"; +import { WorkspaceRow } from "./WorkspaceRow"; + +const FILTER_OPTIONS: { value: FilterMode; label: string }[] = [ + { value: "all", label: "All" }, + { value: "active", label: "Active" }, + { value: "closed", label: "Closed" }, +]; + +export function WorkspacesListView() { + const [searchQuery, setSearchQuery] = useState(""); + const [filterMode, setFilterMode] = useState("all"); + const utils = trpc.useUtils(); + + // Fetch all data + const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); + const { data: allProjects = [] } = trpc.projects.getRecents.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + + // Fetch worktrees for all projects + const worktreeQueries = trpc.useQueries((t) => + allProjects.map((project) => + t.workspaces.getWorktreesByProject({ projectId: project.id }), + ), + ); + + const setActiveWorkspace = useSetActiveWorkspace(); + const closeWorkspacesList = useCloseWorkspacesList(); + + const openWorktree = trpc.workspaces.openWorktree.useMutation({ + onSuccess: () => { + utils.workspaces.getAllGrouped.invalidate(); + utils.workspaces.getActive.invalidate(); + closeWorkspacesList(); + }, + onError: (error) => { + toast.error(`Failed to open workspace: ${error.message}`); + }, + }); + + // Combine open workspaces and closed worktrees into a single list + const allItems = useMemo(() => { + const items: WorkspaceItem[] = []; + + // First, add all open workspaces from groups + for (const group of groups) { + for (const ws of group.workspaces) { + items.push({ + uniqueId: ws.id, + workspaceId: ws.id, + worktreeId: null, + projectId: ws.projectId, + projectName: group.project.name, + worktreePath: ws.worktreePath, + type: ws.type, + branch: ws.branch, + name: ws.name, + lastOpenedAt: ws.lastOpenedAt, + createdAt: ws.createdAt, + isUnread: ws.isUnread, + isOpen: true, + }); + } + } + + // Add closed worktrees (those without active workspaces) + for (let i = 0; i < allProjects.length; i++) { + const project = allProjects[i]; + const worktrees = worktreeQueries[i]?.data; + + if (!worktrees) continue; + + for (const wt of worktrees) { + // Skip if this worktree has an active workspace + if (wt.hasActiveWorkspace) continue; + + items.push({ + uniqueId: `wt-${wt.id}`, + workspaceId: null, + worktreeId: wt.id, + projectId: project.id, + projectName: project.name, + worktreePath: wt.path, + type: "worktree", + branch: wt.branch, + name: wt.branch, + lastOpenedAt: wt.createdAt, + createdAt: wt.createdAt, + isUnread: false, + isOpen: false, + }); + } + } + + return items; + }, [groups, allProjects, worktreeQueries]); + + // Filter by search query and filter mode + const filteredItems = useMemo(() => { + let items = allItems; + + // Apply filter mode + if (filterMode === "active") { + items = items.filter((ws) => ws.isOpen); + } else if (filterMode === "closed") { + items = items.filter((ws) => !ws.isOpen); + } + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + items = items.filter( + (ws) => + ws.name.toLowerCase().includes(query) || + ws.projectName.toLowerCase().includes(query) || + ws.branch.toLowerCase().includes(query), + ); + } + + return items; + }, [allItems, searchQuery, filterMode]); + + // Group by project + const projectGroups = useMemo(() => { + const groupsMap = new Map(); + + for (const item of filteredItems) { + if (!groupsMap.has(item.projectId)) { + groupsMap.set(item.projectId, { + projectId: item.projectId, + projectName: item.projectName, + workspaces: [], + }); + } + groupsMap.get(item.projectId)?.workspaces.push(item); + } + + // Sort workspaces within each group: active first, then by lastOpenedAt + for (const group of groupsMap.values()) { + group.workspaces.sort((a, b) => { + // Active workspaces first + if (a.isOpen !== b.isOpen) return a.isOpen ? -1 : 1; + // Then by most recently opened/created + return b.lastOpenedAt - a.lastOpenedAt; + }); + } + + // Sort groups by most recent activity + return Array.from(groupsMap.values()).sort((a, b) => { + const aRecent = Math.max(...a.workspaces.map((w) => w.lastOpenedAt)); + const bRecent = Math.max(...b.workspaces.map((w) => w.lastOpenedAt)); + return bRecent - aRecent; + }); + }, [filteredItems]); + + const handleSwitch = (item: WorkspaceItem) => { + if (item.workspaceId) { + setActiveWorkspace.mutate({ id: item.workspaceId }); + closeWorkspacesList(); + } + }; + + const handleReopen = (item: WorkspaceItem) => { + if (item.worktreeId) { + openWorktree.mutate({ worktreeId: item.worktreeId }); + } + }; + + // Count stats for filter badges + const activeCount = allItems.filter((w) => w.isOpen).length; + const closedCount = allItems.filter((w) => !w.isOpen).length; + + return ( +
+ {/* Header */} +
+ {/* Filter toggle */} +
+ {FILTER_OPTIONS.map((option) => { + const count = + option.value === "all" + ? allItems.length + : option.value === "active" + ? activeCount + : closedCount; + return ( + + ); + })} +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 h-8 bg-background/50" + /> +
+ + {/* Close button */} + +
+ + {/* Workspaces list grouped by project */} +
+ {projectGroups.map((group) => ( +
+ {/* Project header */} +
+ + {group.projectName} + + + {group.workspaces.length} + +
+ + {/* Workspaces in this project */} + {group.workspaces.map((ws) => ( + handleSwitch(ws)} + onReopen={() => handleReopen(ws)} + isOpening={ + openWorktree.isPending && + openWorktree.variables?.worktreeId === ws.worktreeId + } + /> + ))} +
+ ))} + + {filteredItems.length === 0 && ( +
+ {searchQuery + ? "No workspaces match your search" + : filterMode === "active" + ? "No active workspaces" + : filterMode === "closed" + ? "No closed workspaces" + : "No workspaces yet"} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/index.ts new file mode 100644 index 000000000..7d55b16cb --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/index.ts @@ -0,0 +1 @@ +export { WorkspacesListView } from "./WorkspacesListView"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/types.ts new file mode 100644 index 000000000..587000def --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/types.ts @@ -0,0 +1,26 @@ +export interface WorkspaceItem { + // Unique identifier - either workspace id or worktree id for closed ones + uniqueId: string; + // If open, this is the workspace id + workspaceId: string | null; + // For closed worktrees, this is the worktree id + worktreeId: string | null; + projectId: string; + projectName: string; + worktreePath: string; + type: "worktree" | "branch"; + branch: string; + name: string; + lastOpenedAt: number; + createdAt: number; + isUnread: boolean; + isOpen: boolean; +} + +export interface ProjectGroup { + projectId: string; + projectName: string; + workspaces: WorkspaceItem[]; +} + +export type FilterMode = "all" | "active" | "closed"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/utils.ts b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/utils.ts new file mode 100644 index 000000000..1640b1688 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/utils.ts @@ -0,0 +1,42 @@ +// Time unit constants (in milliseconds) +const MS_PER_SECOND = 1000; +const MS_PER_MINUTE = MS_PER_SECOND * 60; +const MS_PER_HOUR = MS_PER_MINUTE * 60; +const MS_PER_DAY = MS_PER_HOUR * 24; + +// Time threshold constants (in their respective units) +const MINUTES_PER_HOUR = 60; +const HOURS_PER_DAY = 24; +const DAYS_PER_WEEK = 7; +const DAYS_PER_MONTH = 30; +const DAYS_PER_YEAR = 365; + +// Relative time display thresholds (in days) +const TWO_WEEKS_DAYS = 14; +const TWO_MONTHS_DAYS = 60; + +/** + * Returns a human-readable relative time string + * e.g., "2 hours ago", "yesterday", "3 days ago" + */ +export function getRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const minutes = Math.floor(diff / MS_PER_MINUTE); + const hours = Math.floor(diff / MS_PER_HOUR); + const days = Math.floor(diff / MS_PER_DAY); + + if (minutes < 1) return "just now"; + if (minutes < MINUTES_PER_HOUR) return `${minutes}m ago`; + if (hours < HOURS_PER_DAY) return `${hours}h ago`; + if (days === 1) return "yesterday"; + if (days < DAYS_PER_WEEK) return `${days} days ago`; + if (days < TWO_WEEKS_DAYS) return "1 week ago"; + if (days < DAYS_PER_MONTH) + return `${Math.floor(days / DAYS_PER_WEEK)} weeks ago`; + if (days < TWO_MONTHS_DAYS) return "1 month ago"; + if (days < DAYS_PER_YEAR) + return `${Math.floor(days / DAYS_PER_MONTH)} months ago`; + return "over a year ago"; +} diff --git a/apps/desktop/src/renderer/screens/main/hooks/index.ts b/apps/desktop/src/renderer/screens/main/hooks/index.ts index 8337712ea..8b8a83fbc 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/index.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/index.ts @@ -1 +1,2 @@ -// +export { usePRStatus } from "./usePRStatus"; +export { useWorkspaceRename } from "./useWorkspaceRename"; diff --git a/apps/desktop/src/renderer/screens/main/hooks/usePRStatus/index.ts b/apps/desktop/src/renderer/screens/main/hooks/usePRStatus/index.ts new file mode 100644 index 000000000..f552343f7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/hooks/usePRStatus/index.ts @@ -0,0 +1 @@ +export { usePRStatus } from "./usePRStatus"; diff --git a/apps/desktop/src/renderer/screens/main/hooks/usePRStatus/usePRStatus.ts b/apps/desktop/src/renderer/screens/main/hooks/usePRStatus/usePRStatus.ts new file mode 100644 index 000000000..713d7ff91 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/hooks/usePRStatus/usePRStatus.ts @@ -0,0 +1,46 @@ +import type { GitHubStatus } from "@superset/local-db"; +import { trpc } from "renderer/lib/trpc"; + +interface UsePRStatusOptions { + workspaceId: string | undefined; + enabled?: boolean; + refetchInterval?: number; +} + +interface UsePRStatusResult { + pr: GitHubStatus["pr"] | null; + repoUrl: string | null; + branchExistsOnRemote: boolean; + isLoading: boolean; + refetch: () => void; +} + +/** + * Hook to fetch and manage GitHub PR status for a workspace. + * Returns PR info, loading state, and refetch function. + */ +export function usePRStatus({ + workspaceId, + enabled = true, + refetchInterval, +}: UsePRStatusOptions): UsePRStatusResult { + const { + data: githubStatus, + isLoading, + refetch, + } = trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId: workspaceId ?? "" }, + { + enabled: enabled && !!workspaceId, + refetchInterval, + }, + ); + + return { + pr: githubStatus?.pr ?? null, + repoUrl: githubStatus?.repoUrl ?? null, + branchExistsOnRemote: githubStatus?.branchExistsOnRemote ?? false, + isLoading, + refetch, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/index.ts b/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/index.ts new file mode 100644 index 000000000..4b8035beb --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/index.ts @@ -0,0 +1 @@ +export { useWorkspaceRename } from "./useWorkspaceRename"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename.ts b/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/useWorkspaceRename.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename.ts rename to apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/useWorkspaceRename.ts diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 98f2cc947..e339381a8 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -19,6 +19,8 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import type { Tab } from "renderer/stores/tabs/types"; import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils"; +import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; +import { useWorkspaceSidebarStore } from "renderer/stores/workspace-sidebar-state"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -26,6 +28,9 @@ import { SettingsView } from "./components/SettingsView"; import { StartView } from "./components/StartView"; import { TasksView } from "./components/TasksView"; import { TopBar } from "./components/TopBar"; +import { WorkspaceInitEffects } from "./components/WorkspaceInitEffects"; +import { ResizableWorkspaceSidebar } from "./components/WorkspaceSidebar"; +import { WorkspacesListView } from "./components/WorkspacesListView"; import { WorkspaceView } from "./components/WorkspaceView"; function LoadingSpinner() { @@ -54,12 +59,27 @@ export function MainScreen() { onData: () => utils.auth.getState.invalidate(), }); + // Subscribe to workspace initialization progress + const updateInitProgress = useWorkspaceInitStore((s) => s.updateProgress); + trpc.workspaces.onInitProgress.useSubscription(undefined, { + onData: (progress) => { + updateInitProgress(progress); + // Invalidate workspace queries when initialization completes or fails + if (progress.step === "ready" || progress.step === "failed") { + utils.workspaces.getActive.invalidate(); + utils.workspaces.getAllGrouped.invalidate(); + } + }, + }); + const currentView = useCurrentView(); const openSettings = useOpenSettings(); - const { toggleSidebar } = useSidebarStore(); + const toggleSidebar = useSidebarStore((s) => s.toggleSidebar); + const toggleWorkspaceSidebar = useWorkspaceSidebarStore((s) => s.toggleOpen); const hasTasksAccess = useFeatureFlagEnabled( FEATURE_FLAGS.ELECTRIC_TASKS_ACCESS, ); + const { data: activeWorkspace, isLoading: isWorkspaceLoading, @@ -111,6 +131,15 @@ export function MainScreen() { [toggleSidebar, isWorkspaceView], ); + useAppHotkey( + "TOGGLE_WORKSPACE_SIDEBAR", + () => { + toggleWorkspaceSidebar(); + }, + undefined, + [toggleWorkspaceSidebar], + ); + /** * Resolves the target pane for split operations. * If the focused pane is desynced from layout (e.g., was removed), @@ -267,6 +296,9 @@ export function MainScreen() { if (currentView === "tasks" && hasTasksAccess) { return ; } + if (currentView === "workspaces-list") { + return ; + } return ; }; @@ -337,12 +369,16 @@ export function MainScreen() { ) : (
-
{renderContent()}
+
+ + {renderContent()} +
)} + ); } diff --git a/apps/desktop/src/renderer/screens/sign-in/index.tsx b/apps/desktop/src/renderer/screens/sign-in/index.tsx index 3fe2118f9..032ebb07a 100644 --- a/apps/desktop/src/renderer/screens/sign-in/index.tsx +++ b/apps/desktop/src/renderer/screens/sign-in/index.tsx @@ -1,10 +1,11 @@ -import { type AuthProvider, COMPANY } from "@superset/shared/constants"; +import { COMPANY } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; import { useEffect } from "react"; import { FaGithub } from "react-icons/fa"; import { FcGoogle } from "react-icons/fc"; import { posthog } from "renderer/lib/posthog"; import { trpc } from "renderer/lib/trpc"; +import type { AuthProvider } from "shared/auth"; import { SupersetLogo } from "./components/SupersetLogo"; export function SignInScreen() { diff --git a/apps/desktop/src/renderer/stores/app-state.ts b/apps/desktop/src/renderer/stores/app-state.ts index 296752c34..ba6ffcd1c 100644 --- a/apps/desktop/src/renderer/stores/app-state.ts +++ b/apps/desktop/src/renderer/stores/app-state.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; -export type AppView = "workspace" | "settings" | "tasks"; +export type AppView = "workspace" | "settings" | "tasks" | "workspaces-list"; export type SettingsSection = | "account" | "project" @@ -16,6 +16,7 @@ interface AppState { currentView: AppView; isSettingsTabOpen: boolean; isTasksTabOpen: boolean; + isWorkspacesListOpen: boolean; settingsSection: SettingsSection; setView: (view: AppView) => void; openSettings: (section?: SettingsSection) => void; @@ -24,6 +25,8 @@ interface AppState { setSettingsSection: (section: SettingsSection) => void; openTasks: () => void; closeTasks: () => void; + openWorkspacesList: () => void; + closeWorkspacesList: () => void; } export const useAppStore = create()( @@ -32,6 +35,7 @@ export const useAppStore = create()( currentView: "workspace", isSettingsTabOpen: false, isTasksTabOpen: false, + isWorkspacesListOpen: false, settingsSection: "project", setView: (view) => { @@ -65,6 +69,14 @@ export const useAppStore = create()( closeTasks: () => { set({ currentView: "workspace", isTasksTabOpen: false }); }, + + openWorkspacesList: () => { + set({ currentView: "workspaces-list", isWorkspacesListOpen: true }); + }, + + closeWorkspacesList: () => { + set({ currentView: "workspace", isWorkspacesListOpen: false }); + }, }), { name: "AppStore" }, ), @@ -87,3 +99,7 @@ export const useCloseSettingsTab = () => useAppStore((state) => state.closeSettingsTab); export const useOpenTasks = () => useAppStore((state) => state.openTasks); export const useCloseTasks = () => useAppStore((state) => state.closeTasks); +export const useOpenWorkspacesList = () => + useAppStore((state) => state.openWorkspacesList); +export const useCloseWorkspacesList = () => + useAppStore((state) => state.closeWorkspacesList); diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index b289f0bfb..824bd5107 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -6,3 +6,5 @@ export * from "./ringtone"; export * from "./sidebar-state"; export * from "./tabs"; export * from "./theme"; +export * from "./workspace-init"; +export * from "./workspace-sidebar-state"; diff --git a/apps/desktop/src/renderer/stores/new-workspace-modal.ts b/apps/desktop/src/renderer/stores/new-workspace-modal.ts index 0890c7797..38b18916b 100644 --- a/apps/desktop/src/renderer/stores/new-workspace-modal.ts +++ b/apps/desktop/src/renderer/stores/new-workspace-modal.ts @@ -3,7 +3,8 @@ import { devtools } from "zustand/middleware"; interface NewWorkspaceModalState { isOpen: boolean; - openModal: () => void; + preSelectedProjectId: string | null; + openModal: (projectId?: string) => void; closeModal: () => void; } @@ -11,13 +12,14 @@ export const useNewWorkspaceModalStore = create()( devtools( (set) => ({ isOpen: false, + preSelectedProjectId: null, - openModal: () => { - set({ isOpen: true }); + openModal: (projectId?: string) => { + set({ isOpen: true, preSelectedProjectId: projectId ?? null }); }, closeModal: () => { - set({ isOpen: false }); + set({ isOpen: false, preSelectedProjectId: null }); }, }), { name: "NewWorkspaceModalStore" }, @@ -31,3 +33,5 @@ export const useOpenNewWorkspaceModal = () => useNewWorkspaceModalStore((state) => state.openModal); export const useCloseNewWorkspaceModal = () => useNewWorkspaceModalStore((state) => state.closeModal); +export const usePreSelectedProjectId = () => + useNewWorkspaceModalStore((state) => state.preSelectedProjectId); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index d28d8316a..828d0679d 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -4,9 +4,10 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { trpcTabsStorage } from "../../lib/trpc-storage"; import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane"; -import type { TabsState, TabsStore } from "./types"; +import type { AddFileViewerPaneOptions, TabsState, TabsStore } from "./types"; import { type CreatePaneOptions, + createFileViewerPane, createPane, createTabWithPane, extractPaneIdsFromLayout, @@ -125,7 +126,11 @@ export const useTabsStore = create()( const paneIds = getPaneIdsForTab(state.panes, tabId); for (const paneId of paneIds) { - killTerminalForPane(paneId); + // Only kill terminal sessions for terminal panes (avoids unnecessary IPC for file-viewers) + const pane = state.panes[paneId]; + if (pane?.type === "terminal") { + killTerminalForPane(paneId); + } } const newPanes = { ...state.panes }; @@ -285,7 +290,10 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; for (const paneId of removedPaneIds) { - killTerminalForPane(paneId); + // P2: Only kill terminal for actual terminal panes (avoid unnecessary IPC) + if (state.panes[paneId]?.type === "terminal") { + killTerminalForPane(paneId); + } delete newPanes[paneId]; } @@ -340,6 +348,112 @@ export const useTabsStore = create()( return newPane.id; }, + addFileViewerPane: ( + workspaceId: string, + options: AddFileViewerPaneOptions, + ) => { + const state = get(); + const activeTabId = state.activeTabIds[workspaceId]; + const activeTab = state.tabs.find((t) => t.id === activeTabId); + + // If no active tab, create a new one (this shouldn't normally happen) + if (!activeTab) { + const { tabId, paneId } = get().addTab(workspaceId); + // Update the pane to be a file-viewer (must use set() to get fresh state after addTab) + const fileViewerPane = createFileViewerPane(tabId, options); + set((s) => ({ + panes: { + ...s.panes, + [paneId]: { + ...fileViewerPane, + id: paneId, // Keep the original ID + }, + }, + })); + return paneId; + } + + // Look for an existing unlocked file-viewer pane in the active tab + const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); + const fileViewerPanes = tabPaneIds + .map((id) => state.panes[id]) + .filter( + (p) => + p?.type === "file-viewer" && + p.fileViewer && + !p.fileViewer.isLocked, + ); + + // If we found an unlocked file-viewer pane, reuse it + if (fileViewerPanes.length > 0) { + const paneToReuse = fileViewerPanes[0]; + const fileName = + options.filePath.split("/").pop() || options.filePath; + + // Determine default view mode + let viewMode: "raw" | "rendered" | "diff" = "raw"; + if (options.diffCategory) { + viewMode = "diff"; + } else if ( + options.filePath.endsWith(".md") || + options.filePath.endsWith(".markdown") || + options.filePath.endsWith(".mdx") + ) { + viewMode = "rendered"; + } + + set({ + panes: { + ...state.panes, + [paneToReuse.id]: { + ...paneToReuse, + name: fileName, + fileViewer: { + filePath: options.filePath, + viewMode, + isLocked: false, + diffLayout: "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + initialLine: options.line, + initialColumn: options.column, + }, + }, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: paneToReuse.id, + }, + }); + + return paneToReuse.id; + } + + // No reusable pane found, create a new one + const newPane = createFileViewerPane(activeTab.id, options); + + const newLayout: MosaicNode = { + direction: "row", + first: activeTab.layout, + second: newPane.id, + splitPercentage: 50, + }; + + set({ + tabs: state.tabs.map((t) => + t.id === activeTab.id ? { ...t, layout: newLayout } : t, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: newPane.id, + }, + }); + + return newPane.id; + }, + removePane: (paneId) => { const state = get(); const pane = state.panes[paneId]; @@ -354,7 +468,10 @@ export const useTabsStore = create()( return; } - killTerminalForPane(paneId); + // Only kill terminal sessions for terminal panes (avoids unnecessary IPC for file-viewers) + if (pane.type === "terminal") { + killTerminalForPane(paneId); + } const newLayout = removePaneFromLayout(tab.layout, paneId); if (!newLayout) { @@ -428,6 +545,33 @@ export const useTabsStore = create()( })); }, + clearWorkspaceAttention: (workspaceId) => { + const state = get(); + const workspaceTabs = state.tabs.filter( + (t) => t.workspaceId === workspaceId, + ); + const workspacePaneIds = workspaceTabs.flatMap((t) => + extractPaneIdsFromLayout(t.layout), + ); + + if (workspacePaneIds.length === 0) { + return; + } + + const newPanes = { ...state.panes }; + let hasChanges = false; + for (const paneId of workspacePaneIds) { + if (newPanes[paneId]?.needsAttention) { + newPanes[paneId] = { ...newPanes[paneId], needsAttention: false }; + hasChanges = true; + } + } + + if (hasChanges) { + set({ panes: newPanes }); + } + }, + updatePaneCwd: (paneId, cwd, confirmed) => { set((state) => ({ panes: { @@ -463,7 +607,19 @@ export const useTabsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.tabId !== tabId) return; - const newPane = createPane(tabId); + // Clone file-viewer panes instead of creating a terminal + const newPane = + sourcePane.type === "file-viewer" && sourcePane.fileViewer + ? createFileViewerPane(tabId, { + filePath: sourcePane.fileViewer.filePath, + viewMode: sourcePane.fileViewer.viewMode, + isLocked: true, // Lock the cloned pane + diffLayout: sourcePane.fileViewer.diffLayout, + diffCategory: sourcePane.fileViewer.diffCategory, + commitHash: sourcePane.fileViewer.commitHash, + oldPath: sourcePane.fileViewer.oldPath, + }) + : createPane(tabId); let newLayout: MosaicNode; if (path && path.length > 0) { @@ -511,7 +667,19 @@ export const useTabsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.tabId !== tabId) return; - const newPane = createPane(tabId); + // Clone file-viewer panes instead of creating a terminal + const newPane = + sourcePane.type === "file-viewer" && sourcePane.fileViewer + ? createFileViewerPane(tabId, { + filePath: sourcePane.fileViewer.filePath, + viewMode: sourcePane.fileViewer.viewMode, + isLocked: true, // Lock the cloned pane + diffLayout: sourcePane.fileViewer.diffLayout, + diffCategory: sourcePane.fileViewer.diffCategory, + commitHash: sourcePane.fileViewer.commitHash, + oldPath: sourcePane.fileViewer.oldPath, + }) + : createPane(tabId); let newLayout: MosaicNode; if (path && path.length > 0) { diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index bcb0f70af..9638df6e0 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,4 +1,5 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { ChangeCategory } from "shared/changes-types"; import type { BaseTab, BaseTabsState, Pane, PaneType } from "shared/tabs-types"; // Re-export shared types @@ -28,6 +29,20 @@ export interface AddTabOptions { initialCwd?: string; } +/** + * Options for opening a file in a file-viewer pane + */ +export interface AddFileViewerPaneOptions { + filePath: string; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; + /** Line to scroll to (raw mode only) */ + line?: number; + /** Column to scroll to (raw mode only) */ + column?: number; +} + /** * Actions available on the tabs store */ @@ -51,10 +66,15 @@ export interface TabsStore extends TabsState { // Pane operations addPane: (tabId: string, options?: AddTabOptions) => string; + addFileViewerPane: ( + workspaceId: string, + options: AddFileViewerPaneOptions, + ) => string; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; markPaneAsUsed: (paneId: string) => void; setNeedsAttention: (paneId: string, needsAttention: boolean) => void; + clearWorkspaceAttention: (workspaceId: string) => void; updatePaneCwd: ( paneId: string, cwd: string | null, diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index a1e7bef16..62ee90aad 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,6 +1,14 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { ChangeCategory } from "shared/changes-types"; +import type { + DiffLayout, + FileViewerMode, + FileViewerState, +} from "shared/tabs-types"; import type { Pane, PaneType, Tab } from "./types"; +const MARKDOWN_EXTENSIONS = [".md", ".markdown", ".mdx"] as const; + /** * Generates a unique ID with the given prefix */ @@ -82,6 +90,66 @@ export const createPane = ( }; }; +/** + * Options for creating a file-viewer pane + */ +export interface CreateFileViewerPaneOptions { + filePath: string; + viewMode?: FileViewerMode; + isLocked?: boolean; + diffLayout?: DiffLayout; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; + /** Line to scroll to (raw mode only) */ + line?: number; + /** Column to scroll to (raw mode only) */ + column?: number; +} + +/** + * Creates a new file-viewer pane with the given properties + */ +export const createFileViewerPane = ( + tabId: string, + options: CreateFileViewerPaneOptions, +): Pane => { + const id = generateId("pane"); + + // Determine default view mode based on file and category + let defaultViewMode: FileViewerMode = "raw"; + if (options.diffCategory) { + defaultViewMode = "diff"; + } else if ( + MARKDOWN_EXTENSIONS.some((ext) => options.filePath.endsWith(ext)) + ) { + defaultViewMode = "rendered"; + } + + const fileViewer: FileViewerState = { + filePath: options.filePath, + viewMode: options.viewMode ?? defaultViewMode, + isLocked: options.isLocked ?? false, + diffLayout: options.diffLayout ?? "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + initialLine: options.line, + initialColumn: options.column, + }; + + // Use filename for display name + const fileName = options.filePath.split("/").pop() || options.filePath; + + return { + id, + tabId, + type: "file-viewer", + name: fileName, + fileViewer, + }; +}; + /** * Generates a static tab name based on existing tabs * (e.g., "Terminal 1", "Terminal 2", finding the next available number) diff --git a/apps/desktop/src/renderer/stores/workspace-init.ts b/apps/desktop/src/renderer/stores/workspace-init.ts new file mode 100644 index 000000000..b3f4d30f5 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-init.ts @@ -0,0 +1,102 @@ +import type { WorkspaceInitProgress } from "shared/types/workspace-init"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +/** + * Data needed to create a terminal when workspace becomes ready. + * Stored globally so it survives dialog/hook unmounts. + */ +export interface PendingTerminalSetup { + workspaceId: string; + projectId: string; + initialCommands: string[] | null; +} + +interface WorkspaceInitState { + // Map of workspaceId -> progress + initProgress: Record; + + // Map of workspaceId -> pending terminal setup (survives dialog unmount) + pendingTerminalSetups: Record; + + // Actions + updateProgress: (progress: WorkspaceInitProgress) => void; + clearProgress: (workspaceId: string) => void; + addPendingTerminalSetup: (setup: PendingTerminalSetup) => void; + removePendingTerminalSetup: (workspaceId: string) => void; +} + +export const useWorkspaceInitStore = create()( + devtools( + (set, get) => ({ + initProgress: {}, + pendingTerminalSetups: {}, + + updateProgress: (progress) => { + set((state) => ({ + initProgress: { + ...state.initProgress, + [progress.workspaceId]: progress, + }, + })); + + // For memory hygiene, clear "ready" progress after 5 minutes + // (long enough that WorkspaceInitEffects will have processed it) + if (progress.step === "ready") { + setTimeout( + () => { + const current = get().initProgress[progress.workspaceId]; + if (current?.step === "ready") { + get().clearProgress(progress.workspaceId); + } + }, + 5 * 60 * 1000, + ); // 5 minutes + } + }, + + clearProgress: (workspaceId) => { + set((state) => { + const { [workspaceId]: _, ...rest } = state.initProgress; + return { initProgress: rest }; + }); + }, + + addPendingTerminalSetup: (setup) => { + set((state) => ({ + pendingTerminalSetups: { + ...state.pendingTerminalSetups, + [setup.workspaceId]: setup, + }, + })); + }, + + removePendingTerminalSetup: (workspaceId) => { + set((state) => { + const { [workspaceId]: _, ...rest } = state.pendingTerminalSetups; + return { pendingTerminalSetups: rest }; + }); + }, + }), + { name: "WorkspaceInitStore" }, + ), +); + +export const useWorkspaceInitProgress = (workspaceId: string) => + useWorkspaceInitStore((state) => state.initProgress[workspaceId]); + +export const useIsWorkspaceInitializing = (workspaceId: string) => + useWorkspaceInitStore((state) => { + const progress = state.initProgress[workspaceId]; + return ( + progress !== undefined && + progress.step !== "ready" && + progress.step !== "failed" + ); + }); + +export const useHasWorkspaceFailed = (workspaceId: string) => + useWorkspaceInitStore((state) => { + const progress = state.initProgress[workspaceId]; + return progress?.step === "failed"; + }); diff --git a/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts new file mode 100644 index 000000000..adb12801e --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts @@ -0,0 +1,105 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +const DEFAULT_WORKSPACE_SIDEBAR_WIDTH = 280; +export const MIN_WORKSPACE_SIDEBAR_WIDTH = 220; +export const MAX_WORKSPACE_SIDEBAR_WIDTH = 400; + +interface WorkspaceSidebarState { + isOpen: boolean; + width: number; + lastOpenWidth: number; + // Use string[] instead of Set for JSON serialization with Zustand persist + collapsedProjectIds: string[]; + isResizing: boolean; + + toggleOpen: () => void; + setOpen: (open: boolean) => void; + setWidth: (width: number) => void; + setIsResizing: (isResizing: boolean) => void; + toggleProjectCollapsed: (projectId: string) => void; + isProjectCollapsed: (projectId: string) => boolean; +} + +export const useWorkspaceSidebarStore = create()( + devtools( + persist( + (set, get) => ({ + isOpen: true, + width: DEFAULT_WORKSPACE_SIDEBAR_WIDTH, + lastOpenWidth: DEFAULT_WORKSPACE_SIDEBAR_WIDTH, + collapsedProjectIds: [], + isResizing: false, + + toggleOpen: () => { + const { isOpen, lastOpenWidth } = get(); + if (isOpen) { + set({ isOpen: false, width: 0 }); + } else { + set({ + isOpen: true, + width: lastOpenWidth, + }); + } + }, + + setOpen: (open) => { + const { lastOpenWidth } = get(); + set({ + isOpen: open, + width: open ? lastOpenWidth : 0, + }); + }, + + setWidth: (width) => { + const clampedWidth = Math.max( + MIN_WORKSPACE_SIDEBAR_WIDTH, + Math.min(MAX_WORKSPACE_SIDEBAR_WIDTH, width), + ); + + if (width > 0) { + set({ + width: clampedWidth, + lastOpenWidth: clampedWidth, + isOpen: true, + }); + } else { + set({ + width: 0, + isOpen: false, + }); + } + }, + + setIsResizing: (isResizing) => { + set({ isResizing }); + }, + + toggleProjectCollapsed: (projectId) => { + set((state) => ({ + collapsedProjectIds: state.collapsedProjectIds.includes(projectId) + ? state.collapsedProjectIds.filter((id) => id !== projectId) + : [...state.collapsedProjectIds, projectId], + })); + }, + + isProjectCollapsed: (projectId) => { + return get().collapsedProjectIds.includes(projectId); + }, + }), + { + name: "workspace-sidebar-store", + version: 1, + // Exclude ephemeral state from persistence + partialize: (state) => ({ + isOpen: state.isOpen, + width: state.width, + lastOpenWidth: state.lastOpenWidth, + collapsedProjectIds: state.collapsedProjectIds, + // isResizing intentionally excluded - ephemeral UI state + }), + }, + ), + { name: "WorkspaceSidebarStore" }, + ), +); diff --git a/apps/desktop/src/shared/auth.ts b/apps/desktop/src/shared/auth.ts new file mode 100644 index 000000000..2f3348da3 --- /dev/null +++ b/apps/desktop/src/shared/auth.ts @@ -0,0 +1,16 @@ +export { AUTH_PROVIDERS, type AuthProvider } from "@superset/shared/constants"; + +/** + * Auth session - just tokens, user data fetched separately via tRPC + */ +export interface AuthSession { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; +} + +export interface SignInResult { + success: boolean; + error?: string; +} diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 6bc788cea..1ec904ae1 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -46,3 +46,4 @@ export const NOTIFICATION_EVENTS = { // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; +export const DEFAULT_TERMINAL_LINK_BEHAVIOR = "external-editor" as const; diff --git a/apps/desktop/src/shared/detect-language.ts b/apps/desktop/src/shared/detect-language.ts new file mode 100644 index 000000000..7f305cd2b --- /dev/null +++ b/apps/desktop/src/shared/detect-language.ts @@ -0,0 +1,61 @@ +export function detectLanguage(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase(); + + const languageMap: Record = { + // JavaScript/TypeScript + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + + // Web + html: "html", + htm: "html", + css: "css", + scss: "scss", + less: "less", + + // Data formats + json: "json", + yaml: "yaml", + yml: "yaml", + xml: "xml", + toml: "toml", + + // Markdown/Documentation + md: "markdown", + mdx: "markdown", + + // Shell + sh: "shell", + bash: "shell", + zsh: "shell", + fish: "shell", + + // Config + dockerfile: "dockerfile", + makefile: "makefile", + + // Other languages + py: "python", + rb: "ruby", + go: "go", + rs: "rust", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + cs: "csharp", + php: "php", + sql: "sql", + graphql: "graphql", + gql: "graphql", + }; + + return languageMap[ext || ""] || "plaintext"; +} diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index c4e38007b..c889f1127 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -410,20 +410,25 @@ export const HOTKEYS = { category: "Workspace", }), PREV_WORKSPACE: defineHotkey({ - keys: "meta+left", + keys: "meta+up", label: "Previous Workspace", category: "Workspace", }), NEXT_WORKSPACE: defineHotkey({ - keys: "meta+right", + keys: "meta+down", label: "Next Workspace", category: "Workspace", }), // Layout TOGGLE_SIDEBAR: defineHotkey({ + keys: "meta+l", + label: "Toggle Changes Tab", + category: "Layout", + }), + TOGGLE_WORKSPACE_SIDEBAR: defineHotkey({ keys: "meta+b", - label: "Toggle Sidebar", + label: "Toggle Workspaces Sidebar", category: "Layout", }), SPLIT_RIGHT: defineHotkey({ @@ -444,6 +449,12 @@ export const HOTKEYS = { category: "Layout", description: "Split the current pane along its longer side", }), + CLOSE_PANE: defineHotkey({ + keys: "meta+w", + label: "Close Pane", + category: "Layout", + description: "Close the current pane", + }), // Terminal FIND_IN_TERMINAL: defineHotkey({ @@ -452,9 +463,9 @@ export const HOTKEYS = { category: "Terminal", description: "Search text in the active terminal", }), - NEW_TERMINAL: defineHotkey({ + NEW_GROUP: defineHotkey({ keys: "meta+t", - label: "New Terminal", + label: "New Tab", category: "Terminal", }), CLOSE_TERMINAL: defineHotkey({ @@ -468,12 +479,12 @@ export const HOTKEYS = { category: "Terminal", }), PREV_TERMINAL: defineHotkey({ - keys: "meta+up", + keys: "meta+left", label: "Previous Terminal", category: "Terminal", }), NEXT_TERMINAL: defineHotkey({ - keys: "meta+down", + keys: "meta+right", label: "Next Terminal", category: "Terminal", }), @@ -575,6 +586,15 @@ export function getDefaultHotkey( return HOTKEYS[id].defaults[platform]; } +/** + * Get the hotkey binding for the current platform. + * Convenience wrapper around getDefaultHotkey. + * Returns empty string if no hotkey is defined (safe for useHotkeys). + */ +export function getHotkey(id: HotkeyId): string { + return getDefaultHotkey(id, getCurrentPlatform()) ?? ""; +} + export function getEffectiveHotkey( id: HotkeyId, overrides: Partial>, diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index 8ae323601..d8c921186 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -3,10 +3,46 @@ * Renderer extends these with MosaicNode layout specifics. */ +import type { ChangeCategory } from "./changes-types"; + /** * Pane types that can be displayed within a tab */ -export type PaneType = "terminal" | "webview"; +export type PaneType = "terminal" | "webview" | "file-viewer"; + +/** + * File viewer display modes + */ +export type FileViewerMode = "rendered" | "raw" | "diff"; + +/** + * Diff layout options for file viewer + */ +export type DiffLayout = "inline" | "side-by-side"; + +/** + * File viewer pane-specific properties + */ +export interface FileViewerState { + /** Worktree-relative file path */ + filePath: string; + /** Display mode: rendered (markdown), raw (source), or diff */ + viewMode: FileViewerMode; + /** If true, this pane won't be reused for new file clicks */ + isLocked: boolean; + /** Diff display layout */ + diffLayout: DiffLayout; + /** Category for diff source (against-main, committed, staged, unstaged) */ + diffCategory?: ChangeCategory; + /** Commit hash for committed category diffs */ + commitHash?: string; + /** Original path for renamed files */ + oldPath?: string; + /** Initial line to scroll to (raw mode only, transient - applied once) */ + initialLine?: number; + /** Initial column to scroll to (raw mode only, transient - applied once) */ + initialColumn?: number; +} /** * Base Pane interface - shared between main and renderer @@ -23,6 +59,7 @@ export interface Pane { url?: string; // For webview panes cwd?: string | null; // Current working directory cwdConfirmed?: boolean; // True if cwd confirmed via OSC-7, false if seeded + fileViewer?: FileViewerState; // For file-viewer panes } /** diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index a47714c62..72711f77a 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -5,4 +5,5 @@ export * from "./mosaic"; export * from "./ports"; export * from "./tab"; export * from "./workspace"; +export * from "./workspace-init"; export * from "./worktree"; diff --git a/apps/desktop/src/shared/types/workspace-init.ts b/apps/desktop/src/shared/types/workspace-init.ts new file mode 100644 index 000000000..b19d22a42 --- /dev/null +++ b/apps/desktop/src/shared/types/workspace-init.ts @@ -0,0 +1,72 @@ +/** + * Workspace initialization progress types. + * Used for streaming progress updates during workspace creation. + */ + +export type WorkspaceInitStep = + | "pending" + | "syncing" // Syncing with remote + | "verifying" // Verifying base branch exists + | "fetching" // Fetching latest changes + | "creating_worktree" // Creating git worktree + | "copying_config" // Copying .superset configuration + | "finalizing" // Final DB operations + | "ready" + | "failed"; + +export interface WorkspaceInitProgress { + workspaceId: string; + projectId: string; + step: WorkspaceInitStep; + message: string; + error?: string; +} + +export const INIT_STEP_MESSAGES: Record = { + pending: "Preparing...", + syncing: "Syncing with remote...", + verifying: "Verifying base branch...", + fetching: "Fetching latest changes...", + creating_worktree: "Creating git worktree...", + copying_config: "Copying configuration...", + finalizing: "Finalizing setup...", + ready: "Ready", + failed: "Failed", +}; + +/** + * Order of steps for UI progress display. + * Used to show completed/current/pending steps in the progress view. + */ +export const INIT_STEP_ORDER: WorkspaceInitStep[] = [ + "pending", + "syncing", + "verifying", + "fetching", + "creating_worktree", + "copying_config", + "finalizing", + "ready", +]; + +/** + * Get the index of a step in the progress order. + * Returns -1 for "failed" since it's not part of the normal flow. + */ +export function getStepIndex(step: WorkspaceInitStep): number { + if (step === "failed") return -1; + return INIT_STEP_ORDER.indexOf(step); +} + +/** + * Check if a step is complete based on the current step. + */ +export function isStepComplete( + step: WorkspaceInitStep, + currentStep: WorkspaceInitStep, +): boolean { + if (currentStep === "failed") return false; + const stepIndex = getStepIndex(step); + const currentIndex = getStepIndex(currentStep); + return stepIndex < currentIndex; +} diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index b14e1c4af..2bf5535b0 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -118,7 +118,14 @@ mock.module("electron", () => ({ screen: { getPrimaryDisplay: mock(() => ({ workAreaSize: { width: 1920, height: 1080 }, + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, })), + getAllDisplays: mock(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + workAreaSize: { width: 1920, height: 1080 }, + }, + ]), }, Notification: mock(() => ({ show: mock(), diff --git a/apps/marketing/package.json b/apps/marketing/package.json index af17cd237..52fab968e 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -11,10 +11,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/nextjs": "^6.36.2", "@react-three/drei": "^10.7.6", "@react-three/fiber": "^9.4.0", "@sentry/nextjs": "^10.32.1", - "@superset/auth": "workspace:*", "@superset/shared": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", diff --git a/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx b/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx index 2c0c76855..53f41fa7d 100644 --- a/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx +++ b/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx @@ -1,14 +1,13 @@ -import { auth } from "@superset/auth"; +import { auth } from "@clerk/nextjs/server"; import { DOWNLOAD_URL_MAC_ARM64 } from "@superset/shared/constants"; import { Download } from "lucide-react"; -import { headers } from "next/headers"; import { env } from "@/env"; export async function CTAButtons() { - const session = await auth.api.getSession({ headers: await headers() }); + const { userId } = await auth(); - if (session) { + if (userId) { return ( <> ) { return ( - - -