diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx index 4fb3aff0..3bc54862 100644 --- a/packages/ui/src/components/layout/ProjectEditDialog.tsx +++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx @@ -1,436 +1,33 @@ import React from 'react'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { toast } from '@/components/ui'; -import { cn } from '@/lib/utils'; -import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { useI18n } from '@/lib/i18n'; -import { Icon } from "@/components/icon/Icon"; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { ProjectSettingsPanel } from '@/components/sections/projects/ProjectSettingsPanel'; +import type { ProjectIdentitySaveData } from '@/components/sections/projects/useProjectIdentityForm'; +import type { ProjectEntry } from '@/lib/api/types'; interface ProjectEditDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - projectId: string; - projectName: string; - projectPath: string; - initialIcon?: string | null; - initialColor?: string | null; - initialIconBackground?: string | null; - onSave: (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => void; + project: ProjectEntry | null; + onSave: (data: ProjectIdentitySaveData) => void | Promise; } -const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; - -const normalizeIconBackground = (value: string | null): string | null => { - if (!value) { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; -}; - export const ProjectEditDialog: React.FC = ({ open, onOpenChange, - projectId, - projectName, - projectPath, - initialIcon = null, - initialColor = null, - initialIconBackground = null, + project, onSave, }) => { - const { t } = useI18n(); - const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon); - const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon); - const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon); - const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null); - const { currentTheme } = useThemeSystem(); - const [name, setName] = React.useState(projectName); - const [icon, setIcon] = React.useState(initialIcon); - const [color, setColor] = React.useState(initialColor); - const [iconBackground, setIconBackground] = React.useState(normalizeIconBackground(initialIconBackground)); - const [isUploadingIcon, setIsUploadingIcon] = React.useState(false); - const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false); - const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false); - const [pendingRemoveImageIcon, setPendingRemoveImageIcon] = React.useState(false); - const [pendingUploadIconFile, setPendingUploadIconFile] = React.useState(null); - const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState(null); - const [previewImageFailed, setPreviewImageFailed] = React.useState(false); - const fileInputRef = React.useRef(null); - - const clearPendingUploadIcon = React.useCallback(() => { - setPendingUploadIconFile(null); - setPendingUploadIconPreviewUrl((previousUrl) => { - if (previousUrl) { - URL.revokeObjectURL(previousUrl); - } - return null; - }); - }, []); - - React.useEffect(() => { - if (open) { - setName(projectName); - setIcon(initialIcon); - setColor(initialColor); - setIconBackground(normalizeIconBackground(initialIconBackground)); - setPendingRemoveImageIcon(false); - clearPendingUploadIcon(); - setPreviewImageFailed(false); - } - }, [open, projectName, initialIcon, initialColor, initialIconBackground, clearPendingUploadIcon]); - - React.useEffect(() => { - return () => { - clearPendingUploadIcon(); - }; - }, [clearPendingUploadIcon]); - - const handleSave = async () => { - const trimmed = name.trim(); - if (!trimmed) return; - - if (pendingUploadIconFile) { - setIsUploadingIcon(true); - const uploadResult = await uploadProjectIcon(projectId, pendingUploadIconFile); - setIsUploadingIcon(false); - if (!uploadResult.ok) { - toast.error(uploadResult.error || t('projectEditDialog.toast.failedToUploadIcon')); - return; - } - toast.success(t('projectEditDialog.toast.iconUpdated')); - clearPendingUploadIcon(); - setPendingRemoveImageIcon(false); - } - - const willRemoveImageIcon = pendingRemoveImageIcon && hasStoredImageIcon; - - if (willRemoveImageIcon) { - setIsRemovingCustomIcon(true); - const result = await removeProjectIcon(projectId); - setIsRemovingCustomIcon(false); - if (!result.ok) { - toast.error(result.error || t('projectEditDialog.toast.failedToRemoveIcon')); - return; - } - toast.success(t('projectEditDialog.toast.iconRemoved')); - setPendingRemoveImageIcon(false); - setIconBackground(null); - } - - onSave({ - label: trimmed, - icon, - color, - iconBackground: normalizeIconBackground(willRemoveImageIcon ? null : iconBackground), - }); - onOpenChange(false); - }; - - const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null; - const hasStoredImageIcon = Boolean(currentIconImage); - const hasPendingUploadImageIcon = Boolean(pendingUploadIconFile && pendingUploadIconPreviewUrl); - const hasCustomIcon = currentIconImage?.source === 'custom'; - const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon; - const hasRemovableImageIcon = effectiveHasImageIcon; - const showStoredImagePreview = hasStoredImageIcon && !pendingRemoveImageIcon; - const showImagePreview = !previewImageFailed && (hasPendingUploadImageIcon || showStoredImagePreview); - - React.useEffect(() => { - setPreviewImageFailed(false); - }, [projectId, currentIconImage?.updatedAt]); - - const handleUploadIcon = React.useCallback((file: File | null) => { - if (!projectId || !file || isUploadingIcon) { - return; - } - - setPendingRemoveImageIcon(false); - setPreviewImageFailed(false); - setPendingUploadIconFile(file); - setPendingUploadIconPreviewUrl((previousUrl) => { - if (previousUrl) { - URL.revokeObjectURL(previousUrl); - } - return URL.createObjectURL(file); - }); - }, [isUploadingIcon, projectId]); - - const handleRemoveImageIcon = React.useCallback(() => { - if (!projectId || !hasRemovableImageIcon || isRemovingCustomIcon) { - return; - } - - if (hasPendingUploadImageIcon) { - clearPendingUploadIcon(); - } - if (hasStoredImageIcon) { - setPendingRemoveImageIcon(true); - } else { - setPendingRemoveImageIcon(false); - } - setPreviewImageFailed(false); - }, [ - clearPendingUploadIcon, - hasPendingUploadImageIcon, - hasRemovableImageIcon, - hasStoredImageIcon, - isRemovingCustomIcon, - projectId, - ]); - - const handleDiscoverIcon = React.useCallback(async () => { - if (!projectId || isDiscoveringIcon) { - return; - } - - clearPendingUploadIcon(); - setPendingRemoveImageIcon(false); - setPreviewImageFailed(false); - - setIsDiscoveringIcon(true); - void discoverProjectIcon(projectId) - .then((result) => { - if (!result.ok) { - toast.error(result.error || t('projectEditDialog.toast.failedToDiscoverIcon')); - return; - } - if (result.skipped) { - toast.success(t('projectEditDialog.toast.customIconAlreadySet')); - return; - } - toast.success(t('projectEditDialog.toast.iconDiscovered')); - }) - .finally(() => { - setIsDiscoveringIcon(false); - }); - }, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId, t]); - return ( - - - {t('projectEditDialog.title')} - - -
- {/* Name */} -
- - setName(e.target.value)} - placeholder={t('projectEditDialog.field.namePlaceholder')} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSave(); - } - }} - autoFocus - /> -

- {projectPath} -

+ + +
+ {open && project ? ( + + ) : null}
- - {/* Color */} -
- -
- {/* No color option */} - - {PROJECT_COLORS.map((c) => ( -
-
- - {/* Icon */} -
- - { - const file = event.target.files?.[0] ?? null; - void handleUploadIcon(file); - event.currentTarget.value = ''; - }} - /> -
- {/* No icon option */} - - {PROJECT_ICONS.map((i) => { - const iconName = i.Icon; - return ( - - ); - })} -
- {effectiveHasImageIcon && showImagePreview && ( -
- {t('projectEditDialog.field.preview')} - - - {hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? ( - setPreviewImageFailed(true)} - /> - ) : ( - setPreviewImageFailed(true)} - /> - )} - - -
- )} -
- {!hasCustomIcon && ( - <> - - - - )} - {hasRemovableImageIcon && ( - - )} - {pendingRemoveImageIcon && ( - - )} -
-
- - {effectiveHasImageIcon && ( -
- -
- setIconBackground(event.target.value)} - className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent p-1" - aria-label={t('projectEditDialog.field.iconBackgroundAria')} - /> - setIconBackground(event.target.value)} - placeholder="#000000" - className="h-8 w-[8.5rem]" - /> - -
-
- )} -
- - - - - +
); diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index 1fee25b1..da033e90 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import type { Session } from '@opencode-ai/sdk/v2'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -22,12 +23,18 @@ import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import { sessionEvents } from '@/lib/sessionEvents'; import type { WorktreeMetadata } from '@/types/worktree'; import { formatPathForDisplay, cn } from '@/lib/utils'; +import { + PROJECT_SETTINGS_CONTROL_WIDTH, + ProjectSettingsSubsection, +} from '@/components/sections/projects/ProjectSettingsSubsection'; import { useI18n } from '@/lib/i18n'; export interface WorktreeSectionContentProps { projectRef?: { id: string; path: string } | null; } +const SETUP_COMMANDS_SAVE_DELAY_MS = 450; + export const WorktreeSectionContent: React.FC = ({ projectRef: projectRefProp = null }) => { const { t } = useI18n(); const { isMobile, isTablet } = useDeviceInfo(); @@ -37,15 +44,17 @@ export const WorktreeSectionContent: React.FC = ({ const projectPath = projectRefProp?.path ?? activeProject?.path ?? null; const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata); - const sessions = useSessions(); + const sessions = useSessions(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const [setupCommands, setSetupCommands] = React.useState([]); const [waitForSetupCommands, setWaitForSetupCommands] = React.useState(false); const [isLoadingCommands, setIsLoadingCommands] = React.useState(false); + const [commandsSnapshot, setCommandsSnapshot] = React.useState(null); const [isGitRepoLocal, setIsGitRepoLocal] = React.useState(null); const [availableWorktrees, setAvailableWorktrees] = React.useState([]); const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false); + const isSavingCommandsRef = React.useRef(false); const projectRef = React.useMemo(() => { if (projectRefProp?.id && projectRefProp?.path) { @@ -68,7 +77,6 @@ export const WorktreeSectionContent: React.FC = ({ } }, [projectRef, isGitRepoLocal]); - // Load repo info React.useEffect(() => { if (!projectPath) return; @@ -90,7 +98,6 @@ export const WorktreeSectionContent: React.FC = ({ }; }, [projectPath]); - // Load existing worktrees React.useEffect(() => { if (!projectRef) { setAvailableWorktrees([]); @@ -127,7 +134,6 @@ export const WorktreeSectionContent: React.FC = ({ }; }, [projectRef, isGitRepoLocal]); - // Load setup commands React.useEffect(() => { if (!projectRef) return; @@ -141,12 +147,15 @@ export const WorktreeSectionContent: React.FC = ({ getWorktreeSetupWaitEnabled(projectRef), ]); if (!cancelled) { - setSetupCommands(commands.length > 0 ? commands : ['']); + const nextCommands = commands.length > 0 ? commands : ['']; + setSetupCommands(nextCommands); + setCommandsSnapshot(JSON.stringify(nextCommands)); setWaitForSetupCommands(waitForSetup); } } catch { if (!cancelled) { setSetupCommands(['']); + setCommandsSnapshot(JSON.stringify([''])); setWaitForSetupCommands(false); } } finally { @@ -161,6 +170,56 @@ export const WorktreeSectionContent: React.FC = ({ }; }, [projectRef]); + const persistSetupCommands = React.useCallback(async (commands: string[]): Promise => { + if (!projectRef) { + return false; + } + const filtered = commands.filter((cmd) => cmd.trim().length > 0); + try { + const ok = await saveWorktreeSetupCommands(projectRef, filtered); + if (!ok) { + toast.error(t('settings.openchamber.worktrees.setup.toast.saveFailed')); + return false; + } + setCommandsSnapshot(JSON.stringify(commands)); + return true; + } catch { + toast.error(t('settings.openchamber.worktrees.setup.toast.saveFailed')); + return false; + } + }, [projectRef, t]); + + const commandsHaveChanges = React.useMemo(() => { + if (commandsSnapshot === null) { + return false; + } + return commandsSnapshot !== JSON.stringify(setupCommands); + }, [commandsSnapshot, setupCommands]); + + React.useEffect(() => { + if (!commandsHaveChanges || isLoadingCommands || isSavingCommandsRef.current) { + return; + } + + const timer = window.setTimeout(() => { + if (isSavingCommandsRef.current) { + return; + } + isSavingCommandsRef.current = true; + void (async () => { + try { + await persistSetupCommands(setupCommands); + } finally { + isSavingCommandsRef.current = false; + } + })(); + }, SETUP_COMMANDS_SAVE_DELAY_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [commandsHaveChanges, isLoadingCommands, persistSetupCommands, setupCommands]); + const handleSetupCommandChange = React.useCallback((index: number, value: string) => { setSetupCommands((prev) => { const next = [...prev]; @@ -173,25 +232,26 @@ export const WorktreeSectionContent: React.FC = ({ setSetupCommands((prev) => [...prev, '']); }, []); - const persistSetupCommands = React.useCallback(async (commands: string[]) => { - if (!projectRef) return; - const filtered = commands.filter((cmd) => cmd.trim().length > 0); - await saveWorktreeSetupCommands(projectRef, filtered); - }, [projectRef]); - const handleRemoveCommand = React.useCallback((index: number) => { setSetupCommands((prev) => { const next = prev.filter((_, i) => i !== index); - // Keep at least 1 row in UI, but persist empty config when all removed. - void persistSetupCommands(next); return next.length > 0 ? next : ['']; }); - }, [persistSetupCommands]); + }, []); - // Save setup commands on blur const handleCommandBlur = React.useCallback(() => { - void persistSetupCommands(setupCommands); - }, [persistSetupCommands, setupCommands]); + if (!commandsHaveChanges || isSavingCommandsRef.current) { + return; + } + isSavingCommandsRef.current = true; + void (async () => { + try { + await persistSetupCommands(setupCommands); + } finally { + isSavingCommandsRef.current = false; + } + })(); + }, [commandsHaveChanges, persistSetupCommands, setupCommands]); const handleWaitForSetupCommandsChange = React.useCallback((enabled: boolean) => { setWaitForSetupCommands(enabled); @@ -200,22 +260,16 @@ export const WorktreeSectionContent: React.FC = ({ } }, [projectRef]); - // Delete worktree handler const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => { const normalize = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, ''); const normalizedWorktreePath = normalize(worktree.path); - // Find sessions linked to this worktree by: - // 1. Worktree metadata path match - // 2. Session directory match const directSessions = sessions.filter((session) => { - // Check worktree metadata const metadata = getWorktreeMetadata(session.id); if (metadata?.path && normalize(metadata.path) === normalizedWorktreePath) { return true; } - // Check session directory const sessionDir = (session as { directory?: string }).directory; if (sessionDir) { const normalizedSessionDir = normalize(sessionDir); @@ -227,12 +281,8 @@ export const WorktreeSectionContent: React.FC = ({ return false; }); - // Build a set of session IDs that are directly linked const directSessionIds = new Set(directSessions.map((s) => s.id)); - // Search subsessions across all directories, not just the current sync - // context, so subagent sessions created in other worktrees/project roots - // are still included in the delete list. const allKnownSessions = [ ...useGlobalSessionsStore.getState().activeSessions, ...useGlobalSessionsStore.getState().archivedSessions, @@ -252,7 +302,6 @@ export const WorktreeSectionContent: React.FC = ({ const allSubsessions = findSubsessions(directSessionIds); - // Dedupe sessions (in case same session matched both ways) const seenIds = new Set(); const allSessions = [...directSessions, ...allSubsessions].filter((session) => { if (seenIds.has(session.id)) { @@ -269,7 +318,6 @@ export const WorktreeSectionContent: React.FC = ({ }); }, [sessions, getWorktreeMetadata]); - // Refresh worktrees when sessions change (after deletion) const sessionsKey = React.useMemo(() => sessions.map(s => s.id).join(','), [sessions]); React.useEffect(() => { if (isGitRepoLocal && projectPath) { @@ -277,48 +325,69 @@ export const WorktreeSectionContent: React.FC = ({ } }, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]); + const setupTooltip = ( + + + + + + {t('settings.openchamber.worktrees.setup.tooltipPrefix')} + {' '} + $ROOT_PROJECT_PATH + {' '} + {t('settings.openchamber.worktrees.setup.tooltipSuffix')} + + + ); + + const listTooltip = ( + + + + + + {t('settings.openchamber.worktrees.list.tooltip')} + + + ); + if (!projectPath) { return ( -

- {t('settings.openchamber.worktrees.state.selectProject')} -

+ +

+ {t('settings.openchamber.worktrees.state.selectProject')} +

+
); } if (isGitRepoLocal === false) { return ( -

- {t('settings.openchamber.worktrees.state.gitOnly')} -

+ +

+ {t('settings.openchamber.worktrees.state.gitOnly')} +

+
); } return ( -
- {/* Setup commands */} -
-
-
-

{t('settings.openchamber.worktrees.setup.title')}

- - - - - - {t('settings.openchamber.worktrees.setup.tooltipPrefix')} - {' '} - $ROOT_PROJECT_PATH - {' '} - {t('settings.openchamber.worktrees.setup.tooltipSuffix')} - - -
-
- + <> + {isLoadingCommands ? ( -

{t('settings.openchamber.worktrees.setup.loading')}

+

{t('settings.openchamber.worktrees.setup.loading')}

) : ( -
+
{setupCommands.map((command, index) => (
= ({ onChange={(e) => handleSetupCommandChange(index, e.target.value)} onBlur={handleCommandBlur} placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')} - className="h-7 w-[30rem] max-w-full font-mono text-xs" + className="h-7 min-w-0 flex-1 font-mono text-xs" /> -
@@ -352,7 +419,7 @@ export const WorktreeSectionContent: React.FC = ({
)} -
- - {/* Existing worktrees */} -
-
-
-

{t('settings.openchamber.worktrees.list.title')}

- - - - - - {t('settings.openchamber.worktrees.list.tooltip')} - - -
-
+ + {isLoadingWorktrees ? ( -

{t('settings.openchamber.worktrees.list.loading')}

+

{t('settings.openchamber.worktrees.list.loading')}

) : availableWorktrees.length === 0 ? ( -

+

{t('settings.openchamber.worktrees.list.empty')}

) : ( -
+
{availableWorktrees.map((worktree) => (
-
-
-

+

+
+

{worktree.label || worktree.branch || t('settings.openchamber.worktrees.list.detachedHead')}

- + OpenCode
-

+

{formatPathForDisplay(worktree.path, homeDirectory)}

- @@ -427,7 +482,7 @@ export const WorktreeSectionContent: React.FC = ({ ))}
)} -
-
+ + ); }; diff --git a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx index 96643562..c400f296 100644 --- a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx +++ b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx @@ -37,11 +37,17 @@ import { PROJECT_ACTION_ICONS, PROJECT_ACTIONS_UPDATED_EVENT, } from '@/lib/projectActions'; +import { + PROJECT_SETTINGS_CONTROL_WIDTH, + ProjectSettingsSubsection, +} from '@/components/sections/projects/ProjectSettingsSubsection'; 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(); @@ -68,9 +74,10 @@ export const ProjectActionsSection: React.FC = ({ pr const [actions, setActions] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); - const [isSaving, setIsSaving] = 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) { @@ -133,6 +140,69 @@ export const ProjectActionsSection: React.FC = ({ pr 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]); @@ -155,275 +225,224 @@ export const ProjectActionsSection: React.FC = ({ pr 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) { - toast.error(t('settings.projects.actions.toast.saveFailed')); - return; - } - setInitialSnapshot(JSON.stringify({ actions })); - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, { - detail: { projectId: projectRef.id }, - })); - } - toast.success(t('settings.projects.actions.toast.saved')); - } catch { - toast.error(t('settings.projects.actions.toast.saveFailed')); - } finally { - setIsSaving(false); - } - }, [actions, projectRef, t, validationError]); - - const canSave = !isSaving && !isLoading && hasChanges && !validationError; - return ( -
-
-
-

{t('settings.projects.actions.title')}

-

{t('settings.projects.actions.description')}

-
+ {t('settings.projects.actions.actions.add')} -
- -
- {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={cn( - '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 max-w-[14rem]" - /> -
- -
-

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

-