mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +00:00
Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Changes from fix/bug-fixes-1-0 * refactor(auto-mode): enhance orphaned feature detection and improve project initialization - Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads. - Improved project initialization by creating required directories and files in parallel for better performance. - Adjusted planning mode handling in UI components to clarify approval requirements for different modes. - Added refresh functionality for file editor tabs to ensure content consistency with disk state. These changes enhance performance, maintainability, and user experience across the application. * feat(orphaned-features): add orphaned features management routes and UI integration - Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving. - Updated the UI to include an Orphaned Features section in project settings and navigation. - Enhanced the execution service to support new orphaned feature functionalities. These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management. * fix: Normalize line endings and resolve stale dirty states in file editor * chore: Update .gitignore and enhance orphaned feature handling - Added a blank line in .gitignore for better readability. - Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts. - Added validation for target branch existence during orphaned feature resolution. - Improved prompt formatting in execution service for clarity. - Enhanced error handling in project selector for project initialization failures. - Refactored orphaned features section to improve state management and UI responsiveness. These changes improve code maintainability and user experience when managing orphaned features. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -297,9 +297,9 @@ export function AddFeatureDialog({
|
||||
prefilledCategory,
|
||||
]);
|
||||
|
||||
// Clear requirePlanApproval when planning mode is skip or lite
|
||||
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
|
||||
useEffect(() => {
|
||||
if (planningMode === 'skip' || planningMode === 'lite') {
|
||||
if (planningMode === 'skip') {
|
||||
setRequirePlanApproval(false);
|
||||
}
|
||||
}, [planningMode]);
|
||||
@@ -634,14 +634,14 @@ export function AddFeatureDialog({
|
||||
id="add-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||
disabled={planningMode === 'skip'}
|
||||
data-testid="add-feature-planning-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="add-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
planningMode === 'skip' || planningMode === 'lite'
|
||||
planningMode === 'skip'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
|
||||
import { useAgentOutput, useFeature } from '@/hooks/queries';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MODAL_CONSTANTS } from '@/components/views/board-view/dialogs/agent-output-modal.constants';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { BacklogPlanEvent } from '@automaker/types';
|
||||
import { MODAL_CONSTANTS } from './agent-output-modal.constants';
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
open: boolean;
|
||||
@@ -43,7 +43,7 @@ interface AgentOutputModalProps {
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
|
||||
type ViewMode = (typeof MODAL_CONSTANTS.VIEW_MODES)[keyof typeof MODAL_CONSTANTS.VIEW_MODES];
|
||||
|
||||
/**
|
||||
* Renders a single phase entry card with header and content.
|
||||
@@ -164,11 +164,11 @@ export function AgentOutputModal({
|
||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||
|
||||
// Resolve project path - prefer prop, fallback to window.__currentProject
|
||||
const resolvedProjectPath = projectPathProp || window.__currentProject?.path || '';
|
||||
const resolvedProjectPath = projectPathProp || window.__currentProject?.path || undefined;
|
||||
|
||||
// Track additional content from WebSocket events (appended to query data)
|
||||
const [streamedContent, setStreamedContent] = useState<string>('');
|
||||
// Track view mode state
|
||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||
const [streamedContent, setStreamedContent] = useState<string>('');
|
||||
|
||||
// Use React Query for initial output loading
|
||||
const {
|
||||
@@ -221,7 +221,8 @@ export function AgentOutputModal({
|
||||
}, [normalizedSummary]);
|
||||
|
||||
// Determine the effective view mode - default to summary if available, otherwise parsed
|
||||
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
||||
const effectiveViewMode =
|
||||
viewMode ?? (summary ? MODAL_CONSTANTS.VIEW_MODES.SUMMARY : MODAL_CONSTANTS.VIEW_MODES.PARSED);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||
@@ -486,7 +487,8 @@ export function AgentOutputModal({
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
const isAtBottom =
|
||||
scrollHeight - scrollTop - clientHeight < MODAL_CONSTANTS.AUTOSCROLL_THRESHOLD;
|
||||
autoScrollRef.current = isAtBottom;
|
||||
};
|
||||
|
||||
@@ -511,7 +513,7 @@ export function AgentOutputModal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] rounded-xl flex flex-col"
|
||||
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] md:w-[90vw] md:max-w-[1200px] md:max-h-[85vh] rounded-xl flex flex-col"
|
||||
data-testid="agent-output-modal"
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
@@ -593,7 +595,9 @@ export function AgentOutputModal({
|
||||
)}
|
||||
|
||||
{effectiveViewMode === 'changes' ? (
|
||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
<div
|
||||
className={`flex-1 min-h-0 ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MIN} ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MAX} overflow-y-auto scrollbar-visible`}
|
||||
>
|
||||
{resolvedProjectPath ? (
|
||||
<GitDiffPanel
|
||||
projectPath={resolvedProjectPath}
|
||||
@@ -658,7 +662,7 @@ export function AgentOutputModal({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
||||
className={`flex-1 min-h-0 ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MIN} ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MAX} overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible`}
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
|
||||
@@ -192,9 +192,9 @@ export function EditFeatureDialog({
|
||||
}
|
||||
}, [feature, allFeatures]);
|
||||
|
||||
// Clear requirePlanApproval when planning mode is skip or lite
|
||||
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
|
||||
useEffect(() => {
|
||||
if (planningMode === 'skip' || planningMode === 'lite') {
|
||||
if (planningMode === 'skip') {
|
||||
setRequirePlanApproval(false);
|
||||
}
|
||||
}, [planningMode]);
|
||||
@@ -485,14 +485,14 @@ export function EditFeatureDialog({
|
||||
id="edit-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||
disabled={planningMode === 'skip'}
|
||||
data-testid="edit-feature-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="edit-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
planningMode === 'skip' || planningMode === 'lite'
|
||||
planningMode === 'skip'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
|
||||
@@ -199,9 +199,9 @@ export function MassEditDialog({
|
||||
}
|
||||
}, [open, selectedFeatures]);
|
||||
|
||||
// Clear requirePlanApproval when planning mode is skip or lite
|
||||
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
|
||||
useEffect(() => {
|
||||
if (planningMode === 'skip' || planningMode === 'lite') {
|
||||
if (planningMode === 'skip') {
|
||||
setRequirePlanApproval(false);
|
||||
}
|
||||
}, [planningMode]);
|
||||
|
||||
@@ -87,8 +87,8 @@ export function PlanningModeSelect({
|
||||
}: PlanningModeSelectProps) {
|
||||
const selectedMode = modes.find((m) => m.value === mode);
|
||||
|
||||
// Disable approval checkbox for skip/lite modes since they don't use planning
|
||||
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
|
||||
// Disable approval checkbox for skip mode (lite supports approval)
|
||||
const isApprovalDisabled = disabled || mode === 'skip';
|
||||
|
||||
const selectDropdown = (
|
||||
<Select
|
||||
|
||||
@@ -25,9 +25,18 @@ interface EditorTabsProps {
|
||||
|
||||
/** Get a file icon color based on extension */
|
||||
function getFileColor(fileName: string): string {
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
// Files without an extension (no dot, or dotfile with dot at position 0)
|
||||
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
|
||||
const name = fileName.toLowerCase();
|
||||
|
||||
// Handle dotfiles and extensionless files by name first
|
||||
if (name.startsWith('.env')) return 'text-yellow-600';
|
||||
if (name === 'dockerfile' || name.startsWith('dockerfile.')) return 'text-blue-300';
|
||||
if (name === 'makefile' || name === 'gnumakefile') return 'text-orange-300';
|
||||
if (name === '.gitignore' || name === '.dockerignore' || name === '.npmignore')
|
||||
return 'text-gray-400';
|
||||
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
|
||||
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
@@ -71,7 +80,9 @@ function getFileColor(fileName: string): string {
|
||||
case 'zsh':
|
||||
return 'text-green-300';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
// Very faint dot for unknown file types so it's not confused
|
||||
// with the filled dirty-indicator dot
|
||||
return 'text-muted-foreground/30';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* Normalize line endings to `\n` so that comparisons match CodeMirror's
|
||||
* internal representation. CodeMirror always converts `\r\n` and `\r` to
|
||||
* `\n`, so raw disk content with Windows/old-Mac line endings would
|
||||
* otherwise cause a false dirty state.
|
||||
*/
|
||||
export function normalizeLineEndings(text: string): string {
|
||||
return text.indexOf('\r') !== -1 ? text.replace(/\r\n?/g, '\n') : text;
|
||||
}
|
||||
|
||||
export function computeIsDirty(content: string, originalContent: string): boolean {
|
||||
return content !== originalContent;
|
||||
return normalizeLineEndings(content) !== normalizeLineEndings(originalContent);
|
||||
}
|
||||
|
||||
export function updateTabWithContent<
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type FileTreeNode,
|
||||
type EnhancedGitFileStatus,
|
||||
} from './use-file-editor-store';
|
||||
import { normalizeLineEndings } from './file-editor-dirty-utils';
|
||||
import { FileTree } from './components/file-tree';
|
||||
import { CodeEditor, getLanguageName, type CodeEditorHandle } from './components/code-editor';
|
||||
import { EditorTabs } from './components/editor-tabs';
|
||||
@@ -169,6 +170,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
closeAllTabs,
|
||||
setActiveTab,
|
||||
markTabSaved,
|
||||
refreshTabContent,
|
||||
setMarkdownViewMode,
|
||||
setMobileBrowserVisible,
|
||||
activeFileGitDetails,
|
||||
@@ -360,6 +362,30 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
const existing = tabs.find((t) => t.filePath === filePath);
|
||||
if (existing) {
|
||||
setActiveTab(existing.id);
|
||||
// If the tab is showing as dirty, re-read from disk to verify that the
|
||||
// stored content actually differs from what is on disk. This fixes stale
|
||||
// isDirty=true state that can be persisted to localStorage (e.g. the file
|
||||
// was saved externally, or the tab schema changed).
|
||||
// We only do this when the tab IS dirty to avoid a race condition where a
|
||||
// concurrent save clears isDirty and then our stale disk read would wrongly
|
||||
// set it back to true.
|
||||
if (!existing.isBinary && !existing.isTooLarge && existing.isDirty) {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(filePath);
|
||||
if (result.success && result.content !== undefined && !result.content.includes('\0')) {
|
||||
// Re-check isDirty after the async read: a concurrent save may have
|
||||
// already cleared it. Only refresh if the tab is still dirty.
|
||||
const { tabs: currentTabs } = useFileEditorStore.getState();
|
||||
const currentTab = currentTabs.find((t) => t.id === existing.id);
|
||||
if (currentTab?.isDirty) {
|
||||
refreshTabContent(existing.id, result.content);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: if we can't re-read the file, keep the persisted state
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -428,11 +454,15 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize line endings to match CodeMirror's internal representation
|
||||
// (\r\n → \n). This prevents a false dirty state when CodeMirror reports
|
||||
// its already-normalized content back via onChange.
|
||||
const normalizedContent = normalizeLineEndings(result.content);
|
||||
openTab({
|
||||
filePath,
|
||||
fileName,
|
||||
content: result.content,
|
||||
originalContent: result.content,
|
||||
content: normalizedContent,
|
||||
originalContent: normalizedContent,
|
||||
isDirty: false,
|
||||
scrollTop: 0,
|
||||
cursorLine: 1,
|
||||
@@ -446,7 +476,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
logger.error('Failed to open file:', error);
|
||||
}
|
||||
},
|
||||
[tabs, setActiveTab, openTab, maxFileSize]
|
||||
[tabs, setActiveTab, openTab, refreshTabContent, maxFileSize]
|
||||
);
|
||||
|
||||
// ─── Mobile-aware file select ────────────────────────────────
|
||||
@@ -703,6 +733,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
model: string;
|
||||
thinkingLevel: string;
|
||||
reasoningEffort: string;
|
||||
providerId?: string;
|
||||
skipTests: boolean;
|
||||
branchName: string;
|
||||
planningMode: string;
|
||||
@@ -1204,6 +1235,37 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
};
|
||||
}, [effectivePath, loadTree, loadGitStatus]);
|
||||
|
||||
// ─── Refresh persisted tabs from disk ──────────────────────
|
||||
// After mount, re-read all persisted (non-binary, non-large) tabs from disk
|
||||
// to sync originalContent with the actual file state. This clears stale
|
||||
// isDirty flags caused by external file changes or serialization artifacts.
|
||||
const hasRefreshedTabsRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectivePath || hasRefreshedTabsRef.current) return;
|
||||
const { tabs: currentTabs, refreshTabContent: refresh } = useFileEditorStore.getState();
|
||||
if (currentTabs.length === 0) return;
|
||||
|
||||
hasRefreshedTabsRef.current = true;
|
||||
|
||||
const refreshAll = async () => {
|
||||
const api = getElectronAPI();
|
||||
for (const tab of currentTabs) {
|
||||
if (tab.isBinary || tab.isTooLarge) continue;
|
||||
try {
|
||||
const result = await api.readFile(tab.filePath);
|
||||
if (result.success && result.content !== undefined && !result.content.includes('\0')) {
|
||||
refresh(tab.id, result.content);
|
||||
}
|
||||
} catch {
|
||||
// File may no longer exist — leave tab state as-is
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
refreshAll();
|
||||
}, [effectivePath]);
|
||||
|
||||
// Open initial path if provided
|
||||
useEffect(() => {
|
||||
if (initialPath) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, type StorageValue } from 'zustand/middleware';
|
||||
import { updateTabWithContent, markTabAsSaved } from './file-editor-dirty-utils';
|
||||
import {
|
||||
updateTabWithContent,
|
||||
markTabAsSaved,
|
||||
normalizeLineEndings,
|
||||
} from './file-editor-dirty-utils';
|
||||
|
||||
export interface FileTreeNode {
|
||||
name: string;
|
||||
@@ -128,6 +132,8 @@ interface FileEditorState {
|
||||
markTabSaved: (tabId: string, content: string) => void;
|
||||
updateTabScroll: (tabId: string, scrollTop: number) => void;
|
||||
updateTabCursor: (tabId: string, line: number, col: number) => void;
|
||||
/** Re-sync an existing tab's originalContent and isDirty state from freshly-read disk content */
|
||||
refreshTabContent: (tabId: string, diskContent: string) => void;
|
||||
|
||||
setMarkdownViewMode: (mode: MarkdownViewMode) => void;
|
||||
|
||||
@@ -273,6 +279,24 @@ export const useFileEditorStore = create<FileEditorState>()(
|
||||
});
|
||||
},
|
||||
|
||||
refreshTabContent: (tabId, diskContent) => {
|
||||
set({
|
||||
tabs: get().tabs.map((t) => {
|
||||
if (t.id !== tabId) return t;
|
||||
// Normalize line endings so the baseline matches CodeMirror's
|
||||
// internal representation (\r\n → \n). Without this, files with
|
||||
// Windows line endings would always appear dirty.
|
||||
const normalizedDisk = normalizeLineEndings(diskContent);
|
||||
// If the editor content matches the freshly-read disk content, the file
|
||||
// is clean (any previous isDirty was a stale persisted value).
|
||||
// Otherwise keep the user's in-progress edits but update originalContent
|
||||
// so isDirty is calculated against the actual on-disk baseline.
|
||||
const isDirty = normalizeLineEndings(t.content) !== normalizedDisk;
|
||||
return { ...t, originalContent: normalizedDisk, isDirty };
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId, scrollTop) => {
|
||||
set({
|
||||
tabs: get().tabs.map((t) => (t.id === tabId ? { ...t, scrollTop } : t)),
|
||||
@@ -321,7 +345,7 @@ export const useFileEditorStore = create<FileEditorState>()(
|
||||
}),
|
||||
{
|
||||
name: STORE_NAME,
|
||||
version: 1,
|
||||
version: 2,
|
||||
// Only persist tab session state, not transient data (git status, file tree, drag state)
|
||||
partialize: (state) =>
|
||||
({
|
||||
@@ -338,11 +362,30 @@ export const useFileEditorStore = create<FileEditorState>()(
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StorageValue<PersistedFileEditorState>;
|
||||
if (!parsed?.state) return null;
|
||||
// Normalize tabs: ensure originalContent is always a string. Tabs persisted
|
||||
// before originalContent was added to the schema have originalContent=undefined,
|
||||
// which causes isDirty=true on any content comparison. Default to content so
|
||||
// the tab starts in a clean state.
|
||||
// Also recalculate isDirty from content vs originalContent rather than trusting
|
||||
// the persisted value, which can become stale (e.g. file saved externally,
|
||||
// CodeMirror normalization, or schema migration).
|
||||
const normalizedTabs = (parsed.state.tabs ?? []).map((tab) => {
|
||||
const originalContent = normalizeLineEndings(
|
||||
tab.originalContent ?? tab.content ?? ''
|
||||
);
|
||||
const content = tab.content ?? '';
|
||||
return {
|
||||
...tab,
|
||||
originalContent,
|
||||
isDirty: normalizeLineEndings(content) !== originalContent,
|
||||
};
|
||||
});
|
||||
// Convert arrays back to Sets
|
||||
return {
|
||||
...parsed,
|
||||
state: {
|
||||
...parsed.state,
|
||||
tabs: normalizedTabs,
|
||||
expandedFolders: new Set(parsed.state.expandedFolders ?? []),
|
||||
},
|
||||
} as unknown as StorageValue<FileEditorState>;
|
||||
@@ -385,6 +428,22 @@ export const useFileEditorStore = create<FileEditorState>()(
|
||||
state.expandedFolders = state.expandedFolders ?? new Set<string>();
|
||||
state.markdownViewMode = state.markdownViewMode ?? 'split';
|
||||
}
|
||||
// Always ensure each tab has a valid originalContent field.
|
||||
// Tabs persisted before originalContent was added to the schema would have
|
||||
// originalContent=undefined, which causes isDirty=true on any onChange call
|
||||
// (content !== undefined is always true). Fix by defaulting to content so the
|
||||
// tab starts in a clean state; any genuine unsaved changes will be re-detected
|
||||
// when the user next edits the file.
|
||||
if (Array.isArray((state as Record<string, unknown>).tabs)) {
|
||||
(state as Record<string, unknown>).tabs = (
|
||||
(state as Record<string, unknown>).tabs as Array<Record<string, unknown>>
|
||||
).map((tab: Record<string, unknown>) => {
|
||||
if (tab.originalContent === undefined || tab.originalContent === null) {
|
||||
return { ...tab, originalContent: tab.content ?? '' };
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
return state as unknown as FileEditorState;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Workflow,
|
||||
Database,
|
||||
Terminal,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
@@ -23,5 +24,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||
{ id: 'data', label: 'Data', icon: Database },
|
||||
{ id: 'orphaned', label: 'Orphaned Features', icon: Unlink },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ProjectSettingsViewId =
|
||||
| 'commands-scripts'
|
||||
| 'claude'
|
||||
| 'data'
|
||||
| 'orphaned'
|
||||
| 'danger';
|
||||
|
||||
interface UseProjectSettingsViewOptions {
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Unlink,
|
||||
Search,
|
||||
Trash2,
|
||||
GitBranch,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
CheckSquare,
|
||||
MinusSquare,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
interface OrphanedFeatureInfo {
|
||||
feature: Feature;
|
||||
missingBranch: string;
|
||||
}
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean;
|
||||
hasWorktree: boolean;
|
||||
}
|
||||
|
||||
interface OrphanedFeaturesSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function OrphanedFeaturesSection({ project }: OrphanedFeaturesSectionProps) {
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanned, setScanned] = useState(false);
|
||||
const [orphanedFeatures, setOrphanedFeatures] = useState<OrphanedFeatureInfo[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [resolvingIds, setResolvingIds] = useState<Set<string>>(new Set());
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
featureIds: string[];
|
||||
labels: string[];
|
||||
} | null>(null);
|
||||
const [moveDialog, setMoveDialog] = useState<{
|
||||
featureIds: string[];
|
||||
labels: string[];
|
||||
} | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>('__main__');
|
||||
const [loadingWorktrees, setLoadingWorktrees] = useState(false);
|
||||
|
||||
const allSelected = orphanedFeatures.length > 0 && selectedIds.size === orphanedFeatures.length;
|
||||
const someSelected = selectedIds.size > 0 && selectedIds.size < orphanedFeatures.length;
|
||||
const hasSelection = selectedIds.size > 0;
|
||||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return orphanedFeatures
|
||||
.filter((o) => selectedIds.has(o.feature.id))
|
||||
.map((o) => o.feature.title || o.feature.description?.slice(0, 60) || o.feature.id);
|
||||
}, [orphanedFeatures, selectedIds]);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(orphanedFeatures.map((o) => o.feature.id)));
|
||||
}
|
||||
}, [allSelected, orphanedFeatures]);
|
||||
|
||||
const scanForOrphans = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setSelectedIds(new Set());
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.features.getOrphaned(project.path);
|
||||
if (result.success && result.orphanedFeatures) {
|
||||
setOrphanedFeatures(result.orphanedFeatures);
|
||||
setScanned(true);
|
||||
if (result.orphanedFeatures.length === 0) {
|
||||
toast.success('No orphaned features found');
|
||||
} else {
|
||||
toast.info(`Found ${result.orphanedFeatures.length} orphaned feature(s)`);
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to scan for orphaned features', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to scan for orphaned features', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, [project.path]);
|
||||
|
||||
const loadWorktrees = useCallback(async () => {
|
||||
setLoadingWorktrees(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listAll(project.path);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
} finally {
|
||||
setLoadingWorktrees(false);
|
||||
}
|
||||
}, [project.path]);
|
||||
|
||||
const resolveOrphan = useCallback(
|
||||
async (
|
||||
featureId: string,
|
||||
action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||
targetBranch?: string | null
|
||||
) => {
|
||||
setResolvingIds((prev) => new Set(prev).add(featureId));
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.features.resolveOrphaned(
|
||||
project.path,
|
||||
featureId,
|
||||
action,
|
||||
targetBranch
|
||||
);
|
||||
if (result.success) {
|
||||
setOrphanedFeatures((prev) => prev.filter((o) => o.feature.id !== featureId));
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(featureId);
|
||||
return next;
|
||||
});
|
||||
const messages: Record<string, string> = {
|
||||
deleted: 'Feature deleted',
|
||||
'worktree-created': 'Worktree created successfully',
|
||||
moved: 'Feature moved successfully',
|
||||
};
|
||||
toast.success(messages[result.action ?? action] ?? 'Resolved');
|
||||
} else {
|
||||
toast.error('Failed to resolve orphaned feature', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to resolve orphaned feature', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setResolvingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(featureId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[project.path]
|
||||
);
|
||||
|
||||
const bulkResolve = useCallback(
|
||||
async (
|
||||
featureIds: string[],
|
||||
action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||
targetBranch?: string | null
|
||||
) => {
|
||||
const ids = new Set(featureIds);
|
||||
setResolvingIds((prev) => new Set([...prev, ...ids]));
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.features.bulkResolveOrphaned(
|
||||
project.path,
|
||||
featureIds,
|
||||
action,
|
||||
targetBranch
|
||||
);
|
||||
if (result.success || (result.resolvedCount && result.resolvedCount > 0)) {
|
||||
const resolvedIds = new Set(
|
||||
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? featureIds
|
||||
);
|
||||
setOrphanedFeatures((prev) => prev.filter((o) => !resolvedIds.has(o.feature.id)));
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of resolvedIds) {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const actionLabel =
|
||||
action === 'delete'
|
||||
? 'deleted'
|
||||
: action === 'create-worktree'
|
||||
? 'moved to worktrees'
|
||||
: 'moved';
|
||||
if (result.failedCount && result.failedCount > 0) {
|
||||
toast.warning(
|
||||
`${result.resolvedCount} feature(s) ${actionLabel}, ${result.failedCount} failed`
|
||||
);
|
||||
} else {
|
||||
toast.success(`${result.resolvedCount} feature(s) ${actionLabel}`);
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to resolve orphaned features', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to resolve orphaned features', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setResolvingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of featureIds) {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setDeleteConfirm(null);
|
||||
setMoveDialog(null);
|
||||
}
|
||||
},
|
||||
[project.path]
|
||||
);
|
||||
|
||||
const openMoveDialog = useCallback(
|
||||
async (featureIds: string[], labels: string[]) => {
|
||||
setMoveDialog({ featureIds, labels });
|
||||
setSelectedBranch('__main__');
|
||||
await loadWorktrees();
|
||||
},
|
||||
[loadWorktrees]
|
||||
);
|
||||
|
||||
const handleMoveConfirm = useCallback(() => {
|
||||
if (!moveDialog) return;
|
||||
const targetBranch = selectedBranch === '__main__' ? null : selectedBranch;
|
||||
if (moveDialog.featureIds.length === 1) {
|
||||
resolveOrphan(moveDialog.featureIds[0], 'move-to-branch', targetBranch);
|
||||
} else {
|
||||
bulkResolve(moveDialog.featureIds, 'move-to-branch', targetBranch);
|
||||
}
|
||||
setMoveDialog(null);
|
||||
}, [moveDialog, selectedBranch, resolveOrphan, bulkResolve]);
|
||||
|
||||
const isBulkResolving = resolvingIds.size > 0;
|
||||
|
||||
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-amber-500/20 to-amber-600/10 flex items-center justify-center border border-amber-500/20">
|
||||
<Unlink className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Orphaned Features
|
||||
</h2>
|
||||
{scanned && orphanedFeatures.length > 0 && (
|
||||
<span className="ml-auto inline-flex items-center rounded-full bg-amber-500/15 px-2.5 py-0.5 text-xs font-medium text-amber-500 border border-amber-500/25">
|
||||
{orphanedFeatures.length} found
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Detect features whose git branches no longer exist. You can delete them, create a new
|
||||
worktree, or move them to an existing branch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Scan Button */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Scan for Orphaned Features</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Check all features for missing git branches.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={scanForOrphans}
|
||||
loading={scanning}
|
||||
className="gap-2"
|
||||
data-testid="scan-orphaned-features-button"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
{scanning ? 'Scanning...' : scanned ? 'Rescan' : 'Scan for Orphans'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{scanned && (
|
||||
<>
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{orphanedFeatures.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-emerald-500/10 flex items-center justify-center">
|
||||
<GitBranch className="w-6 h-6 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">All clear</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
No orphaned features detected.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Selection toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
data-testid="select-all-orphans"
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="w-4 h-4 text-brand-500" />
|
||||
) : someSelected ? (
|
||||
<MinusSquare className="w-4 h-4 text-brand-500" />
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
<span>
|
||||
{allSelected ? 'Deselect all' : `Select all (${orphanedFeatures.length})`}
|
||||
</span>
|
||||
</button>
|
||||
{hasSelection && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{hasSelection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ids = Array.from(selectedIds);
|
||||
bulkResolve(ids, 'create-worktree');
|
||||
}}
|
||||
disabled={isBulkResolving}
|
||||
className="gap-1.5 text-xs"
|
||||
data-testid="bulk-create-worktree"
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
Create Worktrees ({selectedIds.size})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openMoveDialog(Array.from(selectedIds), selectedLabels)}
|
||||
disabled={isBulkResolving}
|
||||
className="gap-1.5 text-xs"
|
||||
data-testid="bulk-move-to-branch"
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
Move ({selectedIds.size})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDeleteConfirm({
|
||||
featureIds: Array.from(selectedIds),
|
||||
labels: selectedLabels,
|
||||
})
|
||||
}
|
||||
disabled={isBulkResolving}
|
||||
className="gap-1.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
|
||||
data-testid="bulk-delete-orphans"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete ({selectedIds.size})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature list */}
|
||||
<div className="space-y-2">
|
||||
{orphanedFeatures.map(({ feature, missingBranch }) => {
|
||||
const isResolving = resolvingIds.has(feature.id);
|
||||
const isSelected = selectedIds.has(feature.id);
|
||||
return (
|
||||
<div
|
||||
key={feature.id}
|
||||
className={cn(
|
||||
'rounded-xl border p-4',
|
||||
'bg-gradient-to-r from-card/60 to-card/40',
|
||||
'transition-all duration-200',
|
||||
isResolving && 'opacity-60',
|
||||
isSelected ? 'border-brand-500/40 bg-brand-500/5' : 'border-border/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Checkbox */}
|
||||
<div className="pt-0.5">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(feature.id)}
|
||||
disabled={isResolving}
|
||||
data-testid={`select-orphan-${feature.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{feature.title || feature.description?.slice(0, 80) || feature.id}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-500 shrink-0" />
|
||||
Missing branch:{' '}
|
||||
<code className="px-1.5 py-0.5 rounded bg-muted/50 font-mono text-[11px]">
|
||||
{missingBranch}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-item actions */}
|
||||
<div className="flex items-center gap-2 mt-3 ml-7 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resolveOrphan(feature.id, 'create-worktree')}
|
||||
disabled={isResolving}
|
||||
loading={isResolving}
|
||||
className="gap-1.5 text-xs"
|
||||
data-testid={`create-worktree-${feature.id}`}
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
Create Worktree
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openMoveDialog(
|
||||
[feature.id],
|
||||
[feature.title || feature.description?.slice(0, 60) || feature.id]
|
||||
)
|
||||
}
|
||||
disabled={isResolving}
|
||||
className="gap-1.5 text-xs"
|
||||
data-testid={`move-orphan-${feature.id}`}
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
Move to Branch
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDeleteConfirm({
|
||||
featureIds: [feature.id],
|
||||
labels: [
|
||||
feature.title ||
|
||||
feature.description?.slice(0, 60) ||
|
||||
feature.id,
|
||||
],
|
||||
})
|
||||
}
|
||||
disabled={isResolving}
|
||||
className="gap-1.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
|
||||
data-testid={`delete-orphan-${feature.id}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
Delete{' '}
|
||||
{deleteConfirm && deleteConfirm.featureIds.length > 1
|
||||
? `${deleteConfirm.featureIds.length} Orphaned Features`
|
||||
: 'Orphaned Feature'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteConfirm && deleteConfirm.featureIds.length > 1 ? (
|
||||
<>
|
||||
Are you sure you want to permanently delete these{' '}
|
||||
{deleteConfirm.featureIds.length} features?
|
||||
<span className="block mt-2 max-h-32 overflow-y-auto space-y-1">
|
||||
{deleteConfirm.labels.map((label, i) => (
|
||||
<span key={i} className="block text-sm font-medium text-foreground">
|
||||
• {label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to permanently delete this feature?
|
||||
<span className="block mt-2 font-medium text-foreground">
|
||||
"{deleteConfirm?.labels[0]}"
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="block mt-2 text-destructive font-medium">
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
loading={isBulkResolving}
|
||||
onClick={() => {
|
||||
if (deleteConfirm) {
|
||||
if (deleteConfirm.featureIds.length === 1) {
|
||||
resolveOrphan(deleteConfirm.featureIds[0], 'delete');
|
||||
setDeleteConfirm(null);
|
||||
} else {
|
||||
bulkResolve(deleteConfirm.featureIds, 'delete');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
{deleteConfirm && deleteConfirm.featureIds.length > 1
|
||||
? ` (${deleteConfirm.featureIds.length})`
|
||||
: ''}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Move to Branch Dialog */}
|
||||
<Dialog open={!!moveDialog} onOpenChange={(open) => !open && setMoveDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-brand-500" />
|
||||
Move to Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{moveDialog && moveDialog.featureIds.length > 1 ? (
|
||||
<>
|
||||
Select where to move {moveDialog.featureIds.length} features. The branch reference
|
||||
will be updated and the features will be set to pending.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Select where to move this feature. The branch reference will be updated and the
|
||||
feature will be set to pending.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Target Branch</label>
|
||||
<Select
|
||||
value={selectedBranch}
|
||||
onValueChange={setSelectedBranch}
|
||||
disabled={loadingWorktrees}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="move-target-branch-select">
|
||||
<SelectValue placeholder="Select a branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__main__">Main worktree (clear branch reference)</SelectItem>
|
||||
{worktrees
|
||||
.filter((w) => !w.isMain && w.branch)
|
||||
.map((w) => (
|
||||
<SelectItem key={w.branch} value={w.branch}>
|
||||
{w.branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{selectedBranch === '__main__'
|
||||
? 'The branch reference will be cleared and the feature will use the main worktree.'
|
||||
: `The feature will be associated with the "${selectedBranch}" branch.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setMoveDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={isBulkResolving} onClick={handleMoveConfirm}>
|
||||
<ArrowRight className="w-4 h-4 mr-2" />
|
||||
Move
|
||||
{moveDialog && moveDialog.featureIds.length > 1
|
||||
? ` (${moveDialog.featureIds.length})`
|
||||
: ''}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { CommandsAndScriptsSection } from './commands-and-scripts-section';
|
||||
import { ProjectModelsSection } from './project-models-section';
|
||||
import { DataManagementSection } from './data-management-section';
|
||||
import { OrphanedFeaturesSection } from './orphaned-features-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
|
||||
@@ -109,6 +110,8 @@ export function ProjectSettingsView() {
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'data':
|
||||
return <DataManagementSection project={currentProject} />;
|
||||
case 'orphaned':
|
||||
return <OrphanedFeaturesSection project={currentProject} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
|
||||
Reference in New Issue
Block a user