Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/main/lib/host-service-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
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,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 (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger
size="sm"
className={`h-8 gap-1.5 px-2 text-foreground ${className ?? ""}`}
>
<SelectValue>
<span className="flex items-center gap-1.5">
<span className="truncate">
{selected?.isLocal ? "This device" : (selected?.name ?? value)}
</span>
{selected && !selected.isLocal && (
<span
title={selected.isOnline ? "Online" : "Offline"}
className={
selected.isOnline
? "size-1.5 shrink-0 rounded-full bg-emerald-500"
: "size-1.5 shrink-0 rounded-full bg-muted-foreground/60"
}
/>
)}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent align={align}>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
<span className="flex items-center gap-2">
{option.isLocal ? (
<HiOutlineComputerDesktop className="size-4 text-muted-foreground" />
) : (
<HiOutlineServer className="size-4 text-muted-foreground" />
)}
<span className="truncate">
{option.isLocal ? "This device" : option.name}
</span>
{!option.isLocal && !option.isOnline && (
<span className="text-xs text-muted-foreground">offline</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { HostSelect, type HostSelectOption } from "./HostSelect";
Original file line number Diff line number Diff line change
@@ -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<void>;
onReset: () => void | Promise<void>;
}

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 (
<>
<div className="flex w-[28rem] max-w-full items-center gap-2">
<div className="flex h-9 min-w-0 flex-1 items-center overflow-x-auto whitespace-nowrap rounded-md border bg-transparent px-3 dark:bg-input/30">
<span
className="font-mono text-sm text-foreground"
title={displayPath}
>
{displayPath}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
onClick={handleBrowse}
disabled={isBusy || !hostUrl}
aria-label="Change worktree location"
>
<LuFolderOpen className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Change location</TooltipContent>
</Tooltip>
{currentPath ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
onClick={onReset}
disabled={disabled}
aria-label="Reset worktree location"
>
<LuRotateCcw className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset location</TooltipContent>
</Tooltip>
) : null}
</div>

<RemotePathPicker
open={remoteBrowseOpen}
onOpenChange={setRemoteBrowseOpen}
hostUrl={hostUrl}
hostName={hostName}
initialPath={currentPath ?? fallbackPath}
title={browseTitle}
description={
browseDescription ?? `Pick the worktree folder on ${hostName}.`
}
confirmLabel="Use this folder"
onPick={(path) => {
void onSelect(path);
}}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
useSetV2WorktreeBaseDir,
useV2WorktreeLocationSettings,
v2WorktreeLocationQueryKey,
} from "./useV2WorktreeLocationSettings";
export { V2WorktreeLocationPicker } from "./V2WorktreeLocationPicker";
Original file line number Diff line number Diff line change
@@ -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));
},
});
}
Loading
Loading