From 8ddeeb0ce8d0642560bd3fa149236011dc6e3707 Mon Sep 17 00:00:00 2001 From: simosmik Date: Tue, 10 Mar 2026 21:02:32 +0000 Subject: [PATCH 1/2] refactor: new settings page design and new pill component --- .../view/subcomponents/MainContentHeader.tsx | 45 +++- .../subcomponents/MainContentTabSwitcher.tsx | 17 +- .../settings/hooks/useSettingsController.ts | 62 ++++-- src/components/settings/view/Settings.tsx | 208 +++++++----------- src/components/settings/view/SettingsCard.tsx | 22 ++ src/components/settings/view/SettingsRow.tsx | 23 ++ .../settings/view/SettingsSection.tsx | 25 +++ .../settings/view/SettingsSidebar.tsx | 80 +++++++ .../settings/view/SettingsToggle.tsx | 34 +++ .../view/tabs/AppearanceSettingsTab.tsx | 203 +++++++---------- .../tabs/agents-settings/AgentListItem.tsx | 73 +++--- .../agents-settings/AgentsSettingsTab.tsx | 2 +- .../sections/AgentCategoryTabsSection.tsx | 12 +- .../sections/AgentSelectorSection.tsx | 63 +++--- .../sections/content/AccountContent.tsx | 16 +- .../sections/content/McpServersContent.tsx | 40 ++-- .../sections/content/PermissionsContent.tsx | 34 +-- .../view/tabs/git-settings/GitSettingsTab.tsx | 112 +++++----- .../tabs/tasks-settings/TasksSettingsTab.tsx | 145 ++++++------ src/i18n/locales/en/settings.json | 3 + src/i18n/locales/ja/settings.json | 3 + src/i18n/locales/ko/settings.json | 3 + src/i18n/locales/ru/settings.json | 3 + src/i18n/locales/zh-CN/settings.json | 3 + src/index.css | 10 + src/shared/view/ui/Button.tsx | 20 +- src/shared/view/ui/DarkModeToggle.tsx | 19 +- src/shared/view/ui/LanguageSelector.tsx | 46 ++-- src/shared/view/ui/PillBar.tsx | 41 ++++ src/shared/view/ui/index.ts | 1 + 30 files changed, 781 insertions(+), 587 deletions(-) create mode 100644 src/components/settings/view/SettingsCard.tsx create mode 100644 src/components/settings/view/SettingsRow.tsx create mode 100644 src/components/settings/view/SettingsSection.tsx create mode 100644 src/components/settings/view/SettingsSidebar.tsx create mode 100644 src/components/settings/view/SettingsToggle.tsx create mode 100644 src/shared/view/ui/PillBar.tsx diff --git a/src/components/main-content/view/subcomponents/MainContentHeader.tsx b/src/components/main-content/view/subcomponents/MainContentHeader.tsx index e85e4d5..a9025c2 100644 --- a/src/components/main-content/view/subcomponents/MainContentHeader.tsx +++ b/src/components/main-content/view/subcomponents/MainContentHeader.tsx @@ -1,3 +1,4 @@ +import { useCallback, useRef, useState, useEffect } from 'react'; import type { MainContentHeaderProps } from '../../types/types'; import MobileMenuButton from './MobileMenuButton'; import MainContentTabSwitcher from './MainContentTabSwitcher'; @@ -12,6 +13,26 @@ export default function MainContentHeader({ isMobile, onMenuClick, }: MainContentHeaderProps) { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateScrollState = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 2); + setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 2); + }, []); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + updateScrollState(); + const observer = new ResizeObserver(updateScrollState); + observer.observe(el); + return () => observer.disconnect(); + }, [updateScrollState]); + return (
@@ -25,12 +46,24 @@ export default function MainContentHeader({ />
-
- +
+ {canScrollLeft && ( +
+ )} +
+ +
+ {canScrollRight && ( +
+ )}
diff --git a/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx b/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx index ea26381..51a5d64 100644 --- a/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx +++ b/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx @@ -1,7 +1,7 @@ import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react'; import type { Dispatch, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; -import { Tooltip } from '../../../../shared/view/ui'; +import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui'; import type { AppTab } from '../../../../types/app'; import { usePlugins } from '../../../../contexts/PluginsContext'; import PluginIcon from '../../../plugins/view/PluginIcon'; @@ -66,20 +66,17 @@ export default function MainContentTabSwitcher({ const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs]; return ( -
+ {tabs.map((tab) => { const isActive = tab.id === activeTab; const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label; return ( - + ); })} -
+ ); } diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index c6e66ad..f770190 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -191,7 +191,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: const closeTimerRef = useRef(null); const [activeTab, setActiveTab] = useState(() => normalizeMainTab(initialTab)); - const [isSaving, setIsSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null); const [deleteError, setDeleteError] = useState(null); const [projectSortOrder, setProjectSortOrder] = useState('name'); @@ -701,9 +700,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: }, [checkAuthStatus, loginProvider]); const saveSettings = useCallback(() => { - setIsSaving(true); - setSaveStatus(null); - try { const now = new Date().toISOString(); localStorage.setItem('claude-settings', JSON.stringify({ @@ -732,16 +728,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: })); setSaveStatus('success'); - if (closeTimerRef.current !== null) { - window.clearTimeout(closeTimerRef.current); - closeTimerRef.current = null; - } - closeTimerRef.current = window.setTimeout(() => onClose(), 1000); } catch (error) { console.error('Error saving settings:', error); setSaveStatus('error'); - } finally { - setIsSaving(false); } }, [ claudePermissions.allowedTools, @@ -751,7 +740,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: cursorPermissions.allowedCommands, cursorPermissions.disallowedCommands, cursorPermissions.skipPermissions, - onClose, + geminiPermissionMode, projectSortOrder, ]); @@ -804,11 +793,58 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: window.dispatchEvent(new Event('codeEditorSettingsChanged')); }, [codeEditorSettings]); + // Auto-save permissions and sort order with debounce + const autoSaveTimerRef = useRef(null); + const isInitialLoadRef = useRef(true); + + useEffect(() => { + // Skip auto-save on initial load (settings are being loaded from localStorage) + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false; + return; + } + + if (autoSaveTimerRef.current !== null) { + window.clearTimeout(autoSaveTimerRef.current); + } + + autoSaveTimerRef.current = window.setTimeout(() => { + saveSettings(); + }, 500); + + return () => { + if (autoSaveTimerRef.current !== null) { + window.clearTimeout(autoSaveTimerRef.current); + } + }; + }, [saveSettings]); + + // Clear save status after 2 seconds + useEffect(() => { + if (saveStatus === null) { + return; + } + + const timer = window.setTimeout(() => setSaveStatus(null), 2000); + return () => window.clearTimeout(timer); + }, [saveStatus]); + + // Reset initial load flag when settings dialog opens + useEffect(() => { + if (isOpen) { + isInitialLoadRef.current = true; + } + }, [isOpen]); + useEffect(() => () => { if (closeTimerRef.current !== null) { window.clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } + if (autoSaveTimerRef.current !== null) { + window.clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } }, []); return { @@ -816,7 +852,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: setActiveTab, isDarkMode, toggleDarkMode, - isSaving, saveStatus, deleteError, projectSortOrder, @@ -861,6 +896,5 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: loginProvider, selectedProject, handleLoginComplete, - saveSettings, }; } diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 09269d5..a8a424d 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -1,10 +1,10 @@ -import { Settings as SettingsIcon, X } from 'lucide-react'; +import { X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal'; import { Button } from '../../../shared/view/ui'; import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal'; import CodexMcpFormModal from '../view/modals/CodexMcpFormModal'; -import SettingsMainTabs from '../view/SettingsMainTabs'; +import SettingsSidebar from '../view/SettingsSidebar'; import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab'; import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab'; import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab'; @@ -19,7 +19,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set const { activeTab, setActiveTab, - isSaving, saveStatus, deleteError, projectSortOrder, @@ -64,7 +63,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set loginProvider, selectedProject, handleLoginComplete, - saveSettings, } = useSettingsController({ isOpen, initialTab, @@ -85,140 +83,90 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set : false; return ( -
-
-
-
- -

{t('title')}

-
- -
- -
- - -
- {activeTab === 'appearance' && ( - updateCodeEditorSetting('theme', value)} - onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)} - onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)} - onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)} - onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)} - /> - )} - - {activeTab === 'git' && } - - {activeTab === 'agents' && ( - openLoginForProvider('claude')} - onCursorLogin={() => openLoginForProvider('cursor')} - onCodexLogin={() => openLoginForProvider('codex')} - onGeminiLogin={() => openLoginForProvider('gemini')} - claudePermissions={claudePermissions} - onClaudePermissionsChange={setClaudePermissions} - cursorPermissions={cursorPermissions} - onCursorPermissionsChange={setCursorPermissions} - codexPermissionMode={codexPermissionMode} - onCodexPermissionModeChange={setCodexPermissionMode} - geminiPermissionMode={geminiPermissionMode} - onGeminiPermissionModeChange={setGeminiPermissionMode} - mcpServers={mcpServers} - cursorMcpServers={cursorMcpServers} - codexMcpServers={codexMcpServers} - mcpTestResults={mcpTestResults} - mcpServerTools={mcpServerTools} - mcpToolsLoading={mcpToolsLoading} - onOpenMcpForm={openMcpForm} - onDeleteMcpServer={handleMcpDelete} - onTestMcpServer={handleMcpTest} - onDiscoverMcpTools={handleMcpToolsDiscovery} - onOpenCodexMcpForm={openCodexMcpForm} - onDeleteCodexMcpServer={handleCodexMcpDelete} - deleteError={deleteError} - /> - )} - - {activeTab === 'tasks' && ( -
- -
- )} - - {activeTab === 'api' && ( -
- -
- )} - - {activeTab === 'plugins' && ( -
- -
- )} -
-
- -
-
+
+
+ {/* Header */} +
+

{t('title')}

+
{saveStatus === 'success' && ( -
- - - - {t('saveStatus.success')} -
+ {t('saveStatus.success')} )} - {saveStatus === 'error' && ( -
- - - - {t('saveStatus.error')} -
- )} -
-
-
+ + {/* Body: sidebar + content */} +
+ + + {/* Content */} +
+
+ {activeTab === 'appearance' && ( + updateCodeEditorSetting('theme', value)} + onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)} + onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)} + onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)} + onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)} + /> + )} + + {activeTab === 'git' && } + + {activeTab === 'agents' && ( + openLoginForProvider('claude')} + onCursorLogin={() => openLoginForProvider('cursor')} + onCodexLogin={() => openLoginForProvider('codex')} + onGeminiLogin={() => openLoginForProvider('gemini')} + claudePermissions={claudePermissions} + onClaudePermissionsChange={setClaudePermissions} + cursorPermissions={cursorPermissions} + onCursorPermissionsChange={setCursorPermissions} + codexPermissionMode={codexPermissionMode} + onCodexPermissionModeChange={setCodexPermissionMode} + geminiPermissionMode={geminiPermissionMode} + onGeminiPermissionModeChange={setGeminiPermissionMode} + mcpServers={mcpServers} + cursorMcpServers={cursorMcpServers} + codexMcpServers={codexMcpServers} + mcpTestResults={mcpTestResults} + mcpServerTools={mcpServerTools} + mcpToolsLoading={mcpToolsLoading} + onOpenMcpForm={openMcpForm} + onDeleteMcpServer={handleMcpDelete} + onTestMcpServer={handleMcpTest} + onDiscoverMcpTools={handleMcpToolsDiscovery} + onOpenCodexMcpForm={openCodexMcpForm} + onDeleteCodexMcpServer={handleCodexMcpDelete} + deleteError={deleteError} + /> + )} + + {activeTab === 'tasks' && } + + {activeTab === 'api' && } + + {activeTab === 'plugins' && } +
+
+
+ {children} +
+ ); +} diff --git a/src/components/settings/view/SettingsRow.tsx b/src/components/settings/view/SettingsRow.tsx new file mode 100644 index 0000000..0de1e53 --- /dev/null +++ b/src/components/settings/view/SettingsRow.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; +import { cn } from '../../../lib/utils'; + +type SettingsRowProps = { + label: string; + description?: string; + children: ReactNode; + className?: string; +}; + +export default function SettingsRow({ label, description, children, className }: SettingsRowProps) { + return ( +
+
+
{label}
+ {description && ( +
{description}
+ )} +
+
{children}
+
+ ); +} diff --git a/src/components/settings/view/SettingsSection.tsx b/src/components/settings/view/SettingsSection.tsx new file mode 100644 index 0000000..fd1699d --- /dev/null +++ b/src/components/settings/view/SettingsSection.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import { cn } from '../../../lib/utils'; + +type SettingsSectionProps = { + title: string; + description?: string; + children: ReactNode; + className?: string; +}; + +export default function SettingsSection({ title, description, children, className }: SettingsSectionProps) { + return ( +
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {children} +
+ ); +} diff --git a/src/components/settings/view/SettingsSidebar.tsx b/src/components/settings/view/SettingsSidebar.tsx new file mode 100644 index 0000000..c2e88f8 --- /dev/null +++ b/src/components/settings/view/SettingsSidebar.tsx @@ -0,0 +1,80 @@ +import { Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '../../../lib/utils'; +import { PillBar, Pill } from '../../../shared/view/ui'; +import type { SettingsMainTab } from '../types/types'; + +type SettingsSidebarProps = { + activeTab: SettingsMainTab; + onChange: (tab: SettingsMainTab) => void; +}; + +type NavItem = { + id: SettingsMainTab; + labelKey: string; + icon: typeof Bot; +}; + +const NAV_ITEMS: NavItem[] = [ + { id: 'agents', labelKey: 'mainTabs.agents', icon: Bot }, + { id: 'appearance', labelKey: 'mainTabs.appearance', icon: Palette }, + { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch }, + { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key }, + { id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks }, + { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle }, +]; + +export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) { + const { t } = useTranslation('settings'); + + return ( + <> + {/* Desktop sidebar */} + + + {/* Mobile horizontal nav — pill bar */} +
+ + {NAV_ITEMS.map((item) => { + const Icon = item.icon; + + return ( + onChange(item.id)} + className="flex-shrink-0" + > + + {t(item.labelKey)} + + ); + })} + +
+ + ); +} diff --git a/src/components/settings/view/SettingsToggle.tsx b/src/components/settings/view/SettingsToggle.tsx new file mode 100644 index 0000000..35596ff --- /dev/null +++ b/src/components/settings/view/SettingsToggle.tsx @@ -0,0 +1,34 @@ +import { cn } from '../../../lib/utils'; + +type SettingsToggleProps = { + checked: boolean; + onChange: (value: boolean) => void; + ariaLabel: string; + disabled?: boolean; +}; + +export default function SettingsToggle({ checked, onChange, ariaLabel, disabled }: SettingsToggleProps) { + return ( + + ); +} diff --git a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx index 23b1d51..b320ec5 100644 --- a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx +++ b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx @@ -1,8 +1,11 @@ -import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { DarkModeToggle } from '../../../../shared/view/ui'; import type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types'; import LanguageSelector from '../../../../shared/view/ui/LanguageSelector'; +import SettingsCard from '../SettingsCard'; +import SettingsRow from '../SettingsRow'; +import SettingsSection from '../SettingsSection'; +import SettingsToggle from '../SettingsToggle'; type AppearanceSettingsTabProps = { projectSortOrder: ProjectSortOrder; @@ -15,52 +18,6 @@ type AppearanceSettingsTabProps = { onCodeEditorFontSizeChange: (value: string) => void; }; -type ToggleCardProps = { - label: string; - description: string; - checked: boolean; - onChange: (value: boolean) => void; - onIcon?: ReactNode; - offIcon?: ReactNode; - ariaLabel: string; -}; - -function ToggleCard({ - label, - description, - checked, - onChange, - onIcon, - offIcon, - ariaLabel, -}: ToggleCardProps) { - return ( -
-
-
-
{label}
-
{description}
-
- -
-
- ); -} - export default function AppearanceSettingsTab({ projectSortOrder, onProjectSortOrderChange, @@ -72,108 +29,98 @@ export default function AppearanceSettingsTab({ onCodeEditorFontSizeChange, }: AppearanceSettingsTabProps) { const { t } = useTranslation('settings'); - const codeEditorThemeLabel = t('appearanceSettings.codeEditor.theme.label'); return ( -
-
-
-
-
-
{t('appearanceSettings.darkMode.label')}
-
- {t('appearanceSettings.darkMode.description')} -
-
+
+ + + -
-
-
+ + + -
- -
+ + + + + -
-
-
-
-
- {t('appearanceSettings.projectSorting.label')} -
-
- {t('appearanceSettings.projectSorting.description')} -
-
+ + + -
-
-
+ + + -
-

{t('appearanceSettings.codeEditor.title')}

- -
-
-
-
{codeEditorThemeLabel}
-
- {t('appearanceSettings.codeEditor.theme.description')} -
-
+ + + onCodeEditorThemeChange(enabled ? 'dark' : 'light')} - ariaLabel={codeEditorThemeLabel} + ariaLabel={t('appearanceSettings.codeEditor.theme.label')} /> -
-
+ - + + + - + + + - + + + -
-
-
-
- {t('appearanceSettings.codeEditor.fontSize.label')} -
-
- {t('appearanceSettings.codeEditor.fontSize.description')} -
-
+ -
-
-
+ + +
); } diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx index 3bf8b9c..4713593 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next'; +import { cn } from '../../../../../lib/utils'; import SessionProviderLogo from '../../../../llm-logo-provider/SessionProviderLogo'; import type { AgentProvider, AuthStatus } from '../../../types/types'; @@ -36,27 +36,15 @@ const agentConfig: Record = { const colorClasses = { blue: { - border: 'border-l-blue-500 md:border-l-blue-500', - borderBottom: 'border-b-blue-500', - bg: 'bg-blue-50 dark:bg-blue-900/20', dot: 'bg-blue-500', }, purple: { - border: 'border-l-purple-500 md:border-l-purple-500', - borderBottom: 'border-b-purple-500', - bg: 'bg-purple-50 dark:bg-purple-900/20', dot: 'bg-purple-500', }, gray: { - border: 'border-l-gray-700 dark:border-l-gray-300', - borderBottom: 'border-b-gray-700 dark:border-b-gray-300', - bg: 'bg-gray-100 dark:bg-gray-800/50', - dot: 'bg-gray-700 dark:bg-gray-300', + dot: 'bg-foreground/60', }, indigo: { - border: 'border-l-indigo-500 md:border-l-indigo-500', - borderBottom: 'border-b-indigo-500', - bg: 'bg-indigo-50 dark:bg-indigo-900/20', dot: 'bg-indigo-500', }, } as const; @@ -68,7 +56,6 @@ export default function AgentListItem({ onClick, isMobile = false, }: AgentListItemProps) { - const { t } = useTranslation('settings'); const config = agentConfig[agentId]; const colors = colorClasses[config.color]; @@ -76,16 +63,18 @@ export default function AgentListItem({ return ( @@ -95,32 +84,20 @@ export default function AgentListItem({ return ( ); } diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx index a634494..dbf098c 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx @@ -68,7 +68,7 @@ export default function AgentsSettingsTab({ ]); return ( -
+
+
{AGENT_CATEGORIES.map((category) => (
{authStatus.loading ? ( - + {t('agents.authStatus.checking')} ) : authStatus.authenticated ? ( @@ -107,7 +107,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
{authStatus.method !== 'api_key' && ( -
+
@@ -132,7 +132,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo )} {authStatus.error && ( -
+
{t('agents.error', { error: authStatus.error })}
diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/McpServersContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/McpServersContent.tsx index 93c9849..9db223a 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/McpServersContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/McpServersContent.tsx @@ -80,7 +80,7 @@ function ClaudeMcpServers({ const toolsResult = serverTools[serverId]; return ( -
+
@@ -102,19 +102,19 @@ function ClaudeMcpServers({ {server.type === 'stdio' && server.config?.command && (
{t('mcpServers.config.command')}:{' '} - {server.config.command} + {server.config.command}
)} {(server.type === 'sse' || server.type === 'http') && server.config?.url && (
{t('mcpServers.config.url')}:{' '} - {server.config.url} + {server.config.url}
)} {server.config?.args && server.config.args.length > 0 && (
{t('mcpServers.config.args')}:{' '} - {server.config.args.join(' ')} + {server.config.args.join(' ')}
)}
@@ -156,7 +156,7 @@ function ClaudeMcpServers({ onClick={() => onEdit(server)} variant="ghost" size="sm" - className="text-gray-600 hover:text-gray-700" + className="text-muted-foreground hover:text-foreground" title={t('mcpServers.actions.edit')} > @@ -176,7 +176,7 @@ function ClaudeMcpServers({ ); })} {servers.length === 0 && ( -
{t('mcpServers.empty')}
+
{t('mcpServers.empty')}
)}
@@ -214,7 +214,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit +
@@ -226,7 +226,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit {t('mcpServers.config.command')}:{' '} - {server.config.command} + {server.config.command}
)}
@@ -236,7 +236,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit onEdit(server)} variant="ghost" size="sm" - className="text-gray-600 hover:text-gray-700" + className="text-muted-foreground hover:text-foreground" title={t('mcpServers.actions.edit')} > @@ -256,7 +256,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit{t('mcpServers.empty')}
+
{t('mcpServers.empty')}
)}
@@ -278,7 +278,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit return (
- +

{t('mcpServers.title')}

{t('mcpServers.description.codex')}

@@ -297,7 +297,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
{servers.map((server) => ( -
+
@@ -310,19 +310,19 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit {server.config?.command && (
{t('mcpServers.config.command')}:{' '} - {server.config.command} + {server.config.command}
)} {server.config?.args && server.config.args.length > 0 && (
{t('mcpServers.config.args')}:{' '} - {server.config.args.join(' ')} + {server.config.args.join(' ')}
)} {server.config?.env && Object.keys(server.config.env).length > 0 && (
{t('mcpServers.config.environment')}:{' '} - + {Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
@@ -335,7 +335,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit onClick={() => onEdit(server)} variant="ghost" size="sm" - className="text-gray-600 hover:text-gray-700" + className="text-muted-foreground hover:text-foreground" title={t('mcpServers.actions.edit')} > @@ -354,13 +354,13 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
))} {servers.length === 0 && ( -
{t('mcpServers.empty')}
+
{t('mcpServers.empty')}
)}
-
-

{t('mcpServers.help.title')}

-

{t('mcpServers.help.description')}

+
+

{t('mcpServers.help.title')}

+

{t('mcpServers.help.description')}

); diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx index d7e7166..0d575f2 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx @@ -104,7 +104,7 @@ function ClaudePermissions({ type="checkbox" checked={skipPermissions} onChange={(event) => onSkipPermissionsChange(event.target.checked)} - className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" + className="h-4 w-4 rounded border-input bg-card text-primary focus:ring-2 focus:ring-primary" />
@@ -150,7 +150,7 @@ function ClaudePermissions({
-

+

{t('permissions.allowedTools.quickAdd')}

@@ -184,7 +184,7 @@ function ClaudePermissions({
))} {allowedTools.length === 0 && ( -
+
{t('permissions.allowedTools.empty')}
)} @@ -237,7 +237,7 @@ function ClaudePermissions({
))} {disallowedTools.length === 0 && ( -
+
{t('permissions.blockedTools.empty')}
)} @@ -314,7 +314,7 @@ function CursorPermissions({ type="checkbox" checked={skipPermissions} onChange={(event) => onSkipPermissionsChange(event.target.checked)} - className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-purple-600 focus:ring-2 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700" + className="h-4 w-4 rounded border-input bg-card text-primary focus:ring-2 focus:ring-primary" />
@@ -360,7 +360,7 @@ function CursorPermissions({
-

+

{t('permissions.allowedCommands.quickAdd')}

@@ -394,7 +394,7 @@ function CursorPermissions({
))} {allowedCommands.length === 0 && ( -
+
{t('permissions.allowedCommands.empty')}
)} @@ -447,7 +447,7 @@ function CursorPermissions({
))} {disallowedCommands.length === 0 && ( -
+
{t('permissions.blockedCommands.empty')}
)} @@ -489,8 +489,8 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit onPermissionModeChange('default')} > @@ -514,7 +514,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit onPermissionModeChange('acceptEdits')} > @@ -538,7 +538,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit onPermissionModeChange('bypassPermissions')} > @@ -566,7 +566,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit {t('permissions.codex.technicalDetails')} -
+

{t('permissions.codex.modes.default.title')}: {t('permissions.codex.technicalInfo.default')}

{t('permissions.codex.modes.acceptEdits.title')}: {t('permissions.codex.technicalInfo.acceptEdits')}

{t('permissions.codex.modes.bypassPermissions.title')}: {t('permissions.codex.technicalInfo.bypassPermissions')}

@@ -603,8 +603,8 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit onPermissionModeChange('default')} > @@ -629,7 +629,7 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit onPermissionModeChange('auto_edit')} > @@ -654,7 +654,7 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit onPermissionModeChange('yolo')} > diff --git a/src/components/settings/view/tabs/git-settings/GitSettingsTab.tsx b/src/components/settings/view/tabs/git-settings/GitSettingsTab.tsx index d7a1a4e..721c7f7 100644 --- a/src/components/settings/view/tabs/git-settings/GitSettingsTab.tsx +++ b/src/components/settings/view/tabs/git-settings/GitSettingsTab.tsx @@ -1,7 +1,9 @@ -import { Check, GitBranch } from 'lucide-react'; +import { Check } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useGitSettings } from '../../../hooks/useGitSettings'; import { Button, Input } from '../../../../../shared/view/ui'; +import SettingsCard from '../../SettingsCard'; +import SettingsSection from '../../SettingsSection'; export default function GitSettingsTab() { const { t } = useTranslation('settings'); @@ -18,64 +20,62 @@ export default function GitSettingsTab() { return (
-
-
- -

{t('git.title')}

-
+ + +
+
+ + setGitName(event.target.value)} + placeholder="John Doe" + disabled={isLoading} + className="w-full" + /> +

{t('git.name.help')}

+
-

{t('git.description')}

+
+ + setGitEmail(event.target.value)} + placeholder="john@example.com" + disabled={isLoading} + className="w-full" + /> +

{t('git.email.help')}

+
-
-
- - setGitName(event.target.value)} - placeholder="John Doe" - disabled={isLoading} - className="w-full" - /> -

{t('git.name.help')}

+
+ + + {saveStatus === 'success' && ( +
+ + {t('git.status.success')} +
+ )} +
- -
- - setGitEmail(event.target.value)} - placeholder="john@example.com" - disabled={isLoading} - className="w-full" - /> -

{t('git.email.help')}

-
- -
- - - {saveStatus === 'success' && ( -
- - {t('git.status.success')} -
- )} -
-
-
+
+
); } diff --git a/src/components/settings/view/tabs/tasks-settings/TasksSettingsTab.tsx b/src/components/settings/view/tabs/tasks-settings/TasksSettingsTab.tsx index fdb5a1e..5296f09 100644 --- a/src/components/settings/view/tabs/tasks-settings/TasksSettingsTab.tsx +++ b/src/components/settings/view/tabs/tasks-settings/TasksSettingsTab.tsx @@ -1,5 +1,9 @@ import { useTranslation } from 'react-i18next'; import { useTasksSettings } from '../../../../../contexts/TasksSettingsContext'; +import SettingsCard from '../../SettingsCard'; +import SettingsRow from '../../SettingsRow'; +import SettingsSection from '../../SettingsSection'; +import SettingsToggle from '../../SettingsToggle'; type TasksSettingsContextValue = { tasksEnabled: boolean; @@ -19,88 +23,83 @@ export default function TasksSettingsTab() { return (
- {isCheckingInstallation ? ( -
-
-
- {t('tasks.checking')} -
-
- ) : ( - <> - {!isTaskMasterInstalled && ( -
-
-
- - - -
-
-
- {t('tasks.notInstalled.title')} + + {isCheckingInstallation ? ( + +
+
+ {t('tasks.checking')} +
+ + ) : ( + <> + {!isTaskMasterInstalled && ( +
+
+
+ + +
-
-

{t('tasks.notInstalled.description')}

- -
- {t('tasks.notInstalled.installCommand')} +
+
+ {t('tasks.notInstalled.title')}
+
+

{t('tasks.notInstalled.description')}

- +
+ {t('tasks.notInstalled.installCommand')} +
-
-

{t('tasks.notInstalled.afterInstallation')}

-
    -
  1. {t('tasks.notInstalled.steps.restart')}
  2. -
  3. {t('tasks.notInstalled.steps.autoAvailable')}
  4. -
  5. {t('tasks.notInstalled.steps.initCommand')}
  6. -
+ + +
+

{t('tasks.notInstalled.afterInstallation')}

+
    +
  1. {t('tasks.notInstalled.steps.restart')}
  2. +
  3. {t('tasks.notInstalled.steps.autoAvailable')}
  4. +
  5. {t('tasks.notInstalled.steps.initCommand')}
  6. +
+
-
- )} + )} - {isTaskMasterInstalled && ( -
-
-
-
-
{t('tasks.settings.enableLabel')}
-
{t('tasks.settings.enableDescription')}
-
-
-
- )} - - )} + {isTaskMasterInstalled && ( + + + + + + )} + + )} +
); } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 00da313..fdb2f7f 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -309,6 +309,9 @@ }, "codex": { "description": "OpenAI Codex AI assistant" + }, + "gemini": { + "description": "Google Gemini AI assistant" } }, "connectionStatus": "Connection Status", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 812ce1b..927056e 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -309,6 +309,9 @@ }, "codex": { "description": "OpenAI Codex AIアシスタント" + }, + "gemini": { + "description": "Google Gemini AIアシスタント" } }, "connectionStatus": "接続状態", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index aa0af1e..bd42a77 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -309,6 +309,9 @@ }, "codex": { "description": "OpenAI Codex AI 어시스턴트" + }, + "gemini": { + "description": "Google Gemini AI 어시스턴트" } }, "connectionStatus": "연결 상태", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 68decf3..5ba9f46 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -308,6 +308,9 @@ }, "codex": { "description": "AI-ассистент OpenAI Codex" + }, + "gemini": { + "description": "AI-ассистент Google Gemini" } }, "connectionStatus": "Статус подключения", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index a80ab55..555bb82 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -309,6 +309,9 @@ }, "codex": { "description": "OpenAI Codex AI 助手" + }, + "gemini": { + "description": "Google Gemini AI 助手" } }, "connectionStatus": "连接状态", diff --git a/src/index.css b/src/index.css index 1762034..c3de46e 100644 --- a/src/index.css +++ b/src/index.css @@ -905,6 +905,16 @@ transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); } + /* Settings content fade-in transition */ + @keyframes settings-fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } + + .settings-content-enter { + animation: settings-fade-in 150ms ease-out; + } + /* Search result highlight flash */ .search-highlight-flash { animation: search-flash 4s ease-out; diff --git a/src/shared/view/ui/Button.tsx b/src/shared/view/ui/Button.tsx index 189f8f7..2acc93a 100644 --- a/src/shared/view/ui/Button.tsx +++ b/src/shared/view/ui/Button.tsx @@ -4,24 +4,24 @@ import { cn } from '../../../lib/utils'; // Keep visual variants centralized so all button usages stay consistent. const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium touch-manipulation transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { - default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90 active:bg-primary/80', destructive: - 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 active:bg-destructive/80', outline: - 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', - secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground active:bg-accent/80', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 active:bg-secondary/70', + ghost: 'hover:bg-accent hover:text-accent-foreground active:bg-accent/80', link: 'text-primary underline-offset-4 hover:underline', }, size: { - default: 'h-9 px-4 py-2', - sm: 'h-8 rounded-md px-3 text-xs', - lg: 'h-10 rounded-md px-8', - icon: 'h-9 w-9', + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3 text-sm', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', }, }, defaultVariants: { diff --git a/src/shared/view/ui/DarkModeToggle.tsx b/src/shared/view/ui/DarkModeToggle.tsx index 45841fb..be078a1 100644 --- a/src/shared/view/ui/DarkModeToggle.tsx +++ b/src/shared/view/ui/DarkModeToggle.tsx @@ -1,5 +1,6 @@ import { Moon, Sun } from 'lucide-react'; import { useTheme } from '../../../contexts/ThemeContext'; +import { cn } from '../../../lib/utils'; type DarkModeToggleProps = { checked?: boolean; @@ -13,7 +14,6 @@ function DarkModeToggle({ ariaLabel = 'Toggle dark mode', }: DarkModeToggleProps) { const { isDarkMode, toggleDarkMode } = useTheme(); - // Support controlled usage while keeping ThemeContext as the default source of truth. const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function'; const isEnabled = isControlled ? checked : isDarkMode; @@ -29,21 +29,26 @@ function DarkModeToggle({ return ( diff --git a/src/shared/view/ui/LanguageSelector.tsx b/src/shared/view/ui/LanguageSelector.tsx index 762e425..3c0bce9 100644 --- a/src/shared/view/ui/LanguageSelector.tsx +++ b/src/shared/view/ui/LanguageSelector.tsx @@ -28,15 +28,15 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr // Compact style for QuickSettingsPanel if (compact) { return ( -
- - +
+ + {t('account.language')} - {languages.map((lang) => ( - - ))} -
+
); } diff --git a/src/shared/view/ui/PillBar.tsx b/src/shared/view/ui/PillBar.tsx new file mode 100644 index 0000000..beffc2a --- /dev/null +++ b/src/shared/view/ui/PillBar.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; +import { cn } from '../../../lib/utils'; + +/* ── Container ─────────────────────────────────────────────────── */ +type PillBarProps = { + children: ReactNode; + className?: string; +}; + +export function PillBar({ children, className }: PillBarProps) { + return ( +
+ {children} +
+ ); +} + +/* ── Individual pill button ────────────────────────────────────── */ +type PillProps = { + isActive: boolean; + onClick: () => void; + children: ReactNode; + className?: string; +}; + +export function Pill({ isActive, onClick, children, className }: PillProps) { + return ( + + ); +} diff --git a/src/shared/view/ui/index.ts b/src/shared/view/ui/index.ts index 944fd59..a68250f 100644 --- a/src/shared/view/ui/index.ts +++ b/src/shared/view/ui/index.ts @@ -4,3 +4,4 @@ export { default as DarkModeToggle } from './DarkModeToggle'; export { Input } from './Input'; export { ScrollArea } from './ScrollArea'; export { default as Tooltip } from './Tooltip'; +export { PillBar, Pill } from './PillBar'; From aaa14b9fc0b9b51c4fb9d1dba40fada7cbbe0356 Mon Sep 17 00:00:00 2001 From: simosmik Date: Tue, 10 Mar 2026 21:16:24 +0000 Subject: [PATCH 2/2] fix: codeql user value provided path validation --- server/routes/git.js | 45 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index 2214d90..24a8b82 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -61,10 +61,19 @@ function validateBranchName(branch) { return branch; } -function validateFilePath(file) { +function validateFilePath(file, projectPath) { if (!file || file.includes('\0')) { throw new Error('Invalid file path'); } + // Prevent path traversal: resolve the file relative to the project root + // and ensure the result stays within the project directory + if (projectPath) { + const resolved = path.resolve(projectPath, file); + const normalizedRoot = path.resolve(projectPath) + path.sep; + if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) { + throw new Error('Invalid file path: path traversal detected'); + } + } return file; } @@ -75,15 +84,33 @@ function validateRemoteName(remote) { return remote; } +function validateProjectPath(projectPath) { + if (!projectPath || projectPath.includes('\0')) { + throw new Error('Invalid project path'); + } + const resolved = path.resolve(projectPath); + // Must be an absolute path after resolution + if (!path.isAbsolute(resolved)) { + throw new Error('Invalid project path: must be absolute'); + } + // Block obviously dangerous paths + if (resolved === '/' || resolved === path.sep) { + throw new Error('Invalid project path: root directory not allowed'); + } + return resolved; +} + // Helper function to get the actual project path from the encoded project name async function getActualProjectPath(projectName) { + let projectPath; try { - return await extractProjectDirectory(projectName); + projectPath = await extractProjectDirectory(projectName); } catch (error) { console.error(`Error extracting project directory for ${projectName}:`, error); // Fallback to the old method - return projectName.replace(/-/g, '/'); + projectPath = projectName.replace(/-/g, '/'); } + return validateProjectPath(projectPath); } // Helper function to strip git diff headers @@ -230,7 +257,7 @@ router.get('/diff', async (req, res) => { await validateGitRepository(projectPath); // Validate file path - validateFilePath(file); + validateFilePath(file, projectPath); // Check if file is untracked or deleted const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); @@ -295,7 +322,7 @@ router.get('/file-with-diff', async (req, res) => { await validateGitRepository(projectPath); // Validate file path - validateFilePath(file); + validateFilePath(file, projectPath); // Check file status const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); @@ -406,7 +433,7 @@ router.post('/commit', async (req, res) => { // Stage selected files for (const file of files) { - validateFilePath(file); + validateFilePath(file, projectPath); await spawnAsync('git', ['add', file], { cwd: projectPath }); } @@ -610,7 +637,7 @@ router.post('/generate-commit-message', async (req, res) => { let diffContext = ''; for (const file of files) { try { - validateFilePath(file); + validateFilePath(file, projectPath); const { stdout } = await spawnAsync( 'git', ['diff', 'HEAD', '--', file], { cwd: projectPath } @@ -1139,7 +1166,7 @@ router.post('/discard', async (req, res) => { await validateGitRepository(projectPath); // Validate file path - validateFilePath(file); + validateFilePath(file, projectPath); // Check file status to determine correct discard command const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); @@ -1188,7 +1215,7 @@ router.post('/delete-untracked', async (req, res) => { await validateGitRepository(projectPath); // Validate file path - validateFilePath(file); + validateFilePath(file, projectPath); // Check if file is actually untracked const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });