diff --git a/.env.example b/.env.example index a8188822f..e516015f6 100644 --- a/.env.example +++ b/.env.example @@ -28,11 +28,9 @@ NEXT_PUBLIC_MARKETING_URL=http://localhost:3002 NEXT_PUBLIC_DOCS_URL=http://localhost:3004 # ----------------------------------------------------------------------------- -# Clerk Auth +# Better Auth # ----------------------------------------------------------------------------- -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= -CLERK_WEBHOOK_SECRET= +BETTER_AUTH_SECRET= NEXT_PUBLIC_COOKIE_DOMAIN=localhost # ----------------------------------------------------------------------------- @@ -54,7 +52,6 @@ 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 3152585e6..f8350265f 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -170,10 +170,7 @@ 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 }} - 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 }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} @@ -197,16 +194,13 @@ 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 NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ - --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ --env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ @@ -292,12 +286,10 @@ 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 }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} 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 }} @@ -305,15 +297,13 @@ 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 DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --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_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 \ @@ -370,8 +360,7 @@ 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 }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} 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 }} @@ -382,10 +371,9 @@ 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 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 \ @@ -455,8 +443,7 @@ jobs: DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} 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 }} @@ -469,12 +456,11 @@ 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 47e542c92..150188a05 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -73,11 +73,8 @@ 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 }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_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 }} @@ -101,16 +98,13 @@ 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 NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ - --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ --env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ @@ -168,12 +162,10 @@ 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 }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} 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 }} @@ -181,15 +173,13 @@ 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 DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --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_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 \ @@ -230,8 +220,7 @@ 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 }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} 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 }} @@ -242,10 +231,9 @@ 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 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 \ @@ -288,8 +276,7 @@ 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 }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} 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 }} @@ -302,12 +289,11 @@ 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 659f15ea7..6c136b8f5 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,14 +6,6 @@ "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 4f68cc83e..9e7f5e2c3 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 -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. +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. Screenshot 2025-12-24 at 9 34 04 PM diff --git a/apps/admin/package.json b/apps/admin/package.json index c22d46c49..51757481b 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,6 +23,7 @@ "@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 1b39591c8..eb751b99e 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, LuUserX } from "react-icons/lu"; +import { LuChevronRight, LuHouse, LuUsers } from "react-icons/lu"; import { AppSidebarHeader } from "./components/AppSidebarHeader"; import { NavUser } from "./components/NavUser"; @@ -43,11 +43,6 @@ 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 774cc5e09..83bb8abd3 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 { useClerk } from "@clerk/nextjs"; +import { authClient } from "@superset/auth/client"; import type { RouterOutputs } from "@superset/trpc"; import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; import { @@ -25,7 +25,6 @@ import { LuLogOut, LuSettings, } from "react-icons/lu"; - import { env } from "@/env"; export interface NavUserProps { @@ -34,13 +33,22 @@ 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 ( @@ -51,10 +59,7 @@ export function NavUser({ user }: NavUserProps) { className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - + {userInitials} @@ -75,10 +80,7 @@ export function NavUser({ user }: NavUserProps) {
- + {userInitials} @@ -105,9 +107,7 @@ 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 6c7bd5b63..b24cba6e3 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; - avatarUrl: string | null; + image: 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 cdf7dd2d5..e7b05fe6a 100644 --- a/apps/admin/src/app/(dashboard)/layout.tsx +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -1,3 +1,5 @@ +import { auth } from "@superset/auth"; +import { COMPANY } from "@superset/shared/constants"; import { Breadcrumb, BreadcrumbItem, @@ -12,7 +14,10 @@ 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"; @@ -22,11 +27,23 @@ 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) { - throw new Error("User not found"); + redirect(env.NEXT_PUBLIC_WEB_URL); } 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 3ccac15f6..0883614d7 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.listActiveUsers.queryOptions(), + trpc.admin.listUsers.queryOptions(), ); const [userToDelete, setUserToDelete] = useState<{ @@ -55,12 +55,12 @@ export function UsersTable() { } | null>(null); const deleteMutation = useMutation( - trpc.admin.permanentlyDeleteUser.mutationOptions({ + trpc.admin.deleteUser.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ - queryKey: trpc.admin.listActiveUsers.queryKey(), + queryKey: trpc.admin.listUsers.queryKey(), }); - toast.success(`${userToDelete?.name} has been permanently deleted`); + toast.success(`${userToDelete?.name} has been 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 deleted file mode 100644 index c137fbb1a..000000000 --- a/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/DeletedUsersTable.tsx +++ /dev/null @@ -1,296 +0,0 @@ -"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 deleted file mode 100644 index 3b5567553..000000000 --- a/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 9d188ff24..000000000 --- a/apps/admin/src/app/(dashboard)/users/deleted/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 2280d3658..5be8712d8 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,11 +1,8 @@ -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"; @@ -40,21 +37,19 @@ 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 68f00c993..e70937d4b 100644 --- a/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx +++ b/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx @@ -1,27 +1,27 @@ "use client"; -import { useUser } from "@clerk/nextjs"; +import { authClient } from "@superset/auth/client"; 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 { isSignedIn } = useUser(); + const { data: session } = authClient.useSession(); const trpc = useTRPC(); const { data: user } = useQuery({ ...trpc.user.me.queryOptions(), - enabled: isSignedIn, + enabled: !!session?.user, }); useEffect(() => { if (user) { posthog.identify(user.id, { email: user.email, name: user.name }); - } else if (isSignedIn === false) { + } else if (!session?.user) { posthog.reset(); } - }, [user, isSignedIn]); + }, [user, session?.user]); return null; } diff --git a/apps/admin/src/env.ts b/apps/admin/src/env.ts index ba90201c8..5dce7ad6f 100644 --- a/apps/admin/src/env.ts +++ b/apps/admin/src/env.ts @@ -13,14 +13,12 @@ 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(), @@ -34,8 +32,6 @@ 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 087e49b26..566f2fc8a 100644 --- a/apps/admin/src/proxy.ts +++ b/apps/admin/src/proxy.ts @@ -1,47 +1,29 @@ -import { clerkMiddleware } from "@clerk/nextjs/server"; -import { db } from "@superset/db/client"; -import { users } from "@superset/db/schema"; +import { auth } from "@superset/auth"; import { COMPANY } from "@superset/shared/constants"; -import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { env } from "./env"; -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(); +export default async function proxy() { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!clerkId) { + if (!session?.user) { return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); } - const user = await db.query.users.findFirst({ - where: eq(users.clerkId, clerkId), - }); - - if (!user?.email.endsWith(COMPANY.EMAIL_DOMAIN)) { + if (!session.user.email.endsWith(COMPANY.EMAIL_DOMAIN)) { return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); } return NextResponse.next(); -}); +} export const config = { matcher: [ - "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/((?!_next|ingest|monitoring|[^?]*\\.(?: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 95db3b924..b654b409f 100644 --- a/apps/admin/src/trpc/react.tsx +++ b/apps/admin/src/trpc/react.tsx @@ -1,6 +1,5 @@ "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"; @@ -33,7 +32,6 @@ export type UseTRPC = typeof useTRPC; export function TRPCReactProvider(props: { children: React.ReactNode }) { const queryClient = getQueryClient(); - const { getToken } = useAuth(); const [trpcClient] = useState(() => createTRPCClient({ @@ -46,13 +44,17 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) { httpBatchStreamLink({ transformer: SuperJSON, url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, - async headers() { - const token = await getToken(); + headers() { 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 9943f4e41..8de5ba6fc 100644 --- a/apps/api/next.config.ts +++ b/apps/api/next.config.ts @@ -3,7 +3,6 @@ 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 }); } @@ -11,7 +10,6 @@ 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 62ccda6d7..023421e13 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,11 +11,10 @@ "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:*", @@ -23,6 +22,7 @@ "@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 new file mode 100644 index 000000000..6b68abd83 --- /dev/null +++ b/apps/api/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 000000000..86deb03a7 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/connect/route.ts @@ -0,0 +1,45 @@ +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 deleted file mode 100644 index 4f95b0968..000000000 --- a/apps/api/src/app/api/auth/desktop/github/route.ts +++ /dev/null @@ -1,207 +0,0 @@ -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 deleted file mode 100644 index 622408b47..000000000 --- a/apps/api/src/app/api/auth/desktop/google/route.ts +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index 808cc500a..000000000 --- a/apps/api/src/app/api/auth/desktop/refresh/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index f3aff4065..000000000 --- a/apps/api/src/app/api/auth/desktop/tokens.ts +++ /dev/null @@ -1,81 +0,0 @@ -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/electric/[...path]/route.ts b/apps/api/src/app/api/electric/[...path]/route.ts index 90c9cb64e..3617a72d1 100644 --- a/apps/api/src/app/api/electric/[...path]/route.ts +++ b/apps/api/src/app/api/electric/[...path]/route.ts @@ -1,46 +1,22 @@ import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"; -import { db } from "@superset/db/client"; -import { organizationMembers, users } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; +import { auth } from "@superset/auth"; 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 clerkUserId = await authenticateRequest(request); - if (!clerkUserId) { - return new Response("Unauthorized", { status: 401 }); - } - - const user = await db.query.users.findFirst({ - where: eq(users.clerkId, clerkUserId), + const sessionData = await auth.api.getSession({ + headers: request.headers, }); - if (!user) { - return new Response("User not found", { status: 401 }); + if (!sessionData?.user) { + return new Response("Unauthorized", { status: 401 }); } - const url = new URL(request.url); - const organizationId = url.searchParams.get("organizationId"); + const organizationId = sessionData.session.activeOrganizationId; 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 }); + return new Response("No active organization", { status: 400 }); } + const url = new URL(request.url); const originUrl = new URL(env.ELECTRIC_URL); originUrl.searchParams.set("secret", env.ELECTRIC_SECRET); @@ -68,8 +44,6 @@ 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 efd8ca2ff..ba2b1d05f 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -1,7 +1,8 @@ import { db } from "@superset/db/client"; import { - organizationMembers, + members, organizations, + repositories, tasks, users, } from "@superset/db/schema"; @@ -11,9 +12,10 @@ import { QueryBuilder } from "drizzle-orm/pg-core"; export type AllowedTable = | "tasks" - | "organization_members" - | "organizations" - | "users"; + | "repositories" + | "auth.members" + | "auth.organizations" + | "auth.users"; interface WhereClause { fragment: string; @@ -40,25 +42,62 @@ export async function buildWhereClause( case "tasks": return build(tasks, tasks.organizationId, organizationId); - case "organization_members": - return build( - organizationMembers, - organizationMembers.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 }, + }); - case "organizations": - return build(organizations, organizations.id, organizationId); + 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, + ); + 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 "users": { - const members = await db.query.organizationMembers.findMany({ - where: eq(organizationMembers.organizationId, organizationId), + case "auth.users": { + const orgMembers = await db.query.members.findMany({ + where: eq(members.organizationId, organizationId), columns: { userId: true }, }); - if (members.length === 0) { + if (orgMembers.length === 0) { return { fragment: "1 = 0", params: [] }; } - const userIds = [...new Set(members.map((m) => m.userId))]; + const userIds = [...new Set(orgMembers.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 7a76e51a0..b359cb37c 100644 --- a/apps/api/src/app/api/integrations/linear/connect/route.ts +++ b/apps/api/src/app/api/integrations/linear/connect/route.ts @@ -1,13 +1,15 @@ -import { auth } from "@clerk/nextjs/server"; +import { auth } from "@superset/auth"; import { db } from "@superset/db/client"; -import { organizationMembers, users } from "@superset/db/schema"; +import { members } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; import { env } from "@/env"; export async function GET(request: Request) { - const { userId: clerkUserId } = await auth(); + const session = await auth.api.getSession({ + headers: request.headers, + }); - if (!clerkUserId) { + if (!session?.user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -21,18 +23,10 @@ export async function GET(request: Request) { ); } - 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({ + const membership = await db.query.members.findFirst({ where: and( - eq(organizationMembers.organizationId, organizationId), - eq(organizationMembers.userId, user.id), + eq(members.organizationId, organizationId), + eq(members.userId, session.user.id), ), }); @@ -44,7 +38,7 @@ export async function GET(request: Request) { } const state = Buffer.from( - JSON.stringify({ organizationId, userId: user.id }), + JSON.stringify({ organizationId, userId: session.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 a1cfec766..710ea1a1b 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -10,11 +10,9 @@ 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 deleted file mode 100644 index e883f106c..000000000 --- a/apps/api/src/lib/auth.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 9d58e4e26..11ccda86f 100644 --- a/apps/api/src/proxy.ts +++ b/apps/api/src/proxy.ts @@ -1,5 +1,4 @@ -import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; -import { NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { env } from "./env"; @@ -8,7 +7,6 @@ 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); @@ -21,12 +19,7 @@ function getCorsHeaders(origin: string | null) { }; } -export default clerkMiddleware(async (_auth, req) => { - // Allow Sentry and PostHog routes without CORS processing - if (isPublicRoute(req)) { - return NextResponse.next(); - } - +export default function proxy(req: NextRequest) { const origin = req.headers.get("origin"); const corsHeaders = getCorsHeaders(origin); @@ -41,13 +34,11 @@ export default clerkMiddleware(async (_auth, req) => { response.headers.set(key, value); } return response; -}); +} export const config = { matcher: [ - // 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 + "/((?!_next|ingest|monitoring|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", ], }; diff --git a/apps/api/src/trpc/context.ts b/apps/api/src/trpc/context.ts index 98bc13e3b..ccafe79f2 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,6 +7,8 @@ export const createContext = async ({ req: Request; resHeaders: Headers; }) => { - const userId = await authenticateRequest(req); - return createTRPCContext({ userId }); + const session = await auth.api.getSession({ + headers: req.headers, + }); + return createTRPCContext({ session }); }; diff --git a/apps/desktop/docs/LOCAL_FIRST_SYNC.md b/apps/desktop/docs/LOCAL_FIRST_SYNC.md new file mode 100644 index 000000000..e59f70cba --- /dev/null +++ b/apps/desktop/docs/LOCAL_FIRST_SYNC.md @@ -0,0 +1,60 @@ +# 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 a171195b0..2ce99bdab 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -213,6 +213,7 @@ 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 696bb9f33..024186298 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.43", + "version": "0.0.42", "main": "./dist/main/index.js", "resources": "src/resources", "repository": { @@ -39,6 +39,7 @@ "@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:*", @@ -99,6 +100,7 @@ "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", @@ -138,6 +140,7 @@ "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 579fa3403..4c299e24c 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -58,7 +58,9 @@ export async function makeAppSetup( // Always prevent in-app navigation for external URLs if (url.startsWith("http://") || url.startsWith("https://")) { event.preventDefault(); - shell.openExternal(url); + shell.openExternal(url).catch((error) => { + console.error("[app] Failed to open external URL:", url, error); + }); } }), ); 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 479cb0faf..d5a029311 100644 --- a/apps/desktop/src/lib/electron-app/factories/windows/create.ts +++ b/apps/desktop/src/lib/electron-app/factories/windows/create.ts @@ -9,7 +9,9 @@ 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); + shell.openExternal(url).catch((error) => { + console.error("[window] Failed to open external URL:", url, error); + }); 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 ca001efce..3705bad29 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -1,7 +1,6 @@ +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 "../.."; @@ -9,18 +8,16 @@ import { publicProcedure, router } from "../.."; * Authentication router for desktop app * Handles sign in/out and state management */ -export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { +export const createAuthRouter = () => { return router({ - /** - * Get current authentication state - */ getState: publicProcedure.query(() => { return authService.getState(); }), - /** - * Subscribe to auth state changes - */ + getAccessToken: publicProcedure.query(() => { + return authService.getAccessToken(); + }), + onStateChange: publicProcedure.subscription(() => { return observable<{ isSignedIn: boolean }>((emit) => { const handler = (state: { isSignedIn: boolean }) => { @@ -41,7 +38,7 @@ export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { /** * Subscribe to access token (for Electric sync in renderer) - * Emits current token on subscribe and again when tokens refresh + * Emits current token on subscribe and when auth state changes */ onAccessToken: publicProcedure.subscription(() => { return observable<{ accessToken: string | null }>((emit) => { @@ -50,6 +47,7 @@ export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { 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))); } }; @@ -60,28 +58,45 @@ export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { void emitToken(); - authService.on("tokens-refreshed", handler); authService.on("state-changed", handler); return () => { - authService.off("tokens-refreshed", handler); authService.off("state-changed", handler); }; }); }), - /** - * Sign in with OAuth provider - */ + 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 }; + }), + signIn: publicProcedure .input(z.object({ provider: z.enum(AUTH_PROVIDERS) })) .mutation(async ({ input }) => { - return authService.signIn(input.provider, getWindow); + return authService.signIn(input.provider); }), - /** - * 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 bda18dccf..bc63ab91a 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -4,11 +4,6 @@ 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({ @@ -23,8 +18,6 @@ export const createBranchesRouter = () => { defaultBranch: string; checkedOutBranches: Record; }> => { - assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); const branchSummary = await git.branch(["-a"]); @@ -66,8 +59,18 @@ export const createBranchesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const worktree = getRegisteredWorktree(input.worktreePath); - await gitSwitchBranch(input.worktreePath, input.branch); + 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); // 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 48b01555b..8af04dd68 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -1,47 +1,10 @@ +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 { - 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; -} +import { detectLanguage } from "./utils/parse-status"; export const createFileContentsRouter = () => { return router({ @@ -57,8 +20,6 @@ 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; @@ -89,57 +50,10 @@ export const createFileContentsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await secureFs.writeFile( - input.worktreePath, - input.filePath, - input.content, - ); + const fullPath = join(input.worktreePath, input.filePath); + await writeFile(fullPath, input.content, "utf-8"); 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" }; - } - }), }); }; @@ -177,41 +91,26 @@ 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 { - const [original, modified] = await Promise.all([ - safeGitShow(git, `origin/${defaultBranch}:${originalPath}`), - safeGitShow(git, `HEAD:${filePath}`), - ]); + let original = ""; + let modified = ""; + + try { + original = await git.show([`origin/${defaultBranch}:${originalPath}`]); + } catch { + original = ""; + } + + try { + modified = await git.show([`HEAD:${filePath}`]); + } catch { + modified = ""; + } return { original, modified }; } @@ -222,10 +121,20 @@ async function getCommittedVersions( originalPath: string, commitHash: string, ): Promise { - const [original, modified] = await Promise.all([ - safeGitShow(git, `${commitHash}^:${originalPath}`), - safeGitShow(git, `${commitHash}:${filePath}`), - ]); + let original = ""; + let modified = ""; + + try { + original = await git.show([`${commitHash}^:${originalPath}`]); + } catch { + original = ""; + } + + try { + modified = await git.show([`${commitHash}:${filePath}`]); + } catch { + modified = ""; + } return { original, modified }; } @@ -235,10 +144,20 @@ async function getStagedVersions( filePath: string, originalPath: string, ): Promise { - const [original, modified] = await Promise.all([ - safeGitShow(git, `HEAD:${originalPath}`), - safeGitShow(git, `:0:${filePath}`), - ]); + let original = ""; + let modified = ""; + + try { + original = await git.show([`HEAD:${originalPath}`]); + } catch { + original = ""; + } + + try { + modified = await git.show([`:0:${filePath}`]); + } catch { + modified = ""; + } return { original, modified }; } @@ -249,22 +168,22 @@ async function getUnstagedVersions( filePath: string, originalPath: string, ): Promise { - // Try staged version first, fall back to HEAD - let original = await safeGitShow(git, `:0:${originalPath}`); - if (!original) { - original = await safeGitShow(git, `HEAD:${originalPath}`); - } - + let original = ""; let modified = ""; + try { - 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]`; + original = await git.show([`:0:${originalPath}`]); + } catch { + try { + original = await git.show([`HEAD:${originalPath}`]); + } catch { + original = ""; } + } + + try { + modified = await readFile(join(worktreePath, filePath), "utf-8"); } 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 c69364a34..6e6f584cf 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,9 +1,10 @@ +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 }; @@ -20,8 +21,25 @@ async function hasUpstreamBranch( export const createGitOperationsRouter = () => { return router({ - // NOTE: saveFile is defined in file-contents.ts with hardened path validation - // Do NOT add saveFile here - it would overwrite the secure version + 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 }; + }), commit: publicProcedure .input( @@ -32,8 +50,6 @@ 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 }; @@ -48,8 +64,6 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); const hasUpstream = await hasUpstreamBranch(git); @@ -70,8 +84,6 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); try { await git.pull(["--rebase"]); @@ -95,8 +107,6 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); try { await git.pull(["--rebase"]); @@ -124,8 +134,6 @@ 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 deleted file mode 100644 index 2877176b8..000000000 --- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts +++ /dev/null @@ -1,137 +0,0 @@ -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 deleted file mode 100644 index 8fdb09c9e..000000000 --- a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 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 deleted file mode 100644 index 317994323..000000000 --- a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts +++ /dev/null @@ -1,194 +0,0 @@ -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 deleted file mode 100644 index 9a931f0f8..000000000 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ /dev/null @@ -1,469 +0,0 @@ -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 678e1304c..1d3109a65 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -1,13 +1,8 @@ +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({ @@ -19,7 +14,8 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStageFile(input.worktreePath, input.filePath); + const git = simpleGit(input.worktreePath); + await git.add(input.filePath); return { success: true }; }), @@ -31,7 +27,8 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitUnstageFile(input.worktreePath, input.filePath); + const git = simpleGit(input.worktreePath); + await git.reset(["HEAD", "--", input.filePath]); return { success: true }; }), @@ -43,21 +40,24 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitCheckoutFile(input.worktreePath, input.filePath); + const git = simpleGit(input.worktreePath); + await git.checkout(["--", input.filePath]); return { success: true }; }), stageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStageAll(input.worktreePath); + const git = simpleGit(input.worktreePath); + await git.add("-A"); return { success: true }; }), unstageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitUnstageAll(input.worktreePath); + const git = simpleGit(input.worktreePath); + await git.reset(["HEAD"]); return { success: true }; }), @@ -69,7 +69,8 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await secureFs.delete(input.worktreePath, input.filePath); + const fullPath = join(input.worktreePath, input.filePath); + await rm(fullPath, { recursive: true, force: true }); 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 2916e0609..c547b9855 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts @@ -1,8 +1,9 @@ +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, @@ -20,8 +21,6 @@ export const createStatusRouter = () => { }), ) .query(async ({ input }): Promise => { - assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; @@ -65,8 +64,6 @@ export const createStatusRouter = () => { }), ) .query(async ({ input }): Promise => { - assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); const nameStatus = await git.raw([ @@ -144,25 +141,18 @@ 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 stats = await secureFs.stat(worktreePath, file.path); - if (stats.size > MAX_LINE_COUNT_SIZE) continue; - - const content = await secureFs.readFile(worktreePath, file.path); + const fullPath = join(worktreePath, file.path); + const content = await readFile(fullPath, "utf-8"); const lineCount = content.split("\n").length; file.additions = lineCount; file.deletions = 0; - } catch { - // Skip files that fail validation or reading - } + } catch {} } } 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 d1c72efea..d05a8920b 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,6 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { detectLanguage } from "shared/detect-language"; -import { parseDiffNumstat, parseGitLog, parseNameStatus } from "./parse-status"; +import { + detectLanguage, + 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 598f66762..723473efc 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,3 +195,65 @@ 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 9fd7455f7..66ad3766f 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(getWindow), + auth: createAuthRouter(), 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 8c2e5ed6e..ce6245816 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1,13 +1,6 @@ -import { - settings, - TERMINAL_LINK_BEHAVIORS, - type TerminalPreset, -} from "@superset/local-db"; +import { settings, type TerminalPreset } from "@superset/local-db"; import { localDb } from "main/lib/local-db"; -import { - DEFAULT_CONFIRM_ON_QUIT, - DEFAULT_TERMINAL_LINK_BEHAVIOR, -} from "shared/constants"; +import { DEFAULT_CONFIRM_ON_QUIT } from "shared/constants"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -187,25 +180,5 @@ 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 2d70d1eec..d23ca0637 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -7,7 +7,6 @@ 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"; @@ -59,14 +58,6 @@ 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 @@ -191,11 +182,11 @@ export const createTerminalRouter = () => { .where(eq(workspaces.id, workspaceId)) .get(); if (!workspace) { - return undefined; + return null; } if (!workspace.worktreeId) { - return undefined; + return null; } const worktree = localDb @@ -203,7 +194,7 @@ export const createTerminalRouter = () => { .from(worktrees) .where(eq(worktrees.id, workspace.worktreeId)) .get(); - return worktree?.path; + return worktree?.path ?? null; }), /** 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 afbff9fc9..0d2b8e87f 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -10,39 +10,19 @@ 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", "file-viewer"]), + type: z.enum(["terminal", "webview"]), 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 2226de978..258a223fc 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -13,26 +13,6 @@ 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 = {}; @@ -433,158 +413,25 @@ 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 env = await getGitEnv(); - +): Promise { + const git = simpleGit(worktreePath); try { - // 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); + // 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; } } @@ -809,42 +656,6 @@ 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 48868425e..556c89d6d 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,7 +2,6 @@ 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, @@ -45,15 +44,11 @@ export async function fetchGitHubPRStatus( const branchName = branchOutput.trim(); // Check if branch exists on remote and get PR info in parallel - const [branchCheck, prInfo] = await Promise.all([ + const [existsOnRemote, 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, @@ -73,10 +68,12 @@ export async function fetchGitHubPRStatus( async function getRepoUrl(worktreePath: string): Promise { try { - const { stdout } = await execWithShellEnv( + const { stdout } = await execFileAsync( "gh", ["repo", "view", "--json", "url"], - { cwd: worktreePath }, + { + cwd: worktreePath, + }, ); const raw = JSON.parse(stdout); const result = GHRepoResponseSchema.safeParse(raw); @@ -96,8 +93,8 @@ async function getPRForBranch( branch: string, ): Promise { try { - // Use execWithShellEnv to handle macOS GUI app PATH issues - const { stdout } = await execWithShellEnv( + // Use execFile with args array to prevent command injection + const { stdout } = await execFileAsync( "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 c59efa72d..396504588 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,7 +1,4 @@ -import { - type ExecFileOptionsWithStringEncoding, - execFile, -} from "node:child_process"; +import { execFile } from "node:child_process"; import os from "node:os"; import { promisify } from "node:util"; @@ -14,10 +11,6 @@ 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 @@ -36,9 +29,7 @@ export async function getShellEnvironment(): Promise> { return { ...cachedEnv }; } - const shell = - process.env.SHELL || - (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"); + const shell = process.env.SHELL || "/bin/bash"; try { // Use -lc flags (not -ilc): @@ -112,62 +103,3 @@ 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 deleted file mode 100644 index 5c261e716..000000000 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/usability.ts +++ /dev/null @@ -1,122 +0,0 @@ -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 5afb68f0f..670b8e41b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -7,14 +7,11 @@ import { workspaces, worktrees, } from "@superset/local-db"; -import { observable } from "@trpc/server/observable"; -import { and, desc, eq, isNotNull, not } from "drizzle-orm"; +import { and, desc, eq, isNotNull } 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 { @@ -30,11 +27,9 @@ import { hasUncommittedChanges, hasUnpushedCommits, listBranches, - refExistsLocally, refreshDefaultBranch, removeWorktree, safeCheckoutBranch, - sanitizeGitError, worktreeExists, } from "./utils/git"; import { fetchGitHubPRStatus } from "./utils/github"; @@ -42,302 +37,6 @@ 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 @@ -369,13 +68,67 @@ export const createWorkspacesRouter = () => { branch, ); - // 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"; + // 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(); + } + const targetBranch = input.baseBranch || defaultBranch; - // Insert worktree record immediately (before git operations) - // gitStatus will be updated when initialization completes + // 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 const worktree = localDb .insert(worktrees) .values({ @@ -383,7 +136,11 @@ export const createWorkspacesRouter = () => { path: worktreePath, branch, baseBranch: targetBranch, - gitStatus: null, // Will be set when init completes + gitStatus: { + branch, + needsRebase: false, + lastRefreshed: Date.now(), + }, }) .returning() .get(); @@ -399,6 +156,7 @@ export const createWorkspacesRouter = () => { ? Math.max(...projectWorkspaces.map((w) => w.tabOrder)) : -1; + // Insert workspace const workspace = localDb .insert(workspaces) .values({ @@ -412,6 +170,7 @@ export const createWorkspacesRouter = () => { .returning() .get(); + // Update settings localDb .insert(settings) .values({ id: 1, lastActiveWorkspaceId: workspace.id }) @@ -421,6 +180,7 @@ export const createWorkspacesRouter = () => { }) .run(); + // Update project const activeProjects = localDb .select() .from(projects) @@ -443,7 +203,9 @@ export const createWorkspacesRouter = () => { .where(eq(projects.id, input.projectId)) .run(); - // Track workspace creation (not initialization - that's tracked when it completes) + // Load setup configuration from the main repo (where .superset/config.json lives) + const setupConfig = loadSetupConfig(project.mainRepoPath); + track("workspace_created", { workspace_id: workspace.id, project_id: project.id, @@ -451,29 +213,11 @@ 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, }; }), @@ -561,11 +305,22 @@ export const createWorkspacesRouter = () => { }; } - // 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 + // 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(workspaces) .values({ projectId: input.projectId, @@ -574,54 +329,8 @@ export const createWorkspacesRouter = () => { name: branch, tabOrder: 0, }) - .onConflictDoNothing() .returning() - .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"); - } + .get(); // Update settings localDb @@ -633,43 +342,41 @@ export const createWorkspacesRouter = () => { }) .run(); - // 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; + // 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; - 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, + wasExisting: false, }; }), @@ -826,7 +533,6 @@ export const createWorkspacesRouter = () => { name: string; color: string; tabOrder: number; - mainRepoPath: string; }; workspaces: Array<{ id: string; @@ -840,7 +546,6 @@ export const createWorkspacesRouter = () => { createdAt: number; updatedAt: number; lastOpenedAt: number; - isUnread: boolean; }>; } >(); @@ -853,7 +558,6 @@ export const createWorkspacesRouter = () => { color: project.color, // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null tabOrder: project.tabOrder!, - mainRepoPath: project.mainRepoPath, }, workspaces: [], }); @@ -871,7 +575,6 @@ export const createWorkspacesRouter = () => { ...workspace, type: workspace.type as "worktree" | "branch", worktreePath: getWorkspacePath(workspace) ?? "", - isUnread: workspace.isUnread ?? false, }); } } @@ -969,8 +672,7 @@ export const createWorkspacesRouter = () => { ? { branch: worktree.branch, baseBranch, - // Normalize to null to ensure consistent "incomplete init" detection in UI - gitStatus: worktree.gitStatus ?? null, + gitStatus: worktree.gitStatus, } : null, }; @@ -1149,17 +851,6 @@ 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, @@ -1183,51 +874,43 @@ export const createWorkspacesRouter = () => { .get() ?? undefined; if (worktree && project) { - // Acquire project lock before any git operations - // This prevents racing with any concurrent init operations - await workspaceInitManager.acquireProjectLock(project.id); + // Run teardown scripts before removing worktree + const exists = await worktreeExists( + project.mainRepoPath, + worktree.path, + ); - try { - // Run teardown scripts before removing worktree - const exists = await worktreeExists( + if (exists) { + runTeardown( project.mainRepoPath, worktree.path, - ); - - if (exists) { - runTeardown( - 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`, + 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`, + ); } - } finally { - workspaceInitManager.releaseProjectLock(project.id); + } 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}`, + }; } } } @@ -1279,10 +962,6 @@ 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 }; }), @@ -1298,18 +977,10 @@ 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, - // Auto-clear unread state when switching to workspace - isUnread: false, - }) + .set({ lastOpenedAt: now, updatedAt: now }) .where(eq(workspaces.id, input.id)) .run(); @@ -1322,7 +993,7 @@ export const createWorkspacesRouter = () => { }) .run(); - return { success: true, wasUnread }; + return { success: true }; }), reorder: publicProcedure @@ -1660,27 +1331,6 @@ 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 }) => { @@ -1743,154 +1393,6 @@ 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 6afdda383..a9d1e6c56 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, handleAuthDeepLink, isAuthDeepLink } from "./lib/auth"; +import { authService, parseAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; import { localDb } from "./lib/local-db"; import { terminalManager } from "./lib/terminal"; @@ -36,31 +36,15 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME); } -/** - * Process a deep link URL for auth - */ async function processDeepLink(url: string): Promise { - 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); - } + 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); } } diff --git a/apps/desktop/src/main/lib/api-client.ts b/apps/desktop/src/main/lib/api-client.ts index c8b24b647..457c43733 100644 --- a/apps/desktop/src/main/lib/api-client.ts +++ b/apps/desktop/src/main/lib/api-client.ts @@ -7,6 +7,9 @@ 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: [ @@ -14,7 +17,7 @@ export const apiClient = createTRPCClient({ url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, transformer: superjson, async headers() { - const token = await authService.getAccessToken(); + const token = authService.getAccessToken(); if (token) { return { Authorization: `Bearer ${token}`, @@ -22,6 +25,23 @@ 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 5881e38d0..b332b1f4b 100644 --- a/apps/desktop/src/main/lib/app-environment.ts +++ b/apps/desktop/src/main/lib/app-environment.ts @@ -6,6 +6,3 @@ 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 deleted file mode 100644 index 87465c97f..000000000 --- a/apps/desktop/src/main/lib/auth/auth-service.ts +++ /dev/null @@ -1,361 +0,0 @@ -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 new file mode 100644 index 000000000..f56471603 --- /dev/null +++ b/apps/desktop/src/main/lib/auth/auth.ts @@ -0,0 +1,219 @@ +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 faf50a98b..faeba16f7 100644 --- a/apps/desktop/src/main/lib/auth/crypto-storage.ts +++ b/apps/desktop/src/main/lib/auth/crypto-storage.ts @@ -16,8 +16,6 @@ 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 deleted file mode 100644 index 5f6d864e1..000000000 --- a/apps/desktop/src/main/lib/auth/deep-link-handler.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 1749da60e..91e69aedf 100644 --- a/apps/desktop/src/main/lib/auth/index.ts +++ b/apps/desktop/src/main/lib/auth/index.ts @@ -1,4 +1,2 @@ -export { authService } from "./auth-service"; -export type { AuthDeepLinkResult } from "./deep-link-handler"; -export { handleAuthDeepLink, isAuthDeepLink } from "./deep-link-handler"; -export { tokenStorage } from "./token-storage"; +export type { SignInResult } from "./auth"; +export { authService, parseAuthDeepLink } from "./auth"; diff --git a/apps/desktop/src/main/lib/auth/pkce.ts b/apps/desktop/src/main/lib/auth/pkce.ts deleted file mode 100644 index dd0c6a0ae..000000000 --- a/apps/desktop/src/main/lib/auth/pkce.ts +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 4364278dc..000000000 --- a/apps/desktop/src/main/lib/auth/token-storage.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 6ed3c03a1..f8fb5b1e5 100644 --- a/apps/desktop/src/main/lib/terminal/env.test.ts +++ b/apps/desktop/src/main/lib/terminal/env.test.ts @@ -1,10 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { - buildSafeEnv, buildTerminalEnv, FALLBACK_SHELL, getLocale, - removeAppEnvVars, SHELL_CRASH_THRESHOLD_MS, sanitizeEnv, } from "./env"; @@ -104,432 +102,6 @@ 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", @@ -538,132 +110,72 @@ describe("env", () => { workspaceId: "ws-1", }; - // 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 TERM_PROGRAM to Superset", () => { + const result = buildTerminalEnv(baseParams); + expect(result.TERM_PROGRAM).toBe("Superset"); }); - 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 COLORTERM to truecolor", () => { + const result = buildTerminalEnv(baseParams); + expect(result.COLORTERM).toBe("truecolor"); }); - 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(); - }); - - 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 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(); - }); - - 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 set Superset-specific env vars", () => { + 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(); - }); - - 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(); - }); + expect(result.SUPERSET_PANE_ID).toBe("pane-1"); + expect(result.SUPERSET_TAB_ID).toBe("tab-1"); + expect(result.SUPERSET_WORKSPACE_ID).toBe("ws-1"); }); - describe("terminal metadata", () => { - it("should set TERM_PROGRAM to Superset", () => { - const result = buildTerminalEnv(baseParams); - expect(result.TERM_PROGRAM).toBe("Superset"); - }); - - it("should set COLORTERM to truecolor", () => { - const result = buildTerminalEnv(baseParams); - expect(result.COLORTERM).toBe("truecolor"); + it("should handle optional workspace params", () => { + const result = buildTerminalEnv({ + ...baseParams, + workspaceName: "my-workspace", + workspacePath: "/path/to/workspace", + rootPath: "/root/path", }); - it("should set Superset-specific env vars", () => { - const result = buildTerminalEnv(baseParams); + 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"); + }); - 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 default optional params to empty string", () => { + const result = buildTerminalEnv(baseParams); - 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(""); + expect(result.SUPERSET_WORKSPACE_PATH).toBe(""); + expect(result.SUPERSET_ROOT_PATH).toBe(""); + }); - 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 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"; - it("should default optional params to empty string", () => { + try { 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.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 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"); - }); + 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 7709d6686..d5931a908 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -63,254 +63,6 @@ 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; @@ -330,15 +82,9 @@ export function buildTerminalEnv(params: { rootPath, } = params; - // 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 baseEnv = sanitizeEnv(process.env) || {}; const shellEnv = getShellEnv(shell); - const locale = getLocale(rawBaseEnv); + const locale = getLocale(baseEnv); const env: Record = { ...baseEnv, @@ -356,5 +102,7 @@ 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 deleted file mode 100644 index 8b89325ac..000000000 --- a/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -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 deleted file mode 100644 index 039847731..000000000 --- a/apps/desktop/src/main/lib/window-state/bounds-validation.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index dabdb477f..000000000 --- a/apps/desktop/src/main/lib/window-state/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 688464272..000000000 --- a/apps/desktop/src/main/lib/window-state/window-state.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -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 deleted file mode 100644 index 7c749458f..000000000 --- a/apps/desktop/src/main/lib/window-state/window-state.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 99245eb01..000000000 --- a/apps/desktop/src/main/lib/workspace-init-manager.ts +++ /dev/null @@ -1,302 +0,0 @@ -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 0ba5fc33b..c3c2c4e97 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 } from "electron"; +import { Notification, screen } 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,11 +18,6 @@ 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; @@ -34,20 +29,17 @@ let currentWindow: BrowserWindow | null = null; const getWindow = () => currentWindow; export async function MainWindow() { - const savedWindowState = loadWindowState(); - const initialBounds = getInitialWindowBounds(savedWindowState); + const { width, height } = screen.getPrimaryDisplay().workAreaSize; const window = createWindow({ id: "main", title: productName, - width: initialBounds.width, - height: initialBounds.height, - x: initialBounds.x, - y: initialBounds.y, + width, + height, minWidth: 400, minHeight: 400, show: false, - center: initialBounds.center, + center: true, movable: true, resizable: true, alwaysOnTop: false, @@ -58,6 +50,9 @@ 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", }, }); @@ -165,25 +160,10 @@ 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/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx deleted file mode 100644 index 91295eaa9..000000000 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 3a608bf50..000000000 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 d20884501..b5cd6b0e8 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts @@ -1,3 +1,2 @@ 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 c19cc5e1b..61ef8cdd6 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, SafeImage } from "../../components"; +import { CodeBlock } from "../../components"; import type { MarkdownStyleConfig } from "../types"; import "./default.css"; @@ -41,11 +41,7 @@ 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 5173f7e45..077f8d913 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, SafeImage } from "../../components"; +import { CodeBlock } from "../../components"; import type { MarkdownStyleConfig } from "../types"; import "./tufte.css"; @@ -12,7 +12,5 @@ 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 b2df6edd9..98d08f1af 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -36,7 +36,6 @@ import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, - usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; import { ExistingWorktreesList } from "./components/ExistingWorktreesList"; @@ -58,7 +57,6 @@ type Mode = "existing" | "new"; export function NewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); - const preSelectedProjectId = usePreSelectedProjectId(); const [selectedProjectId, setSelectedProjectId] = useState( null, ); @@ -96,15 +94,12 @@ export function NewWorkspaceModal() { ); }, [branchData?.branches, branchSearch]); - // Auto-select project when modal opens (prioritize pre-selected, then current) + // Auto-select current project when modal opens useEffect(() => { - if (isOpen && !selectedProjectId) { - const projectToSelect = preSelectedProjectId ?? currentProjectId; - if (projectToSelect) { - setSelectedProjectId(projectToSelect); - } + if (isOpen && currentProjectId && !selectedProjectId) { + setSelectedProjectId(currentProjectId); } - }, [isOpen, currentProjectId, selectedProjectId, preSelectedProjectId]); + }, [isOpen, currentProjectId, selectedProjectId]); // Effective base branch - use explicit selection or fall back to default const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? null; @@ -172,30 +167,23 @@ export function NewWorkspaceModal() { const workspaceName = title.trim() || undefined; const customBranchName = branchName.trim() || undefined; - try { - const result = await createWorkspace.mutateAsync({ + toast.promise( + createWorkspace.mutateAsync({ projectId: selectedProjectId, name: workspaceName, branchName: customBranchName, baseBranch: effectiveBaseBranch || undefined, - }); - - // 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", - ); - } + }), + { + loading: "Creating workspace...", + success: () => { + handleClose(); + return "Workspace created"; + }, + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }, + ); }; return ( @@ -217,13 +205,11 @@ export function NewWorkspaceModal() { - {recentProjects - .filter((project) => project.id) - .map((project) => ( - - {project.name} - - ))} + {recentProjects.map((project) => ( + + {project.name} + + ))}
diff --git a/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx b/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx deleted file mode 100644 index 9a407e597..000000000 --- a/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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.tsx b/apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx similarity index 59% rename from apps/desktop/src/renderer/contexts/AppProviders.tsx rename to apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx index 784f7afb0..e74933632 100644 --- a/apps/desktop/src/renderer/contexts/AppProviders.tsx +++ b/apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx @@ -1,9 +1,9 @@ import type React from "react"; import { PostHogUserIdentifier } from "renderer/components/PostHogUserIdentifier"; -import { AuthProvider } from "./AuthProvider"; -import { MonacoProvider } from "./MonacoProvider"; -import { PostHogProvider } from "./PostHogProvider"; -import { TRPCProvider } from "./TRPCProvider"; +import { MonacoProvider } from "../MonacoProvider"; +import { OrganizationsProvider } from "../OrganizationsProvider"; +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 new file mode 100644 index 000000000..9457e31d2 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/AppProviders/index.ts @@ -0,0 +1 @@ +export { AppProviders } from "./AppProviders"; diff --git a/apps/desktop/src/renderer/contexts/AuthProvider.tsx b/apps/desktop/src/renderer/contexts/AuthProvider.tsx deleted file mode 100644 index 39badb360..000000000 --- a/apps/desktop/src/renderer/contexts/AuthProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 98130c276..000000000 --- a/apps/desktop/src/renderer/contexts/CollectionsProvider.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 new file mode 100644 index 000000000..1728e325f --- /dev/null +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/CollectionsProvider.tsx @@ -0,0 +1,77 @@ +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/collections/index.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts similarity index 55% rename from apps/desktop/src/renderer/collections/index.ts rename to apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts index c94b4b1f9..7cbff9ec9 100644 --- a/apps/desktop/src/renderer/collections/index.ts +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts @@ -1,83 +1,68 @@ import { snakeCamelMapper } from "@electric-sql/client"; import type { - SelectOrganizationMember, + SelectMember, + SelectOrganization, 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`; -const createHttpTrpcClient = ({ - apiUrl, - getHeaders, -}: { - apiUrl: string; - getHeaders: () => Record; -}) => { - return createTRPCProxyClient({ +interface CreateCollectionsParams { + token: string; + activeOrgId: string; +} + +export function createCollections({ + token, + activeOrgId, +}: CreateCollectionsParams) { + const headers = { Authorization: `Bearer ${token}` }; + + const apiClient = createTRPCProxyClient({ links: [ httpBatchLink({ - url: `${apiUrl}/api/trpc`, - headers: getHeaders, + url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, + headers, 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-${orgId}`, + id: `tasks-${activeOrgId}`, shapeOptions: { url: electricUrl, params: { table: "tasks", + org: activeOrgId, }, - headers: getHeaders(), + headers, columnMapper, }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { const item = transaction.mutations[0].modified; - const result = await httpTrpcClient.task.create.mutate(item); + const result = await apiClient.task.create.mutate(item); return { txid: result.txid }; }, - onUpdate: async ({ transaction }) => { const { modified } = transaction.mutations[0]; - const result = await httpTrpcClient.task.update.mutate(modified); + const result = await apiClient.task.update.mutate(modified); return { txid: result.txid }; }, - onDelete: async ({ transaction }) => { const item = transaction.mutations[0].original; - const result = await httpTrpcClient.task.delete.mutate(item.id); + const result = await apiClient.task.delete.mutate(item.id); return { txid: result.txid }; }, }), @@ -85,40 +70,40 @@ export const createCollections = ({ const repositories = createCollection( electricCollectionOptions({ - id: `repositories-${orgId}`, + id: `repositories-${activeOrgId}`, shapeOptions: { url: electricUrl, params: { table: "repositories", + org: activeOrgId, }, - headers: getHeaders(), + headers, columnMapper, }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { const item = transaction.mutations[0].modified; - const result = await httpTrpcClient.repository.create.mutate(item); + const result = await apiClient.repository.create.mutate(item); return { txid: result.txid }; }, - onUpdate: async ({ transaction }) => { const { modified } = transaction.mutations[0]; - const result = await httpTrpcClient.repository.update.mutate(modified); + const result = await apiClient.repository.update.mutate(modified); return { txid: result.txid }; }, }), ); const members = createCollection( - electricCollectionOptions({ - id: `members-${orgId}`, + electricCollectionOptions({ + id: `members-${activeOrgId}`, shapeOptions: { url: electricUrl, params: { - table: "organization_members", + table: "auth.members", + org: activeOrgId, }, - headers: getHeaders(), + headers, columnMapper, }, getKey: (item) => item.id, @@ -127,34 +112,32 @@ export const createCollections = ({ const users = createCollection( electricCollectionOptions({ - id: `users-${orgId}`, + id: `users-${activeOrgId}`, shapeOptions: { url: electricUrl, params: { - table: "users", + table: "auth.users", + org: activeOrgId, }, - headers: getHeaders(), + headers, columnMapper, }, getKey: (item) => item.id, }), ); - const deviceSettings = createCollection( - localStorageCollectionOptions({ - storageKey: "device-settings", - getKey: (item) => item.key, - storage: localStorage, + const organizations = createCollection( + electricCollectionOptions({ + id: "organizations", + shapeOptions: { + url: electricUrl, + params: { table: "auth.organizations" }, + headers, + columnMapper, + }, + getKey: (item) => item.id, }), ); - return { - tasks, - repositories, - members, - users, - deviceSettings, - }; -}; - -export type Collections = ReturnType; + return { tasks, repositories, members, users, organizations }; +} diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts new file mode 100644 index 000000000..8200e98b6 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts @@ -0,0 +1 @@ +export { CollectionsProvider, useCollections } from "./CollectionsProvider"; diff --git a/apps/desktop/src/renderer/contexts/MonacoProvider.tsx b/apps/desktop/src/renderer/contexts/MonacoProvider/MonacoProvider.tsx similarity index 81% rename from apps/desktop/src/renderer/contexts/MonacoProvider.tsx rename to apps/desktop/src/renderer/contexts/MonacoProvider/MonacoProvider.tsx index 1282cd954..af44e94f7 100644 --- a/apps/desktop/src/renderer/contexts/MonacoProvider.tsx +++ b/apps/desktop/src/renderer/contexts/MonacoProvider/MonacoProvider.tsx @@ -104,31 +104,4 @@ 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 new file mode 100644 index 000000000..91be94062 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/MonacoProvider/index.ts @@ -0,0 +1,6 @@ +export { + MonacoProvider, + monaco, + SUPERSET_THEME, + useMonacoReady, +} from "./MonacoProvider"; diff --git a/apps/desktop/src/renderer/contexts/OrganizationsProvider.tsx b/apps/desktop/src/renderer/contexts/OrganizationsProvider/OrganizationsProvider.tsx similarity index 100% rename from apps/desktop/src/renderer/contexts/OrganizationsProvider.tsx rename to apps/desktop/src/renderer/contexts/OrganizationsProvider/OrganizationsProvider.tsx diff --git a/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts b/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts new file mode 100644 index 000000000..ef167b375 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts @@ -0,0 +1,5 @@ +export { + type Organization, + OrganizationsProvider, + useOrganizations, +} from "./OrganizationsProvider"; diff --git a/apps/desktop/src/renderer/contexts/PostHogProvider.tsx b/apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx similarity index 90% rename from apps/desktop/src/renderer/contexts/PostHogProvider.tsx rename to apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx index 489996944..a5cce0f13 100644 --- a/apps/desktop/src/renderer/contexts/PostHogProvider.tsx +++ b/apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx @@ -1,8 +1,7 @@ import { PostHogProvider as PHProvider } from "posthog-js/react"; import type React from "react"; import { useEffect, useState } from "react"; - -import { initPostHog, posthog } from "../lib/posthog"; +import { initPostHog, posthog } from "renderer/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 new file mode 100644 index 000000000..56caddde4 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/PostHogProvider/index.ts @@ -0,0 +1 @@ +export { PostHogProvider } from "./PostHogProvider"; diff --git a/apps/desktop/src/renderer/contexts/TRPCProvider.tsx b/apps/desktop/src/renderer/contexts/TRPCProvider/TRPCProvider.tsx similarity index 100% rename from apps/desktop/src/renderer/contexts/TRPCProvider.tsx rename to apps/desktop/src/renderer/contexts/TRPCProvider/TRPCProvider.tsx diff --git a/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts b/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts new file mode 100644 index 000000000..080c22d76 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts @@ -0,0 +1 @@ +export { TRPCProvider } from "./TRPCProvider"; diff --git a/apps/desktop/src/renderer/contexts/index.ts b/apps/desktop/src/renderer/contexts/index.ts deleted file mode 100644 index 710daff5f..000000000 --- a/apps/desktop/src/renderer/contexts/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index acd914cb1..000000000 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ /dev/null @@ -1,139 +0,0 @@ -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 f532f964a..3d45899fe 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -2,17 +2,20 @@ 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"; +import { AppProviders } from "./contexts/AppProviders"; 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 438ba8495..60a9c29b7 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -6,4 +6,3 @@ 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 42070b42c..0b2c78c5a 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -1,27 +1,23 @@ +import { toast } from "@superset/ui/sonner"; import { trpc } from "renderer/lib/trpc"; -import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; +import { useOpenConfigModal } from "renderer/stores/config-modal"; +import { useTabsStore } from "renderer/stores/tabs/store"; /** * Mutation hook for creating a new workspace * Automatically invalidates all workspace queries on success - * - * 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. + * Creates a terminal tab with setup commands if present + * Shows config toast if no setup commands are configured */ export function useCreateWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); - const addPendingTerminalSetup = useWorkspaceInitStore( - (s) => s.addPendingTerminalSetup, - ); + 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(); return trpc.workspaces.create.useMutation({ ...options, @@ -29,17 +25,34 @@ export function useCreateWorkspace( // Auto-invalidate all workspace queries await utils.workspaces.invalidate(); - // 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. + // 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 }); + }, + }); + } // 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 debb3a08b..7dc43e36b 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts @@ -1,62 +1,23 @@ -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, - 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) => { + onSuccess: async (...args) => { // Auto-invalidate active workspace and all workspaces queries - 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, - }); - } + await utils.workspaces.getActive.invalidate(); + await utils.workspaces.getAll.invalidate(); // Call user's onSuccess if provided - // biome-ignore lint/suspicious/noExplicitAny: spread args for compatibility - await (options?.onSuccess as any)?.(data, variables, ...rest); + await options?.onSuccess?.(...args); }, }); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts deleted file mode 100644 index cdd2075e1..000000000 --- a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 1ac9dacb7..49f6433bd 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 deleted file mode 100644 index 0e26e6ef7..000000000 --- a/apps/desktop/src/renderer/screens/main/components/PRIcon/PRIcon.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 3c6b8a069..000000000 --- a/apps/desktop/src/renderer/screens/main/components/PRIcon/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 9f16c773f..b782d982a 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 7bbac096c..f21aff0a3 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx @@ -1,73 +1,37 @@ -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(); - - // Confirm on quit setting - const { data: confirmOnQuit, isLoading: isConfirmLoading } = + const { data: confirmOnQuit, isLoading } = 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 handleConfirmToggle = (enabled: boolean) => { + const handleToggle = (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 (
@@ -78,7 +42,6 @@ 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 74285a1a9..1a3a7ac95 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/hooks/useWorkspaceRename"; +import { useWorkspaceRename } from "renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename"; export function WorkspaceSettings() { const { data: activeWorkspace, isLoading } = diff --git a/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts b/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts deleted file mode 100644 index c4a177ae7..000000000 --- a/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 5c1c006d8..c695e48a0 100644 --- a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx @@ -33,12 +33,10 @@ import { HiUser, } from "react-icons/hi2"; import { - ActiveOrganizationProvider, CollectionsProvider, - OrganizationsProvider, - useActiveOrganization, useCollections, -} from "renderer/contexts"; +} from "renderer/contexts/CollectionsProvider"; +import { OrganizationsProvider } from "renderer/contexts/OrganizationsProvider"; import { OrganizationSwitcher } from "./components/OrganizationSwitcher"; interface TaskEditDialogProps { @@ -48,18 +46,16 @@ 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 { - // Use collection's update method - this triggers onUpdate handler - // which sends the mutation to the API - await tasksCollection.update(task.id, (draft) => { + await collections.tasks.update(task.id, (draft: SelectTask) => { draft.title = title; draft.description = description || null; draft.priority = priority as @@ -264,15 +260,12 @@ 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: tasksCollection }), - [tasksCollection, activeOrganizationId], + (q) => q.from({ tasks: collections.tasks }), + [collections], ); // Filter out deleted tasks in JavaScript @@ -354,11 +347,9 @@ 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 8a5f1088c..21543ad0e 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,20 +5,42 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { useLiveQuery } from "@tanstack/react-db"; import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; -import { useActiveOrganization, useOrganizations } from "renderer/contexts"; +import { useCollections } from "renderer/contexts/CollectionsProvider"; +import { trpc } from "renderer/lib/trpc"; export function OrganizationSwitcher() { - const organizations = useOrganizations(); - const { activeOrganization, switchOrganization } = useActiveOrganization(); + 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 initials = activeOrganization.name ?.split(" ") - .map((n) => n[0]) + .map((n: string) => n[0]) .join("") .toUpperCase() .slice(0, 2); + const switchOrganization = async (newOrgId: string) => { + await setActiveOrg.mutateAsync({ organizationId: newOrgId }); + }; + return ( @@ -27,7 +49,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 || "?"} @@ -39,14 +61,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/SidebarControl/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx similarity index 56% rename from apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx index 7952400cc..d2337d7ba 100644 --- a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx @@ -1,7 +1,6 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; -import { VscSourceControl } from "react-icons/vsc"; +import { HiMiniBars3, HiMiniBars3BottomLeft } from "react-icons/hi2"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { useSidebarStore } from "renderer/stores"; @@ -13,26 +12,21 @@ export function SidebarControl() { diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx deleted file mode 100644 index a5a517901..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/index.ts rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts 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 new file mode 100644 index 000000000..b63425907 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx @@ -0,0 +1,240 @@ +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/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx 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 new file mode 100644 index 000000000..1b567dddd --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx @@ -0,0 +1,57 @@ +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 new file mode 100644 index 000000000..0b042f2ef --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx @@ -0,0 +1,93 @@ +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 new file mode 100644 index 000000000..15f87c6e9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx @@ -0,0 +1,162 @@ +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 new file mode 100644 index 000000000..f7bfbfbe8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx @@ -0,0 +1,95 @@ +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/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx similarity index 92% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx index 88f57988d..9d6874045 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -7,7 +7,6 @@ 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"; @@ -27,13 +26,13 @@ export function WorkspaceHoverCardContent({ { enabled: !!workspaceId }, ); - const { - pr, - repoUrl, - branchExistsOnRemote, - isLoading: isLoadingGithub, - } = usePRStatus({ workspaceId }); + const { data: githubStatus, isLoading: isLoadingGithub } = + trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId }, + { enabled: !!workspaceId }, + ); + const pr = githubStatus?.pr; const needsRebase = worktreeInfo?.gitStatus?.needsRebase; const worktreeName = worktreeInfo?.worktreeName; @@ -52,9 +51,9 @@ export function WorkspaceHoverCardContent({ Branch - {repoUrl && branchExistsOnRemote ? ( + {githubStatus?.repoUrl && githubStatus.branchExistsOnRemote ? (
- ) : repoUrl ? ( + ) : githubStatus ? (
No PR for this branch
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/index.ts rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/index.ts rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/index.ts rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/index.ts rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/index.ts rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts 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 new file mode 100644 index 000000000..b0069c3f1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -0,0 +1,330 @@ +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 new file mode 100644 index 000000000..142b12651 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx @@ -0,0 +1,90 @@ +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 new file mode 100644 index 000000000..27c585512 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx @@ -0,0 +1,271 @@ +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/hooks/useWorkspaceRename/useWorkspaceRename.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/useWorkspaceRename.ts rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename.ts 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 0743fe71d..2875da88b 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,35 +1,26 @@ import { trpc } from "renderer/lib/trpc"; import { AvatarDropdown } from "../AvatarDropdown"; -import { OpenInMenuButton } from "./OpenInMenuButton"; +import { SidebarControl } from "./SidebarControl"; import { WindowControls } from "./WindowControls"; -import { WorkspaceSidebarControl } from "./WorkspaceSidebarControl"; +import { WorkspacesTabs } from "./WorkspaceTabs"; export function TopBar() { const { data: platform } = trpc.window.getPlatform.useQuery(); - 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"; - + const isMac = 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 deleted file mode 100644 index 3cce23d23..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx +++ /dev/null @@ -1,206 +0,0 @@ -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/WorkspaceSidebar/PortsList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/index.ts deleted file mode 100644 index 7f04b50ce..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 64bfb4671..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index a0e4152f9..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 2111af01d..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 526fa283d..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index a2758d40b..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 81340b17e..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ /dev/null @@ -1,380 +0,0 @@ -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 deleted file mode 100644 index d6eb509d7..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts deleted file mode 100644 index 369ca7198..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; 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 deleted file mode 100644 index 282bf9f9a..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index b6768dfb7..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4dd9ef18a..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 4ffc0650a..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 3a0b6d1c7..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 0f392fe62..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 9103919cf..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 844ddc41e..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index d8dc22673..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 355d70a1c..abcc51d51 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,15 +1,16 @@ import { DiffEditor, type DiffOnMount } from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef } 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 } from "./editor-actions"; +import { + registerCopyPathLineAction, + registerSaveCommand, +} from "./editor-actions"; interface DiffViewerProps { contents: FileContents; @@ -17,7 +18,6 @@ interface DiffViewerProps { filePath: string; editable?: boolean; onSave?: (content: string) => void; - onChange?: (content: string) => void; } export function DiffViewer({ @@ -26,23 +26,17 @@ 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(); @@ -52,43 +46,13 @@ export function DiffViewer({ registerCopyPathLineAction(originalEditor, filePath); registerCopyPathLineAction(modifiedEditor, filePath); - setIsEditorMounted(true); + if (editable) { + registerSaveCommand(modifiedEditor, handleSave); + } }, - [filePath], + [editable, handleSave, 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 (
@@ -114,12 +78,23 @@ 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 447ffe35f..7a5bb9c59 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,3 +27,10 @@ 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 deleted file mode 100644 index cbc1deac5..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 26fb6ccbc..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 3d984e3aa..0f9a435a8 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 newGroupDisplay = useHotkeyDisplay("NEW_GROUP"); + const newTerminalDisplay = useHotkeyDisplay("NEW_TERMINAL"); const openInAppDisplay = useHotkeyDisplay("OPEN_IN_APP"); const shortcuts = [ - { label: "New Tab", display: newGroupDisplay }, + { label: "New Terminal", display: newTerminalDisplay }, { 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 deleted file mode 100644 index 5154b0d01..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 54863a005..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ /dev/null @@ -1,220 +0,0 @@ -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 deleted file mode 100644 index e905a6c8b..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 146be2b36..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ /dev/null @@ -1,304 +0,0 @@ -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 deleted file mode 100644 index 98c47db44..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index f6945203d..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx +++ /dev/null @@ -1,232 +0,0 @@ -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 deleted file mode 100644 index 15337eaf3..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 7232e304b..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 843916763..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index ba24b334c..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 2c4bbd68f..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 215fc763c..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 074553ac2..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 96c33fa0b..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 a72f6e168..6e09a646d 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,5 +1,10 @@ -import { useEffect, useRef } from "react"; +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 type { MosaicBranch } from "react-mosaic-component"; +import { MosaicWindow } from "react-mosaic-component"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { registerPaneRef, unregisterPaneRef, @@ -9,7 +14,8 @@ import type { Pane, Tab } from "renderer/stores/tabs/types"; import { TabContentContextMenu } from "../TabContentContextMenu"; import { Terminal } from "../Terminal"; import { DirectoryNavigator } from "../Terminal/DirectoryNavigator"; -import { BasePaneWindow, PaneToolbarActions } from "./components"; + +type SplitOrientation = "vertical" | "horizontal"; interface TabPaneProps { paneId: string; @@ -57,11 +63,12 @@ export function TabPane({ onMoveToTab, onMoveToNewTab, }: TabPaneProps) { - const terminalContainerRef = useRef(null); - const getClearCallback = useTerminalCallbacksStore((s) => s.getClearCallback); + const containerRef = useRef(null); + const [splitOrientation, setSplitOrientation] = + useState("vertical"); useEffect(() => { - const container = terminalContainerRef.current; + const container = containerRef.current; if (container) { registerPaneRef(paneId, container); } @@ -70,21 +77,61 @@ 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 ( - path={path} - tabId={tabId} - isActive={isActive} - splitPaneAuto={splitPaneAuto} - removePane={removePane} - setFocusedPane={setFocusedPane} - renderToolbar={(handlers) => ( -
+ title="" + renderToolbar={() => ( +
- +
+ + + + + + + + + + + + + + + + +
)} + className={isActive ? "mosaic-window-focused" : ""} > splitPaneHorizontal(tabId, paneId, path)} @@ -111,10 +189,15 @@ 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 deleted file mode 100644 index 7e7c3d0b2..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index b93b72278..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 5d15fbd1f..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index adc90aacc..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 81c4b584b..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 7ade988b9..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 7ade988b9..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index a239d6e86..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 766f486ae..b6df3608e 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,7 +8,6 @@ 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 { @@ -16,7 +15,6 @@ import { extractPaneIdsFromLayout, getPaneIdsForTab, } from "renderer/stores/tabs/utils"; -import { FileViewerPane } from "./FileViewerPane"; import { TabPane } from "./TabPane"; interface TabViewProps { @@ -37,10 +35,6 @@ 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, @@ -96,31 +90,6 @@ 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 @@ -71,76 +68,6 @@ 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(() => { @@ -270,12 +197,11 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm, fitAddon, cleanup: cleanupQuerySuppression, - } = createTerminalInstance(container, { - cwd: workspaceCwd, - initialTheme: initialThemeRef.current, - onFileLinkClick: (path, line, column) => - handleFileLinkClickRef.current(path, line, column), - }); + } = createTerminalInstance( + container, + workspaceCwd ?? undefined, + initialThemeRef.current, + ); 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 fbd1b980b..3c92f907d 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,26 +93,19 @@ 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, - options: CreateTerminalOptions = {}, + cwd?: string, + initialTheme?: ITheme | null, ): { 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 terminalOptions = { ...TERMINAL_OPTIONS, theme }; - const xterm = new XTerm(terminalOptions); + const options = { ...TERMINAL_OPTIONS, theme }; + const xterm = new XTerm(options); const fitAddon = new FitAddon(); const clipboardAddon = new ClipboardAddon(); @@ -156,25 +149,20 @@ export function createTerminalInstance( const filePathLinkProvider = new FilePathLinkProvider( xterm, (_event, path, line, column) => { - if (onFileLinkClick) { - onFileLinkClick(path, line, column); - } else { - // Fallback to default behavior (external editor) - trpcClient.external.openFileInEditor - .mutate({ + trpcClient.external.openFileInEditor + .mutate({ + path, + line, + column, + cwd, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", path, - line, - column, - cwd, - }) - .catch((error) => { - console.error( - "[Terminal] Failed to open file in editor:", - path, - error, - ); - }); - } + error, + ); + }); }, ); xterm.registerLinkProvider(filePathLinkProvider); @@ -245,7 +233,7 @@ export function setupPasteHandler( /** * Setup keyboard handling for xterm including: - * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens + * - Shortcut forwarding: App hotkeys are re-dispatched to document for react-hotkeys-hook * - 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 b62872350..e04866eb6 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,7 +1,6 @@ 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"; @@ -20,16 +19,9 @@ export function TabsContent() { return allTabs.find((tab) => tab.id === activeTabId) || null; }, [activeWorkspaceId, activeTabIds, allTabs]); - return ( -
-
- {tabToRender ? ( - - ) : ( - - )} -
- -
- ); + if (!tabToRender) { + return ; + } + + return ; } 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 ecc345fae..1a2e20bd0 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,15 +1,19 @@ -import { SidebarControl } from "../../SidebarControl"; -import { ContentHeader } from "./ContentHeader"; +import { SidebarMode, useSidebarStore } from "renderer/stores"; +import { ChangesContent } from "./ChangesContent"; import { TabsContent } from "./TabsContent"; -import { GroupStrip } from "./TabsContent/GroupStrip"; export function ContentView() { - return ( -
- }> - - - -
- ); + const { currentMode } = useSidebarStore(); + + if (currentMode === SidebarMode.Changes) { + 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 f6a00b367..05d9d7b04 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 draggedLeftBy = startXRef.current - e.clientX; - const newWidth = startWidthRef.current + draggedLeftBy; + const delta = e.clientX - startXRef.current; + const newWidth = startWidthRef.current + delta; 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 -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", + "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", "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 2ba11f811..ee0ec6d2e 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,22 +6,13 @@ 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"; -interface ChangesViewProps { - onFileOpen?: ( - file: ChangedFile, - category: ChangeCategory, - commitHash?: string, - ) => void; -} - -export function ChangesView({ onFileOpen }: ChangesViewProps) { +export function ChangesView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const worktreePath = activeWorkspace?.worktreePath; @@ -137,13 +128,11 @@ export function ChangesView({ onFileOpen }: ChangesViewProps) { 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) => { @@ -217,7 +206,6 @@ export function ChangesView({ onFileOpen }: ChangesViewProps) { viewMode={fileListViewMode} onViewModeChange={setFileListViewMode} worktreePath={worktreePath} - workspaceId={activeWorkspace?.id} /> - {availableBranches - .filter((branch) => branch) - .map((branch) => ( - - {branch} - {branch === branchData.defaultBranch && ( - (default) - )} - - ))} + {availableBranches.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 e1e2b8421..0c93c0153 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,10 +8,7 @@ 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"; @@ -24,7 +21,6 @@ interface ChangesHeaderProps { viewMode: ChangesViewMode; onViewModeChange: (mode: ChangesViewMode) => void; worktreePath: string; - workspaceId?: string; } export function ChangesHeader({ @@ -35,7 +31,6 @@ export function ChangesHeader({ viewMode, onViewModeChange, worktreePath, - workspaceId, }: ChangesHeaderProps) { const { baseBranch, setBaseBranch } = useChangesStore(); @@ -44,11 +39,6 @@ export function ChangesHeader({ { enabled: !!worktreePath }, ); - const { pr, isLoading: isPRLoading } = usePRStatus({ - workspaceId, - refetchInterval: 10000, - }); - const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? "main"; const availableBranches = branchData?.remote ?? []; @@ -84,18 +74,16 @@ export function ChangesHeader({ - {sortedBranches - .filter((branch) => branch) - .map((branch) => ( - - {branch} - {branch === branchData.defaultBranch && ( - - (default) - - )} - - ))} + {sortedBranches.map((branch) => ( + + {branch} + {branch === branchData.defaultBranch && ( + + (default) + + )} + + ))} @@ -125,30 +113,6 @@ 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/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PortsList.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PortsList.tsx 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 new file mode 100644 index 000000000..a9b7009d4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PresetContextMenu/index.tsx @@ -0,0 +1,62 @@ +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 new file mode 100644 index 000000000..0ce5492de --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/TabContextMenu.tsx @@ -0,0 +1,72 @@ +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 new file mode 100644 index 000000000..07a860bed --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx @@ -0,0 +1,218 @@ +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 new file mode 100644 index 000000000..63ee4320e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabsCommandDialog/index.tsx @@ -0,0 +1,96 @@ +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 new file mode 100644 index 000000000..2baa29305 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx @@ -0,0 +1,284 @@ +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 86b5df975..751158761 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,28 +1,29 @@ -import { trpc } from "renderer/lib/trpc"; -import { useTabsStore } from "renderer/stores/tabs/store"; -import type { ChangeCategory, ChangedFile } from "shared/changes-types"; +import { useSidebarStore } from "renderer/stores"; +import { SidebarMode } from "renderer/stores/sidebar-state"; import { ChangesView } from "./ChangesView"; +import { ModeCarousel } from "./ModeCarousel"; +import { TabsView } from "./TabsView"; export function Sidebar() { - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const workspaceId = activeWorkspace?.id; + const { currentMode, setMode } = useSidebarStore(); - 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; + const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; 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 new file mode 100644 index 000000000..f641fde5a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..0db773eb9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx @@ -0,0 +1,41 @@ +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 new file mode 100644 index 000000000..9a42acaa8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts @@ -0,0 +1 @@ +export { WorkspaceActionBarLeft } from "./WorkspaceActionBarLeft"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/OpenInMenuButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx similarity index 50% rename from apps/desktop/src/renderer/screens/main/components/TopBar/OpenInMenuButton.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx index 698e7d366..222cade48 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/OpenInMenuButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx @@ -10,12 +10,9 @@ 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 { LuCopy } from "react-icons/lu"; +import { LuArrowUpRight, LuCopy } from "react-icons/lu"; import jetbrainsIcon from "renderer/assets/app-icons/jetbrains.svg"; import vscodeIcon from "renderer/assets/app-icons/vscode.svg"; import { @@ -24,127 +21,121 @@ 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 OpenInMenuButtonProps { +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 { worktreePath: string; - branch?: string; } -export const OpenInMenuButton = memo(function OpenInMenuButton({ +export function WorkspaceActionBarRight({ worktreePath, - branch, -}: OpenInMenuButtonProps) { +}: WorkspaceActionBarRightProps) { + const { data: homeDir } = trpc.window.getHomeDir.useQuery(); const utils = trpc.useUtils(); const { data: lastUsedApp = "cursor" } = - trpc.settings.getLastUsedApp.useQuery(undefined, { - staleTime: 30000, - }); + trpc.settings.getLastUsedApp.useQuery(); 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 currentApp = useMemo(() => getAppOption(lastUsedApp), [lastUsedApp]); + const formattedPath = formatWorktreePath(worktreePath, homeDir); + const currentApp = getAppOption(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 = useCallback(() => { - if (openInApp.isPending || copyPath.isPending) return; + const handleOpenInEditor = () => { openInApp.mutate({ path: worktreePath, app: lastUsedApp }); - }, [worktreePath, lastUsedApp, openInApp, copyPath.isPending]); + }; - const handleOpenInOtherApp = useCallback( - (appId: ExternalApp) => { - if (openInApp.isPending || copyPath.isPending) return; - openInApp.mutate({ path: worktreePath, app: appId }); - }, - [worktreePath, openInApp, copyPath.isPending], - ); + const handleOpenInOtherApp = (appId: ExternalApp) => { + openInApp.mutate({ path: worktreePath, app: appId }); + }; - const handleCopyPath = useCallback(() => { - if (openInApp.isPending || copyPath.isPending) return; + const handleCopyPath = () => { copyPath.mutate(worktreePath); - }, [worktreePath, copyPath, openInApp.isPending]); + }; + + const BUTTON_HEIGHT = 24; return ( -
- {/* Main button - opens in last used app */} + <> + {/* Path - clickable to open */} - -
- - Open in {currentApp.displayLabel ?? currentApp.label} - {showOpenInShortcut && ( - - {openInShortcut} - - )} - - {branch && ( - - /{branch} - - )} -
+ + + Open in {currentApp.displayLabel ?? currentApp.label} + + {showOpenInShortcut ? openInShortcut : "—"} + +
- {/* Dropdown trigger */} + {/* Open dropdown button */} - + {APP_OPTIONS.map((app) => ( {app.label} @@ -165,12 +156,12 @@ export const OpenInMenuButton = memo(function OpenInMenuButton({ VS Code - + {VSCODE_OPTIONS.map((app) => ( {app.label} - {app.id === lastUsedApp && showOpenInShortcut && ( - - {openInShortcut} - + {app.id === lastUsedApp && ( + ⌘O )} ))} @@ -195,12 +184,12 @@ export const OpenInMenuButton = memo(function OpenInMenuButton({ JetBrains - + {JETBRAINS_OPTIONS.map((app) => ( {app.label} @@ -226,6 +215,6 @@ export const OpenInMenuButton = memo(function OpenInMenuButton({ -
+ ); -}); +} 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 new file mode 100644 index 000000000..da70bada5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000..c8caa3fbc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts @@ -0,0 +1 @@ +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 deleted file mode 100644 index 3140858c2..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx +++ /dev/null @@ -1,328 +0,0 @@ -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 deleted file mode 100644 index 6685f11c9..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 ac24a87d6..661543c85 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -3,33 +3,13 @@ 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 { WorkspaceInitializingView } from "./WorkspaceInitializingView"; +import { ResizableSidebar } from "./ResizableSidebar"; +import { WorkspaceActionBar } from "./WorkspaceActionBar"; 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); @@ -61,7 +41,7 @@ export function WorkspaceView() { // Tab management shortcuts useAppHotkey( - "NEW_GROUP", + "NEW_TERMINAL", () => { if (activeWorkspaceId) { addTab(activeWorkspaceId); @@ -83,7 +63,7 @@ export function WorkspaceView() { [focusedPaneId, removePane], ); - // Switch between tabs (⌘+Up/Down) + // Switch between tabs (configurable shortcut) useAppHotkey( "PREV_TERMINAL", () => { @@ -110,7 +90,7 @@ export function WorkspaceView() { [activeWorkspaceId, activeTabId, tabs, setActiveTab], ); - // Switch between panes within a tab (⌘+⌥+Left/Right) + // Switch between panes within a tab (configurable shortcut) useAppHotkey( "PREV_PANE", () => { @@ -170,16 +150,14 @@ 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 deleted file mode 100644 index 8f06de0e4..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index 0a45a4a84..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 8a3d50a2c..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspacesListView.tsx +++ /dev/null @@ -1,282 +0,0 @@ -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 deleted file mode 100644 index 7d55b16cb..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 587000def..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 1640b1688..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -// 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 8b8a83fbc..8337712ea 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/index.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/index.ts @@ -1,2 +1 @@ -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 deleted file mode 100644 index f552343f7..000000000 --- a/apps/desktop/src/renderer/screens/main/hooks/usePRStatus/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 713d7ff91..000000000 --- a/apps/desktop/src/renderer/screens/main/hooks/usePRStatus/usePRStatus.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 4b8035beb..000000000 --- a/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useWorkspaceRename } from "./useWorkspaceRename"; diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index e339381a8..98f2cc947 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -19,8 +19,6 @@ 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"; @@ -28,9 +26,6 @@ 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() { @@ -59,27 +54,12 @@ 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((s) => s.toggleSidebar); - const toggleWorkspaceSidebar = useWorkspaceSidebarStore((s) => s.toggleOpen); + const { toggleSidebar } = useSidebarStore(); const hasTasksAccess = useFeatureFlagEnabled( FEATURE_FLAGS.ELECTRIC_TASKS_ACCESS, ); - const { data: activeWorkspace, isLoading: isWorkspaceLoading, @@ -131,15 +111,6 @@ 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), @@ -296,9 +267,6 @@ export function MainScreen() { if (currentView === "tasks" && hasTasksAccess) { return ; } - if (currentView === "workspaces-list") { - return ; - } return ; }; @@ -369,16 +337,12 @@ 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 032ebb07a..3fe2118f9 100644 --- a/apps/desktop/src/renderer/screens/sign-in/index.tsx +++ b/apps/desktop/src/renderer/screens/sign-in/index.tsx @@ -1,11 +1,10 @@ -import { COMPANY } from "@superset/shared/constants"; +import { type AuthProvider, 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 ba6ffcd1c..296752c34 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" | "workspaces-list"; +export type AppView = "workspace" | "settings" | "tasks"; export type SettingsSection = | "account" | "project" @@ -16,7 +16,6 @@ interface AppState { currentView: AppView; isSettingsTabOpen: boolean; isTasksTabOpen: boolean; - isWorkspacesListOpen: boolean; settingsSection: SettingsSection; setView: (view: AppView) => void; openSettings: (section?: SettingsSection) => void; @@ -25,8 +24,6 @@ interface AppState { setSettingsSection: (section: SettingsSection) => void; openTasks: () => void; closeTasks: () => void; - openWorkspacesList: () => void; - closeWorkspacesList: () => void; } export const useAppStore = create()( @@ -35,7 +32,6 @@ export const useAppStore = create()( currentView: "workspace", isSettingsTabOpen: false, isTasksTabOpen: false, - isWorkspacesListOpen: false, settingsSection: "project", setView: (view) => { @@ -69,14 +65,6 @@ 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" }, ), @@ -99,7 +87,3 @@ 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 824bd5107..b289f0bfb 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -6,5 +6,3 @@ 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 38b18916b..0890c7797 100644 --- a/apps/desktop/src/renderer/stores/new-workspace-modal.ts +++ b/apps/desktop/src/renderer/stores/new-workspace-modal.ts @@ -3,8 +3,7 @@ import { devtools } from "zustand/middleware"; interface NewWorkspaceModalState { isOpen: boolean; - preSelectedProjectId: string | null; - openModal: (projectId?: string) => void; + openModal: () => void; closeModal: () => void; } @@ -12,14 +11,13 @@ export const useNewWorkspaceModalStore = create()( devtools( (set) => ({ isOpen: false, - preSelectedProjectId: null, - openModal: (projectId?: string) => { - set({ isOpen: true, preSelectedProjectId: projectId ?? null }); + openModal: () => { + set({ isOpen: true }); }, closeModal: () => { - set({ isOpen: false, preSelectedProjectId: null }); + set({ isOpen: false }); }, }), { name: "NewWorkspaceModalStore" }, @@ -33,5 +31,3 @@ 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 828d0679d..d28d8316a 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -4,10 +4,9 @@ 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 { AddFileViewerPaneOptions, TabsState, TabsStore } from "./types"; +import type { TabsState, TabsStore } from "./types"; import { type CreatePaneOptions, - createFileViewerPane, createPane, createTabWithPane, extractPaneIdsFromLayout, @@ -126,11 +125,7 @@ export const useTabsStore = create()( const paneIds = getPaneIdsForTab(state.panes, tabId); for (const paneId of paneIds) { - // Only kill terminal sessions for terminal panes (avoids unnecessary IPC for file-viewers) - const pane = state.panes[paneId]; - if (pane?.type === "terminal") { - killTerminalForPane(paneId); - } + killTerminalForPane(paneId); } const newPanes = { ...state.panes }; @@ -290,10 +285,7 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; for (const paneId of removedPaneIds) { - // P2: Only kill terminal for actual terminal panes (avoid unnecessary IPC) - if (state.panes[paneId]?.type === "terminal") { - killTerminalForPane(paneId); - } + killTerminalForPane(paneId); delete newPanes[paneId]; } @@ -348,112 +340,6 @@ 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]; @@ -468,10 +354,7 @@ export const useTabsStore = create()( return; } - // Only kill terminal sessions for terminal panes (avoids unnecessary IPC for file-viewers) - if (pane.type === "terminal") { - killTerminalForPane(paneId); - } + killTerminalForPane(paneId); const newLayout = removePaneFromLayout(tab.layout, paneId); if (!newLayout) { @@ -545,33 +428,6 @@ 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: { @@ -607,19 +463,7 @@ export const useTabsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.tabId !== tabId) return; - // 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); + const newPane = createPane(tabId); let newLayout: MosaicNode; if (path && path.length > 0) { @@ -667,19 +511,7 @@ export const useTabsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.tabId !== tabId) return; - // 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); + const newPane = 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 9638df6e0..bcb0f70af 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,5 +1,4 @@ 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 @@ -29,20 +28,6 @@ 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 */ @@ -66,15 +51,10 @@ 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 62ee90aad..a1e7bef16 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,14 +1,6 @@ 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 */ @@ -90,66 +82,6 @@ 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 deleted file mode 100644 index b3f4d30f5..000000000 --- a/apps/desktop/src/renderer/stores/workspace-init.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index adb12801e..000000000 --- a/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 2f3348da3..000000000 --- a/apps/desktop/src/shared/auth.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 1ec904ae1..6bc788cea 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -46,4 +46,3 @@ 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 deleted file mode 100644 index 7f305cd2b..000000000 --- a/apps/desktop/src/shared/detect-language.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 c889f1127..c4e38007b 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -410,25 +410,20 @@ export const HOTKEYS = { category: "Workspace", }), PREV_WORKSPACE: defineHotkey({ - keys: "meta+up", + keys: "meta+left", label: "Previous Workspace", category: "Workspace", }), NEXT_WORKSPACE: defineHotkey({ - keys: "meta+down", + keys: "meta+right", 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 Workspaces Sidebar", + label: "Toggle Sidebar", category: "Layout", }), SPLIT_RIGHT: defineHotkey({ @@ -449,12 +444,6 @@ 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({ @@ -463,9 +452,9 @@ export const HOTKEYS = { category: "Terminal", description: "Search text in the active terminal", }), - NEW_GROUP: defineHotkey({ + NEW_TERMINAL: defineHotkey({ keys: "meta+t", - label: "New Tab", + label: "New Terminal", category: "Terminal", }), CLOSE_TERMINAL: defineHotkey({ @@ -479,12 +468,12 @@ export const HOTKEYS = { category: "Terminal", }), PREV_TERMINAL: defineHotkey({ - keys: "meta+left", + keys: "meta+up", label: "Previous Terminal", category: "Terminal", }), NEXT_TERMINAL: defineHotkey({ - keys: "meta+right", + keys: "meta+down", label: "Next Terminal", category: "Terminal", }), @@ -586,15 +575,6 @@ 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 d8c921186..8ae323601 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -3,46 +3,10 @@ * 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" | "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; -} +export type PaneType = "terminal" | "webview"; /** * Base Pane interface - shared between main and renderer @@ -59,7 +23,6 @@ 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 72711f77a..a47714c62 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -5,5 +5,4 @@ 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 deleted file mode 100644 index b19d22a42..000000000 --- a/apps/desktop/src/shared/types/workspace-init.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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 2bf5535b0..b14e1c4af 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -118,14 +118,7 @@ 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 52fab968e..af17cd237 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 53f41fa7d..2c0c76855 100644 --- a/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx +++ b/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx @@ -1,13 +1,14 @@ -import { auth } from "@clerk/nextjs/server"; +import { auth } from "@superset/auth"; 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 { userId } = await auth(); + const session = await auth.api.getSession({ headers: await headers() }); - if (userId) { + if (session) { return ( <>
) { return ( - - - -