{workspaceTabError.message}
) : ( -Loading workspaces...
diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx new file mode 100644 index 00000000..1a3411e0 --- /dev/null +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -0,0 +1,557 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "@/lib/router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ProjectWorkspace } from "@paperclipai/shared"; +import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { ChoosePathButton } from "../components/PathInstructionsModal"; +import { projectsApi } from "../api/projects"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; +import { queryKeys } from "../lib/queryKeys"; +import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; + +type WorkspaceFormState = { + name: string; + sourceType: ProjectWorkspaceSourceType; + cwd: string; + repoUrl: string; + repoRef: string; + defaultRef: string; + visibility: ProjectWorkspaceVisibility; + setupCommand: string; + cleanupCommand: string; + remoteProvider: string; + remoteWorkspaceRef: string; + sharedWorkspaceKey: string; +}; + +type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"]; +type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"]; + +const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [ + { value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." }, + { value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." }, + { value: "git_repo", label: "Remote git repo", description: "A repo URL with optional refs and local checkout." }, + { value: "remote_managed", label: "Remote-managed workspace", description: "A hosted workspace tracked by external reference." }, +]; + +const VISIBILITY_OPTIONS: Array<{ value: ProjectWorkspaceVisibility; label: string }> = [ + { value: "default", label: "Default" }, + { value: "advanced", label: "Advanced" }, +]; + +function isSafeExternalUrl(value: string | null | undefined) { + if (!value) return false; + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function isAbsolutePath(value: string) { + return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); +} + +function readText(value: string | null | undefined) { + return value ?? ""; +} + +function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState { + return { + name: workspace.name, + sourceType: workspace.sourceType, + cwd: readText(workspace.cwd), + repoUrl: readText(workspace.repoUrl), + repoRef: readText(workspace.repoRef), + defaultRef: readText(workspace.defaultRef), + visibility: workspace.visibility, + setupCommand: readText(workspace.setupCommand), + cleanupCommand: readText(workspace.cleanupCommand), + remoteProvider: readText(workspace.remoteProvider), + remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef), + sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey), + }; +} + +function normalizeText(value: string) { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) { + const patch: RecordLoading workspace…
; + if (projectQuery.error) { + return ( ++ {projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load workspace"} +
+ ); + } + if (!project || !workspace || !form || !initialState) { + returnWorkspace not found for this project.
; + } + + const saveChanges = () => { + const validationError = validateWorkspaceForm(form); + if (validationError) { + setErrorMessage(validationError); + return; + } + const patch = buildWorkspacePatch(initialState, form); + if (Object.keys(patch).length === 0) return; + updateWorkspace.mutate(patch); + }; + + const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null; + + return ( ++ Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace + checkout behavior and let you override setup or cleanup commands when one workspace needs special handling. +
+{errorMessage}
: null} + {!errorMessage && !isDirty ?No unsaved changes.
: null} +No runtime services are attached to this workspace.
+ )} +