diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index 82036020b34..eb1c5a9e1fb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -26,12 +26,14 @@ import { createPortal } from "react-dom"; import { HiOutlineCog6Tooth } from "react-icons/hi2"; import { useHotkeyDisplay } from "renderer/hotkeys"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; import { DashboardSidebarHelpMenu } from "./components/DashboardSidebarHelpMenu"; import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay"; import { DashboardSidebarPortsList } from "./components/DashboardSidebarPortsList"; import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection"; import { DashboardSidebarSectionRenameProvider } from "./components/DashboardSidebarSectionRenameContext"; +import { V2SetupScriptCard } from "./components/V2SetupScriptCard"; import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData"; import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts"; import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider"; @@ -101,6 +103,9 @@ export function DashboardSidebar({ const matchRoute = useMatchRoute(); const settingsHotkey = useHotkeyDisplay("OPEN_SETTINGS").text; const isSettingsOpen = !!matchRoute({ to: "/settings", fuzzy: true }); + const { activeHostUrl } = useLocalHostService(); + const v2RouteMatch = matchRoute({ to: "/v2-workspace/$workspaceId" }); + const activeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null; const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), @@ -130,6 +135,26 @@ export function DashboardSidebar({ .filter((g): g is DashboardSidebarProject => g != null); }, [groups, projectOrder]); + const activeV2Project = useMemo(() => { + if (!activeV2WorkspaceId) return null; + for (const project of groups) { + for (const child of project.children) { + if ( + child.type === "workspace" && + child.workspace.id === activeV2WorkspaceId + ) { + return project; + } + if (child.type === "section") { + for (const ws of child.section.workspaces) { + if (ws.id === activeV2WorkspaceId) return project; + } + } + } + } + return null; + }, [groups, activeV2WorkspaceId]); + const handleDragEnd = useCallback( ({ active, over }: DragEndEvent) => { if (over && active.id !== over.id) { @@ -204,6 +229,13 @@ export function DashboardSidebar({ {!isCollapsed && } + {!isCollapsed && activeV2Project && activeHostUrl && ( + + )}
+ s.isDismissed(projectId), + ); + const dismiss = useV2SetupCardDismissalsStore((s) => s.dismiss); + + const { data: shouldShow } = useQuery({ + queryKey: ["host-config", "shouldShowSetupCard", hostUrl, projectId], + queryFn: () => + getHostServiceClientByUrl(hostUrl).config.shouldShowSetupCard.query({ + projectId, + }), + refetchOnWindowFocus: true, + }); + + if (isCollapsed || isDismissed || !shouldShow) return null; + + return ( + + + + navigate({ + to: "/settings/projects/$projectId", + params: { projectId }, + }) + } + onDismiss={() => dismiss(projectId)} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts new file mode 100644 index 00000000000..6e034e14506 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts @@ -0,0 +1 @@ +export { V2SetupScriptCard } from "./V2SetupScriptCard"; 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 6069985f4e4..b73abb2729b 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 @@ -11,6 +11,7 @@ import { IconUploadField } from "./components/IconUploadField"; import { NameSection } from "./components/NameSection"; import { ProjectLocationSection } from "./components/ProjectLocationSection"; import { RepositorySection } from "./components/RepositorySection"; +import { V2ScriptsEditor } from "./components/V2ScriptsEditor"; interface V2ProjectSettingsProps { projectId: string; @@ -81,6 +82,15 @@ export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) { /> + {activeHostUrl && ( + + + + )} +
typeof s === "string") + : []; + const teardown = Array.isArray(parsed?.teardown) + ? parsed.teardown.filter( + (s: unknown): s is string => typeof s === "string", + ) + : []; + return { + setup: setup.join("\n"), + teardown: teardown.join("\n"), + }; + } catch { + return { setup: "", teardown: "" }; + } +} + +function toCommandsArray(value: string): string[] { + return value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function arraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +type SaveStatus = "idle" | "saving" | "saved"; + +export function V2ScriptsEditor({ + hostUrl, + projectId, + className, +}: V2ScriptsEditorProps) { + const queryClient = useQueryClient(); + + const configQueryKey = [ + "host-config", + "getConfigContent", + hostUrl, + projectId, + ]; + + const { data: configData, isLoading } = useQuery({ + queryKey: configQueryKey, + queryFn: () => + getHostServiceClientByUrl(hostUrl).config.getConfigContent.query({ + projectId, + }), + }); + + const [setupValue, setSetupValue] = useState(""); + const [teardownValue, setTeardownValue] = useState(""); + const [saveStatus, setSaveStatus] = useState("idle"); + const focusedRef = useRef<"setup" | "teardown" | null>(null); + const lastSavedRef = useRef<{ setup: string[]; teardown: string[] }>({ + setup: [], + teardown: [], + }); + const savedTimerRef = useRef(null); + + useEffect(() => { + // Don't clobber an in-progress edit when the server-side query refetches. + if (focusedRef.current) return; + const parsed = parseConfigContent(configData?.content ?? null); + setSetupValue(parsed.setup); + setTeardownValue(parsed.teardown); + lastSavedRef.current = { + setup: toCommandsArray(parsed.setup), + teardown: toCommandsArray(parsed.teardown), + }; + }, [configData?.content]); + + useEffect(() => { + return () => { + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + }; + }, []); + + const updateMutation = useMutation({ + mutationFn: (input: { + projectId: string; + setup: string[]; + teardown: string[]; + }) => getHostServiceClientByUrl(hostUrl).config.updateConfig.mutate(input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: configQueryKey }); + }, + }); + + const flushSave = useCallback( + async (next: { setup: string[]; teardown: string[] }) => { + if ( + arraysEqual(next.setup, lastSavedRef.current.setup) && + arraysEqual(next.teardown, lastSavedRef.current.teardown) + ) { + return; + } + + if (savedTimerRef.current) { + clearTimeout(savedTimerRef.current); + savedTimerRef.current = null; + } + + setSaveStatus("saving"); + try { + await updateMutation.mutateAsync({ projectId, ...next }); + lastSavedRef.current = next; + setSaveStatus("saved"); + savedTimerRef.current = setTimeout(() => { + setSaveStatus("idle"); + savedTimerRef.current = null; + }, 2000); + } catch (error) { + console.error("[v2-scripts/save] failed", error); + setSaveStatus("idle"); + } + }, + [projectId, updateMutation], + ); + + const handleBlur = useCallback( + async (field: "setup" | "teardown") => { + focusedRef.current = null; + + const trimmedSetup = setupValue + .split("\n") + .map((line) => line.trim()) + .join("\n") + .replace(/^\n+|\n+$/g, ""); + const trimmedTeardown = teardownValue + .split("\n") + .map((line) => line.trim()) + .join("\n") + .replace(/^\n+|\n+$/g, ""); + + if (trimmedSetup !== setupValue) setSetupValue(trimmedSetup); + if (trimmedTeardown !== teardownValue) setTeardownValue(trimmedTeardown); + + await flushSave({ + setup: toCommandsArray(field === "setup" ? trimmedSetup : setupValue), + teardown: toCommandsArray( + field === "teardown" ? trimmedTeardown : teardownValue, + ), + }); + }, + [flushSave, setupValue, teardownValue], + ); + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ {saveStatus === "saving" && ( + + + Saving… + + )} + {saveStatus === "saved" && ( + + + Saved + + )} +
+ +
+ + + + Setup + Teardown + + + { + focusedRef.current = "setup"; + }} + onBlur={() => handleBlur("setup")} + /> + + + { + focusedRef.current = "teardown"; + }} + onBlur={() => handleBlur("teardown")} + /> + + +
+ ); +} + +interface ScriptFieldProps { + field: "setup" | "teardown"; + description: string; + placeholder: string; + value: string; + onChange: (value: string) => void; + onFocus: () => void; + onBlur: () => void; +} + +function ScriptField({ + description, + placeholder, + value, + onChange, + onFocus, + onBlur, +}: ScriptFieldProps) { + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + + const importFirstFile = useCallback( + async (files: File[]) => { + const scriptFile = files.find((file) => + file.name.match(/\.(sh|bash|zsh|command)$/i), + ); + if (!scriptFile) return; + try { + onChange(await scriptFile.text()); + } catch (error) { + console.error("[v2-scripts/import] failed to read file", error); + } + }, + [onChange], + ); + + return ( +
+

{description}

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