From 99e54c39b7bcff175790c0092a69d8d1a2425df8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 17 May 2026 12:06:58 -0700 Subject: [PATCH 1/7] feat(desktop): polish v2 project settings to Linear-style layout Restructures the v2 project settings page to match the canonical SettingsRow pattern used in OrganizationSettings: flat list of rows with a uniform w-96 control column, no inner borders or card chrome. - Move project icon thumbnail into the page header beside the title; clicking the thumbnail opens a dropdown menu (Upload / Use GitHub icon / Remove) instead of inline buttons. - Host scope chip rendered inline in the header (single-host hidden). - Repository and Location each render a single w-96 field with a ghost icon button absolutely positioned inside it: FaGithub + tooltip "Open in GitHub" for Repository, LuFolderOpen + tooltip "Change location" for Location. - Location path display uses overflow-x-auto whitespace-nowrap so long paths scroll horizontally inside the field instead of breaking mid-word; ClickablePath gains an opt-in truncate prop (kept for v1 callers). - Scripts editor uses underline-style tabs (no pill chrome, no gap, px-3 per trigger, hover transition), a skeleton-shaped loading state matching the rendered layout, and a tucked-in "Import" button inside the textarea corner. - All inputs normalized to text-sm; mono fields keep font-mono. - Delete project row aligned to py-2.5 / gap-8 rhythm without a separator above. --- .../ClickablePath/ClickablePath.tsx | 13 +- .../V2ProjectSettings/V2ProjectSettings.tsx | 245 +++++++++++++----- .../DeleteProjectSection.tsx | 6 +- .../IconUploadField/IconUploadField.tsx | 113 ++++---- .../components/NameSection/NameSection.tsx | 2 + .../ProjectLocationSection.tsx | 58 +++-- .../RepositorySection/RepositorySection.tsx | 31 ++- .../V2ScriptsEditor/V2ScriptsEditor.tsx | 111 ++++---- 8 files changed, 374 insertions(+), 205 deletions(-) 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..84e829513d9 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,16 @@ 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..3f6dbd23938 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,13 +1,22 @@ +import { Label } from "@superset/ui/label"; +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 { useMemo } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { type ReactNode, 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"; @@ -20,12 +29,46 @@ interface V2ProjectSettingsProps { hostId: string | null; } +interface ProjectSettingsHostOption { + id: string; + name: string; + isLocal: boolean; + isOnline: boolean; +} + +function SettingsRow({ + label, + hint, + htmlFor, + children, +}: { + label: string; + hint?: ReactNode; + htmlFor?: string; + children: ReactNode; +}) { + return ( +
+
+ + {hint &&

{hint}

} +
+
{children}
+
+ ); +} + 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 +81,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 +139,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..74cb6b6afc8 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 @@ -11,8 +11,10 @@ import { 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 { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; @@ -27,6 +29,7 @@ interface ProjectLocationSectionProps { projectId: string; currentPath: string | null; repoCloneUrl: string | null; + hostId: string | null; hostUrl: string | null; hostName: string; isRemoteTarget: boolean; @@ -37,6 +40,7 @@ export function ProjectLocationSection({ projectId, currentPath, repoCloneUrl, + hostId, hostUrl, hostName, isRemoteTarget, @@ -248,27 +252,42 @@ export function ProjectLocationSection({ return ( <> -
-
- {currentPath ? ( - - ) : ( - - Not set up on {hostName}. - +
+
+
+ {currentPath ? ( + + ) : ( + + Not set up on {hostName} + + )} +
+ {currentPath && ( + + + + + Change location + )}
- {currentPath ? ( - - ) : ( + {!currentPath && (
{isRemoteTarget ? (
@@ -380,6 +399,7 @@ export function ProjectLocationSection({ navigate({ to: "/settings/projects/$projectId", params: { projectId: target.id }, + search: { hostId: hostId ?? undefined }, }); }} > 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/V2ScriptsEditor/V2ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx index 959bfc52f11..e70ece33b8c 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, HiDocumentArrowUp } from "react-icons/hi2"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { EXTERNAL_LINKS } from "shared/constants"; 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)} @@ -366,7 +362,6 @@ export function V2ScriptsEditor({ } interface ScriptFieldProps { - description: string; placeholder: string; value: string; onChange: (value: string) => void; @@ -375,7 +370,6 @@ interface ScriptFieldProps { } function ScriptField({ - description, placeholder, value, onChange, @@ -401,9 +395,7 @@ function ScriptField({ ); return ( -
-

{description}

- + <> {/* biome-ignore lint/a11y/useSemanticElements: drop zone wrapper */}
+ {isDragOver && (
@@ -449,16 +450,6 @@ function ScriptField({
)}
- - -
+ ); } From 8e3d089a401e647d57b6fb137b2eee0ee1e8b8ac Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 17 May 2026 12:08:19 -0700 Subject: [PATCH 2/7] fix: address review feedback on PR #4665 - cli/projects list: gracefully degrade when the host service can't be reached and the user did not explicitly pass --host/--local. Falls back to showing organization projects with setUp="?" and path="-" instead of throwing, restoring the previously API-only listing behavior. Still throws when --host or --local was requested explicitly, so the user gets a clear error in that case. (P1 flagged by greptile-apps and cubic-dev-ai.) - cli/projects setup: reject "Project ID specified twice" whenever both --project and the positional argument are passed, even when the values match. (Flagged by coderabbitai.) - v2 project settings: add `select-text cursor-text` to both AlertDialogDescription blocks in ProjectLocationSection so users can copy the conflict message and the From/To paths into bug reports. Aligns with the renderer-wide "error text must be selectable" rule in apps/desktop/AGENTS.md. (Flagged by coderabbitai.) --- .../ProjectLocationSection.tsx | 4 +-- .../cli/src/commands/projects/list/command.ts | 29 +++++++++++++------ .../src/commands/projects/setup/command.ts | 12 ++++---- 3 files changed, 28 insertions(+), 17 deletions(-) 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 74cb6b6afc8..7b8cf4908c3 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 @@ -381,7 +381,7 @@ export function ProjectLocationSection({ Repository already linked - + This repository is already linked to project " {conflict?.name ?? ""}" in this organization. Open that project to set it up on {hostName}. @@ -419,7 +419,7 @@ export function ProjectLocationSection({ Relocate project? -
+
From
{currentPath}
diff --git a/packages/cli/src/commands/projects/list/command.ts b/packages/cli/src/commands/projects/list/command.ts index 29c6055a35c..afd8a33f1a5 100644 --- a/packages/cli/src/commands/projects/list/command.ts +++ b/packages/cli/src/commands/projects/list/command.ts @@ -25,17 +25,28 @@ export default command({ host: options.host ?? undefined, local: options.local ?? undefined, }); - const target = resolveHostTarget({ - requestedHostId: hostId, - organizationId, - userJwt: ctx.bearer, - }); - const hostProjects = await target.client.project.list.query(); - const hostProjectById = new Map( - hostProjects.map((project) => [project.id, project]), - ); + const hostExplicit = options.host !== undefined || options.local === true; + + let hostProjectById: Map | null = + null; + try { + const target = resolveHostTarget({ + requestedHostId: hostId, + organizationId, + userJwt: ctx.bearer, + }); + const hostProjects = await target.client.project.list.query(); + hostProjectById = new Map( + hostProjects.map((project) => [project.id, project]), + ); + } catch (err) { + if (hostExplicit) throw err; + } return projects.map((project) => { + if (!hostProjectById) { + return { ...project, setUp: "?", path: "-" }; + } const hostProject = hostProjectById.get(project.id); return { ...project, diff --git a/packages/cli/src/commands/projects/setup/command.ts b/packages/cli/src/commands/projects/setup/command.ts index ec4b475f50f..1481fc85163 100644 --- a/packages/cli/src/commands/projects/setup/command.ts +++ b/packages/cli/src/commands/projects/setup/command.ts @@ -24,6 +24,12 @@ export default command({ ), }, run: async ({ ctx, args, options }) => { + if (options.project !== undefined && args.id !== undefined) { + throw new CLIError( + "Project ID specified twice", + "Use either --project or the positional project ID, not both.", + ); + } const projectId = (options.project ?? args.id) as string | undefined; if (!projectId) { throw new CLIError( @@ -31,12 +37,6 @@ export default command({ "Pass --project , or provide the project ID as the first argument.", ); } - if (options.project && args.id && options.project !== args.id) { - throw new CLIError( - "Project ID specified twice", - "Use either --project or the positional project ID, not both.", - ); - } const organizationId = ctx.config.organizationId; if (!organizationId) { throw new CLIError("No active organization", "Run: superset auth login"); From cf666af7b576a1376054e18905ed9e29b6028dfd Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 17 May 2026 12:43:26 -0700 Subject: [PATCH 3/7] refactor(desktop): tighten v2 Location empty state UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote empty state previously rendered three side-by-side blocks (a decorative "Not set up on X" input chrome, plus two stacked input+button rows for Import and Clone) — busy and repeated the host name three times. Replace with a single inline form: mode Select (Clone / Import) + path input + "Set up" button. Mode defaults to Clone when a GitHub remote is linked, otherwise Import. The Clone option in the dropdown disables and shows a tooltip when no repo is linked. Local empty state drops the decorative left container too and just shows the two outline buttons that trigger the native folder picker. No behavior change: handleImport / handleClone still drive the same setup logic and conflict precheck. --- .../ProjectLocationSection.tsx | 229 +++++++++--------- 1 file changed, 117 insertions(+), 112 deletions(-) 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 7b8cf4908c3..5222b19de27 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 @@ -10,6 +10,13 @@ import { } from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; @@ -56,6 +63,9 @@ export function ProjectLocationSection({ const [isSubmitting, setIsSubmitting] = useState(false); const [remoteImportPath, setRemoteImportPath] = useState(""); const [remoteCloneParentDir, setRemoteCloneParentDir] = useState(""); + const [remoteMode, setRemoteMode] = useState<"clone" | "import">( + repoCloneUrl ? "clone" : "import", + ); const runSetup = async (repoPath: string, allowRelocate: boolean) => { if (!hostUrl) { @@ -252,122 +262,117 @@ export function ProjectLocationSection({ return ( <> -
+ {currentPath ? (
-
- {currentPath ? ( - - ) : ( - - Not set up on {hostName} - - )} +
+
- {currentPath && ( - - - - - Change location - + + + + + Change location + +
+ ) : isRemoteTarget ? ( +
+ + {remoteMode === "clone" ? ( + + setRemoteCloneParentDir(event.currentTarget.value) + } + placeholder={`Parent directory on ${hostName}`} + className="w-72" + disabled={!repoCloneUrl} + /> + ) : ( + + setRemoteImportPath(event.currentTarget.value) + } + placeholder={`Existing repo path on ${hostName}`} + className="w-72" + /> )} +
- {!currentPath && ( -
- {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} - /> - -
-
- ) : ( - <> - - - - )} -
- )} -
+ ) : ( +
+ + +
+ )} Date: Sun, 17 May 2026 12:52:44 -0700 Subject: [PATCH 4/7] feat(desktop): move v2 project setup form into a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline empty-state setup form (mode select + path input + button) with a single "Set up project…" button that opens a new SetupProjectModal. The empty Location row now reads: Not set up on {host}. [Set up project…] The modal has Clone / Import existing tabs, a Repository readout on the Clone tab, and a path input. For local-host setup the input is paired with a browse button (native folder picker); for remote-host setup the input is text-only with a hostName-qualified placeholder. Conflict precheck is preserved: on import, if the path is already linked to another project, the modal closes and the existing "Repository already linked" AlertDialog opens via the parent. ProjectLocationSection drops the now-unused remoteImportPath / remoteCloneParentDir / remoteMode state and the inline submit handlers; runSetup / runClone live in the modal. The Change… flow and the Relocate confirmation dialog are untouched. --- .../ProjectLocationSection.tsx | 273 +++------------ .../SetupProjectModal/SetupProjectModal.tsx | 330 ++++++++++++++++++ .../components/SetupProjectModal/index.ts | 1 + 3 files changed, 372 insertions(+), 232 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/SetupProjectModal.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/index.ts 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 5222b19de27..dd89dbc0c20 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,6 @@ import { AlertDialogTitle, } from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@superset/ui/select"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; @@ -26,6 +18,7 @@ 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; @@ -61,65 +54,7 @@ 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 [remoteMode, setRemoteMode] = useState<"clone" | "import">( - repoCloneUrl ? "clone" : "import", - ); - - 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 pickPath = async (title: string) => { if (!hostUrl) { @@ -139,47 +74,17 @@ 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 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; } - 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({ @@ -188,76 +93,41 @@ 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); - } - }; - - const handleClone = 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); - } return; } - const parentDir = await pickPath("Select parent directory to clone into"); - if (!parentDir) return; - setIsSubmitting(true); - try { - await runClone(parentDir); - } finally { - setIsSubmitting(false); - } + setPendingPath(path); }; - 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; - } + 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 ( @@ -284,96 +154,35 @@ export function ProjectLocationSection({ Change location
- ) : isRemoteTarget ? ( -
- - {remoteMode === "clone" ? ( - - setRemoteCloneParentDir(event.currentTarget.value) - } - placeholder={`Parent directory on ${hostName}`} - className="w-72" - disabled={!repoCloneUrl} - /> - ) : ( - - setRemoteImportPath(event.currentTarget.value) - } - placeholder={`Existing repo path on ${hostName}`} - className="w-72" - /> - )} - -
) : ( -
+
+ + Not set up on {hostName} + -
)} + + { 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..d1854146b09 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SetupProjectModal/SetupProjectModal.tsx @@ -0,0 +1,330 @@ +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 { 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); + + 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(); + }} + /> + {!isRemoteTarget && ( + + )} +
+
+ + )} +
+ + +
+ +
+ 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(); + }} + /> + {!isRemoteTarget && ( + + )} +
+
+
+
+ + + + + +
+
+ ); +} 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"; From 6e940513de2541965a634d6bf9906fecf32ba798 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 17 May 2026 13:19:59 -0700 Subject: [PATCH 5/7] feat(desktop): add RemotePathPicker for browsing host filesystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When setting up a project on a remote host you couldn't previously pick a directory — only type the absolute path blind. Add a reusable folder browser that talks to the host service over tRPC. Backend: - New filesystem.browseHost protected query on the host-service router. Takes an optional absolute or ~-prefixed path (defaults to homedir()), normalizes it, and returns { path, parentPath, homePath, entries[] } with entries sorted dotfiles-last and folders-first. Hidden files filtered by default; pass includeHidden:true to see them. Unlike the existing listDirectory endpoint, this is not scoped to a workspace — used for setup flows where no workspace exists yet on the host. Path must be absolute or start with ~ for safety; relative paths are rejected. Frontend (renderer/components/RemotePathPicker): - Reusable component that opens a Dialog with a path input (~-aware), Up / Home / Refresh icon buttons, and a scrollable list of subfolders. Single-click highlights a folder in the path input; double-click descends into it. "Use this folder" picks the current path and closes. Reusable across any future flow that needs to pick a path on a host (workspace settings, script paths, etc.). Integration: - SetupProjectModal: the browse button now opens RemotePathPicker for remote hosts and the existing native Electron picker for local hosts, behind the same LuFolderOpen icon. Picked path fills the Clone parent-directory or Import existing-repo input. --- .../RemotePathPicker/RemotePathPicker.tsx | 255 +++++++++++++++ .../components/RemotePathPicker/index.ts | 1 + .../SetupProjectModal/SetupProjectModal.tsx | 295 ++++++++++-------- .../src/trpc/router/filesystem/filesystem.ts | 104 +++++- 4 files changed, 525 insertions(+), 130 deletions(-) create mode 100644 apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx create mode 100644 apps/desktop/src/renderer/components/RemotePathPicker/index.ts 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..1e1b4b85b74 --- /dev/null +++ b/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx @@ -0,0 +1,255 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { Skeleton } from "@superset/ui/skeleton"; +import { toast } from "@superset/ui/sonner"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { + LuArrowUp, + LuFolder, + LuFolderOpen, + LuHouse, + 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; + /** Initial directory shown when the picker opens. Defaults to `~`. */ + 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 }[]; +} + +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, + ); + const [pathDraft, setPathDraft] = useState(initialPath ?? ""); + + useEffect(() => { + if (open) { + setCurrentPath(initialPath ?? null); + setPathDraft(initialPath ?? ""); + } + }, [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); + setPathDraft(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 goTo = (path: string) => { + setCurrentPath(path); + }; + + const goUp = () => { + if (query.data?.parentPath) goTo(query.data.parentPath); + }; + + const goHome = () => { + if (query.data?.homePath) goTo(query.data.homePath); + else setCurrentPath(null); + }; + + const submitPathDraft = () => { + const trimmed = pathDraft.trim(); + if (!trimmed) return; + setCurrentPath(trimmed); + }; + + const handlePick = () => { + const target = query.data?.path ?? currentPath; + if (!target) return; + onPick(target); + onOpenChange(false); + }; + + return ( + + + + {title} + + {description ?? `Browse folders on ${hostName}.`} + + + +
+
+ + + setPathDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitPathDraft(); + } + }} + onBlur={submitPathDraft} + placeholder={`Path on ${hostName} (~ for home)`} + className="flex-1 font-mono text-sm" + spellCheck={false} + /> + +
+ + + {query.isLoading ? ( +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : query.data ? ( + query.data.entries.filter((e) => e.isDirectory).length === 0 ? ( +
+ {query.data.entries.length === 0 + ? "Empty folder" + : "No subfolders"} +
+ ) : ( +
    + {query.data.entries + .filter((entry) => entry.isDirectory) + .map((entry) => { + const childPath = `${query.data.path.replace(/\/$/, "")}/${entry.name}`; + return ( +
  • + +
  • + ); + })} +
+ ) + ) : ( +
+ No data +
+ )} +
+
+ + + + + +
+
+ ); +} 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/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 index d1854146b09..ab5b2b826ea 100644 --- 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 @@ -13,6 +13,7 @@ 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"; @@ -52,6 +53,9 @@ export function SetupProjectModal({ 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; @@ -172,159 +176,194 @@ export function SetupProjectModal({ const cloneDisabled = !repoCloneUrl; return ( - - - - Set up project on {hostName} - - Clone the repository, or import an existing folder on the host. - - + <> + + + + Set up project on {hostName} + + Clone the repository, or import an existing folder on the host. + + - setMode(value as SetupMode)} - > - - - Clone - - - Import existing - - + 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(); - }} - /> - {!isRemoteTarget && ( + + {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(); - }} - /> - {!isRemoteTarget && ( + +
+ +
+ 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/packages/host-service/src/trpc/router/filesystem/filesystem.ts b/packages/host-service/src/trpc/router/filesystem/filesystem.ts index ab1df1ad405..2034ffbef23 100644 --- a/packages/host-service/src/trpc/router/filesystem/filesystem.ts +++ b/packages/host-service/src/trpc/router/filesystem/filesystem.ts @@ -1,10 +1,29 @@ -import { stat } from "node:fs/promises"; -import { isAbsolute, join, normalize, resolve } from "node:path"; +import { readdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, normalize, resolve } from "node:path"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, queryProcedure, router } from "../../index"; +function expandTildeAbsolute(input: string): string { + const trimmed = input.trim(); + if (trimmed.startsWith("~")) { + const home = homedir(); + const rest = trimmed.slice(1); + if (rest === "" || rest.startsWith("/") || rest.startsWith("\\")) { + return normalize(join(home, rest)); + } + } + if (!isAbsolute(trimmed)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Path must be absolute or start with ~", + }); + } + return normalize(trimmed); +} + function getFilesystemService(ctx: HostServiceContext, workspaceId: string) { try { return ctx.runtime.filesystem.getServiceForWorkspace(workspaceId); @@ -51,6 +70,87 @@ const writeFileContentSchema = z.union([ ]); export const filesystemRouter = router({ + /** + * Browse any directory on the host filesystem. Unlike `listDirectory`, + * this is not scoped to a workspace — used by the project setup flow to + * pick a parent/repo path on a host that doesn't yet have a workspace. + * + * Path handling: absolute paths or ~-prefixed paths only. Returns the + * normalized absolute path along with subdirectory entries, sorted with + * dotfiles last. + */ + browseHost: protectedProcedure + .input( + z.object({ + path: z.string().optional(), + includeHidden: z.boolean().optional(), + }), + ) + .query(async ({ input }) => { + const targetPath = input.path + ? expandTildeAbsolute(input.path) + : homedir(); + + let stats: Awaited>; + try { + stats = await stat(targetPath); + } catch { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Path not found: ${targetPath}`, + }); + } + if (!stats.isDirectory()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Not a directory: ${targetPath}`, + }); + } + + let rawEntries: Array<{ + name: string; + isDirectory: boolean; + isSymlink: boolean; + }>; + try { + const dirents = await readdir(targetPath, { + withFileTypes: true, + encoding: "utf8", + }); + rawEntries = dirents.map((d) => ({ + name: d.name, + isDirectory: d.isDirectory(), + isSymlink: d.isSymbolicLink(), + })); + } catch (err) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + err instanceof Error + ? err.message + : `Cannot read directory: ${targetPath}`, + }); + } + + const entries = rawEntries + .filter((e) => input.includeHidden || !e.name.startsWith(".")) + .sort((a, b) => { + const aHidden = a.name.startsWith("."); + const bHidden = b.name.startsWith("."); + if (aHidden !== bHidden) return aHidden ? 1 : -1; + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + const parent = dirname(targetPath); + return { + path: targetPath, + parentPath: parent === targetPath ? null : parent, + homePath: homedir(), + entries, + }; + }), + listDirectory: queryProcedure .input( z.object({ From c4a21e32a90d5b44f0265a747ad8b9cdc80bb13e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 17 May 2026 13:25:15 -0700 Subject: [PATCH 6/7] refactor(desktop): redesign RemotePathPicker with Linear design Replace the four-button toolbar + editable path input with a clean breadcrumb-driven path picker: - Breadcrumb at top doubles as path display and navigation. Clicking any earlier segment jumps to that level. Last segment is bold. If the path lives under $HOME it starts with a "Home" segment; long paths collapse middle segments with BreadcrumbEllipsis. - Folders below in a borderless scroll area. Single click on a folder descends into it (replaces the earlier double-click pattern, which was easy to miss). Hover state on each row, no outer border. - Refresh moves into a quiet icon button next to the breadcrumb. - Symlink indicator: small LuExternalLink at the row end instead of a text "link" badge. - Empty state shows a muted folder glyph plus "Empty folder" / "No subfolders" message. - Loading state renders skeleton rows that match the rendered row layout (icon + label). - Borders only where they carry weight: under the breadcrumb row and above the footer. The dialog itself drops outer padding and gains per-section padding so the borders span the full dialog width. - Drops the Up / Home / Edit-path toolbar buttons; up-navigation happens via breadcrumb segments and home-navigation via the first "Home" segment. The current path is always visible in the breadcrumb, so no separate read-only path readout is needed. --- .../RemotePathPicker/RemotePathPicker.tsx | 292 ++++++++++-------- 1 file changed, 163 insertions(+), 129 deletions(-) diff --git a/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx b/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx index 1e1b4b85b74..486873984c1 100644 --- a/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx +++ b/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx @@ -1,3 +1,12 @@ +import { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@superset/ui/breadcrumb"; import { Button } from "@superset/ui/button"; import { Dialog, @@ -7,17 +16,16 @@ import { DialogHeader, DialogTitle, } from "@superset/ui/dialog"; -import { Input } from "@superset/ui/input"; 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 { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { - LuArrowUp, + LuExternalLink, LuFolder, LuFolderOpen, - LuHouse, LuRefreshCw, } from "react-icons/lu"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -27,7 +35,6 @@ interface RemotePathPickerProps { onOpenChange: (open: boolean) => void; hostUrl: string | null; hostName: string; - /** Initial directory shown when the picker opens. Defaults to `~`. */ initialPath?: string | null; onPick: (absolutePath: string) => void; title?: string; @@ -42,6 +49,41 @@ interface BrowseResult { 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, @@ -56,12 +98,10 @@ export function RemotePathPicker({ const [currentPath, setCurrentPath] = useState( initialPath ?? null, ); - const [pathDraft, setPathDraft] = useState(initialPath ?? ""); useEffect(() => { if (open) { setCurrentPath(initialPath ?? null); - setPathDraft(initialPath ?? ""); } }, [open, initialPath]); @@ -78,10 +118,7 @@ export function RemotePathPicker({ }); useEffect(() => { - if (query.data) { - setCurrentPath(query.data.path); - setPathDraft(query.data.path); - } + if (query.data) setCurrentPath(query.data.path); }, [query.data]); useEffect(() => { @@ -94,145 +131,142 @@ export function RemotePathPicker({ } }, [query.error]); - const goTo = (path: string) => { - setCurrentPath(path); - }; - - const goUp = () => { - if (query.data?.parentPath) goTo(query.data.parentPath); - }; + const allSegments = query.data + ? pathToSegments(query.data.path, query.data.homePath) + : []; - const goHome = () => { - if (query.data?.homePath) goTo(query.data.homePath); - else setCurrentPath(null); - }; + const segments: (Segment | "ellipsis")[] = + allSegments.length > MAX_VISIBLE_SEGMENTS + ? [ + allSegments[0], + "ellipsis", + ...allSegments.slice(-(MAX_VISIBLE_SEGMENTS - 1)), + ] + : allSegments; - const submitPathDraft = () => { - const trimmed = pathDraft.trim(); - if (!trimmed) return; - setCurrentPath(trimmed); - }; + const folders = query.data?.entries.filter((e) => e.isDirectory) ?? []; const handlePick = () => { - const target = query.data?.path ?? currentPath; - if (!target) return; - onPick(target); + if (!query.data) return; + onPick(query.data.path); onOpenChange(false); }; return ( - - + + {title} {description ?? `Browse folders on ${hostName}.`} -
-
- - - setPathDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - submitPathDraft(); - } - }} - onBlur={submitPathDraft} - placeholder={`Path on ${hostName} (~ for home)`} - className="flex-1 font-mono text-sm" - spellCheck={false} - /> - -
- - - {query.isLoading ? ( -
- {[0, 1, 2, 3, 4].map((i) => ( - - ))} -
- ) : query.data ? ( - query.data.entries.filter((e) => e.isDirectory).length === 0 ? ( -
- {query.data.entries.length === 0 - ? "Empty folder" - : "No subfolders"} -
- ) : ( -
    - {query.data.entries - .filter((entry) => entry.isDirectory) - .map((entry) => { - const childPath = `${query.data.path.replace(/\/$/, "")}/${entry.name}`; +
    +
    + {query.data ? ( + + + {segments.map((seg, i) => { + const isLast = i === segments.length - 1; + if (seg === "ellipsis") { return ( -
  • - -
  • + + + + + + ); - })} -
- ) + } + return ( + + + {isLast ? ( + + {seg.label} + + ) : ( + + + + )} + + {!isLast && } + + ); + })} + + ) : ( -
- No data -
+ )} -
+
+
- + + {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/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 3f6dbd23938..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,4 +1,3 @@ -import { Label } from "@superset/ui/label"; import { Select, SelectContent, @@ -10,7 +9,7 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { type ReactNode, useMemo } from "react"; +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"; @@ -22,6 +21,7 @@ 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 { @@ -36,30 +36,6 @@ interface ProjectSettingsHostOption { isOnline: boolean; } -function SettingsRow({ - label, - hint, - htmlFor, - children, -}: { - label: string; - hint?: ReactNode; - htmlFor?: string; - children: ReactNode; -}) { - return ( -
-
- - {hint &&

{hint}

} -
-
{children}
-
- ); -} - export function V2ProjectSettings({ projectId, hostId, 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 dd89dbc0c20..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 @@ -14,6 +14,7 @@ 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"; @@ -55,6 +56,7 @@ export function ProjectLocationSection({ const [conflict, setConflict] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [setupOpen, setSetupOpen] = useState(false); + const [changeBrowseOpen, setChangeBrowseOpen] = useState(false); const pickPath = async (title: string) => { if (!hostUrl) { @@ -74,9 +76,7 @@ export function ProjectLocationSection({ } }; - const handleChange = async () => { - const path = await pickPath("Select new project location"); - if (!path) return; + const proposeRelocate = async (path: string) => { if (path === currentPath) { toast.info("Project is already at that location"); return; @@ -102,6 +102,16 @@ export function ProjectLocationSection({ setPendingPath(path); }; + const handleChange = async () => { + if (isRemoteTarget) { + setChangeBrowseOpen(true); + return; + } + const path = await pickPath("Select new project location"); + if (!path) return; + await proposeRelocate(path); + }; + const handleConfirmRelocate = async () => { if (!pendingPath) return; if (!hostUrl) { @@ -183,6 +193,20 @@ export function ProjectLocationSection({ onConflict={setConflict} /> + { + void proposeRelocate(path); + }} + /> + { 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/V2ScriptsEditor/V2ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx index e70ece33b8c..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,11 +1,11 @@ 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 { HiCheckCircle, HiDocumentArrowUp } from "react-icons/hi2"; +import { HiCheckCircle } from "react-icons/hi2"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { ScriptField } from "./components/ScriptField"; interface V2ScriptsEditorProps { hostUrl: string; @@ -360,107 +360,3 @@ export function V2ScriptsEditor({
); } - -interface ScriptFieldProps { - placeholder: string; - value: string; - onChange: (value: string) => void; - onFocus: () => void; - onBlur: () => void; -} - -function ScriptField({ - 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 ( - <> - {/* 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)); - }} - > -