diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 7f9b54e4..aa8dea27 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -837,6 +837,20 @@ export class SettingsService { delete updated.defaultFeatureModel; } + // Handle devCommand special cases: + // - null means delete the key (use auto-detection) + // - string means custom command + if ('devCommand' in updates && updates.devCommand === null) { + delete updated.devCommand; + } + + // Handle testCommand special cases: + // - null means delete the key (use auto-detection) + // - string means custom command + if ('testCommand' in updates && updates.testCommand === null) { + delete updated.testCommand; + } + await writeSettingsJson(settingsPath, updated); logger.info(`Project settings updated for ${projectPath}`); diff --git a/apps/ui/src/components/views/project-settings-view/commands-section.tsx b/apps/ui/src/components/views/project-settings-view/commands-section.tsx new file mode 100644 index 00000000..6577c07c --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/commands-section.tsx @@ -0,0 +1,316 @@ +import { useState, useEffect, useCallback, type KeyboardEvent } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Terminal, Save, RotateCcw, Info, X, Play, FlaskConical } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import { useProjectSettings } from '@/hooks/queries'; +import { useUpdateProjectSettings } from '@/hooks/mutations'; +import type { Project } from '@/lib/electron'; + +/** Preset dev server commands for quick selection */ +const DEV_SERVER_PRESETS = [ + { label: 'npm run dev', command: 'npm run dev' }, + { label: 'yarn dev', command: 'yarn dev' }, + { label: 'pnpm dev', command: 'pnpm dev' }, + { label: 'bun dev', command: 'bun dev' }, + { label: 'npm start', command: 'npm start' }, + { label: 'cargo watch', command: 'cargo watch -x run' }, + { label: 'go run', command: 'go run .' }, +] as const; + +/** Preset test commands for quick selection */ +const TEST_PRESETS = [ + { label: 'npm test', command: 'npm test' }, + { label: 'yarn test', command: 'yarn test' }, + { label: 'pnpm test', command: 'pnpm test' }, + { label: 'bun test', command: 'bun test' }, + { label: 'pytest', command: 'pytest' }, + { label: 'cargo test', command: 'cargo test' }, + { label: 'go test', command: 'go test ./...' }, +] as const; + +interface CommandsSectionProps { + project: Project; +} + +export function CommandsSection({ project }: CommandsSectionProps) { + // Fetch project settings using TanStack Query + const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path); + + // Mutation hook for updating project settings + const updateSettingsMutation = useUpdateProjectSettings(project.path); + + // Local state for the input fields + const [devCommand, setDevCommand] = useState(''); + const [originalDevCommand, setOriginalDevCommand] = useState(''); + const [testCommand, setTestCommand] = useState(''); + const [originalTestCommand, setOriginalTestCommand] = useState(''); + + // Sync local state when project settings load or project changes + useEffect(() => { + // Reset local state when project changes to avoid showing stale values + setDevCommand(''); + setOriginalDevCommand(''); + setTestCommand(''); + setOriginalTestCommand(''); + }, [project.path]); + + useEffect(() => { + if (projectSettings) { + const dev = projectSettings.devCommand || ''; + const test = projectSettings.testCommand || ''; + setDevCommand(dev); + setOriginalDevCommand(dev); + setTestCommand(test); + setOriginalTestCommand(test); + } + }, [projectSettings]); + + // Check if there are unsaved changes + const hasDevChanges = devCommand !== originalDevCommand; + const hasTestChanges = testCommand !== originalTestCommand; + const hasChanges = hasDevChanges || hasTestChanges; + const isSaving = updateSettingsMutation.isPending; + + // Save all commands + const handleSave = useCallback(() => { + const normalizedDevCommand = devCommand.trim(); + const normalizedTestCommand = testCommand.trim(); + + updateSettingsMutation.mutate( + { + devCommand: normalizedDevCommand || null, + testCommand: normalizedTestCommand || null, + }, + { + onSuccess: () => { + setDevCommand(normalizedDevCommand); + setOriginalDevCommand(normalizedDevCommand); + setTestCommand(normalizedTestCommand); + setOriginalTestCommand(normalizedTestCommand); + }, + } + ); + }, [devCommand, testCommand, updateSettingsMutation]); + + // Reset to original values + const handleReset = useCallback(() => { + setDevCommand(originalDevCommand); + setTestCommand(originalTestCommand); + }, [originalDevCommand, originalTestCommand]); + + // Use a preset command + const handleUseDevPreset = useCallback((command: string) => { + setDevCommand(command); + }, []); + + const handleUseTestPreset = useCallback((command: string) => { + setTestCommand(command); + }, []); + + // Clear commands + const handleClearDev = useCallback(() => { + setDevCommand(''); + }, []); + + const handleClearTest = useCallback(() => { + setTestCommand(''); + }, []); + + // Handle keyboard shortcuts (Enter to save) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' && hasChanges && !isSaving) { + e.preventDefault(); + handleSave(); + } + }, + [hasChanges, isSaving, handleSave] + ); + + return ( +
+
+
+
+ +
+

Project Commands

+
+

+ Configure custom commands for development and testing. +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ Failed to load project settings. Please try again. +
+ ) : ( + <> + {/* Dev Server Command Section */} +
+
+ +

Dev Server

+ {hasDevChanges && ( + (unsaved) + )} +
+ +
+
+ setDevCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., npm run dev, yarn dev, cargo watch" + className="font-mono text-sm pr-8" + data-testid="dev-command-input" + /> + {devCommand && ( + + )} +
+

+ Leave empty to auto-detect based on your package manager. +

+ + {/* Dev Presets */} +
+ {DEV_SERVER_PRESETS.map((preset) => ( + + ))} +
+
+
+ + {/* Divider */} +
+ + {/* Test Command Section */} +
+
+ +

Test Runner

+ {hasTestChanges && ( + (unsaved) + )} +
+ +
+
+ setTestCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., npm test, pytest, cargo test" + className="font-mono text-sm pr-8" + data-testid="test-command-input" + /> + {testCommand && ( + + )} +
+

+ Leave empty to auto-detect based on your project structure. +

+ + {/* Test Presets */} +
+ {TEST_PRESETS.map((preset) => ( + + ))} +
+
+
+ + {/* Auto-detection Info */} +
+ +
+

Auto-detection

+

+ When no custom command is set, the system automatically detects your package + manager and test framework based on project files (package.json, Cargo.toml, + go.mod, etc.). +

+
+
+ + {/* Action Buttons */} +
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts index 1e9cde9d..93eba60b 100644 --- a/apps/ui/src/components/views/project-settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts @@ -6,8 +6,7 @@ import { AlertTriangle, Workflow, Database, - FlaskConical, - Play, + Terminal, } from 'lucide-react'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; @@ -20,8 +19,7 @@ export interface ProjectNavigationItem { export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [ { id: 'identity', label: 'Identity', icon: User }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, - { id: 'testing', label: 'Testing', icon: FlaskConical }, - { id: 'devServer', label: 'Dev Server', icon: Play }, + { id: 'commands', label: 'Commands', icon: Terminal }, { id: 'theme', label: 'Theme', icon: Palette }, { id: 'claude', label: 'Models', icon: Workflow }, { id: 'data', label: 'Data', icon: Database }, diff --git a/apps/ui/src/components/views/project-settings-view/dev-server-section.tsx b/apps/ui/src/components/views/project-settings-view/dev-server-section.tsx deleted file mode 100644 index a4751657..00000000 --- a/apps/ui/src/components/views/project-settings-view/dev-server-section.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { useState, useEffect, useCallback, type KeyboardEvent } from 'react'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Play, Save, RotateCcw, Info, X } from 'lucide-react'; -import { Spinner } from '@/components/ui/spinner'; -import { cn } from '@/lib/utils'; -import { useProjectSettings } from '@/hooks/queries'; -import { useUpdateProjectSettings } from '@/hooks/mutations'; -import type { Project } from '@/lib/electron'; - -/** Preset dev server commands for quick selection */ -const DEV_SERVER_PRESETS = [ - { label: 'npm run dev', command: 'npm run dev' }, - { label: 'yarn dev', command: 'yarn dev' }, - { label: 'pnpm dev', command: 'pnpm dev' }, - { label: 'bun dev', command: 'bun dev' }, - { label: 'npm start', command: 'npm start' }, - { label: 'cargo watch', command: 'cargo watch -x run' }, - { label: 'go run', command: 'go run .' }, -] as const; - -interface DevServerSectionProps { - project: Project; -} - -export function DevServerSection({ project }: DevServerSectionProps) { - // Fetch project settings using TanStack Query - const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path); - - // Mutation hook for updating project settings - const updateSettingsMutation = useUpdateProjectSettings(project.path); - - // Local state for the input field - const [devCommand, setDevCommand] = useState(''); - const [originalDevCommand, setOriginalDevCommand] = useState(''); - - // Sync local state when project settings load or project changes - useEffect(() => { - // Reset local state when project changes to avoid showing stale values - setDevCommand(''); - setOriginalDevCommand(''); - }, [project.path]); - - useEffect(() => { - if (projectSettings) { - const command = projectSettings.devCommand || ''; - setDevCommand(command); - setOriginalDevCommand(command); - } - }, [projectSettings]); - - // Check if there are unsaved changes - const hasChanges = devCommand !== originalDevCommand; - const isSaving = updateSettingsMutation.isPending; - - // Save dev command - const handleSave = useCallback(() => { - const normalizedCommand = devCommand.trim(); - updateSettingsMutation.mutate( - { devCommand: normalizedCommand || undefined }, - { - onSuccess: () => { - setDevCommand(normalizedCommand); - setOriginalDevCommand(normalizedCommand); - }, - } - ); - }, [devCommand, updateSettingsMutation]); - - // Reset to original value - const handleReset = useCallback(() => { - setDevCommand(originalDevCommand); - }, [originalDevCommand]); - - // Use a preset command - const handleUsePreset = useCallback((command: string) => { - setDevCommand(command); - }, []); - - // Clear the command to use auto-detection - const handleClear = useCallback(() => { - setDevCommand(''); - }, []); - - // Handle keyboard shortcuts (Enter to save) - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter' && hasChanges && !isSaving) { - e.preventDefault(); - handleSave(); - } - }, - [hasChanges, isSaving, handleSave] - ); - - return ( -
-
-
-
- -
-

- Dev Server Configuration -

-
-

- Configure how the development server is started for this project. -

-
- -
- {isLoading ? ( -
- -
- ) : isError ? ( -
- Failed to load project settings. Please try again. -
- ) : ( - <> - {/* Dev Command Input */} -
-
- - {hasChanges && ( - (unsaved changes) - )} -
-
- setDevCommand(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="e.g., npm run dev, yarn dev, cargo watch, go run ." - className="font-mono text-sm pr-8" - data-testid="dev-command-input" - /> - {devCommand && ( - - )} -
-

- The command to start the development server for this project. If not specified, the - system will auto-detect based on your package manager (npm/yarn/pnpm/bun run dev). -

-
- - {/* Auto-detection Info */} -
- -
-

Auto-detection

-

- When no custom command is set, the dev server automatically detects your package - manager (npm, yarn, pnpm, or bun) and runs the "dev" script. Set a - custom command if your project uses a different script name (e.g., start, serve) - or requires additional flags. -

-
-
- - {/* Quick Presets */} -
- -
- {DEV_SERVER_PRESETS.map((preset) => ( - - ))} -
-

- Click a preset to use it as your dev server command. Press Enter to save. -

-
- - {/* Action Buttons */} -
- - -
- - )} -
-
- ); -} diff --git a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts index 4241f84a..02a09908 100644 --- a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts +++ b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts @@ -4,8 +4,7 @@ export type ProjectSettingsViewId = | 'identity' | 'theme' | 'worktrees' - | 'testing' - | 'devServer' + | 'commands' | 'claude' | 'data' | 'danger'; diff --git a/apps/ui/src/components/views/project-settings-view/index.ts b/apps/ui/src/components/views/project-settings-view/index.ts index 1e70ea79..fe8fb22b 100644 --- a/apps/ui/src/components/views/project-settings-view/index.ts +++ b/apps/ui/src/components/views/project-settings-view/index.ts @@ -2,6 +2,6 @@ export { ProjectSettingsView } from './project-settings-view'; export { ProjectIdentitySection } from './project-identity-section'; export { ProjectThemeSection } from './project-theme-section'; export { WorktreePreferencesSection } from './worktree-preferences-section'; -export { TestingSection } from './testing-section'; +export { CommandsSection } from './commands-section'; export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks'; export { ProjectSettingsNavigation } from './components/project-settings-navigation'; diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx index e97365cc..b57f3b8f 100644 --- a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button'; import { ProjectIdentitySection } from './project-identity-section'; import { ProjectThemeSection } from './project-theme-section'; import { WorktreePreferencesSection } from './worktree-preferences-section'; -import { TestingSection } from './testing-section'; -import { DevServerSection } from './dev-server-section'; +import { CommandsSection } from './commands-section'; import { ProjectModelsSection } from './project-models-section'; import { DataManagementSection } from './data-management-section'; import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; @@ -88,10 +87,8 @@ export function ProjectSettingsView() { return ; case 'worktrees': return ; - case 'testing': - return ; - case 'devServer': - return ; + case 'commands': + return ; case 'claude': return ; case 'data': diff --git a/apps/ui/src/components/views/project-settings-view/testing-section.tsx b/apps/ui/src/components/views/project-settings-view/testing-section.tsx deleted file mode 100644 index c457145f..00000000 --- a/apps/ui/src/components/views/project-settings-view/testing-section.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { FlaskConical, Save, RotateCcw, Info } from 'lucide-react'; -import { Spinner } from '@/components/ui/spinner'; -import { cn } from '@/lib/utils'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import { toast } from 'sonner'; -import type { Project } from '@/lib/electron'; - -interface TestingSectionProps { - project: Project; -} - -export function TestingSection({ project }: TestingSectionProps) { - const [testCommand, setTestCommand] = useState(''); - const [originalTestCommand, setOriginalTestCommand] = useState(''); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - - // Check if there are unsaved changes - const hasChanges = testCommand !== originalTestCommand; - - // Load project settings when project changes - useEffect(() => { - let isCancelled = false; - const currentPath = project.path; - - const loadProjectSettings = async () => { - setIsLoading(true); - try { - const httpClient = getHttpApiClient(); - const response = await httpClient.settings.getProject(currentPath); - - // Avoid updating state if component unmounted or project changed - if (isCancelled) return; - - if (response.success && response.settings) { - const command = response.settings.testCommand || ''; - setTestCommand(command); - setOriginalTestCommand(command); - } - } catch (error) { - if (!isCancelled) { - console.error('Failed to load project settings:', error); - } - } finally { - if (!isCancelled) { - setIsLoading(false); - } - } - }; - - loadProjectSettings(); - - return () => { - isCancelled = true; - }; - }, [project.path]); - - // Save test command - const handleSave = useCallback(async () => { - setIsSaving(true); - try { - const httpClient = getHttpApiClient(); - const normalizedCommand = testCommand.trim(); - const response = await httpClient.settings.updateProject(project.path, { - testCommand: normalizedCommand || undefined, - }); - - if (response.success) { - setTestCommand(normalizedCommand); - setOriginalTestCommand(normalizedCommand); - toast.success('Test command saved'); - } else { - toast.error('Failed to save test command', { - description: response.error, - }); - } - } catch (error) { - console.error('Failed to save test command:', error); - toast.error('Failed to save test command'); - } finally { - setIsSaving(false); - } - }, [project.path, testCommand]); - - // Reset to original value - const handleReset = useCallback(() => { - setTestCommand(originalTestCommand); - }, [originalTestCommand]); - - // Use a preset command - const handleUsePreset = useCallback((command: string) => { - setTestCommand(command); - }, []); - - return ( -
-
-
-
- -
-

- Testing Configuration -

-
-

- Configure how tests are run for this project. -

-
- -
- {isLoading ? ( -
- -
- ) : ( - <> - {/* Test Command Input */} -
-
- - {hasChanges && ( - (unsaved changes) - )} -
- setTestCommand(e.target.value)} - placeholder="e.g., npm test, yarn test, pytest, go test ./..." - className="font-mono text-sm" - data-testid="test-command-input" - /> -

- The command to run tests for this project. If not specified, the test runner will - auto-detect based on your project structure (package.json, Cargo.toml, go.mod, - etc.). -

-
- - {/* Auto-detection Info */} -
- -
-

Auto-detection

-

- When no custom command is set, the test runner automatically detects and uses the - appropriate test framework based on your project files (Vitest, Jest, Pytest, - Cargo, Go Test, etc.). -

-
-
- - {/* Quick Presets */} -
- -
- {[ - { label: 'npm test', command: 'npm test' }, - { label: 'yarn test', command: 'yarn test' }, - { label: 'pnpm test', command: 'pnpm test' }, - { label: 'bun test', command: 'bun test' }, - { label: 'pytest', command: 'pytest' }, - { label: 'cargo test', command: 'cargo test' }, - { label: 'go test', command: 'go test ./...' }, - ].map((preset) => ( - - ))} -
-

- Click a preset to use it as your test command. -

-
- - {/* Action Buttons */} -
- - -
- - )} -
-
- ); -}