import React from 'react'; import { Button } from '@/components/ui/button'; 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 { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; 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'; import { PROJECT_SETTINGS_CONTROL_WIDTH, ProjectSettingsSubsection, } from '@/components/sections/projects/ProjectSettingsSubsection'; import { SETTINGS_SELECT_SIZE } from '@/components/sections/shared/SettingsSection'; import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; type EditableProjectAction = OpenChamberProjectAction; const AUTO_SAVE_DELAY_MS = 450; 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 = ({ projectRef }) => { const { t } = useI18n(); const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []); const desktopSshInstances = useDesktopSshStore((state) => state.instances); const loadDesktopSsh = useDesktopSshStore((state) => state.load); const [actions, setActions] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); const [initialSnapshot, setInitialSnapshot] = React.useState(null); const [expandedActions, setExpandedActions] = React.useState>({}); const isSavingRef = React.useRef(false); const validationToastShownRef = React.useRef(null); 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) { return t('settings.projects.actions.validation.fillNameAndCommand'); } return null; }, [actions, t]); const hasChanges = React.useMemo(() => { if (initialSnapshot === null) { return false; } return initialSnapshot !== JSON.stringify({ actions }); }, [actions, initialSnapshot]); const persistActions = React.useCallback(async (nextActions: EditableProjectAction[]) => { const ok = await saveProjectActionsState(projectRef, { actions: nextActions, primaryActionId: null, }); if (!ok) { toast.error(t('settings.projects.actions.toast.saveFailed')); return false; } setInitialSnapshot(JSON.stringify({ actions: nextActions })); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, { detail: { projectId: projectRef.id }, })); } return true; }, [projectRef, t]); React.useEffect(() => { if (!hasChanges || isLoading || validationError || isSavingRef.current) { return; } const timer = window.setTimeout(() => { if (isSavingRef.current) { return; } isSavingRef.current = true; void (async () => { try { await persistActions(actions); } finally { isSavingRef.current = false; } })(); }, AUTO_SAVE_DELAY_MS); return () => { window.clearTimeout(timer); }; }, [actions, hasChanges, isLoading, persistActions, validationError]); React.useEffect(() => { if (!hasChanges || !validationError || isLoading) { if (!validationError) { validationToastShownRef.current = null; } return; } const timer = window.setTimeout(() => { if (validationToastShownRef.current === validationError) { return; } validationToastShownRef.current = validationError; toast.error(validationError); }, 1000); return () => { window.clearTimeout(timer); }; }, [hasChanges, isLoading, validationError]); 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))); }, []); return ( {t('settings.projects.actions.actions.add')} )} contentClassName="space-y-0" > {isLoading ? (

{t('settings.projects.actions.state.loading')}

) : actions.length === 0 ? (

{t('settings.projects.actions.state.empty')}

) : (
{actions.map((action) => { const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play'; const selectedIconName = PROJECT_ACTION_ICON_MAP[selectedIconKey] || 'play'; const isOpen = expandedActions[action.id] ?? false; const title = action.name.trim() || t('settings.projects.actions.state.untitled'); return ( { setExpandedActions((prev) => ({ ...prev, [action.id]: open, })); }} className="py-1.5" >
{isOpen ? ( ) : ( )}
{title}
{PROJECT_ACTION_ICONS.map((entry) => { const iconName = entry.Icon; const selected = (action.icon || 'play') === entry.key; return ( ); })}
updateAction(action.id, (current) => ({ ...current, name: event.target.value }))} placeholder={t('settings.projects.actions.field.actionNamePlaceholder')} className="h-7 flex-1 min-w-0" />

{t('settings.projects.actions.field.command')}