diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index 856cabe681a..e1c518a4d80 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -464,6 +464,7 @@ export class HostServiceCoordinator extends EventEmitter { : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), DESKTOP_VITE_PORT: String(sharedEnv.DESKTOP_VITE_PORT), SUPERSET_HOME_DIR: SUPERSET_HOME_DIR, + SUPERSET_LEGACY_WORKTREE_BASE_DIR: row?.worktreeBaseDir ?? "", SUPERSET_AGENT_HOOK_PORT: String(sharedEnv.DESKTOP_NOTIFICATIONS_PORT), SUPERSET_AGENT_HOOK_VERSION: HOOK_PROTOCOL_VERSION, AUTH_TOKEN: config.authToken, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/BranchPrefixControl.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/BranchPrefixControl.tsx new file mode 100644 index 00000000000..c6c0484c393 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/BranchPrefixControl.tsx @@ -0,0 +1,112 @@ +import { + type BranchPrefixMode, + sanitizeSegment, +} from "@superset/shared/workspace-launch"; +import { Input } from "@superset/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { useEffect, useState } from "react"; +import { + BRANCH_PREFIX_MODE_LABELS, + BRANCH_PREFIX_MODE_LABELS_WITH_DEFAULT, +} from "../../utils/branch-prefix"; + +/** Select value standing in for "no override — inherit the host default". */ +const DEFAULT_VALUE = "default"; + +/** Mode communicated by the control. `null` only appears when `showDefault`. */ +export type BranchPrefixControlMode = BranchPrefixMode | null; + +interface BranchPrefixControlProps { + mode: BranchPrefixControlMode; + customPrefix: string | null; + /** + * When true, prepends a "Use global default" option whose value is `null`. + * Used by the per-project override; the host-wide default omits it. + */ + showDefault?: boolean; + disabled?: boolean; + onChange: (next: { + mode: BranchPrefixControlMode; + customPrefix: string | null; + }) => void; +} + +/** + * Shared select+input for the v2 branch-prefix setting. Used by the host-wide + * default (`V2GitSettings`) and the per-project override (`BranchPrefixSection`). + * Sanitizes the custom prefix on blur. Empty custom on blur is treated as + * "user is still typing": the input clears but no mutation fires. + */ +export function BranchPrefixControl({ + mode, + customPrefix, + showDefault = false, + disabled, + onChange, +}: BranchPrefixControlProps) { + const [customPrefixInput, setCustomPrefixInput] = useState( + customPrefix ?? "", + ); + useEffect(() => { + setCustomPrefixInput(customPrefix ?? ""); + }, [customPrefix]); + + const selectValue = mode ?? DEFAULT_VALUE; + + const labels = showDefault + ? BRANCH_PREFIX_MODE_LABELS_WITH_DEFAULT + : BRANCH_PREFIX_MODE_LABELS; + + const handleModeChange = (value: string) => { + const nextMode: BranchPrefixControlMode = + value === DEFAULT_VALUE ? null : (value as BranchPrefixMode); + onChange({ mode: nextMode, customPrefix: customPrefixInput || null }); + }; + + const handleCustomPrefixBlur = () => { + const sanitized = sanitizeSegment(customPrefixInput); + setCustomPrefixInput(sanitized); + // Empty sanitized prefix: don't persist `mode=custom, customPrefix=null` + // — that lies about user intent. Leave the dropdown alone so they can + // type again; an explicit mode change is how they exit `custom`. + if (!sanitized) return; + onChange({ mode: "custom", customPrefix: sanitized }); + }; + + return ( +
+ + {selectValue === "custom" && ( + setCustomPrefixInput(e.target.value)} + onBlur={handleCustomPrefixBlur} + className="w-[120px]" + disabled={disabled} + /> + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/index.ts new file mode 100644 index 00000000000..ffe87e68ce9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/index.ts @@ -0,0 +1,2 @@ +export type { BranchPrefixControlMode } from "./BranchPrefixControl"; +export { BranchPrefixControl } from "./BranchPrefixControl"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/HostSelect.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/HostSelect.tsx new file mode 100644 index 00000000000..f786ff3888d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/HostSelect.tsx @@ -0,0 +1,79 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { HiOutlineComputerDesktop, HiOutlineServer } from "react-icons/hi2"; + +export interface HostSelectOption { + id: string; + name: string; + isLocal: boolean; + isOnline: boolean; +} + +interface HostSelectProps { + value: string; + options: HostSelectOption[]; + onValueChange: (id: string) => void; + align?: "start" | "end"; + className?: string; +} + +export function HostSelect({ + value, + options, + onValueChange, + align = "end", + className, +}: HostSelectProps) { + const selected = options.find((option) => option.id === value); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/index.ts new file mode 100644 index 00000000000..a774c78e893 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/index.ts @@ -0,0 +1 @@ +export { HostSelect, type HostSelectOption } from "./HostSelect"; 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/components/SettingsRow/SettingsRow.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/SettingsRow.tsx rename to apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsRow/SettingsRow.tsx 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/components/SettingsRow/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsRow/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/V2WorktreeLocationPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/V2WorktreeLocationPicker.tsx new file mode 100644 index 00000000000..65f197adbd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/V2WorktreeLocationPicker.tsx @@ -0,0 +1,118 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useState } from "react"; +import { LuFolderOpen, LuRotateCcw } from "react-icons/lu"; +import { RemotePathPicker } from "renderer/components/RemotePathPicker"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +interface V2WorktreeLocationPickerProps { + currentPath: string | null | undefined; + fallbackPath: string | null | undefined; + hostUrl: string | null; + hostName: string; + isRemoteTarget: boolean; + disabled?: boolean; + browseTitle?: string; + browseDescription?: string; + onSelect: (path: string) => void | Promise; + onReset: () => void | Promise; +} + +export function V2WorktreeLocationPicker({ + currentPath, + fallbackPath, + hostUrl, + hostName, + isRemoteTarget, + disabled, + browseTitle = "Select worktree location", + browseDescription, + onSelect, + onReset, +}: V2WorktreeLocationPickerProps) { + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + const [remoteBrowseOpen, setRemoteBrowseOpen] = useState(false); + + const displayPath = currentPath ?? fallbackPath ?? "Host unavailable"; + const isBusy = disabled || selectDirectory.isPending; + + const handleBrowse = async () => { + if (isBusy) return; + if (isRemoteTarget) { + setRemoteBrowseOpen(true); + return; + } + const result = await selectDirectory.mutateAsync({ + title: browseTitle, + defaultPath: currentPath ?? fallbackPath ?? undefined, + }); + if (!result.canceled && result.path) { + await onSelect(result.path); + } + }; + + return ( + <> +
+
+ + {displayPath} + +
+ + + + + Change location + + {currentPath ? ( + + + + + Reset location + + ) : null} +
+ + { + void onSelect(path); + }} + /> + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/index.ts new file mode 100644 index 00000000000..a07600c0171 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/index.ts @@ -0,0 +1,6 @@ +export { + useSetV2WorktreeBaseDir, + useV2WorktreeLocationSettings, + v2WorktreeLocationQueryKey, +} from "./useV2WorktreeLocationSettings"; +export { V2WorktreeLocationPicker } from "./V2WorktreeLocationPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/useV2WorktreeLocationSettings.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/useV2WorktreeLocationSettings.ts new file mode 100644 index 00000000000..8ccaf32107e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/useV2WorktreeLocationSettings.ts @@ -0,0 +1,44 @@ +import { toast } from "@superset/ui/sonner"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; + +export function v2WorktreeLocationQueryKey(hostUrl: string | null) { + return ["host-settings", "worktree-location", hostUrl] as const; +} + +export function useV2WorktreeLocationSettings( + hostUrl: string | null, + opts?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: v2WorktreeLocationQueryKey(hostUrl), + enabled: Boolean(hostUrl) && (opts?.enabled ?? true), + queryFn: async () => { + if (!hostUrl) throw new Error("Host unavailable"); + return getHostServiceClientByUrl( + hostUrl, + ).settings.worktreeLocation.get.query(); + }, + }); +} + +export function useSetV2WorktreeBaseDir(hostUrl: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (path: string | null) => { + if (!hostUrl) throw new Error("Host unavailable"); + return getHostServiceClientByUrl( + hostUrl, + ).settings.worktreeLocation.set.mutate({ path }); + }, + onSuccess: (data, path) => { + queryClient.setQueryData(v2WorktreeLocationQueryKey(hostUrl), data); + toast.success( + path ? "Worktree location updated" : "Worktree location reset", + ); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : String(err)); + }, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx index 25467ebdd37..d0c03e34b12 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx @@ -15,16 +15,13 @@ import { import { Switch } from "@superset/ui/switch"; import { useEffect, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { - useDefaultWorktreePath, - WorktreeLocationPicker, -} from "../../../components/WorktreeLocationPicker"; import { BRANCH_PREFIX_MODE_LABELS } from "../../../utils/branch-prefix"; import { isItemVisible, SETTING_ITEM_ID, type SettingItemId, } from "../../../utils/settings-search"; +import { UserWorktreeLocationSection } from "./components/UserWorktreeLocationSection"; interface GitSettingsProps { visibleItems?: SettingItemId[] | null; @@ -110,30 +107,6 @@ export function GitSettings({ visibleItems }: GitSettingsProps) { }); }; - const { data: worktreeBaseDir, isLoading: isWorktreeBaseDirLoading } = - electronTrpc.settings.getWorktreeBaseDir.useQuery(); - const setWorktreeBaseDir = - electronTrpc.settings.setWorktreeBaseDir.useMutation({ - onMutate: async ({ path }) => { - await utils.settings.getWorktreeBaseDir.cancel(); - const previous = utils.settings.getWorktreeBaseDir.getData(); - utils.settings.getWorktreeBaseDir.setData(undefined, path); - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous !== undefined) { - utils.settings.getWorktreeBaseDir.setData( - undefined, - context.previous, - ); - } - }, - onSettled: () => { - utils.settings.getWorktreeBaseDir.invalidate(); - }, - }); - const defaultWorktreePath = useDefaultWorktreePath(); - const previewPrefix = resolveBranchPrefix({ mode: branchPrefix?.mode ?? "none", @@ -231,24 +204,7 @@ export function GitSettings({ visibleItems }: GitSettingsProps) { )} - {showWorktreeLocation && ( -
- -

- Base directory for new worktrees -

- setWorktreeBaseDir.mutate({ path })} - onReset={() => setWorktreeBaseDir.mutate({ path: null })} - /> -
- )} + {showWorktreeLocation && } ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/UserWorktreeLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/UserWorktreeLocationSection.tsx new file mode 100644 index 00000000000..0f20d0be6f0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/UserWorktreeLocationSection.tsx @@ -0,0 +1,172 @@ +import { Label } from "@superset/ui/label"; +import { useMemo, useState } from "react"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + HostSelect, + type HostSelectOption, +} from "renderer/routes/_authenticated/settings/components/HostSelect"; +import { + useSetV2WorktreeBaseDir, + useV2WorktreeLocationSettings, + V2WorktreeLocationPicker, +} from "renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker"; +import { + useDefaultWorktreePath, + WorktreeLocationPicker, +} from "renderer/routes/_authenticated/settings/components/WorktreeLocationPicker"; + +export function UserWorktreeLocationSection() { + const isV2CloudEnabled = useIsV2CloudEnabled(); + return isV2CloudEnabled ? : ; +} + +function V1Body() { + const utils = electronTrpc.useUtils(); + const defaultWorktreePath = useDefaultWorktreePath(); + + const { data: worktreeBaseDir, isLoading } = + electronTrpc.settings.getWorktreeBaseDir.useQuery(); + const setWorktreeBaseDir = + electronTrpc.settings.setWorktreeBaseDir.useMutation({ + onMutate: async ({ path }) => { + await utils.settings.getWorktreeBaseDir.cancel(); + const previous = utils.settings.getWorktreeBaseDir.getData(); + utils.settings.getWorktreeBaseDir.setData(undefined, path); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getWorktreeBaseDir.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + utils.settings.getWorktreeBaseDir.invalidate(); + }, + }); + + return ( +
+ +

+ Base directory for new worktrees +

+ setWorktreeBaseDir.mutate({ path })} + onReset={() => setWorktreeBaseDir.mutate({ path: null })} + /> +
+ ); +} + +function V2Body() { + const { machineId } = useLocalHostService(); + const { currentDeviceName, localHostId, otherHosts } = + useWorkspaceHostOptions(); + const defaultWorktreePath = useDefaultWorktreePath(); + + const hostOptions = useMemo(() => { + const opts: HostSelectOption[] = []; + if (localHostId) { + opts.push({ + id: localHostId, + name: currentDeviceName ?? "This device", + isLocal: true, + isOnline: true, + }); + } + for (const host of otherHosts) { + opts.push({ + id: host.id, + name: host.name, + isLocal: false, + isOnline: host.isOnline, + }); + } + return opts; + }, [currentDeviceName, localHostId, otherHosts]); + + const [selectedHostId, setSelectedHostId] = useState( + () => localHostId ?? machineId ?? null, + ); + const effectiveHostId = + selectedHostId && hostOptions.some((o) => o.id === selectedHostId) + ? selectedHostId + : (hostOptions[0]?.id ?? null); + + const targetHostUrl = useHostUrl(effectiveHostId); + const selectedHost = + hostOptions.find((o) => o.id === effectiveHostId) ?? null; + const isLocal = selectedHost?.isLocal ?? true; + const isOnline = selectedHost?.isOnline ?? false; + const hasMultipleHosts = hostOptions.length > 1; + + const settingsQuery = useV2WorktreeLocationSettings(targetHostUrl, { + enabled: isOnline, + }); + const setLocation = useSetV2WorktreeBaseDir(targetHostUrl); + + const disabled = + !targetHostUrl || + !isOnline || + settingsQuery.isLoading || + setLocation.isPending; + + return ( +
+
+
+ +

+ {hasMultipleHosts + ? `Base directory for new worktrees on ${ + selectedHost?.isLocal + ? "this device" + : (selectedHost?.name ?? "this device") + }` + : "Base directory for new worktrees"} +

+
+ {hasMultipleHosts && effectiveHostId ? ( + + ) : null} +
+ setLocation.mutate(path)} + onReset={() => setLocation.mutate(null)} + /> + {hasMultipleHosts && !isOnline ? ( +

+ {selectedHost?.name ?? "This device"} is offline. +

+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/index.ts new file mode 100644 index 00000000000..6b2fab60f15 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/index.ts @@ -0,0 +1 @@ +export { UserWorktreeLocationSection } from "./UserWorktreeLocationSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx new file mode 100644 index 00000000000..ba015bd730d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx @@ -0,0 +1,235 @@ +import { + type BranchPrefixMode, + resolveBranchPrefix, +} from "@superset/shared/workspace-launch"; +import { toast } from "@superset/ui/sonner"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable"; +import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { BranchPrefixControl } from "../../../components/BranchPrefixControl"; +import { + HostSelect, + type HostSelectOption, +} from "../../../components/HostSelect"; +import { SettingsRow } from "../../../components/SettingsRow"; +import { + useSetV2WorktreeBaseDir, + useV2WorktreeLocationSettings, + V2WorktreeLocationPicker, +} from "../../../components/V2WorktreeLocationPicker"; +import { useDefaultWorktreePath } from "../../../components/WorktreeLocationPicker"; + +interface V2GitSettingsProps { + hostId: string | null; +} + +/** + * v2 Git settings — host-wide branch-prefix default for whichever device the + * picker has selected. Per-host setting; the dropdown only appears when the + * user has 2+ devices in this org. + */ +export function V2GitSettings({ hostId }: V2GitSettingsProps) { + const navigate = useNavigate(); + const hostService = useLocalHostService(); + const { machineId } = hostService; + const { currentDeviceName, localHostId, otherHosts } = + useWorkspaceHostOptions(); + const targetHostUrl = useHostUrl(hostId); + const targetHostId = hostId ?? machineId; + const queryClient = useQueryClient(); + + const hostOptions = useMemo(() => { + const options: HostSelectOption[] = []; + 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((o) => o.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((o) => o.id === targetHostId) ?? null, + [hostOptions, targetHostId], + ); + const hasMultipleHosts = hostOptions.length > 1; + const isRemoteTarget = Boolean(selectedHost && !selectedHost.isLocal); + const isHostOnline = selectedHost?.isOnline ?? true; + const selectedHostName = selectedHost?.isLocal + ? "this device" + : (selectedHost?.name ?? "this device"); + + const worktreeQuery = useV2WorktreeLocationSettings(targetHostUrl, { + enabled: isHostOnline, + }); + const setWorktreeBaseDir = useSetV2WorktreeBaseDir(targetHostUrl); + const defaultWorktreePath = useDefaultWorktreePath(); + + const branchPrefixQuery = useQuery({ + queryKey: ["host-branch-prefix", targetHostUrl] as const, + enabled: !!targetHostUrl && isHostOnline, + queryFn: () => { + if (!targetHostUrl) throw new Error("Host service unavailable"); + return getHostServiceClientByUrl( + targetHostUrl, + ).settings.branchPrefix.get.query(); + }, + }); + + const gitInfoQuery = useQuery({ + queryKey: ["host-git-info", targetHostUrl] as const, + enabled: !!targetHostUrl && isHostOnline, + staleTime: 5 * 60 * 1000, + queryFn: () => { + if (!targetHostUrl) throw new Error("Host service unavailable"); + return getHostServiceClientByUrl( + targetHostUrl, + ).settings.branchPrefix.gitInfo.query(); + }, + }); + + const mode: BranchPrefixMode = branchPrefixQuery.data?.mode ?? "none"; + const customPrefix = branchPrefixQuery.data?.customPrefix ?? null; + + const setMutation = useMutation({ + mutationFn: (vars: { + mode: BranchPrefixMode; + customPrefix: string | null; + }) => { + if (!targetHostUrl) { + throw new Error( + getHostServiceUnavailableMessage(hostService, { + action: "update the branch prefix", + }), + ); + } + return getHostServiceClientByUrl( + targetHostUrl, + ).settings.branchPrefix.set.mutate(vars); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["host-branch-prefix", targetHostUrl], + }); + }, + onError: (err) => + toast.error( + err instanceof Error ? err.message : "Failed to update branch prefix", + ), + }); + + const previewPrefix = + resolveBranchPrefix({ + mode, + customPrefix, + authorPrefix: gitInfoQuery.data?.authorName, + githubUsername: gitInfoQuery.data?.githubUsername, + }) || + (mode === "author" ? "author-name" : mode === "github" ? "username" : null); + + const controlsDisabled = + !targetHostUrl || + !isHostOnline || + branchPrefixQuery.isLoading || + setMutation.isPending; + + return ( +
+
+
+

Git & worktrees

+

+ Branch behavior for new workspaces on this device. Projects can + override the prefix individually. +

+
+ {hasMultipleHosts && targetHostId ? ( + { + void navigate({ + to: "/settings/git", + search: { hostId: nextHostId }, + replace: true, + }); + }} + /> + ) : null} +
+ +
+ + Group new branches under a folder.{" "} + + {previewPrefix ? `${previewPrefix}/branch-name` : "branch-name"} + + + } + > + + setMutation.mutate({ + mode: next.mode ?? "none", + customPrefix: next.customPrefix, + }) + } + /> + + + setWorktreeBaseDir.mutate(path)} + onReset={() => setWorktreeBaseDir.mutate(null)} + /> + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/index.ts new file mode 100644 index 00000000000..5443e5f9119 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/index.ts @@ -0,0 +1 @@ +export { V2GitSettings } from "./V2GitSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx index 8e9bbc0e3fd..5881c009e2c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx @@ -4,14 +4,19 @@ import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useSettingsSearchQuery } from "renderer/stores/settings-state"; import { getVisibleItemsForSection } from "../utils/settings-search"; import { GitSettings } from "./components/GitSettings"; +import { V2GitSettings } from "./components/V2GitSettings"; export const Route = createFileRoute("/_authenticated/settings/git/")({ component: GitSettingsPage, + validateSearch: (search: Record): { hostId?: string } => ({ + hostId: typeof search.hostId === "string" ? search.hostId : undefined, + }), }); function GitSettingsPage() { const searchQuery = useSettingsSearchQuery(); const isV2CloudEnabled = useIsV2CloudEnabled(); + const { hostId } = Route.useSearch(); const visibleItems = useMemo( () => @@ -23,5 +28,9 @@ function GitSettingsPage() { [searchQuery, isV2CloudEnabled], ); + if (isV2CloudEnabled) { + return ; + } + return ; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx index 178b02be496..f71b96c0d1e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx @@ -2,17 +2,20 @@ import { toast } from "@superset/ui/sonner"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useMemo } from "react"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { authClient } from "renderer/lib/auth-client"; import { type PersistableTransaction, useOptimisticCollectionActions, } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import type { CandidateRow } from "./components/AddMemberDropdown"; import { AddMemberDropdown } from "./components/AddMemberDropdown"; import { HostHeader } from "./components/HostHeader"; import type { MemberRowData } from "./components/MembersTable"; import { MembersTable } from "./components/MembersTable"; +import { WorktreeLocationSection } from "./components/WorktreeLocationSection"; function notifyOnPersist( tx: PersistableTransaction | null, @@ -33,6 +36,8 @@ export function HostSettings({ hostId }: HostSettingsProps) { const { data: session } = authClient.useSession(); const currentUserId = session?.user?.id ?? null; const actions = useOptimisticCollectionActions(); + const { machineId } = useLocalHostService(); + const hostUrl = useHostUrl(hostId); const { data: hostRows = [], isReady: hostReady } = useLiveQuery( (q) => @@ -119,6 +124,7 @@ export function HostSettings({ hostId }: HostSettingsProps) { hostUserRows.find((r) => r.userId === currentUserId)?.role === "owner" ); }, [hostUserRows, currentUserId]); + const isRemoteTarget = Boolean(machineId && hostId !== machineId); if (!host) { if (!hostReady) return null; @@ -163,28 +169,38 @@ export function HostSettings({ hostId }: HostSettingsProps) { canRename={isOwner} /> -
-
-
-

Members

- {!isOwner && ( -

- Only owners can change membership. -

+
+ + +
+
+
+

Members

+ {!isOwner && ( +

+ Only owners can change membership. +

+ )} +
+ {isOwner && ( + )}
- {isOwner && ( - - )} -
- - -
+ + + + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx new file mode 100644 index 00000000000..eb13752aeab --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx @@ -0,0 +1,60 @@ +import { + useSetV2WorktreeBaseDir, + useV2WorktreeLocationSettings, + V2WorktreeLocationPicker, +} from "../../../../../../components/V2WorktreeLocationPicker"; + +interface WorktreeLocationSectionProps { + hostUrl: string | null; + hostName: string; + isRemoteTarget: boolean; + isOnline: boolean; + canEdit: boolean; +} + +export function WorktreeLocationSection({ + hostUrl, + hostName, + isRemoteTarget, + isOnline, + canEdit, +}: WorktreeLocationSectionProps) { + const settingsQuery = useV2WorktreeLocationSettings(hostUrl, { + enabled: isOnline, + }); + const setLocation = useSetV2WorktreeBaseDir(hostUrl); + + const disabled = + !canEdit || + !isOnline || + !hostUrl || + settingsQuery.isLoading || + setLocation.isPending; + + return ( +
+
+

Worktrees

+

+ Default location for new worktree workspaces on this host. +

+
+ setLocation.mutate(path)} + onReset={() => setLocation.mutate(null)} + /> + {!canEdit ? ( +

+ Only host owners can change this location. +

+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/index.ts new file mode 100644 index 00000000000..8d38884a55f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/index.ts @@ -0,0 +1 @@ +export { WorktreeLocationSection } from "./WorktreeLocationSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts index 5b860fb8946..39601d6a9cf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test"; import { + getAllowedSectionsForVariant, + getVisibleItemsForSection, SETTING_ITEM_ID, type SettingsItem, searchSettings, @@ -62,4 +64,15 @@ describe("settings search - font settings", () => { expect(editorFont?.section).toBe("appearance"); expect(terminalFont?.section).toBe("appearance"); }); + + it("keeps the Git tab visible in v2 for the user worktree location", () => { + expect(getAllowedSectionsForVariant(true).has("git")).toBe(true); + expect( + getVisibleItemsForSection({ + section: "git", + searchQuery: "", + isV2: true, + }), + ).toEqual([SETTING_ITEM_ID.GIT_WORKTREE_LOCATION]); + }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 6fd9c328ffb..d7f25fab62f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -84,6 +84,7 @@ export const SETTING_ITEM_ID = { HOST_MEMBERS: "host-members", HOST_INVITE_MEMBER: "host-invite-member", HOST_MEMBER_ROLE: "host-member-role", + HOST_WORKTREE_LOCATION: "host-worktree-location", } as const; export type SettingItemId = @@ -137,9 +138,10 @@ export const SETTING_ITEM_VARIANT: Record = { [SETTING_ITEM_ID.BEHAVIOR_RESOURCE_MONITOR]: "shared", [SETTING_ITEM_ID.BEHAVIOR_OPEN_LINKS_IN_APP]: "v1", - [SETTING_ITEM_ID.GIT_BRANCH_PREFIX]: "v1", + // Branch prefix exists in both UIs — v1 `GitSettings`, v2 `V2GitSettings`. + [SETTING_ITEM_ID.GIT_BRANCH_PREFIX]: "shared", [SETTING_ITEM_ID.GIT_DELETE_LOCAL_BRANCH]: "v1", - [SETTING_ITEM_ID.GIT_WORKTREE_LOCATION]: "v1", + [SETTING_ITEM_ID.GIT_WORKTREE_LOCATION]: "shared", [SETTING_ITEM_ID.AGENTS_ENABLED]: "shared", [SETTING_ITEM_ID.AGENTS_COMMANDS]: "shared", @@ -173,7 +175,7 @@ export const SETTING_ITEM_VARIANT: Record = { [SETTING_ITEM_ID.PROJECT_PATH]: "shared", [SETTING_ITEM_ID.PROJECT_SCRIPTS]: "v1", [SETTING_ITEM_ID.PROJECT_BRANCH_PREFIX]: "v1", - [SETTING_ITEM_ID.PROJECT_WORKTREE_LOCATION]: "v1", + [SETTING_ITEM_ID.PROJECT_WORKTREE_LOCATION]: "shared", [SETTING_ITEM_ID.PROJECT_IMPORT_WORKTREES]: "v1", [SETTING_ITEM_ID.PROJECT_ENV_VARS]: "v2", @@ -191,6 +193,7 @@ export const SETTING_ITEM_VARIANT: Record = { [SETTING_ITEM_ID.HOST_MEMBERS]: "shared", [SETTING_ITEM_ID.HOST_INVITE_MEMBER]: "shared", [SETTING_ITEM_ID.HOST_MEMBER_ROLE]: "shared", + [SETTING_ITEM_ID.HOST_WORKTREE_LOCATION]: "v2", }; export function isItemAllowedForVariant( @@ -590,7 +593,7 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ id: SETTING_ITEM_ID.GIT_WORKTREE_LOCATION, section: "git", title: "Worktree location", - description: "Base directory where new worktrees are created on disk", + description: "User-level base directory where new worktrees are created", keywords: [ "git", "worktree", @@ -1087,7 +1090,7 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ id: SETTING_ITEM_ID.PROJECT_WORKTREE_LOCATION, section: "project", title: "Worktree Location", - description: "Override the global worktree directory for this project", + description: "Override the host worktree directory for this project", keywords: [ "project", "worktree", @@ -1290,6 +1293,24 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "device", ], }, + { + id: SETTING_ITEM_ID.HOST_WORKTREE_LOCATION, + section: "hosts", + title: "Worktree location", + description: "Default location for new worktree workspaces on this host", + keywords: [ + "host", + "hosts", + "worktree", + "worktrees", + "location", + "directory", + "path", + "folder", + "storage", + "default", + ], + }, { id: SETTING_ITEM_ID.HOST_INVITE_MEMBER, section: "hosts", 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 d236f20265d..fecb6da9a1b 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,41 +1,32 @@ -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@superset/ui/select"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; -import { HiOutlineComputerDesktop, HiOutlineServer } from "react-icons/hi2"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + HostSelect, + type HostSelectOption, +} from "../../../../components/HostSelect"; +import { SettingsRow } from "../../../../components/SettingsRow"; +import { BranchPrefixSection } from "./components/BranchPrefixSection"; import { DeleteProjectSection } from "./components/DeleteProjectSection"; import { IconUploadField } from "./components/IconUploadField"; import { NameSection } from "./components/NameSection"; import { ProjectLocationSection } from "./components/ProjectLocationSection"; import { RepositorySection } from "./components/RepositorySection"; -import { SettingsRow } from "./components/SettingsRow"; import { V2ScriptsEditor } from "./components/V2ScriptsEditor"; +import { WorktreeLocationSection } from "./components/WorktreeLocationSection"; interface V2ProjectSettingsProps { projectId: string; hostId: string | null; } -interface ProjectSettingsHostOption { - id: string; - name: string; - isLocal: boolean; - isOnline: boolean; -} - export function V2ProjectSettings({ projectId, hostId, @@ -57,8 +48,8 @@ export function V2ProjectSettings({ [collections, projectId], ); - const hostOptions = useMemo(() => { - const options: ProjectSettingsHostOption[] = []; + const hostOptions = useMemo(() => { + const options: HostSelectOption[] = []; if (localHostId) { options.push({ id: localHostId, @@ -132,8 +123,9 @@ export function V2ProjectSettings({

{project.name}

{hasMultipleHosts && targetHostId ? ( - + /> ) : null} @@ -203,6 +149,20 @@ export function V2ProjectSettings({ currentRepoCloneUrl={project.repoCloneUrl} /> + {targetHostUrl && hostProject && ( + + refetchHostProject()} + /> + + )}
@@ -218,6 +178,21 @@ export function V2ProjectSettings({ onChanged={() => refetchHostProject()} /> + + refetchHostProject()} + /> + {targetHostUrl && (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/BranchPrefixSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/BranchPrefixSection.tsx new file mode 100644 index 00000000000..bee986a0cb9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/BranchPrefixSection.tsx @@ -0,0 +1,48 @@ +import type { BranchPrefixMode } from "@superset/shared/workspace-launch"; +import { toast } from "@superset/ui/sonner"; +import { useMutation } from "@tanstack/react-query"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { BranchPrefixControl } from "renderer/routes/_authenticated/settings/components/BranchPrefixControl"; + +interface BranchPrefixSectionProps { + projectId: string; + hostUrl: string; + /** Current override; `null` means the project inherits the host default. */ + mode: BranchPrefixMode | null; + customPrefix: string | null; + onChanged: () => void; +} + +export function BranchPrefixSection({ + projectId, + hostUrl, + mode, + customPrefix, + onChanged, +}: BranchPrefixSectionProps) { + const setMutation = useMutation({ + mutationFn: (vars: { + mode: BranchPrefixMode | null; + customPrefix: string | null; + }) => + getHostServiceClientByUrl(hostUrl).project.setBranchPrefix.mutate({ + projectId, + ...vars, + }), + onSuccess: () => onChanged(), + onError: (err) => + toast.error( + err instanceof Error ? err.message : "Failed to update branch prefix", + ), + }); + + return ( + setMutation.mutate(next)} + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/index.ts new file mode 100644 index 00000000000..7a69965a79d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/index.ts @@ -0,0 +1 @@ +export { BranchPrefixSection } from "./BranchPrefixSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx new file mode 100644 index 00000000000..993c5ca9b98 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx @@ -0,0 +1,78 @@ +import { toast } from "@superset/ui/sonner"; +import { useMutation } from "@tanstack/react-query"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + useV2WorktreeLocationSettings, + V2WorktreeLocationPicker, +} from "../../../../../../components/V2WorktreeLocationPicker"; + +interface WorktreeLocationSectionProps { + projectId: string; + currentPath: string | null; + hostUrl: string | null; + hostName: string; + isRemoteTarget: boolean; + isHostOnline: boolean; + isProjectSetup: boolean; + onChanged?: () => void; +} + +export function WorktreeLocationSection({ + projectId, + currentPath, + hostUrl, + hostName, + isRemoteTarget, + isHostOnline, + isProjectSetup, + onChanged, +}: WorktreeLocationSectionProps) { + const hostSettingsQuery = useV2WorktreeLocationSettings(hostUrl, { + enabled: isHostOnline, + }); + + const setLocation = useMutation({ + mutationFn: async (path: string | null) => { + if (!hostUrl) throw new Error("Host unavailable"); + return getHostServiceClientByUrl( + hostUrl, + ).project.setWorktreeBaseDir.mutate({ projectId, path }); + }, + onSuccess: (_data, path) => { + toast.success( + path + ? "Project worktree location updated" + : "Project worktree location reset", + ); + onChanged?.(); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : String(err)); + }, + }); + + return ( + setLocation.mutate(path)} + onReset={() => setLocation.mutate(null)} + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/index.ts new file mode 100644 index 00000000000..8d38884a55f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/index.ts @@ -0,0 +1 @@ +export { WorktreeLocationSection } from "./WorktreeLocationSection"; diff --git a/packages/host-service/drizzle/0005_host_settings_and_project_overrides.sql b/packages/host-service/drizzle/0005_host_settings_and_project_overrides.sql new file mode 100644 index 00000000000..70121dee4c2 --- /dev/null +++ b/packages/host-service/drizzle/0005_host_settings_and_project_overrides.sql @@ -0,0 +1,10 @@ +CREATE TABLE `host_settings` ( + `id` integer PRIMARY KEY DEFAULT 1 NOT NULL, + `worktree_base_dir` text, + `branch_prefix_mode` text, + `branch_prefix_custom` text +); +--> statement-breakpoint +ALTER TABLE `projects` ADD `worktree_base_dir` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `branch_prefix_mode` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `branch_prefix_custom` text; \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0005_snapshot.json b/packages/host-service/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000000..700d32b888a --- /dev/null +++ b/packages/host-service/drizzle/meta/0005_snapshot.json @@ -0,0 +1,651 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0c6f294e-13f5-4aa7-86cd-2997c78d76cf", + "prevId": "db84ad81-bfc3-48e0-8393-7e8d004c7ffb", + "tables": { + "host_agent_configs": { + "name": "host_agent_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preset_id": { + "name": "preset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "args_json": { + "name": "args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "prompt_transport": { + "name": "prompt_transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_args_json": { + "name": "prompt_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "env_json": { + "name": "env_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_agent_configs_display_order_idx": { + "name": "host_agent_configs_display_order_idx", + "columns": [ + "display_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_settings": { + "name": "host_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_workspace_id": { + "name": "origin_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", + "columns": [ + "origin_workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "origin_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_owner": { + "name": "upstream_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_repo": { + "name": "upstream_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_branch": { + "name": "upstream_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_upstream_ref_idx": { + "name": "workspaces_upstream_ref_idx", + "columns": [ + "upstream_owner", + "upstream_repo", + "upstream_branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index 055ebf3d6ac..252c0e1e1cb 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1777572778580, "tag": "0004_mean_blacklash", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1779922528385, + "tag": "0005_host_settings_and_project_overrides", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index ed12e47044e..fcbbb6cda82 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -1,3 +1,4 @@ +import type { BranchPrefixMode } from "@superset/shared/workspace-launch"; import { index, integer, @@ -39,6 +40,11 @@ export const projects = sqliteTable( repoName: text("repo_name"), repoUrl: text("repo_url"), remoteName: text("remote_name"), + worktreeBaseDir: text("worktree_base_dir"), + // Per-project branch-prefix override. A null `branchPrefixMode` means + // "fall back to the host-wide default" in `host_settings`. + branchPrefixMode: text("branch_prefix_mode").$type(), + branchPrefixCustom: text("branch_prefix_custom"), createdAt: integer("created_at") .notNull() .$defaultFn(() => Date.now()), @@ -46,6 +52,19 @@ export const projects = sqliteTable( (table) => [index("projects_repo_path_idx").on(table.repoPath)], ); +/** + * Single-row host-wide settings (always `id = 1`). The host-service has no + * generic settings store yet; this row holds host-wide knobs (worktree base + * dir, branch-prefix default) that projects fall back to when they have no + * override of their own. + */ +export const hostSettings = sqliteTable("host_settings", { + id: integer().primaryKey().default(1), + worktreeBaseDir: text("worktree_base_dir"), + branchPrefixMode: text("branch_prefix_mode").$type(), + branchPrefixCustom: text("branch_prefix_custom"), +}); + export const pullRequests = sqliteTable( "pull_requests", { diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 1a41d1b3547..668e75e32cd 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -3,12 +3,14 @@ import { type ParsedGitHubRemote, parseGitHubRemote, } from "@superset/shared/github-remote"; +import { BRANCH_PREFIX_MODES } from "@superset/shared/workspace-launch"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { createUserSimpleGit } from "../../../runtime/git/simple-git"; import { protectedProcedure, router } from "../../index"; +import { normalizeWorktreeBaseDir } from "../workspace-creation/shared/worktree-paths"; import { createFromClone, createFromEmpty, @@ -34,6 +36,7 @@ export const projectRouter = router({ repoOwner: projects.repoOwner, repoName: projects.repoName, repoUrl: projects.repoUrl, + worktreeBaseDir: projects.worktreeBaseDir, }) .from(projects) .all(); @@ -50,6 +53,9 @@ export const projectRouter = router({ repoOwner: projects.repoOwner, repoName: projects.repoName, repoUrl: projects.repoUrl, + worktreeBaseDir: projects.worktreeBaseDir, + branchPrefixMode: projects.branchPrefixMode, + branchPrefixCustom: projects.branchPrefixCustom, }) .from(projects) .where(eq(projects.id, input.projectId)) @@ -57,6 +63,68 @@ export const projectRouter = router({ ); }), + setWorktreeBaseDir: protectedProcedure + .input( + z.object({ + projectId: z.string().uuid(), + path: z.string().nullable(), + }), + ) + .mutation(({ ctx, input }) => { + const worktreeBaseDir = normalizeWorktreeBaseDir(input.path); + ctx.db + .update(projects) + .set({ worktreeBaseDir }) + .where(eq(projects.id, input.projectId)) + .run(); + + const project = ctx.db.query.projects + .findFirst({ where: eq(projects.id, input.projectId) }) + .sync(); + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project is not set up on this host", + }); + } + + return { + id: project.id, + worktreeBaseDir: project.worktreeBaseDir ?? null, + }; + }), + + /** + * Set this project's branch-prefix override. A `null` mode clears the + * override so the project falls back to the host-wide default. + */ + setBranchPrefix: protectedProcedure + .input( + z.object({ + projectId: z.string().uuid(), + mode: z.enum(BRANCH_PREFIX_MODES).nullable(), + customPrefix: z.string().nullable().optional(), + }), + ) + .mutation(({ ctx, input }) => { + const updated = ctx.db + .update(projects) + .set({ + branchPrefixMode: input.mode, + branchPrefixCustom: input.customPrefix ?? null, + }) + .where(eq(projects.id, input.projectId)) + .returning({ id: projects.id }) + .get(); + if (!updated) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project not set up locally: ${input.projectId}`, + }); + } + return { success: true as const }; + }), + findBackfillConflict: protectedProcedure .input( z.object({ diff --git a/packages/host-service/src/trpc/router/settings/branch-prefix.ts b/packages/host-service/src/trpc/router/settings/branch-prefix.ts new file mode 100644 index 00000000000..1c9fb49d106 --- /dev/null +++ b/packages/host-service/src/trpc/router/settings/branch-prefix.ts @@ -0,0 +1,59 @@ +import { + BRANCH_PREFIX_MODES, + type BranchPrefixMode, +} from "@superset/shared/workspace-launch"; +import { z } from "zod"; +import { hostSettings } from "../../../db/schema"; +import { createUserSimpleGit } from "../../../runtime/git/simple-git"; +import { protectedProcedure, router } from "../../index"; +import { resolveGitInfo } from "../workspace-creation/utils/branch-prefix"; + +/** + * Host-wide branch-prefix default. Projects without their own override fall + * back to this. Stored in the single-row `host_settings` table (`id = 1`). + */ +export const branchPrefixRouter = router({ + /** The host-wide default. `none` when never configured. */ + get: protectedProcedure.query(({ ctx }) => { + const row = ctx.db.select().from(hostSettings).get(); + return { + mode: (row?.branchPrefixMode ?? "none") satisfies BranchPrefixMode, + customPrefix: row?.branchPrefixCustom ?? null, + }; + }), + + /** Set the host-wide default, upserting the single settings row. */ + set: protectedProcedure + .input( + z.object({ + mode: z.enum(BRANCH_PREFIX_MODES), + customPrefix: z.string().nullable().optional(), + }), + ) + .mutation(({ ctx, input }) => { + ctx.db + .insert(hostSettings) + .values({ + id: 1, + branchPrefixMode: input.mode, + branchPrefixCustom: input.customPrefix ?? null, + }) + .onConflictDoUpdate({ + target: hostSettings.id, + set: { + branchPrefixMode: input.mode, + branchPrefixCustom: input.customPrefix ?? null, + }, + }) + .run(); + return { success: true as const }; + }), + + /** + * Git identity for the settings preview — lets the UI show what the + * `author`/`github` modes would actually resolve to. + */ + gitInfo: protectedProcedure.query(({ ctx }) => { + return resolveGitInfo(createUserSimpleGit(), ctx.execGh); + }), +}); diff --git a/packages/host-service/src/trpc/router/settings/index.ts b/packages/host-service/src/trpc/router/settings/index.ts index 18b91e73211..cd2e7378e95 100644 --- a/packages/host-service/src/trpc/router/settings/index.ts +++ b/packages/host-service/src/trpc/router/settings/index.ts @@ -1,8 +1,13 @@ import { router } from "../../index"; import { agentConfigsRouter } from "./agent-configs"; +import { branchPrefixRouter } from "./branch-prefix"; +import { worktreeLocationRouter } from "./worktree-location"; export const settingsRouter = router({ agentConfigs: agentConfigsRouter, + branchPrefix: branchPrefixRouter, + worktreeLocation: worktreeLocationRouter, }); export type { HostAgentConfig } from "./agent-configs"; +export type { HostWorktreeLocationSettings } from "./worktree-location"; diff --git a/packages/host-service/src/trpc/router/settings/worktree-location.ts b/packages/host-service/src/trpc/router/settings/worktree-location.ts new file mode 100644 index 00000000000..9fe31568579 --- /dev/null +++ b/packages/host-service/src/trpc/router/settings/worktree-location.ts @@ -0,0 +1,75 @@ +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { hostSettings } from "../../../db/schema"; +import type { HostServiceContext } from "../../../types"; +import { protectedProcedure, router } from "../../index"; +import { + defaultWorktreesRoot, + normalizeWorktreeBaseDir, +} from "../workspace-creation/shared/worktree-paths"; + +const HOST_SETTINGS_ID = 1; +// Set by the desktop coordinator from the v1 user setting so a first-run +// host-service inherits the previous worktree location instead of silently +// falling back to the default. +const LEGACY_WORKTREE_BASE_DIR_ENV = "SUPERSET_LEGACY_WORKTREE_BASE_DIR"; + +export interface HostWorktreeLocationSettings { + worktreeBaseDir: string | null; + defaultWorktreeBaseDir: string; +} + +function toOutput( + worktreeBaseDir: string | null, +): HostWorktreeLocationSettings { + return { + worktreeBaseDir, + defaultWorktreeBaseDir: defaultWorktreesRoot(), + }; +} + +export function getHostWorktreeBaseDir( + ctx: Pick, +): string | null { + const existing = ctx.db + .select({ worktreeBaseDir: hostSettings.worktreeBaseDir }) + .from(hostSettings) + .where(eq(hostSettings.id, HOST_SETTINGS_ID)) + .get(); + if (existing) return existing.worktreeBaseDir ?? null; + + // v1 didn't validate paths, so a malformed legacy value shouldn't brick + // the first .get() — treat anything that won't normalize as "no legacy". + let legacy: string | null = null; + try { + legacy = normalizeWorktreeBaseDir( + process.env[LEGACY_WORKTREE_BASE_DIR_ENV], + ); + } catch {} + ctx.db + .insert(hostSettings) + .values({ id: HOST_SETTINGS_ID, worktreeBaseDir: legacy }) + .run(); + return legacy; +} + +export const worktreeLocationRouter = router({ + get: protectedProcedure.query(({ ctx }) => + toOutput(getHostWorktreeBaseDir(ctx)), + ), + + set: protectedProcedure + .input(z.object({ path: z.string().nullable() })) + .mutation(({ ctx, input }) => { + const worktreeBaseDir = normalizeWorktreeBaseDir(input.path); + ctx.db + .insert(hostSettings) + .values({ id: HOST_SETTINGS_ID, worktreeBaseDir }) + .onConflictDoUpdate({ + target: hostSettings.id, + set: { worktreeBaseDir }, + }) + .run(); + return toOutput(worktreeBaseDir); + }), +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts index 8eda98c8a5a..0661d247bd2 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts @@ -1,22 +1,49 @@ import { homedir } from "node:os"; -import { join, resolve, sep } from "node:path"; +import { isAbsolute, join, normalize, resolve, sep } from "node:path"; import { TRPCError } from "@trpc/server"; // Kept outside the primary checkout so editors, file watchers, and // ignore rules treat worktrees as separate trees, not nested ones. -function supersetWorktreesRoot(): string { +export function defaultWorktreesRoot(): string { return join(homedir(), ".superset", "worktrees"); } -export function projectWorktreesRoot(projectId: string): string { - return resolve(supersetWorktreesRoot(), projectId); +export function normalizeWorktreeBaseDir( + input: string | null | undefined, +): string | null { + const trimmed = input?.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith("~")) { + const rest = trimmed.slice(1); + if (rest === "" || rest.startsWith("/") || rest.startsWith("\\")) { + return normalize(join(homedir(), rest)); + } + } + + if (!isAbsolute(trimmed)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Worktree location must be an absolute path or start with ~", + }); + } + + return resolve(trimmed); +} + +export function projectWorktreesRoot( + projectId: string, + worktreeBaseDir?: string | null, +): string { + return resolve(worktreeBaseDir ?? defaultWorktreesRoot(), projectId); } export function safeResolveWorktreePath( projectId: string, branchName: string, + worktreeBaseDir?: string | null, ): string { - const projectRoot = projectWorktreesRoot(projectId); + const projectRoot = projectWorktreesRoot(projectId, worktreeBaseDir); const worktreePath = resolve(projectRoot, branchName); if ( worktreePath !== projectRoot && diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.test.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.test.ts new file mode 100644 index 00000000000..1940a66f172 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.test.ts @@ -0,0 +1,140 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, it } from "bun:test"; +import { resolve } from "node:path"; +import type { BranchPrefixMode } from "@superset/shared/workspace-launch"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { SimpleGit } from "simple-git"; +import * as schema from "../../../../db/schema"; +import { hostSettings } from "../../../../db/schema"; +import type { HostServiceContext } from "../../../../types"; +import type { LocalProject } from "../shared/local-project"; +import { resolveProjectBranchPrefix } from "./branch-prefix"; + +const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../../../../drizzle"); + +function createTestDb() { + const sqlite = new Database(":memory:"); + const db = drizzle(sqlite, { schema }); + migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + return db; +} + +type TestDb = ReturnType; + +/** Git stub whose `user.name` is fixed — only the `author` mode reads it. */ +function gitWithAuthor(authorName: string | null): SimpleGit { + return { + getConfig: async () => ({ value: authorName }), + } as unknown as SimpleGit; +} + +function makeProject(overrides: Partial): LocalProject { + return { + id: "00000000-0000-0000-0000-000000000000", + repoPath: "/tmp/repo", + branchPrefixMode: null, + branchPrefixCustom: null, + ...overrides, + } as LocalProject; +} + +function makeCtx(db: TestDb): HostServiceContext { + return { db } as unknown as HostServiceContext; +} + +function setGlobal( + db: TestDb, + mode: BranchPrefixMode | null, + customPrefix: string | null, +) { + db.insert(hostSettings) + .values({ id: 1, branchPrefixMode: mode, branchPrefixCustom: customPrefix }) + .run(); +} + +describe("resolveProjectBranchPrefix", () => { + it("returns undefined when nothing is configured", async () => { + const result = await resolveProjectBranchPrefix({ + ctx: makeCtx(createTestDb()), + project: makeProject({}), + git: gitWithAuthor(null), + existingBranches: [], + }); + expect(result).toBeUndefined(); + }); + + it("falls back to the host-wide default", async () => { + const db = createTestDb(); + setGlobal(db, "custom", "team"); + const result = await resolveProjectBranchPrefix({ + ctx: makeCtx(db), + project: makeProject({}), + git: gitWithAuthor(null), + existingBranches: [], + }); + expect(result).toBe("team"); + }); + + it("project override wins over the host-wide default", async () => { + const db = createTestDb(); + setGlobal(db, "custom", "team"); + const result = await resolveProjectBranchPrefix({ + ctx: makeCtx(db), + project: makeProject({ + branchPrefixMode: "custom", + branchPrefixCustom: "proj", + }), + git: gitWithAuthor(null), + existingBranches: [], + }); + expect(result).toBe("proj"); + }); + + it("a null project mode inherits the host-wide default", async () => { + const db = createTestDb(); + setGlobal(db, "custom", "team"); + const result = await resolveProjectBranchPrefix({ + ctx: makeCtx(db), + // branchPrefixCustom set but mode null — must NOT count as an override. + project: makeProject({ branchPrefixCustom: "stale" }), + git: gitWithAuthor(null), + existingBranches: [], + }); + expect(result).toBe("team"); + }); + + it("a project `none` override suppresses the host-wide default", async () => { + const db = createTestDb(); + setGlobal(db, "custom", "team"); + const result = await resolveProjectBranchPrefix({ + ctx: makeCtx(db), + project: makeProject({ branchPrefixMode: "none" }), + git: gitWithAuthor(null), + existingBranches: [], + }); + expect(result).toBeUndefined(); + }); + + it("drops the prefix when it collides with an existing branch", async () => { + const db = createTestDb(); + setGlobal(db, "custom", "team"); + const result = await resolveProjectBranchPrefix({ + ctx: makeCtx(db), + project: makeProject({}), + git: gitWithAuthor(null), + existingBranches: ["main", "Team"], + }); + expect(result).toBeUndefined(); + }); + + it("resolves the `author` mode from git user.name", async () => { + const result = await resolveProjectBranchPrefix({ + ctx: makeCtx(createTestDb()), + project: makeProject({ branchPrefixMode: "author" }), + git: gitWithAuthor("Jane Doe"), + existingBranches: [], + }); + expect(result).toBe("Jane-Doe"); + }); +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.ts new file mode 100644 index 00000000000..0b564378c97 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.ts @@ -0,0 +1,102 @@ +import { + type BranchPrefixMode, + resolveBranchPrefix, +} from "@superset/shared/workspace-launch"; +import type { SimpleGit } from "simple-git"; +import { hostSettings } from "../../../../db/schema"; +import type { HostServiceContext } from "../../../../types"; +import type { LocalProject } from "../shared/local-project"; +import type { ExecGh } from "./exec-gh"; + +/** Reads `user.name` from git config. Returns null when unset or unreadable. */ +export async function getGitAuthorName(git: SimpleGit): Promise { + try { + const name = await git.getConfig("user.name"); + return name.value?.trim() || null; + } catch (error) { + console.warn("[branch-prefix] failed to read git user.name:", error); + return null; + } +} + +/** Resolves the authenticated GitHub username via `gh api user`. */ +export async function getGitHubUsername( + execGh: ExecGh, +): Promise { + try { + const result = await execGh(["api", "user", "--jq", ".login"]); + return typeof result === "string" && result.trim() ? result.trim() : null; + } catch (error) { + console.warn("[branch-prefix] failed to read GitHub username:", error); + return null; + } +} + +export interface ResolvedGitInfo { + githubUsername: string | null; + authorName: string | null; +} + +/** Git identity used to preview `author`/`github` prefixes in settings. */ +export async function resolveGitInfo( + git: SimpleGit, + execGh: ExecGh, +): Promise { + const [githubUsername, authorName] = await Promise.all([ + getGitHubUsername(execGh), + getGitAuthorName(git), + ]); + return { githubUsername, authorName }; +} + +/** + * Resolves the branch prefix to apply to a *new* branch in this project. + * + * A project-level override (any non-null `branchPrefixMode`) wins over the + * host-wide default in `host_settings`; absent both, no prefix is applied. + * The resolved prefix is dropped when it would collide with an existing + * branch name — git can't hold both `censys` and `censys/foo`. + * + * Returns the prefix segment (e.g. `censys`) or `undefined` for no prefix. + */ +export async function resolveProjectBranchPrefix({ + ctx, + project, + git, + existingBranches, +}: { + ctx: HostServiceContext; + project: LocalProject; + git: SimpleGit; + existingBranches: string[]; +}): Promise { + const global = ctx.db.select().from(hostSettings).get(); + // Project override wins; otherwise fall back to the host-wide default. + const source = project.branchPrefixMode != null ? project : global; + const mode: BranchPrefixMode = source?.branchPrefixMode ?? "none"; + const customPrefix = source?.branchPrefixCustom ?? null; + + if (mode === "none") return undefined; + + let authorName: string | null = null; + let githubUsername: string | null = null; + if (mode === "author") { + authorName = await getGitAuthorName(git); + } else if (mode === "github") { + [githubUsername, authorName] = await Promise.all([ + getGitHubUsername(ctx.execGh), + getGitAuthorName(git), + ]); + } + + const prefix = resolveBranchPrefix({ + mode, + customPrefix, + authorPrefix: authorName, + githubUsername, + }); + if (!prefix) return undefined; + + const existingSet = new Set(existingBranches.map((b) => b.toLowerCase())); + return existingSet.has(prefix.toLowerCase()) ? undefined : prefix; +} diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index fdd55e983ca..aeee21af4e6 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -16,6 +16,7 @@ import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; import { type AgentRunResult, runAgentInWorkspace } from "../agents"; import { ensureMainWorkspace } from "../project/utils/ensure-main-workspace"; +import { getHostWorktreeBaseDir } from "../settings/worktree-location"; import { adoptExistingWorktree } from "../workspace-creation/shared/adopt-existing-worktree"; import { getWorktreeBranchAtPath, @@ -32,6 +33,7 @@ import { type GeneratedWorkspaceNames, generateWorkspaceNamesFromPrompt, } from "../workspace-creation/utils/ai-workspace-names"; +import { resolveProjectBranchPrefix } from "../workspace-creation/utils/branch-prefix"; import type { ExecGh } from "../workspace-creation/utils/exec-gh"; import { listBranchNames } from "../workspace-creation/utils/list-branch-names"; import { @@ -560,6 +562,8 @@ export const workspacesRouter = router({ await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); const git = await ctx.git(localProject.repoPath); + const worktreeBaseDir = + localProject.worktreeBaseDir ?? getHostWorktreeBaseDir(ctx); // Free branches still claimed by registrations whose dirs are // gone — without this, `git worktree add` later fails with @@ -670,6 +674,7 @@ export const workspacesRouter = router({ worktreePath = safeResolveWorktreePath( localProject.id, resolvedBranch, + worktreeBaseDir, ); mkdirSync(dirname(worktreePath), { recursive: true }); @@ -828,12 +833,31 @@ export const workspacesRouter = router({ // Typed branch: resolve start point via the existing-branch- // aware planner. Title-rename can race with that lookup. resolvedBranch = typedBranch; - const [planResult, aiNames] = await Promise.all([ + const [planResult, aiNames, existing] = await Promise.all([ planBranchSource(git, resolvedBranch, input.baseBranch), aiNamesPromise ?? Promise.resolve(null), + listBranchNames(ctx, localProject.repoPath), ]); plan = planResult; aiTitle = aiNames?.title ?? null; + // Namespace newly-created branches under the configured + // prefix. A typed branch that resolves to an existing ref is + // checked out as-is and never re-prefixed. + if (!plan.usedExistingBranch) { + const prefix = await resolveProjectBranchPrefix({ + ctx, + project: localProject, + git, + existingBranches: existing, + }); + if (prefix) { + resolvedBranch = deduplicateBranchName( + `${prefix}/${resolvedBranch}`, + existing, + ); + plan = { ...plan, branch: resolvedBranch }; + } + } } else { // Auto-gen branch: kick the LLM, the start-point resolve, // and the dedupe list off in parallel — none of them depend @@ -846,8 +870,15 @@ export const workspacesRouter = router({ listBranchNames(ctx, localProject.repoPath), ]); aiTitle = aiNames?.title ?? null; + const prefix = await resolveProjectBranchPrefix({ + ctx, + project: localProject, + git, + existingBranches: existing, + }); const candidate = aiNames?.branchName || generateFriendlyBranchName(); - resolvedBranch = deduplicateBranchName(candidate, existing); + const prefixed = prefix ? `${prefix}/${candidate}` : candidate; + resolvedBranch = deduplicateBranchName(prefixed, existing); plan = { branch: resolvedBranch, startPoint, @@ -895,6 +926,7 @@ export const workspacesRouter = router({ worktreePath = safeResolveWorktreePath( localProject.id, resolvedBranch, + worktreeBaseDir, ); mkdirSync(dirname(worktreePath), { recursive: true }); diff --git a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts index a1cc4f9cd77..ed75e43106f 100644 --- a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts +++ b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { existsSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { TRPCClientError } from "@trpc/client"; import { eq } from "drizzle-orm"; @@ -50,6 +51,128 @@ describe("workspace.create + workspace.delete integration", () => { expect(existsSync(persisted?.worktreePath ?? "")).toBe(true); }); + test("create() uses the configured host worktree location", async () => { + const customRoot = realpathSync( + mkdtempSync(join(tmpdir(), "host-service-worktrees-")), + ); + + try { + const scenario = await createProjectScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + await scenario.host.trpc.settings.worktreeLocation.set.mutate({ + path: customRoot, + }); + + const result = await scenario.host.trpc.workspaces.create.mutate({ + projectId: scenario.projectId, + name: "custom root", + branch: "feature/custom-root", + }); + + const persisted = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result?.workspace?.id ?? "")) + .get(); + + expect(persisted?.worktreePath).toBe( + join(customRoot, scenario.projectId, "feature", "custom-root"), + ); + expect(existsSync(persisted?.worktreePath ?? "")).toBe(true); + } finally { + rmSync(customRoot, { recursive: true, force: true }); + } + }); + + test("create() seeds the host location from the legacy desktop setting", async () => { + const previousLegacyValue = process.env.SUPERSET_LEGACY_WORKTREE_BASE_DIR; + const legacyRoot = realpathSync( + mkdtempSync(join(tmpdir(), "host-service-worktrees-legacy-")), + ); + process.env.SUPERSET_LEGACY_WORKTREE_BASE_DIR = legacyRoot; + + try { + const scenario = await createProjectScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + const result = await scenario.host.trpc.workspaces.create.mutate({ + projectId: scenario.projectId, + name: "legacy root", + branch: "feature/legacy-root", + }); + + const persisted = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result?.workspace?.id ?? "")) + .get(); + const settings = + await scenario.host.trpc.settings.worktreeLocation.get.query(); + + expect(settings.worktreeBaseDir).toBe(legacyRoot); + expect(persisted?.worktreePath).toBe( + join(legacyRoot, scenario.projectId, "feature", "legacy-root"), + ); + } finally { + if (previousLegacyValue === undefined) { + delete process.env.SUPERSET_LEGACY_WORKTREE_BASE_DIR; + } else { + process.env.SUPERSET_LEGACY_WORKTREE_BASE_DIR = previousLegacyValue; + } + rmSync(legacyRoot, { recursive: true, force: true }); + } + }); + + test("create() lets a project override the host worktree location", async () => { + const hostRoot = realpathSync( + mkdtempSync(join(tmpdir(), "host-service-worktrees-host-")), + ); + const projectRoot = realpathSync( + mkdtempSync(join(tmpdir(), "host-service-worktrees-project-")), + ); + + try { + const scenario = await createProjectScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + await scenario.host.trpc.settings.worktreeLocation.set.mutate({ + path: hostRoot, + }); + await scenario.host.trpc.project.setWorktreeBaseDir.mutate({ + projectId: scenario.projectId, + path: projectRoot, + }); + + const result = await scenario.host.trpc.workspaces.create.mutate({ + projectId: scenario.projectId, + name: "project root", + branch: "feature/project-root", + }); + + const persisted = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result?.workspace?.id ?? "")) + .get(); + + expect(persisted?.worktreePath).toBe( + join(projectRoot, scenario.projectId, "feature", "project-root"), + ); + expect(persisted?.worktreePath.startsWith(hostRoot)).toBe(false); + expect(existsSync(persisted?.worktreePath ?? "")).toBe(true); + } finally { + rmSync(hostRoot, { recursive: true, force: true }); + rmSync(projectRoot, { recursive: true, force: true }); + } + }); + test("create() adopts an existing worktree at a non-canonical path instead of failing on `git worktree add`", async () => { // Regress: when the user typed a branch that already has a worktree // somewhere outside `~/.superset/worktrees//`, diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index 0a2b200fd6b..c2060863e3f 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -194,17 +194,10 @@ export const TERMINAL_LINK_BEHAVIORS = [ export type TerminalLinkBehavior = (typeof TERMINAL_LINK_BEHAVIORS)[number]; -/** - * Branch prefix modes for workspace branch naming - */ -export const BRANCH_PREFIX_MODES = [ - "none", - "github", - "author", - "custom", -] as const; - -export type BranchPrefixMode = (typeof BRANCH_PREFIX_MODES)[number]; +export { + BRANCH_PREFIX_MODES, + type BranchPrefixMode, +} from "@superset/shared/workspace-launch"; export const FILE_OPEN_MODES = ["split-pane", "new-tab"] as const; diff --git a/packages/shared/src/workspace-launch/branch.ts b/packages/shared/src/workspace-launch/branch.ts index cc993440eae..cd6d4475366 100644 --- a/packages/shared/src/workspace-launch/branch.ts +++ b/packages/shared/src/workspace-launch/branch.ts @@ -1,6 +1,25 @@ export const DEFAULT_BRANCH_SEGMENT_MAX_LENGTH = 50; export const DEFAULT_BRANCH_NAME_MAX_LENGTH = 100; +/** + * Branch prefix modes for workspace branch naming. Single source of truth; + * `@superset/local-db` re-exports these so callers that can't depend on + * local-db (host-service) share the same definition. + * + * - `none`: no prefix + * - `github`: the user's GitHub username + * - `author`: the git `user.name` author name + * - `custom`: a user-defined string + */ +export const BRANCH_PREFIX_MODES = [ + "none", + "github", + "author", + "custom", +] as const; + +export type BranchPrefixMode = (typeof BRANCH_PREFIX_MODES)[number]; + interface SanitizeSegmentOptions { preserveCase?: boolean; } @@ -170,7 +189,7 @@ export function resolveBranchPrefix({ authorPrefix, githubUsername, }: { - mode: "github" | "author" | "custom" | "none" | null | undefined; + mode: BranchPrefixMode | null | undefined; customPrefix?: string | null; authorPrefix?: string | null; githubUsername?: string | null; diff --git a/plans/v2-branch-prefixes-design.md b/plans/v2-branch-prefixes-design.md new file mode 100644 index 00000000000..14867c80cab --- /dev/null +++ b/plans/v2-branch-prefixes-design.md @@ -0,0 +1,202 @@ +# V2 configurable branch prefixes — retrospective design doc + +**Ticket:** SUPER-835 · **Branch:** `configurable-branch-prefi` · +**Footprint:** +1412 / −15 across 22 files + +## Why the diff stat looked huge + +`git diff main..HEAD --stat` reports ~2200 added / ~1979 deleted across 79 +files, but that is comparing two diverged tips. Against the merge-base +(`59a2a341a`), the actual branch footprint is **+1412 / −15 across 22 +files**. The deletions in the misleading stat (`full-disk-access.test.ts`, +`argv.test.ts`, `focusTerminalPane/`, `setup-script-prompt.md`, etc.) are +work landed on `main` *after* this branch was cut and are not part of this +change. Of the real 1412 added lines, ~640 are the auto-generated drizzle +snapshot (`0005_snapshot.json`) and ~140 are tests — the hand-written +feature surface is ~600 lines. + +## Goal + +Bring v1's "branch prefix" feature to the v2 (host-service) workspace flow. +Requested by Censys for an org-wide v2 trial: new workspace branches should +be namespaced under a configurable segment (e.g. `censys/my-feature`, +`kho/my-feature`) so multi-user remotes stay tidy. + +## Behaviour (v1 parity) + +| Mode | Resolves to | +|----------|-----------------------------------| +| `none` | no prefix | +| `github` | the authed `gh` user's login | +| `author` | git `user.name` | +| `custom` | a user-typed string (sanitized) | + +- **Two levels of configuration**: a host-wide global default plus an + optional per-project override. Project override wins when its mode is set; + otherwise the global default applies; otherwise `none`. +- **Applied to new branches only.** Auto-generated names (AI / friendly + random) and typed branch names that don't already exist get prefixed. + Existing branches and PR checkouts are never re-prefixed. +- **Collision guard.** If the resolved prefix equals an existing branch + name, it's dropped — git cannot hold both `censys` and `censys/foo`. +- **Dedupe survives prefixing.** `deduplicateBranchName` runs *after* the + prefix is applied, so `censys/login` colliding with an existing branch + becomes `censys/login-2` rather than `censys/login` being abandoned. + +## Storage + +v1 stored both settings in the desktop's local SQLite (per-user, per +machine). v2 runs workspace creation inside `packages/host-service`, which +has its own SQLite DB — so v2 storage is **host-local**, mirroring the v1 +locality decision but on a different physical store. + +Two new columns and one new table (`drizzle/0005_branch_prefix_settings.sql`): + +```sql +CREATE TABLE host_settings ( + id integer PRIMARY KEY DEFAULT 1 NOT NULL, + branch_prefix_mode text, + branch_prefix_custom text +); +ALTER TABLE projects ADD branch_prefix_mode text; +ALTER TABLE projects ADD branch_prefix_custom text; +``` + +- `host_settings` is a single-row table (`id = 1`, upserted on + `onConflictDoUpdate`). The host-service has no generic settings store + today; this is the minimum-viable shape for the one global setting v2 + needs. If a second host-level setting appears, this generalizes to a + proper key/value or wider columns then. +- `projects.branch_prefix_mode = NULL` means "inherit the host default." + Any non-null value (including `'none'`) is an explicit override. + +## Module map + +``` +packages/shared/src/workspace-launch/branch.ts + • BRANCH_PREFIX_MODES (single source of truth) + BranchPrefixMode + • resolveBranchPrefix({ mode, customPrefix, authorPrefix, githubUsername }) + — already existed; now consumes the shared type + +packages/local-db/src/schema/zod.ts + • Re-exports BRANCH_PREFIX_MODES / BranchPrefixMode from shared + (host-service cannot depend on local-db) + +packages/host-service/src/db/schema.ts + • projects.branchPrefixMode / branchPrefixCustom + • hostSettings table + +packages/host-service/src/trpc/router/ + ├ settings/branch-prefix.ts — { get, set, gitInfo } host-wide + ├ project/project.ts → setBranchPrefix — per-project override + └ workspaces/workspaces.ts — invokes prefix during create + └ workspace-creation/utils/branch-prefix.ts + • getGitAuthorName (git user.name, null on failure) + • getGitHubUsername (gh api user --jq .login, null on failure) + • resolveGitInfo (parallel, for the settings preview) + • resolveProjectBranchPrefix (cascade + collision guard) + +apps/desktop/src/renderer/routes/_authenticated/settings/ + ├ components/BranchPrefixControl/ — shared select+input + ├ git/components/V2GitSettings/ — host-wide default UI + └ v2-project/$projectId/.../BranchPrefixSection/ — per-project override UI +``` + +## tRPC surface + +| Procedure | Shape | +|----------------------------------------|------------------------------------------------------------------------------------------------| +| `settings.branchPrefix.get` | `→ { mode: BranchPrefixMode, customPrefix: string \| null }` | +| `settings.branchPrefix.set` | `{ mode, customPrefix? } →` | +| `settings.branchPrefix.gitInfo` | `→ { githubUsername, authorName }` — drives the preview chip | +| `project.get` | now also returns `branchPrefixMode`, `branchPrefixCustom` | +| `project.setBranchPrefix` | `{ projectId, mode: BranchPrefixMode \| null, customPrefix? } →` — `null` mode clears override | + +## Resolution at workspace-create time + +`workspaces.create` calls `resolveProjectBranchPrefix` along both branches +of the existing typed-vs-auto-generated split: + +``` +typed branch path: + plan = planBranchSource(...) + if (!plan.usedExistingBranch): + prefix = resolveProjectBranchPrefix(...) + if (prefix): + resolvedBranch = deduplicateBranchName(`${prefix}/${typed}`, existing) + plan.branch = resolvedBranch + +auto-generated path: + prefix = resolveProjectBranchPrefix(...) + candidate = aiNames?.branchName || generateFriendlyBranchName() + prefixed = prefix ? `${prefix}/${candidate}` : candidate + resolvedBranch = deduplicateBranchName(prefixed, existing) +``` + +Three properties this gets right that are easy to get wrong: +1. **Existing-branch checkouts skip prefixing** (`plan.usedExistingBranch` + gate). PR checkouts and `git checkout existing-name` workflows are + untouched. +2. **`listBranchNames` is fetched in the same `Promise.all`** that already + loads the AI title — no extra serial git call. +3. **Dedupe runs after prefixing**, so the collision guard handles "prefix + equals an existing branch name," and `deduplicateBranchName` handles + "full prefixed name collides." + +## Renderer + +`BranchPrefixControl` is the shared select+(conditional custom input) used +by both surfaces. The two consumers differ only in whether the dropdown +includes a `"Use global default"` entry (`showDefault` prop) — the +host-wide setting cannot itself defer to a default, so it omits it. + +Custom-prefix behavior worth calling out: +- The text input is locally controlled and syncs from props via `useEffect`, + so optimistic edits don't fight the query. +- `onBlur` sanitizes with `sanitizeSegment` from `@superset/shared`. +- An empty sanitized prefix on blur is treated as "still typing": the input + clears but no mutation fires. This avoids persisting + `{ mode: 'custom', customPrefix: null }`, which would lie about user + intent — the only way to leave `custom` mode is via the dropdown. + +## Decisions / tradeoffs + +- **Why a `host_settings` table and not a key/value store?** YAGNI. One + global setting today; a real settings store can be introduced when the + second one appears. The `id = 1` upsert keeps the call site simple. +- **Why not put `BRANCH_PREFIX_MODES` in `@superset/local-db`?** + `host-service` can't depend on `local-db`. The constants moved to + `@superset/shared/workspace-launch` (next to `resolveBranchPrefix`); the + old `local-db` export is a re-export so existing v1 callers don't churn. +- **Why drop the `gh` username cache from the original draft (the refactor + commit)?** `gh api user --jq .login` is cheap and the call only happens + on workspace-create / settings open. The cache added complexity (state, + invalidation when the user re-auths) without measurable wins. Both the + settings preview and the per-create resolution call it directly. +- **Why prefix only "new" branches?** Re-prefixing existing branches or PR + checkouts would rename other people's work and break remotes. v1's rule + was the right one; we kept it. +- **Why per-project override at all?** A user working in monorepo-A and + open-source-B may want `kho/` in one and no prefix in the other. The + cascade (project → host → `none`) lets the global default stay useful + even when one project opts out. + +## Tests + +`workspace-creation/utils/branch-prefix.test.ts` covers the resolver: +- mode cascading (project beats global; null project falls back to global; + both empty → `undefined`) +- per-mode resolution (`author`, `github`, `custom`) +- the collision guard (prefix equals existing branch name → undefined) +- `gh` / `git user.name` lookup failure paths (return `null`, don't throw) + +## Out of scope / follow-ups + +See `.spec/improvements/SUPER-794/follow-ups.md`. Not addressed here: +- Migration path for v1 users who already had a configured prefix in the + desktop local DB. Currently they reconfigure once in v2. +- Org-level / team-level defaults (cloud-pushed). Host-local was the + product decision for the ticket; org defaults would layer on top of the + same cascade. +- Prefix display in the workspace list (currently only visible when you + expand a workspace and see the branch). diff --git a/plans/v2-branch-prefixes.md b/plans/v2-branch-prefixes.md new file mode 100644 index 00000000000..41dfa308702 --- /dev/null +++ b/plans/v2-branch-prefixes.md @@ -0,0 +1,45 @@ +# V2 configurable branch prefixes (SUPER-835) + +Brings back v1's "branch prefix" feature for the v2 (host-service) workspace +flow. Requested by Censys for their org-wide v2 trial. + +## Behaviour (v1 parity) + +- Modes: `none`, `github` (GitHub username), `author` (git `user.name`), + `custom` (free string). +- Two levels: a host-wide global default plus an optional per-project + override. Project override wins when its mode is set; otherwise the global + default applies; otherwise `none`. +- The resolved prefix is prepended as a path segment: `prefix/branch-name`. +- Applied to **new** branches only — auto-generated names (AI / friendly + random) and user-typed branch names that don't already exist. Existing + branches and PR checkouts are never re-prefixed. +- Collision guard: if the prefix equals an existing branch name, it's dropped + (git can't have both `censys` and `censys/foo`). + +## Where it lives + +v1 stored this in the desktop's local SQLite. v2 runs workspace creation in +`packages/host-service`, which has its own SQLite DB — so storage is +host-local there too (per the product decision for this ticket). + +## Changes + +1. **`@superset/shared/workspace-launch/branch.ts`** — export + `BRANCH_PREFIX_MODES` + `BranchPrefixMode` (host-service can't depend on + `@superset/local-db`). `resolveBranchPrefix` already lived here. +2. **host-service DB** (`packages/host-service/src/db/schema.ts`) — + `projects.branchPrefixMode` / `branchPrefixCustom` columns + a single-row + `host_settings` table for the global default. New drizzle migration + (auto-applied on startup). +3. **host-service `branch-prefix.ts` util** — git author / GitHub username + lookups + `resolveProjectBranchPrefix` (cascade + collision guard). +4. **host-service `settings` router** — `getBranchPrefix`, `setBranchPrefix`, + `getGitInfo` (for the UI preview). +5. **host-service `project` router** — `get` returns the prefix columns; + new `setBranchPrefix` mutation. +6. **host-service `workspaces.create`** — applies the resolved prefix when + deriving a new branch name. +7. **Renderer** — `V2GitSettings` (global default, on the existing + `/settings/git` route) and a `BranchPrefixSection` in `V2ProjectSettings` + (per-project override with a "use global default" option).