2026-02-27 20:45:03 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import {
|
|
|
|
|
RiAddLine,
|
|
|
|
|
RiArrowDownSLine,
|
|
|
|
|
RiArrowRightSLine,
|
|
|
|
|
RiDeleteBinLine,
|
|
|
|
|
RiInformationLine,
|
|
|
|
|
RiPlayLine,
|
|
|
|
|
} from '@remixicon/react';
|
2026-03-20 01:01:03 +02:00
|
|
|
import { Button } from '@/components/ui/button';
|
2026-02-27 20:45:03 +02:00
|
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
|
|
|
import {
|
|
|
|
|
Collapsible,
|
|
|
|
|
CollapsibleContent,
|
|
|
|
|
CollapsibleTrigger,
|
|
|
|
|
} from '@/components/ui/collapsible';
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from '@/components/ui/select';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
|
|
|
import { toast } from '@/components/ui';
|
|
|
|
|
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
|
|
|
|
import { isDesktopShell } from '@/lib/desktop';
|
|
|
|
|
import {
|
|
|
|
|
getProjectActionsState,
|
|
|
|
|
saveProjectActionsState,
|
|
|
|
|
type OpenChamberProjectAction,
|
|
|
|
|
type ProjectRef,
|
|
|
|
|
} from '@/lib/openchamberConfig';
|
|
|
|
|
import {
|
|
|
|
|
buildProjectActionDesktopForwardOptions,
|
|
|
|
|
PROJECT_ACTION_ICON_MAP,
|
|
|
|
|
PROJECT_ACTION_ICONS,
|
|
|
|
|
PROJECT_ACTIONS_UPDATED_EVENT,
|
|
|
|
|
} from '@/lib/projectActions';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-02-27 20:45:03 +02:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
|
|
|
|
|
type EditableProjectAction = OpenChamberProjectAction;
|
|
|
|
|
|
|
|
|
|
const createActionId = (): string => {
|
|
|
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
|
|
|
return crypto.randomUUID();
|
|
|
|
|
}
|
|
|
|
|
return `action_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createEmptyAction = (): EditableProjectAction => ({
|
|
|
|
|
id: createActionId(),
|
|
|
|
|
name: '',
|
|
|
|
|
command: '',
|
|
|
|
|
icon: 'play',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
interface ProjectActionsSectionProps {
|
|
|
|
|
projectRef: ProjectRef;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ projectRef }) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-02-27 20:45:03 +02:00
|
|
|
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
|
|
|
|
|
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
|
|
|
|
|
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
|
|
|
|
|
|
|
|
|
|
const [actions, setActions] = React.useState<EditableProjectAction[]>([]);
|
|
|
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
|
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
|
|
|
const [initialSnapshot, setInitialSnapshot] = React.useState<string | null>(null);
|
|
|
|
|
const [expandedActions, setExpandedActions] = React.useState<Record<string, boolean>>({});
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isDesktopShellApp) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void loadDesktopSsh().catch(() => undefined);
|
|
|
|
|
}, [isDesktopShellApp, loadDesktopSsh]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const state = await getProjectActionsState(projectRef);
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setActions(state.actions);
|
|
|
|
|
setInitialSnapshot(JSON.stringify({ actions: state.actions }));
|
|
|
|
|
} catch {
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setActions([]);
|
|
|
|
|
setInitialSnapshot(JSON.stringify({ actions: [] }));
|
|
|
|
|
} finally {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [projectRef]);
|
|
|
|
|
|
|
|
|
|
const desktopForwardOptions = React.useMemo(() => {
|
|
|
|
|
if (!isDesktopShellApp) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
return buildProjectActionDesktopForwardOptions(desktopSshInstances);
|
|
|
|
|
}, [desktopSshInstances, isDesktopShellApp]);
|
|
|
|
|
|
|
|
|
|
const validationError = React.useMemo(() => {
|
|
|
|
|
const hasIncomplete = actions.some((entry) => {
|
|
|
|
|
return entry.name.trim().length === 0 || entry.command.trim().length === 0;
|
|
|
|
|
});
|
|
|
|
|
if (hasIncomplete) {
|
2026-04-26 14:03:39 +03:00
|
|
|
return t('settings.projects.actions.validation.fillNameAndCommand');
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
|
|
|
|
return null;
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [actions, t]);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const hasChanges = React.useMemo(() => {
|
|
|
|
|
if (initialSnapshot === null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return initialSnapshot !== JSON.stringify({ actions });
|
|
|
|
|
}, [actions, initialSnapshot]);
|
|
|
|
|
|
|
|
|
|
const handleAddAction = React.useCallback(() => {
|
|
|
|
|
const nextAction = createEmptyAction();
|
|
|
|
|
setActions((prev) => [...prev, nextAction]);
|
|
|
|
|
setExpandedActions((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[nextAction.id]: true,
|
|
|
|
|
}));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleRemoveAction = React.useCallback((id: string) => {
|
|
|
|
|
setActions((prev) => prev.filter((entry) => entry.id !== id));
|
|
|
|
|
setExpandedActions((prev) => {
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
delete next[id];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const updateAction = React.useCallback((id: string, updater: (current: EditableProjectAction) => EditableProjectAction) => {
|
|
|
|
|
setActions((prev) => prev.map((entry) => (entry.id === id ? updater(entry) : entry)));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleSave = React.useCallback(async () => {
|
|
|
|
|
if (validationError) {
|
|
|
|
|
toast.error(validationError);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setIsSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const ok = await saveProjectActionsState(projectRef, {
|
|
|
|
|
actions,
|
|
|
|
|
primaryActionId: null,
|
|
|
|
|
});
|
|
|
|
|
if (!ok) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.projects.actions.toast.saveFailed'));
|
2026-02-27 20:45:03 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setInitialSnapshot(JSON.stringify({ actions }));
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, {
|
|
|
|
|
detail: { projectId: projectRef.id },
|
|
|
|
|
}));
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('settings.projects.actions.toast.saved'));
|
2026-02-27 20:45:03 +02:00
|
|
|
} catch {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.projects.actions.toast.saveFailed'));
|
2026-02-27 20:45:03 +02:00
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [actions, projectRef, t, validationError]);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const canSave = !isSaving && !isLoading && hasChanges && !validationError;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<div className="mb-1 flex items-start justify-between gap-2">
|
|
|
|
|
<div>
|
2026-04-26 14:03:39 +03:00
|
|
|
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.projects.actions.title')}</h3>
|
|
|
|
|
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.description')}</p>
|
2026-02-27 20:45:03 +02:00
|
|
|
</div>
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
|
2026-02-27 20:45:03 +02:00
|
|
|
<RiAddLine className="h-3.5 w-3.5" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.projects.actions.actions.add')}
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-27 20:45:03 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<section className="pb-2 pt-0 space-y-2">
|
|
|
|
|
{isLoading ? (
|
2026-04-26 14:03:39 +03:00
|
|
|
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.loading')}</p>
|
2026-02-27 20:45:03 +02:00
|
|
|
) : actions.length === 0 ? (
|
|
|
|
|
<div className="py-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.empty')}</p>
|
2026-02-27 20:45:03 +02:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-0 max-w-[30rem]">
|
|
|
|
|
{actions.map((action) => {
|
|
|
|
|
const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
|
|
|
|
|
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
|
|
|
|
const isOpen = expandedActions[action.id] ?? false;
|
2026-04-26 14:03:39 +03:00
|
|
|
const title = action.name.trim() || t('settings.projects.actions.state.untitled');
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Collapsible
|
|
|
|
|
key={action.id}
|
|
|
|
|
open={isOpen}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
setExpandedActions((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[action.id]: open,
|
|
|
|
|
}));
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'py-1.5'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
<CollapsibleTrigger className="group flex-1 justify-start gap-2 rounded-md px-0 pr-1 py-1 hover:bg-[var(--interactive-hover)] focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]">
|
|
|
|
|
{isOpen ? (
|
|
|
|
|
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<SelectedIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="typography-ui-label text-foreground truncate">{title}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</CollapsibleTrigger>
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button
|
2026-02-27 20:45:03 +02:00
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="xs"
|
|
|
|
|
className="!font-normal h-7 w-7 px-0 text-muted-foreground hover:text-[var(--status-error)]"
|
|
|
|
|
onClick={() => handleRemoveAction(action.id)}
|
|
|
|
|
>
|
|
|
|
|
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-27 20:45:03 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<CollapsibleContent className="pt-1.5">
|
|
|
|
|
<div className="space-y-2 pb-6 pl-3 pr-3">
|
|
|
|
|
<div className="flex items-center gap-2 py-1">
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--interactive-border)] text-foreground hover:bg-[var(--interactive-hover)]"
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={t('settings.projects.actions.field.selectIconAria')}
|
2026-02-27 20:45:03 +02:00
|
|
|
>
|
|
|
|
|
<SelectedIcon className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="start" className="w-56 p-2">
|
|
|
|
|
<div className="grid grid-cols-6 gap-1">
|
|
|
|
|
{PROJECT_ACTION_ICONS.map((entry) => {
|
|
|
|
|
const Icon = entry.Icon;
|
|
|
|
|
const selected = (action.icon || 'play') === entry.key;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={entry.key}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => updateAction(action.id, (current) => ({ ...current, icon: entry.key }))}
|
|
|
|
|
className={cn(
|
|
|
|
|
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-foreground hover:bg-[var(--interactive-hover)]',
|
|
|
|
|
selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/10 text-[var(--primary-base)]'
|
|
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={t('settings.projects.actions.field.iconAria', { icon: entry.label })}
|
2026-02-27 20:45:03 +02:00
|
|
|
>
|
|
|
|
|
<Icon className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
|
|
|
|
<Input
|
|
|
|
|
value={action.name}
|
|
|
|
|
onChange={(event) => updateAction(action.id, (current) => ({ ...current, name: event.target.value }))}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('settings.projects.actions.field.actionNamePlaceholder')}
|
2026-02-27 20:45:03 +02:00
|
|
|
className="h-7 max-w-[14rem]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="py-1">
|
2026-04-26 14:03:39 +03:00
|
|
|
<p className="typography-meta mb-0.5 text-muted-foreground">{t('settings.projects.actions.field.command')}</p>
|
2026-02-27 20:45:03 +02:00
|
|
|
<Textarea
|
|
|
|
|
value={action.command}
|
|
|
|
|
onChange={(event) => updateAction(action.id, (current) => ({ ...current, command: event.target.value }))}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('settings.projects.actions.field.commandPlaceholder')}
|
2026-02-27 20:45:03 +02:00
|
|
|
className="min-h-[88px] max-w-[30rem] font-mono text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="py-1">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
2026-04-26 14:03:39 +03:00
|
|
|
<span className="typography-ui-label text-foreground">{t('settings.projects.actions.field.autoOpenUrl')}</span>
|
2026-02-27 20:45:03 +02:00
|
|
|
<div
|
|
|
|
|
className="group flex cursor-pointer items-center gap-2"
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-pressed={action.autoOpenUrl === true}
|
|
|
|
|
onClick={() => updateAction(action.id, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
...(current.autoOpenUrl === true ? { autoOpenUrl: undefined } : { autoOpenUrl: true }),
|
|
|
|
|
}))}
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (event.key === ' ' || event.key === 'Enter') {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
updateAction(action.id, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
...(current.autoOpenUrl === true ? { autoOpenUrl: undefined } : { autoOpenUrl: true }),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={action.autoOpenUrl === true}
|
|
|
|
|
onChange={(checked) => updateAction(action.id, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
...(checked ? { autoOpenUrl: true } : { autoOpenUrl: undefined }),
|
|
|
|
|
}))}
|
2026-04-26 14:03:39 +03:00
|
|
|
ariaLabel={t('settings.projects.actions.field.autoOpenUrlForAria', { title })}
|
2026-02-27 20:45:03 +02:00
|
|
|
/>
|
2026-04-26 14:03:39 +03:00
|
|
|
<span className="typography-ui-label font-normal text-foreground/80">{t('settings.projects.actions.field.autoOpenUrlDescription')}</span>
|
2026-02-27 20:45:03 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{action.autoOpenUrl === true ? (
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
value={action.openUrl || ''}
|
|
|
|
|
onChange={(event) => updateAction(action.id, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
openUrl: event.target.value,
|
|
|
|
|
}))}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('settings.projects.actions.field.overrideUrlPlaceholder')}
|
2026-02-27 20:45:03 +02:00
|
|
|
className="h-7 w-full max-w-[24rem]"
|
|
|
|
|
/>
|
2026-04-30 22:41:33 +03:00
|
|
|
<Tooltip>
|
2026-02-27 20:45:03 +02:00
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<RiInformationLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={8} className="max-w-xs">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.projects.actions.field.overrideUrlTooltip')}
|
2026-02-27 20:45:03 +02:00
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isDesktopShellApp ? (
|
|
|
|
|
<div className="mt-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
<p className="typography-meta mb-0.5 text-muted-foreground">{t('settings.projects.actions.field.desktopSshForward')}</p>
|
2026-02-27 20:45:03 +02:00
|
|
|
{desktopForwardOptions.length > 0 ? (
|
|
|
|
|
<Select
|
|
|
|
|
value={
|
|
|
|
|
action.desktopOpenSshForward && desktopForwardOptions.some((entry) => entry.id === action.desktopOpenSshForward)
|
|
|
|
|
? action.desktopOpenSshForward
|
|
|
|
|
: '__none__'
|
|
|
|
|
}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
updateAction(action.id, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
...(value === '__none__' ? { desktopOpenSshForward: undefined } : { desktopOpenSshForward: value }),
|
|
|
|
|
}));
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 w-full max-w-[30rem]">
|
2026-04-26 14:03:39 +03:00
|
|
|
<SelectValue placeholder={t('settings.projects.actions.field.useOutputManualUrl')} />
|
2026-02-27 20:45:03 +02:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-04-26 14:03:39 +03:00
|
|
|
<SelectItem value="__none__">{t('settings.projects.actions.field.useOutputManualUrl')}</SelectItem>
|
2026-02-27 20:45:03 +02:00
|
|
|
{desktopForwardOptions.map((entry) => (
|
|
|
|
|
<SelectItem key={entry.id} value={entry.id}>{entry.label}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
) : (
|
2026-04-26 14:03:39 +03:00
|
|
|
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.noDesktopSshForwards')}</p>
|
2026-02-27 20:45:03 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
</Collapsible>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="pt-3">
|
|
|
|
|
{validationError ? (
|
|
|
|
|
<p className="typography-meta mb-2 text-[var(--status-warning)]">{validationError}</p>
|
|
|
|
|
) : null}
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button
|
2026-02-27 20:45:03 +02:00
|
|
|
type="button"
|
|
|
|
|
size="xs"
|
|
|
|
|
className="!font-normal"
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={!canSave}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{isSaving ? t('settings.common.actions.saving') : t('settings.projects.actions.actions.save')}
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-27 20:45:03 +02:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|