diff --git a/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx b/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx new file mode 100644 index 00000000000..486873984c1 --- /dev/null +++ b/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx @@ -0,0 +1,289 @@ +import { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@superset/ui/breadcrumb"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { Skeleton } from "@superset/ui/skeleton"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { useQuery } from "@tanstack/react-query"; +import { Fragment, useEffect, useState } from "react"; +import { + LuExternalLink, + LuFolder, + LuFolderOpen, + LuRefreshCw, +} from "react-icons/lu"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; + +interface RemotePathPickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + hostUrl: string | null; + hostName: string; + initialPath?: string | null; + onPick: (absolutePath: string) => void; + title?: string; + description?: string; + confirmLabel?: string; +} + +interface BrowseResult { + path: string; + parentPath: string | null; + homePath: string; + entries: { name: string; isDirectory: boolean; isSymlink: boolean }[]; +} + +interface Segment { + label: string; + path: string; +} + +const MAX_VISIBLE_SEGMENTS = 4; + +function pathToSegments(path: string, homePath: string | null): Segment[] { + const segments: Segment[] = []; + if (homePath && (path === homePath || path === `${homePath}/`)) { + return [{ label: "Home", path: homePath }]; + } + if (homePath && path.startsWith(`${homePath}/`)) { + segments.push({ label: "Home", path: homePath }); + const rest = path.slice(homePath.length + 1); + let cumulative = homePath; + for (const part of rest.split("/").filter(Boolean)) { + cumulative = `${cumulative}/${part}`; + segments.push({ label: part, path: cumulative }); + } + return segments; + } + segments.push({ label: "/", path: "/" }); + let cumulative = ""; + for (const part of path.split("/").filter(Boolean)) { + cumulative = `${cumulative}/${part}`; + segments.push({ label: part, path: cumulative }); + } + return segments; +} + +function joinPath(base: string, child: string): string { + return `${base.replace(/\/$/, "")}/${child}`; +} + +export function RemotePathPicker({ + open, + onOpenChange, + hostUrl, + hostName, + initialPath, + onPick, + title = "Choose a folder", + description, + confirmLabel = "Use this folder", +}: RemotePathPickerProps) { + const [currentPath, setCurrentPath] = useState( + initialPath ?? null, + ); + + useEffect(() => { + if (open) { + setCurrentPath(initialPath ?? null); + } + }, [open, initialPath]); + + const query = useQuery({ + enabled: open && !!hostUrl, + queryKey: ["remote-path-picker", hostUrl, currentPath], + queryFn: async () => { + if (!hostUrl) throw new Error("Host unavailable"); + const client = getHostServiceClientByUrl(hostUrl); + return await client.filesystem.browseHost.query({ + path: currentPath ?? undefined, + }); + }, + }); + + useEffect(() => { + if (query.data) setCurrentPath(query.data.path); + }, [query.data]); + + useEffect(() => { + if (query.error) { + toast.error( + query.error instanceof Error + ? query.error.message + : "Could not list directory", + ); + } + }, [query.error]); + + const allSegments = query.data + ? pathToSegments(query.data.path, query.data.homePath) + : []; + + const segments: (Segment | "ellipsis")[] = + allSegments.length > MAX_VISIBLE_SEGMENTS + ? [ + allSegments[0], + "ellipsis", + ...allSegments.slice(-(MAX_VISIBLE_SEGMENTS - 1)), + ] + : allSegments; + + const folders = query.data?.entries.filter((e) => e.isDirectory) ?? []; + + const handlePick = () => { + if (!query.data) return; + onPick(query.data.path); + onOpenChange(false); + }; + + return ( + + + + {title} + + {description ?? `Browse folders on ${hostName}.`} + + + +
+
+ {query.data ? ( + + + {segments.map((seg, i) => { + const isLast = i === segments.length - 1; + if (seg === "ellipsis") { + return ( + + + + + + + ); + } + return ( + + + {isLast ? ( + + {seg.label} + + ) : ( + + + + )} + + {!isLast && } + + ); + })} + + + ) : ( + + )} +
+ +
+ + + {query.isLoading ? ( +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+ ) : folders.length === 0 ? ( +
+ + + {query.data?.entries.length === 0 + ? "Empty folder" + : "No subfolders"} + +
+ ) : ( +
    + {folders.map((entry) => { + const childPath = query.data + ? joinPath(query.data.path, entry.name) + : entry.name; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/RemotePathPicker/index.ts b/apps/desktop/src/renderer/components/RemotePathPicker/index.ts new file mode 100644 index 00000000000..5612f48eb35 --- /dev/null +++ b/apps/desktop/src/renderer/components/RemotePathPicker/index.ts @@ -0,0 +1 @@ +export { RemotePathPicker } from "./RemotePathPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/ClickablePath/ClickablePath.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/ClickablePath/ClickablePath.tsx index 648292c35e4..783b64f9e99 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/ClickablePath/ClickablePath.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/ClickablePath/ClickablePath.tsx @@ -15,9 +15,14 @@ import { useThemeStore } from "renderer/stores/theme"; interface ClickablePathProps { path: string; className?: string; + truncate?: boolean; } -export function ClickablePath({ path, className }: ClickablePathProps) { +export function ClickablePath({ + path, + className, + truncate, +}: ClickablePathProps) { const activeTheme = useThemeStore((state) => state.activeTheme); const [isOpen, setIsOpen] = useState(false); const utils = electronTrpc.useUtils(); @@ -55,14 +60,18 @@ export function ClickablePath({ path, className }: ClickablePathProps) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index eb7a546ea60..7439fe18095 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -1,18 +1,27 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; +import { HiOutlineComputerDesktop, HiOutlineServer } from "react-icons/hi2"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { SettingsSection } from "../../../../project/$projectId/components/ProjectSettings"; -import { ProjectSettingsHeader } from "../../../../project/$projectId/components/ProjectSettingsHeader"; import { DeleteProjectSection } from "./components/DeleteProjectSection"; import { IconUploadField } from "./components/IconUploadField"; import { NameSection } from "./components/NameSection"; import { ProjectLocationSection } from "./components/ProjectLocationSection"; import { RepositorySection } from "./components/RepositorySection"; +import { SettingsRow } from "./components/SettingsRow"; import { V2ScriptsEditor } from "./components/V2ScriptsEditor"; interface V2ProjectSettingsProps { @@ -20,12 +29,22 @@ interface V2ProjectSettingsProps { hostId: string | null; } +interface ProjectSettingsHostOption { + id: string; + name: string; + isLocal: boolean; + isOnline: boolean; +} + export function V2ProjectSettings({ projectId, hostId, }: V2ProjectSettingsProps) { + const navigate = useNavigate(); const collections = useCollections(); const { machineId } = useLocalHostService(); + const { currentDeviceName, localHostId, otherHosts } = + useWorkspaceHostOptions(); const targetHostUrl = useHostUrl(hostId); const targetHostId = hostId ?? machineId; @@ -38,22 +57,45 @@ export function V2ProjectSettings({ [collections, projectId], ); - const { data: hostRows = [] } = useLiveQuery( - (q) => - q - .from({ hosts: collections.v2Hosts }) - .where(({ hosts }) => eq(hosts.machineId, targetHostId ?? "")) - .select(({ hosts }) => ({ - machineId: hosts.machineId, - name: hosts.name, - })), - [collections, targetHostId], + const hostOptions = useMemo(() => { + const options: ProjectSettingsHostOption[] = []; + if (localHostId) { + options.push({ + id: localHostId, + name: currentDeviceName ?? "This device", + isLocal: true, + isOnline: true, + }); + } + for (const host of otherHosts) { + options.push({ + id: host.id, + name: host.name, + isLocal: false, + isOnline: host.isOnline, + }); + } + if (targetHostId && !options.some((option) => option.id === targetHostId)) { + options.push({ + id: targetHostId, + name: targetHostId === machineId ? "This device" : targetHostId, + isLocal: targetHostId === machineId, + isOnline: targetHostId === machineId, + }); + } + return options; + }, [currentDeviceName, localHostId, machineId, otherHosts, targetHostId]); + + const selectedHost = useMemo( + () => hostOptions.find((option) => option.id === targetHostId) ?? null, + [hostOptions, targetHostId], ); const targetHostName = useMemo(() => { - if (hostRows[0]?.name) return hostRows[0].name; + if (selectedHost?.name) return selectedHost.name; if (!targetHostId || targetHostId === machineId) return "this device"; return targetHostId; - }, [hostRows, machineId, targetHostId]); + }, [machineId, selectedHost, targetHostId]); + const hasMultipleHosts = hostOptions.length > 1; const isRemoteTarget = Boolean( targetHostId && machineId && targetHostId !== machineId, ); @@ -73,61 +115,122 @@ export function V2ProjectSettings({ return (
- - -
- - - - - - - - - - refetchHostProject()} - /> - - - +
+
- - - {targetHostUrl && ( - {project.name} +
+ {hasMultipleHosts && targetHostId ? ( + + ) : null} +
+ +
+
+ + + + + + +
-
+
+ + refetchHostProject()} + /> + + {targetHostUrl && ( +
+
+

Scripts

+

+ Runs in a terminal for setup, teardown, and the workspace Run + button. +

+
+ +
+ )} +
+ +
-
+
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/DeleteProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/DeleteProjectSection.tsx index 02d1a2500fd..48bd4e53261 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/DeleteProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -54,13 +54,9 @@ export function DeleteProjectSection({ }; return ( -
+
Delete project
-

- Removes the project from the organization. Workspaces and local clones - on any host are not affected. -

diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.tsx index 889d3f7cb26..7a2269e8e2a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.tsx @@ -1,8 +1,14 @@ -import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; import { toast } from "@superset/ui/sonner"; import { useCallback, useRef, useState } from "react"; import { FaGithub } from "react-icons/fa"; -import { LuImagePlus, LuTrash2 } from "react-icons/lu"; +import { LuImagePlus, LuTrash2, LuUpload } from "react-icons/lu"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; const ACCEPTED_MIME_TYPES = "image/png,image/jpeg,image/webp"; @@ -105,25 +111,64 @@ export function IconUploadField({ } }, [projectId]); + const hasSecondaryActions = hasGitHubRepo || Boolean(iconUrl); + + const Thumbnail = ( + + ); + return ( -
- + <> + {hasSecondaryActions ? ( + + {Thumbnail} + + + + Upload image… + + {hasGitHubRepo && ( + + + Use GitHub icon + + )} + {iconUrl && ( + <> + + + + Remove icon + + + )} + + + ) : ( + Thumbnail + )} - {hasGitHubRepo && ( - - )} - {iconUrl && ( - - )} -
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NameSection/NameSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NameSection/NameSection.tsx index a9d5712f034..216de0d8dda 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NameSection/NameSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NameSection/NameSection.tsx @@ -27,6 +27,7 @@ export function NameSection({ projectId, currentName }: NameSectionProps) { return ( setValue(e.target.value)} onBlur={commit} @@ -42,6 +43,7 @@ export function NameSection({ projectId, currentName }: NameSectionProps) { } }} placeholder="Project name" + className="w-96" /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx index 03e4e89e740..80ac1d0fb3c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx @@ -9,14 +9,17 @@ import { AlertDialogTitle, } from "@superset/ui/alert-dialog"; 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 { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; +import { LuFolderOpen } from "react-icons/lu"; +import { RemotePathPicker } from "renderer/components/RemotePathPicker"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { ClickablePath } from "../../../../../../components/ClickablePath"; +import { SetupProjectModal } from "../SetupProjectModal"; interface BackfillConflict { id: string; @@ -27,6 +30,7 @@ interface ProjectLocationSectionProps { projectId: string; currentPath: string | null; repoCloneUrl: string | null; + hostId: string | null; hostUrl: string | null; hostName: string; isRemoteTarget: boolean; @@ -37,6 +41,7 @@ export function ProjectLocationSection({ projectId, currentPath, repoCloneUrl, + hostId, hostUrl, hostName, isRemoteTarget, @@ -50,62 +55,8 @@ export function ProjectLocationSection({ const [pendingPath, setPendingPath] = useState(null); const [conflict, setConflict] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [remoteImportPath, setRemoteImportPath] = useState(""); - const [remoteCloneParentDir, setRemoteCloneParentDir] = useState(""); - - const runSetup = async (repoPath: string, allowRelocate: boolean) => { - if (!hostUrl) { - toast.error(`Host unavailable: ${hostName}`); - return false; - } - try { - const client = getHostServiceClientByUrl(hostUrl); - const result = await client.project.setup.mutate({ - projectId, - mode: { kind: "import", repoPath, allowRelocate }, - }); - toast.success( - allowRelocate - ? `Project relocated to ${result.repoPath}` - : `Project set up at ${result.repoPath}`, - ); - if (result.mainWorkspaceId) { - ensureWorkspaceInSidebar(result.mainWorkspaceId, projectId); - } else { - ensureProjectInSidebar(projectId); - } - onChanged?.(); - return true; - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - return false; - } - }; - - const runClone = async (parentDir: string) => { - if (!hostUrl) { - toast.error(`Host unavailable: ${hostName}`); - return false; - } - try { - const client = getHostServiceClientByUrl(hostUrl); - const result = await client.project.setup.mutate({ - projectId, - mode: { kind: "clone", parentDir }, - }); - toast.success(`Cloned to ${result.repoPath}`); - if (result.mainWorkspaceId) { - ensureWorkspaceInSidebar(result.mainWorkspaceId, projectId); - } else { - ensureProjectInSidebar(projectId); - } - onChanged?.(); - return true; - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - return false; - } - }; + const [setupOpen, setSetupOpen] = useState(false); + const [changeBrowseOpen, setChangeBrowseOpen] = useState(false); const pickPath = async (title: string) => { if (!hostUrl) { @@ -125,47 +76,15 @@ export function ProjectLocationSection({ } }; - const handleImport = async () => { - if (isRemoteTarget) { - const path = remoteImportPath.trim(); - if (!path) { - toast.error(`Enter a path on ${hostName}`); - return; - } - if (!hostUrl) { - toast.error(`Host unavailable: ${hostName}`); - return; - } - setIsSubmitting(true); - let keepSubmitting = false; - try { - const client = getHostServiceClientByUrl(hostUrl); - const precheck = await client.project.findBackfillConflict.query({ - projectId, - repoPath: path, - }); - if (precheck.conflict) { - setConflict(precheck.conflict); - keepSubmitting = true; - return; - } - await runSetup(path, false); - setRemoteImportPath(""); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } finally { - if (!keepSubmitting) setIsSubmitting(false); - } + const proposeRelocate = async (path: string) => { + if (path === currentPath) { + toast.info("Project is already at that location"); return; } - const path = await pickPath("Select project location"); - if (!path) return; if (!hostUrl) { toast.error(`Host unavailable: ${hostName}`); return; } - setIsSubmitting(true); - let keepSubmitting = false; try { const client = getHostServiceClientByUrl(hostUrl); const precheck = await client.project.findBackfillConflict.query({ @@ -174,181 +93,119 @@ export function ProjectLocationSection({ }); if (precheck.conflict) { setConflict(precheck.conflict); - keepSubmitting = true; return; } - await runSetup(path, false); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); - } finally { - if (!keepSubmitting) setIsSubmitting(false); + return; } + setPendingPath(path); }; - const handleClone = async () => { + const handleChange = async () => { if (isRemoteTarget) { - const parentDir = remoteCloneParentDir.trim(); - if (!parentDir) { - toast.error(`Enter a parent directory on ${hostName}`); - return; - } - setIsSubmitting(true); - try { - await runClone(parentDir); - } finally { - setIsSubmitting(false); - } + setChangeBrowseOpen(true); return; } - const parentDir = await pickPath("Select parent directory to clone into"); - if (!parentDir) return; - setIsSubmitting(true); - try { - await runClone(parentDir); - } finally { - setIsSubmitting(false); - } - }; - - const handleChange = async () => { const path = await pickPath("Select new project location"); if (!path) return; - if (path === currentPath) { - toast.info("Project is already at that location"); - return; - } + await proposeRelocate(path); + }; + + const handleConfirmRelocate = async () => { + if (!pendingPath) return; if (!hostUrl) { toast.error(`Host unavailable: ${hostName}`); return; } + setIsSubmitting(true); try { const client = getHostServiceClientByUrl(hostUrl); - const precheck = await client.project.findBackfillConflict.query({ + const result = await client.project.setup.mutate({ projectId, - repoPath: path, + mode: { kind: "import", repoPath: pendingPath, allowRelocate: true }, }); - if (precheck.conflict) { - setConflict(precheck.conflict); - return; + toast.success(`Project relocated to ${result.repoPath}`); + if (result.mainWorkspaceId) { + ensureWorkspaceInSidebar(result.mainWorkspaceId, projectId); + } else { + ensureProjectInSidebar(projectId); } + onChanged?.(); + setPendingPath(null); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); - return; + } finally { + setIsSubmitting(false); } - setPendingPath(path); - }; - - const handleConfirmRelocate = async () => { - if (!pendingPath) return; - setIsSubmitting(true); - const ok = await runSetup(pendingPath, true); - setIsSubmitting(false); - if (ok) setPendingPath(null); }; return ( <> -
-
- {currentPath ? ( - - ) : ( - - Not set up on {hostName}. - - )} + {currentPath ? ( +
+
+ +
+ + + + + Change location +
- {currentPath ? ( + ) : ( +
+ + Not set up on {hostName} + - ) : ( -
- {isRemoteTarget ? ( -
-
- - setRemoteImportPath(event.currentTarget.value) - } - placeholder={`Existing repo path on ${hostName}`} - className="h-8" - /> - -
-
- - setRemoteCloneParentDir(event.currentTarget.value) - } - placeholder={`Parent directory on ${hostName}`} - className="h-8" - disabled={!repoCloneUrl} - /> - -
-
- ) : ( - <> - - - - )} -
- )} -
+
+ )} + + + + { + void proposeRelocate(path); + }} + /> Repository already linked - + This repository is already linked to project " {conflict?.name ?? ""}" in this organization. Open that project to set it up on {hostName}. @@ -380,6 +237,7 @@ export function ProjectLocationSection({ navigate({ to: "/settings/projects/$projectId", params: { projectId: target.id }, + search: { hostId: hostId ?? undefined }, }); }} > @@ -399,7 +257,7 @@ export function ProjectLocationSection({ Relocate project? -
+
From
{currentPath}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx index df61285c4e8..f83a12a65f1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx @@ -1,6 +1,7 @@ import { parseGitHubRemote } from "@superset/shared/github-remote"; import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useEffect, useRef, useState } from "react"; import { FaGithub } from "react-icons/fa"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -38,8 +39,9 @@ export function RepositorySection({ : null; return ( -
+
setValue(e.target.value)} onFocus={() => { @@ -61,19 +63,24 @@ export function RepositorySection({ } }} placeholder="https://github.com/owner/repo" - className="font-mono text-sm flex-1 min-w-0" + className="w-full font-mono text-sm pr-9" /> {parsed && ( - + + + + + Open in GitHub + )}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/SettingsRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/SettingsRow.tsx new file mode 100644 index 00000000000..15f77c26f50 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/SettingsRow.tsx @@ -0,0 +1,28 @@ +import { Label } from "@superset/ui/label"; +import type { ReactNode } from "react"; + +interface SettingsRowProps { + label: string; + hint?: ReactNode; + htmlFor?: string; + children: ReactNode; +} + +export function SettingsRow({ + label, + hint, + htmlFor, + children, +}: SettingsRowProps) { + return ( +
+
+ + {hint &&

{hint}

} +
+
{children}
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/index.ts new file mode 100644 index 00000000000..72b43177544 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/index.ts @@ -0,0 +1 @@ +export { SettingsRow } from "./SettingsRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/SetupProjectModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/SetupProjectModal.tsx new file mode 100644 index 00000000000..ab5b2b826ea --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/SetupProjectModal.tsx @@ -0,0 +1,369 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; +import { useEffect, useState } from "react"; +import { LuFolderOpen, LuLoaderCircle } from "react-icons/lu"; +import { RemotePathPicker } from "renderer/components/RemotePathPicker"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; + +type SetupMode = "clone" | "import"; + +interface SetupProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: string; + hostUrl: string | null; + hostName: string; + repoCloneUrl: string | null; + isRemoteTarget: boolean; + onChanged?: () => void; + onConflict: (conflict: { id: string; name: string }) => void; +} + +export function SetupProjectModal({ + open, + onOpenChange, + projectId, + hostUrl, + hostName, + repoCloneUrl, + isRemoteTarget, + onChanged, + onConflict, +}: SetupProjectModalProps) { + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + const { ensureProjectInSidebar, ensureWorkspaceInSidebar } = + useDashboardSidebarState(); + + const [mode, setMode] = useState( + repoCloneUrl ? "clone" : "import", + ); + const [parentDir, setParentDir] = useState(""); + const [importPath, setImportPath] = useState(""); + const [working, setWorking] = useState(false); + const [browseTarget, setBrowseTarget] = useState< + "parentDir" | "importPath" | null + >(null); + + useEffect(() => { + if (!open) return; + setMode(repoCloneUrl ? "clone" : "import"); + }, [open, repoCloneUrl]); + + const reset = () => { + setParentDir(""); + setImportPath(""); + setWorking(false); + }; + + const handleOpenChange = (next: boolean) => { + if (!next && working) return; + if (!next) reset(); + onOpenChange(next); + }; + + const browseFor = async ( + title: string, + target: "parentDir" | "importPath", + ) => { + try { + const result = await selectDirectory.mutateAsync({ title }); + if (result.canceled || !result.path) return; + if (target === "parentDir") setParentDir(result.path); + else setImportPath(result.path); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }; + + const runClone = async () => { + if (!hostUrl) { + toast.error(`Host unavailable: ${hostName}`); + return; + } + const trimmed = parentDir.trim(); + if (!trimmed) { + toast.error( + isRemoteTarget + ? `Enter a parent directory on ${hostName}` + : "Pick a parent directory", + ); + return; + } + setWorking(true); + try { + const client = getHostServiceClientByUrl(hostUrl); + const result = await client.project.setup.mutate({ + projectId, + mode: { kind: "clone", parentDir: trimmed }, + }); + toast.success(`Cloned to ${result.repoPath}`); + if (result.mainWorkspaceId) { + ensureWorkspaceInSidebar(result.mainWorkspaceId, projectId); + } else { + ensureProjectInSidebar(projectId); + } + onChanged?.(); + reset(); + onOpenChange(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setWorking(false); + } + }; + + const runImport = async () => { + if (!hostUrl) { + toast.error(`Host unavailable: ${hostName}`); + return; + } + const trimmed = importPath.trim(); + if (!trimmed) { + toast.error( + isRemoteTarget + ? `Enter a path on ${hostName}` + : "Pick a project location", + ); + return; + } + setWorking(true); + try { + const client = getHostServiceClientByUrl(hostUrl); + const precheck = await client.project.findBackfillConflict.query({ + projectId, + repoPath: trimmed, + }); + if (precheck.conflict) { + onConflict(precheck.conflict); + onOpenChange(false); + return; + } + const result = await client.project.setup.mutate({ + projectId, + mode: { kind: "import", repoPath: trimmed, allowRelocate: false }, + }); + toast.success(`Project set up at ${result.repoPath}`); + if (result.mainWorkspaceId) { + ensureWorkspaceInSidebar(result.mainWorkspaceId, projectId); + } else { + ensureProjectInSidebar(projectId); + } + onChanged?.(); + reset(); + onOpenChange(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setWorking(false); + } + }; + + const submit = mode === "clone" ? runClone : runImport; + const submitLabel = mode === "clone" ? "Clone" : "Import"; + const cloneDisabled = !repoCloneUrl; + + return ( + <> + + + + Set up project on {hostName} + + Clone the repository, or import an existing folder on the host. + + + + setMode(value as SetupMode)} + > + + + Clone + + + Import existing + + + + + {cloneDisabled ? ( +

+ Link a GitHub repository on the project first to enable + cloning. +

+ ) : ( + <> + {repoCloneUrl && ( +
+ +

+ {repoCloneUrl} +

+
+ )} +
+ +
+ setParentDir(e.target.value)} + placeholder={ + isRemoteTarget + ? "/home/user/projects" + : "Pick a folder…" + } + disabled={working} + className="flex-1 font-mono text-sm" + onKeyDown={(e) => { + if (e.key === "Enter" && !working) void runClone(); + }} + /> + +
+
+ + )} +
+ + +
+ +
+ setImportPath(e.target.value)} + placeholder={ + isRemoteTarget + ? "/home/user/projects/my-repo" + : "Pick a folder…" + } + disabled={working} + className="flex-1 font-mono text-sm" + onKeyDown={(e) => { + if (e.key === "Enter" && !working) void runImport(); + }} + /> + +
+
+
+
+ + + + + +
+
+ + { + if (!next) setBrowseTarget(null); + }} + hostUrl={hostUrl} + hostName={hostName} + initialPath={ + browseTarget === "parentDir" + ? parentDir || undefined + : browseTarget === "importPath" + ? importPath || undefined + : undefined + } + title={ + browseTarget === "parentDir" + ? "Choose a parent directory" + : "Choose an existing repo folder" + } + confirmLabel={ + browseTarget === "parentDir" ? "Use this folder" : "Use this repo" + } + onPick={(path) => { + if (browseTarget === "parentDir") setParentDir(path); + else if (browseTarget === "importPath") setImportPath(path); + }} + /> + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/index.ts new file mode 100644 index 00000000000..9bc4df4abd6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/index.ts @@ -0,0 +1 @@ +export { SetupProjectModal } from "./SetupProjectModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx index 959bfc52f11..87bf0f491f3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx @@ -1,16 +1,11 @@ -import { Button } from "@superset/ui/button"; +import { Skeleton } from "@superset/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; -import { Textarea } from "@superset/ui/textarea"; import { cn } from "@superset/ui/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - HiArrowTopRightOnSquare, - HiCheckCircle, - HiDocumentArrowUp, -} from "react-icons/hi2"; +import { HiCheckCircle } from "react-icons/hi2"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { EXTERNAL_LINKS } from "shared/constants"; +import { ScriptField } from "./components/ScriptField"; interface V2ScriptsEditorProps { hostUrl: string; @@ -283,50 +278,53 @@ export function V2ScriptsEditor({ if (isLoading) { return ( -
-
+
+
+ + + +
+
); } return (
-
-
- {saveStatus === "saving" && ( - - - Saving… - - )} - {saveStatus === "saved" && ( - - - Saved - - )} -
- -
- - - Setup - Teardown - Run - +
+ + + Setup + + + Teardown + + + Run + + +
+ {saveStatus === "saving" && Saving…} + {saveStatus === "saved" && ( + + + Saved + + )} +
+
handleChange("setup", value)} @@ -338,7 +336,6 @@ export function V2ScriptsEditor({ handleChange("teardown", value)} @@ -350,7 +347,6 @@ export function V2ScriptsEditor({ handleChange("run", value)} @@ -364,112 +360,3 @@ export function V2ScriptsEditor({
); } - -interface ScriptFieldProps { - description: string; - placeholder: string; - value: string; - onChange: (value: string) => void; - onFocus: () => void; - onBlur: () => void; -} - -function ScriptField({ - description, - placeholder, - value, - onChange, - onFocus, - onBlur, -}: ScriptFieldProps) { - const [isDragOver, setIsDragOver] = useState(false); - const fileInputRef = useRef(null); - - const importFirstFile = useCallback( - async (files: File[]) => { - const scriptFile = files.find((file) => - file.name.match(/\.(sh|bash|zsh|command)$/i), - ); - if (!scriptFile) return; - try { - onChange(await scriptFile.text()); - } catch (error) { - console.error("[v2-scripts/import] failed to read file", error); - } - }, - [onChange], - ); - - return ( -
-

{description}

- - {/* biome-ignore lint/a11y/useSemanticElements: drop zone wrapper */} -
{ - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); - }} - onDragLeave={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - }} - onDrop={async (e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - await importFirstFile(Array.from(e.dataTransfer.files)); - }} - > -