From e110c058a26b5040f1d8500f6f1111deb10af4b1 Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 22 Jan 2026 17:13:16 +0100 Subject: [PATCH 1/3] feat: enhance dev server configuration and command handling - Updated the `/start-dev` route to accept a custom development command from project settings, allowing for greater flexibility in starting dev servers. - Implemented a new `parseCustomCommand` method in the `DevServerService` to handle custom command parsing, including support for quoted strings. - Added a new `DevServerSection` component in the UI for configuring the dev server command, featuring quick presets and auto-detection options. - Updated project settings interface to include a `devCommand` property for storing custom commands. This update improves the user experience by allowing users to specify custom commands for their development servers, enhancing the overall development workflow. --- apps/server/src/routes/worktree/index.ts | 2 +- .../src/routes/worktree/routes/start-dev.ts | 33 ++- .../server/src/services/dev-server-service.ts | 92 +++++-- .../config/navigation.ts | 2 + .../dev-server-section.tsx | 256 ++++++++++++++++++ .../hooks/use-project-settings-view.ts | 1 + .../project-settings-view.tsx | 3 + libs/types/src/settings.ts | 8 + 8 files changed, 375 insertions(+), 22 deletions(-) create mode 100644 apps/ui/src/components/views/project-settings-view/dev-server-section.tsx diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 94d64e1b..992a7b48 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -134,7 +134,7 @@ export function createWorktreeRoutes( router.post( '/start-dev', validatePathParams('projectPath', 'worktreePath'), - createStartDevHandler() + createStartDevHandler(settingsService) ); router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); diff --git a/apps/server/src/routes/worktree/routes/start-dev.ts b/apps/server/src/routes/worktree/routes/start-dev.ts index 13b93f9b..4bb111e8 100644 --- a/apps/server/src/routes/worktree/routes/start-dev.ts +++ b/apps/server/src/routes/worktree/routes/start-dev.ts @@ -1,16 +1,22 @@ /** * POST /start-dev endpoint - Start a dev server for a worktree * - * Spins up a development server (npm run dev) in the worktree directory - * on a unique port, allowing preview of the worktree's changes without - * affecting the main dev server. + * Spins up a development server in the worktree directory on a unique port, + * allowing preview of the worktree's changes without affecting the main dev server. + * + * If a custom devCommand is configured in project settings, it will be used. + * Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used. */ import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; import { getDevServerService } from '../../../services/dev-server-service.js'; import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; -export function createStartDevHandler() { +const logger = createLogger('start-dev'); + +export function createStartDevHandler(settingsService?: SettingsService) { return async (req: Request, res: Response): Promise => { try { const { projectPath, worktreePath } = req.body as { @@ -34,8 +40,25 @@ export function createStartDevHandler() { return; } + // Get custom dev command from project settings (if configured) + let customCommand: string | undefined; + if (settingsService) { + const projectSettings = await settingsService.getProjectSettings(projectPath); + const devCommand = projectSettings?.devCommand?.trim(); + if (devCommand) { + customCommand = devCommand; + logger.debug(`Using custom dev command from project settings: ${customCommand}`); + } else { + logger.debug('No custom dev command configured, using auto-detection'); + } + } + const devServerService = getDevServerService(); - const result = await devServerService.startDevServer(projectPath, worktreePath); + const result = await devServerService.startDevServer( + projectPath, + worktreePath, + customCommand + ); if (result.success && result.result) { res.json({ diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 49f5218c..5e4cb947 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -273,12 +273,56 @@ class DevServerService { } } + /** + * Parse a custom command string into cmd and args + * Handles quoted strings with spaces (e.g., "my command" arg1 arg2) + */ + private parseCustomCommand(command: string): { cmd: string; args: string[] } { + const tokens: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < command.length; i++) { + const char = command[i]; + + if (inQuote) { + if (char === quoteChar) { + inQuote = false; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = true; + quoteChar = char; + } else if (char === ' ') { + if (current) { + tokens.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current) { + tokens.push(current); + } + + const [cmd, ...args] = tokens; + return { cmd: cmd || '', args }; + } + /** * Start a dev server for a worktree + * @param projectPath - The project root path + * @param worktreePath - The worktree directory path + * @param customCommand - Optional custom command to run instead of auto-detected dev command */ async startDevServer( projectPath: string, - worktreePath: string + worktreePath: string, + customCommand?: string ): Promise<{ success: boolean; result?: { @@ -311,22 +355,38 @@ class DevServerService { }; } - // Check for package.json - const packageJsonPath = path.join(worktreePath, 'package.json'); - if (!(await this.fileExists(packageJsonPath))) { - return { - success: false, - error: `No package.json found in: ${worktreePath}`, - }; - } + // Determine the dev command to use + let devCommand: { cmd: string; args: string[] }; - // Get dev command - const devCommand = await this.getDevCommand(worktreePath); - if (!devCommand) { - return { - success: false, - error: `Could not determine dev command for: ${worktreePath}`, - }; + if (customCommand) { + // Use the provided custom command + devCommand = this.parseCustomCommand(customCommand); + if (!devCommand.cmd) { + return { + success: false, + error: 'Invalid custom command: command cannot be empty', + }; + } + logger.debug(`Using custom command: ${customCommand}`); + } else { + // Check for package.json when auto-detecting + const packageJsonPath = path.join(worktreePath, 'package.json'); + if (!(await this.fileExists(packageJsonPath))) { + return { + success: false, + error: `No package.json found in: ${worktreePath}`, + }; + } + + // Get dev command from package manager detection + const detectedCommand = await this.getDevCommand(worktreePath); + if (!detectedCommand) { + return { + success: false, + error: `Could not determine dev command for: ${worktreePath}`, + }; + } + devCommand = detectedCommand; } // Find available port 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 14054305..1e9cde9d 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 @@ -7,6 +7,7 @@ import { Workflow, Database, FlaskConical, + Play, } from 'lucide-react'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; @@ -20,6 +21,7 @@ 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: '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 new file mode 100644 index 00000000..136cad76 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/dev-server-section.tsx @@ -0,0 +1,256 @@ +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 { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; +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) { + const [devCommand, setDevCommand] = useState(''); + const [originalDevCommand, setOriginalDevCommand] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // Check if there are unsaved changes + const hasChanges = devCommand !== originalDevCommand; + + // 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.devCommand || ''; + setDevCommand(command); + setOriginalDevCommand(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 dev command + const handleSave = useCallback(async () => { + setIsSaving(true); + try { + const httpClient = getHttpApiClient(); + const normalizedCommand = devCommand.trim(); + const response = await httpClient.settings.updateProject(project.path, { + devCommand: normalizedCommand || undefined, + }); + + if (response.success) { + setDevCommand(normalizedCommand); + setOriginalDevCommand(normalizedCommand); + toast.success('Dev server command saved'); + } else { + toast.error('Failed to save dev server command', { + description: response.error, + }); + } + } catch (error) { + console.error('Failed to save dev server command:', error); + toast.error('Failed to save dev server command'); + } finally { + setIsSaving(false); + } + }, [project.path, devCommand]); + + // 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 ? ( +
+ +
+ ) : ( + <> + {/* 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 c93ae311..4241f84a 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 @@ -5,6 +5,7 @@ export type ProjectSettingsViewId = | 'theme' | 'worktrees' | 'testing' + | 'devServer' | 'claude' | 'data' | 'danger'; 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 c2868908..e97365cc 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 @@ -6,6 +6,7 @@ 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 { ProjectModelsSection } from './project-models-section'; import { DataManagementSection } from './data-management-section'; import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; @@ -89,6 +90,8 @@ export function ProjectSettingsView() { return ; case 'testing': return ; + case 'devServer': + return ; case 'claude': return ; case 'data': diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index c33036eb..54ada432 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1190,6 +1190,14 @@ export interface ProjectSettings { */ testCommand?: string; + // Dev Server Configuration + /** + * Custom command to start the development server for this project. + * If not specified, auto-detection will be used based on project structure. + * Examples: "npm run dev", "yarn dev", "pnpm dev", "cargo watch", "go run ." + */ + devCommand?: string; + // Phase Model Overrides (per-project) /** * Override phase model settings for this project. From 57ce198ae9c5a9175d669ef4cd1affb752a013fe Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 22 Jan 2026 17:49:06 +0100 Subject: [PATCH 2/3] fix: normalize custom command handling and improve project settings loading - Updated the `DevServerService` to normalize custom commands by trimming whitespace and treating empty strings as undefined. - Refactored the `DevServerSection` component to utilize TanStack Query for fetching project settings, improving data handling and error management. - Enhanced the save functionality to use mutation hooks for updating project settings, streamlining the save process and ensuring better state management. These changes enhance the reliability and user experience when configuring development server commands. --- .../server/src/services/dev-server-service.ts | 9 +- .../dev-server-section.tsx | 107 +++++++----------- 2 files changed, 47 insertions(+), 69 deletions(-) diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 5e4cb947..74ed8220 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -358,16 +358,19 @@ class DevServerService { // Determine the dev command to use let devCommand: { cmd: string; args: string[] }; - if (customCommand) { + // Normalize custom command: trim whitespace and treat empty strings as undefined + const normalizedCustomCommand = customCommand?.trim(); + + if (normalizedCustomCommand) { // Use the provided custom command - devCommand = this.parseCustomCommand(customCommand); + devCommand = this.parseCustomCommand(normalizedCustomCommand); if (!devCommand.cmd) { return { success: false, error: 'Invalid custom command: command cannot be empty', }; } - logger.debug(`Using custom command: ${customCommand}`); + logger.debug(`Using custom command: ${normalizedCustomCommand}`); } else { // Check for package.json when auto-detecting const packageJsonPath = path.join(worktreePath, 'package.json'); 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 index 136cad76..a4751657 100644 --- 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 @@ -5,8 +5,8 @@ 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 { getHttpApiClient } from '@/lib/http-api-client'; -import { toast } from 'sonner'; +import { useProjectSettings } from '@/hooks/queries'; +import { useUpdateProjectSettings } from '@/hooks/mutations'; import type { Project } from '@/lib/electron'; /** Preset dev server commands for quick selection */ @@ -25,77 +25,48 @@ interface DevServerSectionProps { } 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(''); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); + + // 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; - - // 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.devCommand || ''; - setDevCommand(command); - setOriginalDevCommand(command); - } - } catch (error) { - if (!isCancelled) { - console.error('Failed to load project settings:', error); - } - } finally { - if (!isCancelled) { - setIsLoading(false); - } - } - }; - - loadProjectSettings(); - - return () => { - isCancelled = true; - }; - }, [project.path]); + const isSaving = updateSettingsMutation.isPending; // Save dev command - const handleSave = useCallback(async () => { - setIsSaving(true); - try { - const httpClient = getHttpApiClient(); - const normalizedCommand = devCommand.trim(); - const response = await httpClient.settings.updateProject(project.path, { - devCommand: normalizedCommand || undefined, - }); - - if (response.success) { - setDevCommand(normalizedCommand); - setOriginalDevCommand(normalizedCommand); - toast.success('Dev server command saved'); - } else { - toast.error('Failed to save dev server command', { - description: response.error, - }); + const handleSave = useCallback(() => { + const normalizedCommand = devCommand.trim(); + updateSettingsMutation.mutate( + { devCommand: normalizedCommand || undefined }, + { + onSuccess: () => { + setDevCommand(normalizedCommand); + setOriginalDevCommand(normalizedCommand); + }, } - } catch (error) { - console.error('Failed to save dev server command:', error); - toast.error('Failed to save dev server command'); - } finally { - setIsSaving(false); - } - }, [project.path, devCommand]); + ); + }, [devCommand, updateSettingsMutation]); // Reset to original value const handleReset = useCallback(() => { @@ -151,6 +122,10 @@ export function DevServerSection({ project }: DevServerSectionProps) {
+ ) : isError ? ( +
+ Failed to load project settings. Please try again. +
) : ( <> {/* Dev Command Input */} @@ -179,7 +154,7 @@ export function DevServerSection({ project }: DevServerSectionProps) { size="sm" onClick={handleClear} className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground" - title="Clear to use auto-detection" + aria-label="Clear dev command" > From b4be3c11e20e283a287c9a5ab57200161eaf183c Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 22 Jan 2026 21:47:35 +0100 Subject: [PATCH 3/3] refactor: consolidate dev and test command configuration into a new CommandsSection - Introduced a new `CommandsSection` component to manage both development and test commands, replacing the previous `DevServerSection` and `TestingSection`. - Updated the `SettingsService` to handle special cases for `devCommand` and `testCommand`, allowing for null values to delete commands. - Removed deprecated sections and streamlined the project settings view to enhance user experience and maintainability. This refactor simplifies command management and improves the overall structure of the project settings interface. --- apps/server/src/services/settings-service.ts | 14 + .../commands-section.tsx | 316 ++++++++++++++++++ .../config/navigation.ts | 6 +- .../dev-server-section.tsx | 231 ------------- .../hooks/use-project-settings-view.ts | 3 +- .../views/project-settings-view/index.ts | 2 +- .../project-settings-view.tsx | 9 +- .../project-settings-view/testing-section.tsx | 223 ------------ 8 files changed, 337 insertions(+), 467 deletions(-) create mode 100644 apps/ui/src/components/views/project-settings-view/commands-section.tsx delete mode 100644 apps/ui/src/components/views/project-settings-view/dev-server-section.tsx delete mode 100644 apps/ui/src/components/views/project-settings-view/testing-section.tsx 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 */} -
- - -
- - )} -
-
- ); -}