-
Notifications
You must be signed in to change notification settings - Fork 975
feat: configurable branch prefixes for v2 workspaces #4833
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className="flex items-center gap-2"> | ||
| <Select | ||
| value={selectValue} | ||
| onValueChange={handleModeChange} | ||
| disabled={disabled} | ||
| > | ||
| <SelectTrigger className={showDefault ? "w-[200px]" : "w-[180px]"}> | ||
| <SelectValue /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| {Object.entries(labels).map(([value, label]) => ( | ||
| <SelectItem key={value} value={value}> | ||
| {label} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| {selectValue === "custom" && ( | ||
| <Input | ||
| placeholder="Prefix" | ||
| value={customPrefixInput} | ||
| onChange={(e) => setCustomPrefixInput(e.target.value)} | ||
| onBlur={handleCustomPrefixBlur} | ||
| className="w-[120px]" | ||
| disabled={disabled} | ||
| /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export type { BranchPrefixControlMode } from "./BranchPrefixControl"; | ||
| export { BranchPrefixControl } from "./BranchPrefixControl"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { | ||
| type BranchPrefixMode, | ||
| resolveBranchPrefix, | ||
| } from "@superset/shared/workspace-launch"; | ||
| import { Label } from "@superset/ui/label"; | ||
| import { toast } from "@superset/ui/sonner"; | ||
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; | ||
| import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable"; | ||
| import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; | ||
| import { BranchPrefixControl } from "../../../components/BranchPrefixControl"; | ||
|
|
||
| const BRANCH_PREFIX_QUERY_KEY = ["host-branch-prefix"] as const; | ||
|
|
||
| /** | ||
| * v2 Git settings — the host-wide branch-prefix default. Projects without | ||
| * their own override inherit this. The v1 equivalent lives in `GitSettings`. | ||
| */ | ||
| export function V2GitSettings() { | ||
| const hostService = useLocalHostService(); | ||
| const { activeHostUrl } = hostService; | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| const branchPrefixQuery = useQuery({ | ||
| queryKey: [...BRANCH_PREFIX_QUERY_KEY, activeHostUrl] as const, | ||
| enabled: !!activeHostUrl, | ||
| queryFn: () => { | ||
| if (!activeHostUrl) throw new Error("Host service unavailable"); | ||
| return getHostServiceClientByUrl( | ||
| activeHostUrl, | ||
| ).settings.branchPrefix.get.query(); | ||
| }, | ||
| }); | ||
|
|
||
| const gitInfoQuery = useQuery({ | ||
| queryKey: ["host-git-info", activeHostUrl] as const, | ||
| enabled: !!activeHostUrl, | ||
| staleTime: 5 * 60 * 1000, | ||
| queryFn: () => { | ||
| if (!activeHostUrl) throw new Error("Host service unavailable"); | ||
| return getHostServiceClientByUrl( | ||
| activeHostUrl, | ||
| ).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 (!activeHostUrl) { | ||
| throw new Error( | ||
| getHostServiceUnavailableMessage(hostService, { | ||
| action: "update the branch prefix", | ||
| }), | ||
| ); | ||
| } | ||
| return getHostServiceClientByUrl( | ||
| activeHostUrl, | ||
| ).settings.branchPrefix.set.mutate(vars); | ||
| }, | ||
| onSuccess: () => { | ||
| void queryClient.invalidateQueries({ | ||
| queryKey: BRANCH_PREFIX_QUERY_KEY, | ||
| }); | ||
| }, | ||
| 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 = | ||
| !activeHostUrl || branchPrefixQuery.isLoading || setMutation.isPending; | ||
|
|
||
| return ( | ||
| <div className="p-6 max-w-4xl w-full"> | ||
| <div className="mb-8"> | ||
| <h2 className="text-xl font-semibold">Git & worktrees</h2> | ||
| <p className="text-sm text-muted-foreground mt-1"> | ||
| Configure git branch behavior for new workspaces | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="flex items-center justify-between"> | ||
| <div className="space-y-0.5"> | ||
| <Label className="text-sm font-medium">Branch prefix</Label> | ||
| <p className="text-xs text-muted-foreground"> | ||
| Group new branches under a folder. Projects can override this.{" "} | ||
| <code className="bg-muted px-1.5 py-0.5 rounded text-foreground"> | ||
| {previewPrefix ? `${previewPrefix}/branch-name` : "branch-name"} | ||
| </code> | ||
| </p> | ||
| </div> | ||
| <BranchPrefixControl | ||
| mode={mode} | ||
| customPrefix={customPrefix} | ||
| disabled={controlsDisabled} | ||
| onChange={(next) => | ||
| // Host-wide control never produces null mode (no "default" option). | ||
| setMutation.mutate({ | ||
| mode: next.mode ?? "none", | ||
| customPrefix: next.customPrefix, | ||
| }) | ||
| } | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { V2GitSettings } from "./V2GitSettings"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <BranchPrefixControl | ||
| mode={mode} | ||
| customPrefix={customPrefix} | ||
| showDefault | ||
| disabled={setMutation.isPending} | ||
| onChange={(next) => setMutation.mutate(next)} | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { BranchPrefixSection } from "./BranchPrefixSection"; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,8 @@ | ||||||||||||||||||||||||||||
| CREATE TABLE `host_settings` ( | ||||||||||||||||||||||||||||
| `id` integer PRIMARY KEY DEFAULT 1 NOT NULL, | ||||||||||||||||||||||||||||
| `branch_prefix_mode` text, | ||||||||||||||||||||||||||||
| `branch_prefix_custom` text | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
Comment on lines
+1
to
+5
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enforce
Suggested migration hardening📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| --> statement-breakpoint | ||||||||||||||||||||||||||||
| ALTER TABLE `projects` ADD `branch_prefix_mode` text;--> statement-breakpoint | ||||||||||||||||||||||||||||
| ALTER TABLE `projects` ADD `branch_prefix_custom` text; | ||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mode:"custom"with a null valueWhen the user deletes the text in the custom-prefix input and blurs,
sanitizeSegment("")returns"", which is then coerced tonullbysanitized || null. The mutation saves{ mode: "custom", customPrefix: null }.resolveBranchPrefixreturnsnullfor this state (customPrefix || null), so no prefix is applied — but the Select still shows "Custom". A user who clears the field expecting "Custom" to stop applying a prefix will see the correct preview (justbranch-name) but the mode label is misleading. The same pattern is present inBranchPrefixSection.tsx. Switchingmodeback to"none"when the sanitized value is empty would make the saved state match the user's intent.Prompt To Fix With AI