diff --git a/apps/desktop/src/renderer/lib/pathBasename/index.ts b/apps/desktop/src/renderer/lib/pathBasename/index.ts new file mode 100644 index 00000000000..602dae448af --- /dev/null +++ b/apps/desktop/src/renderer/lib/pathBasename/index.ts @@ -0,0 +1 @@ +export { getBaseName } from "./pathBasename"; diff --git a/apps/desktop/src/renderer/lib/pathBasename/pathBasename.test.ts b/apps/desktop/src/renderer/lib/pathBasename/pathBasename.test.ts new file mode 100644 index 00000000000..833b022bf68 --- /dev/null +++ b/apps/desktop/src/renderer/lib/pathBasename/pathBasename.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "bun:test"; +import { getBaseName } from "./pathBasename"; + +describe("getBaseName", () => { + describe("posix paths", () => { + it("returns the final segment of a standard absolute path", () => { + expect(getBaseName("/Users/alice/projects/superset")).toBe("superset"); + }); + + it("returns the final segment when the path has a file extension", () => { + expect(getBaseName("/workspace/nested/notes.txt")).toBe("notes.txt"); + }); + + it("returns the last non-empty segment for a trailing slash", () => { + expect(getBaseName("/Users/alice/projects/superset/")).toBe("superset"); + }); + + it("collapses multiple trailing slashes", () => { + expect(getBaseName("/Users/alice/projects/superset///")).toBe("superset"); + }); + + it("returns the single segment when path has no separators", () => { + expect(getBaseName("superset")).toBe("superset"); + }); + + it("returns the segment for a single-segment absolute path", () => { + expect(getBaseName("/superset")).toBe("superset"); + }); + + it("preserves dots in folder names", () => { + expect(getBaseName("/Users/alice/my.project.v2")).toBe("my.project.v2"); + }); + + it("preserves a dotfile folder name", () => { + expect(getBaseName("/Users/alice/.config")).toBe(".config"); + }); + + it("preserves spaces in folder names", () => { + expect(getBaseName("/Users/alice/My Cool Project")).toBe( + "My Cool Project", + ); + }); + + it("preserves unicode characters in folder names", () => { + expect(getBaseName("/Users/alice/プロジェクト")).toBe("プロジェクト"); + }); + + it("preserves emoji in folder names", () => { + expect(getBaseName("/Users/alice/🚀-rocket")).toBe("🚀-rocket"); + }); + + it("handles consecutive internal slashes", () => { + expect(getBaseName("/Users//alice///projects/superset")).toBe("superset"); + }); + }); + + describe("windows paths", () => { + it("returns the final segment of a backslash path", () => { + expect(getBaseName("C:\\Users\\alice\\projects\\superset")).toBe( + "superset", + ); + }); + + it("handles a trailing backslash", () => { + expect(getBaseName("C:\\Users\\alice\\projects\\superset\\")).toBe( + "superset", + ); + }); + + it("handles mixed forward and back slashes", () => { + expect(getBaseName("C:\\Users\\alice/projects\\superset")).toBe( + "superset", + ); + }); + + it("handles UNC-style paths", () => { + expect(getBaseName("\\\\server\\share\\project")).toBe("project"); + }); + + it("handles consecutive trailing backslashes", () => { + expect(getBaseName("C:\\Users\\alice\\superset\\\\\\")).toBe("superset"); + }); + }); + + describe("edge cases", () => { + it("returns the original input for an empty string", () => { + expect(getBaseName("")).toBe(""); + }); + + it("returns the original input for only forward slashes", () => { + expect(getBaseName("/")).toBe("/"); + }); + + it("returns the original input for multiple forward slashes", () => { + expect(getBaseName("///")).toBe("///"); + }); + + it("returns the original input for only backslashes", () => { + expect(getBaseName("\\")).toBe("\\"); + }); + + it("returns the drive letter for a windows drive root", () => { + expect(getBaseName("C:\\")).toBe("C:"); + }); + + it("preserves a relative path final segment", () => { + expect(getBaseName("projects/superset")).toBe("superset"); + }); + + it("preserves a dot-relative path final segment", () => { + expect(getBaseName("./projects/superset")).toBe("superset"); + }); + + it("returns '..' for a parent-directory-only input", () => { + expect(getBaseName("..")).toBe(".."); + }); + + it("returns '.' for a current-directory input", () => { + expect(getBaseName(".")).toBe("."); + }); + + it("preserves hyphens and underscores in names", () => { + expect(getBaseName("/tmp/my-cool_project-2")).toBe("my-cool_project-2"); + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/pathBasename/pathBasename.ts b/apps/desktop/src/renderer/lib/pathBasename/pathBasename.ts new file mode 100644 index 00000000000..074fbe61668 --- /dev/null +++ b/apps/desktop/src/renderer/lib/pathBasename/pathBasename.ts @@ -0,0 +1,3 @@ +export function getBaseName(absolutePath: string): string { + return absolutePath.split(/[/\\]/).filter(Boolean).pop() ?? absolutePath; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/FolderFirstImportModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/FolderFirstImportModal.tsx deleted file mode 100644 index 0d727e8dfc5..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/FolderFirstImportModal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 { type FormEvent, useState } from "react"; -import type { - FolderFirstImportState, - UseFolderFirstImportResult, -} from "../../hooks/useFolderFirstImport"; - -interface FolderFirstImportModalProps { - state: FolderFirstImportState; - onCancel: UseFolderFirstImportResult["cancel"]; - onConfirmCreateAsNew: UseFolderFirstImportResult["confirmCreateAsNew"]; -} - -export function FolderFirstImportModal({ - state, - onCancel, - onConfirmCreateAsNew, -}: FolderFirstImportModalProps) { - const open = state.kind !== "idle"; - return ( - { - if (!next) onCancel(); - }} - > - - {state.kind === "no-match" && ( - - )} - - - ); -} - -interface NoMatchContentProps { - repoPath: string; - working: boolean; - onCancel: () => void; - onConfirm: (input: { name: string }) => Promise; -} - -function NoMatchContent({ - repoPath, - working, - onCancel, - onConfirm, -}: NoMatchContentProps) { - const [name, setName] = useState(""); - const trimmed = name.trim(); - const canSubmit = trimmed.length > 0 && !working; - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - if (!canSubmit) return; - void onConfirm({ name: trimmed }); - }; - - return ( -
- - Create a new project? - - No existing project matches this folder. Name it to create a new - project bound to the folder's git remote. - - -
-
- - - {repoPath} - -
-
- - setName(event.target.value)} - disabled={working} - placeholder="e.g. my-project" - /> -
-
- - - - -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/index.ts deleted file mode 100644 index e337b5a82cf..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FolderFirstImportModal } from "./FolderFirstImportModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts index 98b18691822..ceca8dd81eb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts @@ -1,5 +1,4 @@ export { - type FolderFirstImportState, type UseFolderFirstImportResult, useFolderFirstImport, } from "./useFolderFirstImport"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts index a8939de39bc..b44b1bd88fc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts @@ -1,36 +1,14 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { getBaseName } from "renderer/lib/pathBasename"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -interface FolderImportCandidate { - id: string; - name: string; - slug: string; - organizationId: string; - organizationName: string; -} - -// idle — no modal. -// no-match — picked folder has no cloud project; user names it. -// (1-match has no state — setup runs immediately.) -export type FolderFirstImportState = - | { kind: "idle" } - | { kind: "no-match"; repoPath: string; working: boolean }; - export interface UseFolderFirstImportResult { - state: FolderFirstImportState; start: () => Promise; - /** No-op while a mutation is working. */ - cancel: () => void; - confirmCreateAsNew: (input: { name: string }) => Promise; } -type SetupInvokeResult = - | { status: "ok"; projectId: string; repoPath: string } - | { status: "error"; message: string }; - export function useFolderFirstImport(options?: { onSuccess?: (result: { projectId: string; repoPath: string }) => void; onError?: (message: string) => void; @@ -39,17 +17,12 @@ export function useFolderFirstImport(options?: { const { ensureProjectInSidebar } = useDashboardSidebarState(); const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); - const [state, setState] = useState({ kind: "idle" }); - - const reset = useCallback(() => setState({ kind: "idle" }), []); - const reportSuccess = useCallback( (result: { projectId: string; repoPath: string }) => { ensureProjectInSidebar(result.projectId); options?.onSuccess?.(result); - reset(); }, - [ensureProjectInSidebar, options, reset], + [ensureProjectInSidebar, options], ); const reportError = useCallback( @@ -59,26 +32,6 @@ export function useFolderFirstImport(options?: { [options], ); - const runSetup = useCallback( - async (projectId: string, repoPath: string): Promise => { - if (!activeHostUrl) { - return { status: "error", message: "Host service not available" }; - } - const client = getHostServiceClientByUrl(activeHostUrl); - try { - const result = await client.project.setup.mutate({ - projectId, - mode: { kind: "import", repoPath }, - }); - return { status: "ok", projectId, repoPath: result.repoPath }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { status: "error", message }; - } - }, - [activeHostUrl], - ); - const start = useCallback(async () => { if (!activeHostUrl) { reportError("Host service not available"); @@ -98,7 +51,7 @@ export function useFolderFirstImport(options?: { } const client = getHostServiceClientByUrl(activeHostUrl); - let candidates: FolderImportCandidate[]; + let candidates: Array<{ id: string }>; try { const response = await client.project.findByPath.query({ repoPath }); candidates = response.candidates; @@ -108,10 +61,6 @@ export function useFolderFirstImport(options?: { } const [only, ...rest] = candidates; - if (!only) { - setState({ kind: "no-match", repoPath, working: false }); - return; - } if (rest.length > 0) { // Unreachable given single-org findByGitHubRemote + the unique // index on (organizationId, lower(repoCloneUrl)). Surface loudly @@ -121,52 +70,25 @@ export function useFolderFirstImport(options?: { ); return; } - const result = await runSetup(only.id, repoPath); - if (result.status === "ok") { - reportSuccess(result); - } else { - reportError(result.message); - } - }, [activeHostUrl, reportError, reportSuccess, runSetup, selectDirectory]); - - const cancel = useCallback(() => { - setState((prev) => { - // Don't drop the modal while a mutation is mid-flight; the user will - // see the disabled state and wait, or the mutation will resolve and - // reset us. - if (prev.kind !== "idle" && prev.working) return prev; - return { kind: "idle" }; - }); - }, []); - const confirmCreateAsNew = useCallback( - async ({ name }: { name: string }) => { - if (state.kind !== "no-match") return; - if (!activeHostUrl) { - reportError("Host service not available"); - return; - } - const repoPath = state.repoPath; - setState({ kind: "no-match", repoPath, working: true }); - const client = getHostServiceClientByUrl(activeHostUrl); - try { + try { + if (only) { + const result = await client.project.setup.mutate({ + projectId: only.id, + mode: { kind: "import", repoPath }, + }); + reportSuccess({ projectId: only.id, repoPath: result.repoPath }); + } else { const result = await client.project.create.mutate({ - name, + name: getBaseName(repoPath), mode: { kind: "importLocal", repoPath }, }); reportSuccess(result); - } catch (err) { - reportError(err instanceof Error ? err.message : String(err)); - setState({ kind: "no-match", repoPath, working: false }); } - }, - [activeHostUrl, reportError, reportSuccess, state], - ); + } catch (err) { + reportError(err instanceof Error ? err.message : String(err)); + } + }, [activeHostUrl, reportError, reportSuccess, selectDirectory]); - return { - state, - start, - cancel, - confirmCreateAsNew, - }; + return { start }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index dc6482eb1f3..024de48a058 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -22,7 +22,6 @@ import { import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; import { useCurrentPlan } from "renderer/hooks/useCurrentPlan"; import { useHotkeyDisplay } from "renderer/hotkeys"; -import { FolderFirstImportModal } from "renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal"; import { useFolderFirstImport } from "renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport"; import { OrganizationDropdown } from "renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown"; import { useTasksFilterStore } from "renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state"; @@ -187,12 +186,6 @@ export function DashboardSidebarHeader({ New Workspace ({shortcutText}) - - ); } @@ -291,12 +284,6 @@ export function DashboardSidebarHeader({ {shortcutText} - - ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/new-item-paths.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/new-item-paths.ts index c3a07c8f5f6..c2b40d25926 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/new-item-paths.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/new-item-paths.ts @@ -1,3 +1,7 @@ +import { getBaseName } from "renderer/lib/pathBasename"; + +export { getBaseName }; + export function getPathSeparator(absolutePath: string): string { return absolutePath.includes("\\") ? "\\" : "/"; } @@ -10,10 +14,6 @@ export function joinAbsolutePath( return `${parentAbsolutePath.replace(/[\\/]+$/, "")}${separator}${name}`; } -export function getBaseName(absolutePath: string): string { - return absolutePath.split(/[/\\]/).pop() ?? absolutePath; -} - export function getParentPath(absolutePath: string): string { const trimmedPath = absolutePath.replace(/[\\/]+$/, ""); const lastSeparatorIndex = Math.max(