mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch * feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count * feat: Add validation for remote names and improve error handling * Address PR comments and mobile layout fixes * ``` refactor: Extract PR target resolution logic into dedicated service ``` * feat: Add app shell UI and improve service imports. Address PR comments * fix: Improve security validation and cache handling in git operations * feat: Add GET /list endpoint and improve parameter handling * chore: Improve validation, accessibility, and error handling across apps * chore: Format vite server port configuration * fix: Add error handling for gh pr list command and improve offline fallbacks * fix: Preserve existing PR creation time and improve remote handling
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
Workflow,
|
||||
Database,
|
||||
Terminal,
|
||||
ScrollText,
|
||||
} 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: 'commands', label: 'Commands', icon: Terminal },
|
||||
{ id: 'scripts', label: 'Terminal Scripts', icon: ScrollText },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||
{ id: 'data', label: 'Data', icon: Database },
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ProjectSettingsViewId =
|
||||
| 'theme'
|
||||
| 'worktrees'
|
||||
| 'commands'
|
||||
| 'scripts'
|
||||
| 'claude'
|
||||
| 'data'
|
||||
| 'danger';
|
||||
|
||||
@@ -3,5 +3,6 @@ export { ProjectIdentitySection } from './project-identity-section';
|
||||
export { ProjectThemeSection } from './project-theme-section';
|
||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
export { CommandsSection } from './commands-section';
|
||||
export { TerminalScriptsSection } from './terminal-scripts-section';
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { CommandsSection } from './commands-section';
|
||||
import { TerminalScriptsSection } from './terminal-scripts-section';
|
||||
import { ProjectModelsSection } from './project-models-section';
|
||||
import { DataManagementSection } from './data-management-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
@@ -91,6 +92,8 @@ export function ProjectSettingsView() {
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'commands':
|
||||
return <CommandsSection project={currentProject} />;
|
||||
case 'scripts':
|
||||
return <TerminalScriptsSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'data':
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared terminal script constants used by both the settings section
|
||||
* (terminal-scripts-section.tsx) and the terminal header dropdown
|
||||
* (terminal-scripts-dropdown.tsx).
|
||||
*
|
||||
* Centralising the default scripts here ensures both components show
|
||||
* the same fallback list and removes the duplicated definition.
|
||||
*/
|
||||
|
||||
export interface TerminalScript {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Default scripts shown when the user has not configured any custom scripts yet. */
|
||||
export const DEFAULT_TERMINAL_SCRIPTS: TerminalScript[] = [
|
||||
{ id: 'default-dev', name: 'Dev Server', command: 'npm run dev' },
|
||||
{ id: 'default-format', name: 'Format', command: 'npm run format' },
|
||||
{ id: 'default-test', name: 'Test', command: 'npm run test' },
|
||||
{ id: 'default-lint', name: 'Lint', command: 'npm run lint' },
|
||||
];
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollText, Save, RotateCcw, Info, Plus, GripVertical, Trash2 } 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';
|
||||
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
||||
|
||||
/** Preset scripts for quick addition */
|
||||
const SCRIPT_PRESETS = [
|
||||
{ name: 'Dev Server', command: 'npm run dev' },
|
||||
{ name: 'Build', command: 'npm run build' },
|
||||
{ name: 'Test', command: 'npm run test' },
|
||||
{ name: 'Lint', command: 'npm run lint' },
|
||||
{ name: 'Format', command: 'npm run format' },
|
||||
{ name: 'Type Check', command: 'npm run typecheck' },
|
||||
{ name: 'Start', command: 'npm start' },
|
||||
{ name: 'Clean', command: 'npm run clean' },
|
||||
] as const;
|
||||
|
||||
interface ScriptEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface TerminalScriptsSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/** Generate a unique ID for a new script */
|
||||
function generateId(): string {
|
||||
return `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function TerminalScriptsSection({ project }: TerminalScriptsSectionProps) {
|
||||
// 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 scripts
|
||||
const [scripts, setScripts] = useState<ScriptEntry[]>([]);
|
||||
const [originalScripts, setOriginalScripts] = useState<ScriptEntry[]>([]);
|
||||
|
||||
// Dragging state
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// Reset local state when project changes
|
||||
useEffect(() => {
|
||||
setScripts([]);
|
||||
setOriginalScripts([]);
|
||||
}, [project.path]);
|
||||
|
||||
// Sync local state when project settings load or project path changes.
|
||||
// Including project.path ensures originalScripts is re-populated after a
|
||||
// project switch even if projectSettings is cached from a previous render.
|
||||
useEffect(() => {
|
||||
if (projectSettings) {
|
||||
const configured = projectSettings.terminalScripts;
|
||||
const scriptList =
|
||||
configured && configured.length > 0
|
||||
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
|
||||
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
|
||||
setScripts(scriptList);
|
||||
setOriginalScripts(JSON.parse(JSON.stringify(scriptList)));
|
||||
}
|
||||
}, [projectSettings, project.path]);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
|
||||
const isSaving = updateSettingsMutation.isPending;
|
||||
|
||||
// Save scripts
|
||||
const handleSave = useCallback(() => {
|
||||
// Filter out scripts with empty names or commands
|
||||
const validScripts = scripts.filter((s) => s.name.trim() && s.command.trim());
|
||||
const normalizedScripts = validScripts.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name.trim(),
|
||||
command: s.command.trim(),
|
||||
}));
|
||||
|
||||
updateSettingsMutation.mutate(
|
||||
{ terminalScripts: normalizedScripts },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setScripts(normalizedScripts);
|
||||
setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [scripts, updateSettingsMutation]);
|
||||
|
||||
// Reset to original values
|
||||
const handleReset = useCallback(() => {
|
||||
setScripts(JSON.parse(JSON.stringify(originalScripts)));
|
||||
}, [originalScripts]);
|
||||
|
||||
// Add a new empty script entry
|
||||
const handleAddScript = useCallback(() => {
|
||||
setScripts((prev) => [...prev, { id: generateId(), name: '', command: '' }]);
|
||||
}, []);
|
||||
|
||||
// Add a preset script
|
||||
const handleAddPreset = useCallback((preset: { name: string; command: string }) => {
|
||||
setScripts((prev) => [
|
||||
...prev,
|
||||
{ id: generateId(), name: preset.name, command: preset.command },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Remove a script by index
|
||||
const handleRemoveScript = useCallback((index: number) => {
|
||||
setScripts((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// Update a script field
|
||||
const handleUpdateScript = useCallback(
|
||||
(index: number, field: 'name' | 'command', value: string) => {
|
||||
setScripts((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts (Enter to save)
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && hasChanges && !isSaving) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[hasChanges, isSaving, handleSave]
|
||||
);
|
||||
|
||||
// Drag and drop handlers for reordering
|
||||
const handleDragStart = useCallback((index: number) => {
|
||||
setDraggedIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
setDragOverIndex(index);
|
||||
},
|
||||
[draggedIndex]
|
||||
);
|
||||
|
||||
// Accept the drop so the browser sets dropEffect correctly (prevents 'none')
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null && dragOverIndex !== null && draggedIndex !== dragOverIndex) {
|
||||
setScripts((prev) => {
|
||||
const newScripts = [...prev];
|
||||
const [removed] = newScripts.splice(draggedIndex, 1);
|
||||
newScripts.splice(dragOverIndex, 0, removed);
|
||||
return newScripts;
|
||||
});
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
},
|
||||
[draggedIndex, dragOverIndex]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback((_e: React.DragEvent) => {
|
||||
// The reorder is already performed in handleDrop. This handler only
|
||||
// needs to reset the drag state (e.g. when the drop was cancelled by
|
||||
// releasing outside a valid target or pressing Escape).
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<ScrollText className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Terminal Quick Scripts
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure quick-access scripts that appear in the terminal header dropdown. Click any
|
||||
script to run it instantly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||
Failed to load project settings. Please try again.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Scripts List */}
|
||||
<div className="space-y-2">
|
||||
{scripts.map((script, index) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded-lg border border-border/30 bg-accent/10 transition-all',
|
||||
draggedIndex === index && 'opacity-50',
|
||||
dragOverIndex === index && 'border-brand-500/50 bg-brand-500/5'
|
||||
)}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e)}
|
||||
onDragEnd={(e) => handleDragEnd(e)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Script name */}
|
||||
<Input
|
||||
value={script.name}
|
||||
onChange={(e) => handleUpdateScript(index, 'name', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Script name"
|
||||
className="h-8 text-sm flex-[0.4] min-w-0"
|
||||
/>
|
||||
|
||||
{/* Script command */}
|
||||
<Input
|
||||
value={script.command}
|
||||
onChange={(e) => handleUpdateScript(index, 'command', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Command to run"
|
||||
className="h-8 text-sm font-mono flex-[0.6] min-w-0"
|
||||
/>
|
||||
|
||||
{/* Remove button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveScript(index)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={`Remove ${script.name || 'script'}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{scripts.length === 0 && (
|
||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||
No scripts configured. Add some below or use a preset.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Script Button */}
|
||||
<Button variant="outline" size="sm" onClick={handleAddScript} className="gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Script
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Presets */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Quick Add Presets</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SCRIPT_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.command}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddPreset(preset)}
|
||||
className="text-xs font-mono h-7 px-2"
|
||||
>
|
||||
{preset.command}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Terminal Quick Scripts</p>
|
||||
<p>
|
||||
These scripts appear in the terminal header as a dropdown menu (the{' '}
|
||||
<ScrollText className="inline-block w-3 h-3 mx-0.5 align-middle" /> icon).
|
||||
Clicking a script will type the command into the active terminal and press Enter.
|
||||
Drag to reorder scripts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user