mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 11:03:08 +00:00
Feature: worktree view customization and stability fixes (#805)
* Changes from feature/worktree-view-customization * Feature: Git sync, set-tracking, and push divergence handling (#796) * Add quick-add feature with improved workflows (#802) * Changes from feature/quick-add * feat: Clarify system prompt and improve error handling across services. Address PR Feedback * feat: Improve PR description parsing and refactor event handling * feat: Add context options to pipeline orchestrator initialization * fix: Deduplicate React and handle CJS interop for use-sync-external-store Resolve "Cannot read properties of null (reading 'useState')" errors by deduplicating React/react-dom and ensuring use-sync-external-store is bundled together with React to prevent CJS packages from resolving to different React instances. * Changes from feature/worktree-view-customization * refactor: Remove unused worktree swap and highlight props * refactor: Consolidate feature completion logic and improve thinking level defaults * feat: Increase max turn limit to 10000 - Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts - Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts - Update UI clamping logic from 2000 to 10000 in app-store.ts - Update fallback values from 1000 to 10000 in use-settings-sync.ts - Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS - Update documentation to reflect new range: 1-10000 Allows agents to perform up to 10000 turns for complex feature execution. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * feat: Add model resolution, improve session handling, and enhance UI stability * refactor: Remove unused sync and tracking branch props from worktree components * feat: Add PR number update functionality to worktrees. Address pr feedback * feat: Optimize Gemini CLI startup and add tool result tracking * refactor: Improve error handling and simplify worktree task cleanup --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -310,6 +310,8 @@ export function SessionManager({
|
||||
});
|
||||
if (activeSessionsList.length > 0) {
|
||||
onSelectSession(activeSessionsList[0].id);
|
||||
} else {
|
||||
onSelectSession(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
220
apps/ui/src/components/ui/codemirror-diff-view.tsx
Normal file
220
apps/ui/src/components/ui/codemirror-diff-view.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* CodeMirror-based unified diff viewer.
|
||||
*
|
||||
* Uses @codemirror/merge's `unifiedMergeView` extension to display a
|
||||
* syntax-highlighted inline diff between the original and modified file content.
|
||||
* The viewer is read-only and collapses unchanged regions.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorState, type Extension } from '@codemirror/state';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { unifiedMergeView } from '@codemirror/merge';
|
||||
import { getLanguageExtension } from '@/lib/codemirror-languages';
|
||||
import { reconstructFilesFromDiff } from '@/lib/diff-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Reuse the same syntax highlighting from the code editor
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
|
||||
{ tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
{ tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
|
||||
{ tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.function(t.variableName), color: 'var(--primary)' },
|
||||
{ tag: t.typeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
|
||||
{ tag: t.className, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
|
||||
{ tag: t.definition(t.variableName), color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.operator, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.bracket, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.punctuation, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
|
||||
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.tagName, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
{ tag: t.heading, color: 'var(--foreground)', fontWeight: 'bold' },
|
||||
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||
{ tag: t.strong, fontWeight: 'bold' },
|
||||
{ tag: t.link, color: 'var(--primary)', textDecoration: 'underline' },
|
||||
{ tag: t.content, color: 'var(--foreground)' },
|
||||
{ tag: t.regexp, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.meta, color: 'var(--muted-foreground)' },
|
||||
]);
|
||||
|
||||
const diffViewTheme = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
fontSize: '12px',
|
||||
fontFamily:
|
||||
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
|
||||
backgroundColor: 'var(--background)',
|
||||
color: 'var(--foreground)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily:
|
||||
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '0',
|
||||
minHeight: 'auto',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 0.5rem',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted-foreground)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--border)',
|
||||
paddingRight: '0.25rem',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
minWidth: '3rem',
|
||||
textAlign: 'right',
|
||||
paddingRight: '0.5rem',
|
||||
fontSize: '11px',
|
||||
},
|
||||
|
||||
// --- GitHub-style diff colors (dark mode) ---
|
||||
|
||||
// Added/changed lines: green background
|
||||
'&.cm-merge-b .cm-changedLine': {
|
||||
backgroundColor: 'rgba(46, 160, 67, 0.15)',
|
||||
},
|
||||
// Highlighted text within added/changed lines: stronger green
|
||||
'&.cm-merge-b .cm-changedText': {
|
||||
background: 'rgba(46, 160, 67, 0.4)',
|
||||
},
|
||||
|
||||
// Deleted chunk container: red background
|
||||
'.cm-deletedChunk': {
|
||||
backgroundColor: 'rgba(248, 81, 73, 0.1)',
|
||||
paddingLeft: '6px',
|
||||
},
|
||||
// Individual deleted lines within the chunk
|
||||
'.cm-deletedChunk .cm-deletedLine': {
|
||||
backgroundColor: 'rgba(248, 81, 73, 0.15)',
|
||||
},
|
||||
// Highlighted text within deleted lines: stronger red
|
||||
'.cm-deletedChunk .cm-deletedText': {
|
||||
background: 'rgba(248, 81, 73, 0.4)',
|
||||
},
|
||||
// Remove strikethrough from deleted text (GitHub doesn't use it)
|
||||
'.cm-insertedLine, .cm-deletedLine, .cm-deletedLine del': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
|
||||
// Gutter markers for changed lines (green bar)
|
||||
'&.cm-merge-b .cm-changedLineGutter': {
|
||||
background: '#3fb950',
|
||||
},
|
||||
// Gutter markers for deleted lines (red bar)
|
||||
'.cm-deletedLineGutter': {
|
||||
background: '#f85149',
|
||||
},
|
||||
|
||||
// Collapse button styling
|
||||
'.cm-collapsedLines': {
|
||||
color: 'var(--muted-foreground)',
|
||||
backgroundColor: 'var(--muted)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 8px',
|
||||
fontSize: '11px',
|
||||
},
|
||||
|
||||
// Selection styling
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
|
||||
interface CodeMirrorDiffViewProps {
|
||||
/** The unified diff text for a single file */
|
||||
fileDiff: string;
|
||||
/** File path for language detection */
|
||||
filePath: string;
|
||||
/** Max height of the diff view (CSS value) */
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CodeMirrorDiffView({
|
||||
fileDiff,
|
||||
filePath,
|
||||
maxHeight = '400px',
|
||||
className,
|
||||
}: CodeMirrorDiffViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
const { oldContent, newContent } = useMemo(() => reconstructFilesFromDiff(fileDiff), [fileDiff]);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const exts: Extension[] = [
|
||||
EditorView.darkTheme.of(true),
|
||||
diffViewTheme,
|
||||
syntaxHighlighting(syntaxColors),
|
||||
EditorView.editable.of(false),
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.lineWrapping,
|
||||
unifiedMergeView({
|
||||
original: oldContent,
|
||||
highlightChanges: true,
|
||||
gutter: true,
|
||||
syntaxHighlightDeletions: true,
|
||||
mergeControls: false,
|
||||
collapseUnchanged: { margin: 3, minSize: 4 },
|
||||
}),
|
||||
];
|
||||
|
||||
const langExt = getLanguageExtension(filePath);
|
||||
if (langExt) {
|
||||
exts.push(langExt);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [oldContent, filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Clean up previous view
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: newContent,
|
||||
extensions,
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [newContent, extensions]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('overflow-auto', className)} style={{ maxHeight }} />
|
||||
);
|
||||
}
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import { CodeMirrorDiffView } from '@/components/ui/codemirror-diff-view';
|
||||
import { Button } from './button';
|
||||
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { parseDiff, splitDiffByFile } from '@/lib/diff-utils';
|
||||
import type { ParsedFileDiff } from '@/lib/diff-utils';
|
||||
import type { FileStatus, MergeStateInfo } from '@/types/electron';
|
||||
|
||||
interface GitDiffPanelProps {
|
||||
@@ -37,23 +40,6 @@ interface GitDiffPanelProps {
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
@@ -129,174 +115,6 @@ function getStagingState(file: FileStatus): 'staged' | 'unstaged' | 'partial' {
|
||||
return 'unstaged';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// New file diff
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
files.push(currentFile);
|
||||
}
|
||||
// Extract file path from diff header
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : 'unknown',
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// New file indicator
|
||||
if (line.startsWith('new file mode')) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted file indicator
|
||||
if (line.startsWith('deleted file mode')) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Renamed file indicator
|
||||
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip index, ---/+++ lines
|
||||
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hunk header
|
||||
if (line.startsWith('@@')) {
|
||||
if (currentHunk && currentFile) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
// Parse line numbers from @@ -old,count +new,count @@
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: 'header', content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Diff content lines
|
||||
if (currentHunk) {
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.substring(1) || '',
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last file and hunk
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
lineNumber,
|
||||
}: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}) {
|
||||
const bgClass = {
|
||||
context: 'bg-transparent',
|
||||
addition: 'bg-green-500/10',
|
||||
deletion: 'bg-red-500/10',
|
||||
header: 'bg-blue-500/10',
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: 'text-foreground-secondary',
|
||||
addition: 'text-green-400',
|
||||
deletion: 'text-red-400',
|
||||
header: 'text-blue-400',
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: ' ',
|
||||
addition: '+',
|
||||
deletion: '-',
|
||||
header: '',
|
||||
};
|
||||
|
||||
if (type === 'header') {
|
||||
return (
|
||||
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex font-mono text-xs', bgClass[type])}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.old ?? ''}
|
||||
</span>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.new ?? ''}
|
||||
</span>
|
||||
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
|
||||
{prefix[type]}
|
||||
</span>
|
||||
<span className={cn('flex-1 px-2 whitespace-pre-wrap break-all', textClass[type])}>
|
||||
{content || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
|
||||
if (state === 'staged') {
|
||||
return (
|
||||
@@ -401,6 +219,7 @@ function MergeStateBanner({ mergeState }: { mergeState: MergeStateInfo }) {
|
||||
|
||||
function FileDiffSection({
|
||||
fileDiff,
|
||||
rawDiff,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
fileStatus,
|
||||
@@ -410,6 +229,8 @@ function FileDiffSection({
|
||||
isStagingFile,
|
||||
}: {
|
||||
fileDiff: ParsedFileDiff;
|
||||
/** Raw unified diff string for this file, used by CodeMirror merge view */
|
||||
rawDiff?: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
fileStatus?: FileStatus;
|
||||
@@ -418,14 +239,8 @@ function FileDiffSection({
|
||||
onUnstage?: (filePath: string) => void;
|
||||
isStagingFile?: boolean;
|
||||
}) {
|
||||
const additions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||
0
|
||||
);
|
||||
const deletions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
|
||||
0
|
||||
);
|
||||
const additions = fileDiff.additions;
|
||||
const deletions = fileDiff.deletions;
|
||||
|
||||
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
|
||||
|
||||
@@ -521,20 +336,9 @@ function FileDiffSection({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto scrollbar-visible">
|
||||
{fileDiff.hunks.map((hunk, hunkIndex) => (
|
||||
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<DiffLine
|
||||
key={lineIndex}
|
||||
type={line.type}
|
||||
content={line.content}
|
||||
lineNumber={line.lineNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{isExpanded && rawDiff && (
|
||||
<div className="bg-background border-t border-border">
|
||||
<CodeMirrorDiffView fileDiff={rawDiff} filePath={fileDiff.filePath} maxHeight="400px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -619,6 +423,16 @@ export function GitDiffPanel({
|
||||
return diffs;
|
||||
}, [diffContent, mergeState, fileStatusMap]);
|
||||
|
||||
// Build a map from file path to raw diff string for CodeMirror merge view
|
||||
const fileDiffMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
const perFileDiffs = splitDiffByFile(diffContent);
|
||||
for (const entry of perFileDiffs) {
|
||||
map.set(entry.filePath, entry.diff);
|
||||
}
|
||||
return map;
|
||||
}, [diffContent]);
|
||||
|
||||
const toggleFile = (filePath: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -822,25 +636,9 @@ export function GitDiffPanel({
|
||||
return { staged, partial, unstaged, total: files.length };
|
||||
}, [enableStaging, files]);
|
||||
|
||||
// Total stats
|
||||
const totalAdditions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
const totalDeletions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'deletion').length,
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
// Total stats (pre-computed by shared parseDiff)
|
||||
const totalAdditions = parsedDiffs.reduce((acc, file) => acc + file.additions, 0);
|
||||
const totalDeletions = parsedDiffs.reduce((acc, file) => acc + file.deletions, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1053,6 +851,7 @@ export function GitDiffPanel({
|
||||
<FileDiffSection
|
||||
key={fileDiff.filePath}
|
||||
fileDiff={fileDiff}
|
||||
rawDiff={fileDiffMap.get(fileDiff.filePath)}
|
||||
isExpanded={expandedFiles.has(fileDiff.filePath)}
|
||||
onToggle={() => toggleFile(fileDiff.filePath)}
|
||||
fileStatus={fileStatusMap.get(fileDiff.filePath)}
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
PlanApprovalDialog,
|
||||
MergeRebaseDialog,
|
||||
QuickAddDialog,
|
||||
ChangePRNumberDialog,
|
||||
} from './board-view/dialogs';
|
||||
import type { DependencyLinkType } from './board-view/dialogs';
|
||||
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||
@@ -198,6 +199,7 @@ export function BoardView() {
|
||||
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||
const [showChangePRNumberDialog, setShowChangePRNumberDialog] = useState(false);
|
||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
|
||||
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
|
||||
@@ -1030,7 +1032,8 @@ export function BoardView() {
|
||||
skipTests: defaultSkipTests,
|
||||
model: resolveModelString(modelEntry.model) as ModelAlias,
|
||||
thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none',
|
||||
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '',
|
||||
reasoningEffort: modelEntry.reasoningEffort,
|
||||
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined,
|
||||
priority: 2,
|
||||
planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip',
|
||||
requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false,
|
||||
@@ -1064,7 +1067,8 @@ export function BoardView() {
|
||||
skipTests: defaultSkipTests,
|
||||
model: resolveModelString(modelEntry.model) as ModelAlias,
|
||||
thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none',
|
||||
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '',
|
||||
reasoningEffort: modelEntry.reasoningEffort,
|
||||
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined,
|
||||
priority: 2,
|
||||
planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip',
|
||||
requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false,
|
||||
@@ -1691,6 +1695,10 @@ export function BoardView() {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreatePRDialog(true);
|
||||
}}
|
||||
onChangePRNumber={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowChangePRNumberDialog(true);
|
||||
}}
|
||||
onCreateBranch={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
@@ -2229,6 +2237,18 @@ export function BoardView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Change PR Number Dialog */}
|
||||
<ChangePRNumberDialog
|
||||
open={showChangePRNumberDialog}
|
||||
onOpenChange={setShowChangePRNumberDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
projectPath={currentProject?.path || null}
|
||||
onChanged={() => {
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Create Branch Dialog */}
|
||||
<CreateBranchDialog
|
||||
open={showCreateBranchDialog}
|
||||
|
||||
@@ -28,7 +28,11 @@ import { cn } from '@/lib/utils';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
|
||||
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
|
||||
import {
|
||||
supportsReasoningEffort,
|
||||
isAdaptiveThinkingModel,
|
||||
getThinkingLevelsForModel,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
PrioritySelector,
|
||||
WorkModeSelector,
|
||||
@@ -211,6 +215,7 @@ export function AddFeatureDialog({
|
||||
defaultRequirePlanApproval,
|
||||
useWorktrees,
|
||||
defaultFeatureModel,
|
||||
defaultThinkingLevel,
|
||||
currentProject,
|
||||
} = useAppStore();
|
||||
|
||||
@@ -240,7 +245,22 @@ export function AddFeatureDialog({
|
||||
);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setModelEntry(effectiveDefaultFeatureModel);
|
||||
|
||||
// Apply defaultThinkingLevel from settings to the model entry.
|
||||
// This ensures the "Quick-Select Defaults" thinking level setting is respected
|
||||
// even when the user doesn't change the model in the dropdown.
|
||||
const modelId =
|
||||
typeof effectiveDefaultFeatureModel.model === 'string'
|
||||
? effectiveDefaultFeatureModel.model
|
||||
: '';
|
||||
const availableLevels = getThinkingLevelsForModel(modelId);
|
||||
const effectiveThinkingLevel = availableLevels.includes(defaultThinkingLevel)
|
||||
? defaultThinkingLevel
|
||||
: availableLevels[0];
|
||||
setModelEntry({
|
||||
...effectiveDefaultFeatureModel,
|
||||
thinkingLevel: effectiveThinkingLevel,
|
||||
});
|
||||
|
||||
// Initialize description history (empty for new feature)
|
||||
setDescriptionHistory([]);
|
||||
@@ -269,6 +289,7 @@ export function AddFeatureDialog({
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
effectiveDefaultFeatureModel,
|
||||
defaultThinkingLevel,
|
||||
useWorktrees,
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
@@ -394,7 +415,19 @@ export function AddFeatureDialog({
|
||||
// When a non-main worktree is selected, use its branch name for custom mode
|
||||
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||
setPriority(2);
|
||||
setModelEntry(effectiveDefaultFeatureModel);
|
||||
// Apply defaultThinkingLevel to the model entry (same logic as dialog open)
|
||||
const resetModelId =
|
||||
typeof effectiveDefaultFeatureModel.model === 'string'
|
||||
? effectiveDefaultFeatureModel.model
|
||||
: '';
|
||||
const resetAvailableLevels = getThinkingLevelsForModel(resetModelId);
|
||||
const resetThinkingLevel = resetAvailableLevels.includes(defaultThinkingLevel)
|
||||
? defaultThinkingLevel
|
||||
: resetAvailableLevels[0];
|
||||
setModelEntry({
|
||||
...effectiveDefaultFeatureModel,
|
||||
thinkingLevel: resetThinkingLevel,
|
||||
});
|
||||
setWorkMode(
|
||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitPullRequest } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
pr?: {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChangePRNumberDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
projectPath: string | null;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function ChangePRNumberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
projectPath,
|
||||
onChanged,
|
||||
}: ChangePRNumberDialogProps) {
|
||||
const [prNumberInput, setPrNumberInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Initialize with current PR number when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree?.pr?.number) {
|
||||
setPrNumberInput(String(worktree.pr.number));
|
||||
} else if (open) {
|
||||
setPrNumberInput('');
|
||||
}
|
||||
setError(null);
|
||||
}, [open, worktree]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
const trimmed = prNumberInput.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
setError('Please enter a valid positive PR number');
|
||||
return;
|
||||
}
|
||||
const prNumber = Number(trimmed);
|
||||
if (prNumber <= 0) {
|
||||
setError('Please enter a valid positive PR number');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.updatePRNumber) {
|
||||
setError('Worktree API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.updatePRNumber(
|
||||
worktree.path,
|
||||
prNumber,
|
||||
projectPath || undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
const prInfo = result.result?.prInfo;
|
||||
toast.success('PR tracking updated', {
|
||||
description: prInfo?.title
|
||||
? `Now tracking PR #${prNumber}: ${prInfo.title}`
|
||||
: `Now tracking PR #${prNumber}`,
|
||||
});
|
||||
onOpenChange(false);
|
||||
onChanged();
|
||||
} else {
|
||||
setError(result.error || 'Failed to update PR number');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update PR number');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [worktree, prNumberInput, projectPath, onOpenChange, onChanged]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[isLoading, handleSubmit]
|
||||
);
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isLoading) {
|
||||
onOpenChange(isOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitPullRequest className="w-5 h-5" />
|
||||
Change Tracked PR Number
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update which pull request number is tracked for{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
|
||||
{worktree.pr && (
|
||||
<span className="block mt-1 text-xs">
|
||||
Currently tracking PR #{worktree.pr.number}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pr-number">Pull Request Number</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">#</span>
|
||||
<Input
|
||||
id="pr-number"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 42"
|
||||
value={prNumberInput}
|
||||
onChange={(e) => {
|
||||
setPrNumberInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the GitHub PR number to associate with this worktree. The PR info will be
|
||||
fetched from GitHub if available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading || !prNumberInput.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitPullRequest className="w-4 h-4 mr-2" />
|
||||
Update PR
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useWorktreeBranches } from '@/hooks/queries';
|
||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
@@ -313,7 +314,7 @@ export function CreatePRDialog({
|
||||
const result = await api.worktree.generatePRDescription(
|
||||
worktree.path,
|
||||
branchNameForApi,
|
||||
prDescriptionModelOverride.effectiveModel,
|
||||
resolveModelString(prDescriptionModelOverride.effectiveModel),
|
||||
prDescriptionModelOverride.effectiveModelEntry.thinkingLevel,
|
||||
prDescriptionModelOverride.effectiveModelEntry.providerId
|
||||
);
|
||||
@@ -501,7 +502,7 @@ export function CreatePRDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[550px] flex flex-col">
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitPullRequest className="w-5 h-5" />
|
||||
|
||||
@@ -25,6 +25,7 @@ export { ViewStashesDialog } from './view-stashes-dialog';
|
||||
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
|
||||
export { CherryPickDialog } from './cherry-pick-dialog';
|
||||
export { GitPullDialog } from './git-pull-dialog';
|
||||
export { ChangePRNumberDialog } from './change-pr-number-dialog';
|
||||
export {
|
||||
BranchConflictDialog,
|
||||
type BranchConflictData,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-nocheck - feature update logic with partial updates and image/file handling
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Feature,
|
||||
FeatureImage,
|
||||
@@ -18,11 +19,29 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
||||
import { truncateDescription } from '@/lib/utils';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
|
||||
const logger = createLogger('BoardActions');
|
||||
|
||||
const MAX_DUPLICATES = 50;
|
||||
|
||||
/**
|
||||
* Removes a running task from all worktrees for a given project.
|
||||
* Used when stopping features to ensure the task is removed from all worktree contexts,
|
||||
* not just the current one.
|
||||
*/
|
||||
function removeRunningTaskFromAllWorktrees(projectId: string, featureId: string): void {
|
||||
const store = useAppStore.getState();
|
||||
const prefix = `${projectId}::`;
|
||||
for (const [key, worktreeState] of Object.entries(store.autoModeByWorktree)) {
|
||||
if (key.startsWith(prefix) && worktreeState.runningTasks?.includes(featureId)) {
|
||||
const branchPart = key.slice(prefix.length);
|
||||
const branch = branchPart === '__main__' ? null : branchPart;
|
||||
store.removeRunningTask(projectId, branch, featureId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UseBoardActionsProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
features: Feature[];
|
||||
@@ -84,6 +103,8 @@ export function useBoardActions({
|
||||
onWorktreeAutoSelect,
|
||||
currentWorktreeBranch,
|
||||
}: UseBoardActionsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
|
||||
// subscribing to the entire store. Bare useAppStore() causes the host component
|
||||
// (BoardView) to re-render on EVERY store change, which cascades through effects
|
||||
@@ -503,6 +524,10 @@ export function useBoardActions({
|
||||
if (isRunning) {
|
||||
try {
|
||||
await autoMode.stopFeature(featureId);
|
||||
// Remove from all worktrees
|
||||
if (currentProject) {
|
||||
removeRunningTaskFromAllWorktrees(currentProject.id, featureId);
|
||||
}
|
||||
toast.success('Agent stopped', {
|
||||
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
@@ -533,7 +558,7 @@ export function useBoardActions({
|
||||
removeFeature(featureId);
|
||||
await persistFeatureDelete(featureId);
|
||||
},
|
||||
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
||||
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete, currentProject]
|
||||
);
|
||||
|
||||
const handleRunFeature = useCallback(
|
||||
@@ -999,6 +1024,31 @@ export function useBoardActions({
|
||||
? 'waiting_approval'
|
||||
: 'backlog';
|
||||
|
||||
// Remove the running task from ALL worktrees for this project.
|
||||
// autoMode.stopFeature only removes from its scoped worktree (branchName),
|
||||
// but the feature may be tracked under a different worktree branch.
|
||||
// Without this, runningAutoTasksAllWorktrees still contains the feature
|
||||
// and the board column logic forces it into in_progress.
|
||||
if (currentProject) {
|
||||
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
|
||||
}
|
||||
|
||||
// Optimistically update the React Query features cache so the board
|
||||
// moves the card immediately. Without this, the card stays in
|
||||
// "in_progress" until the next poll cycle (30s) because the async
|
||||
// refetch races with the persistFeatureUpdate write.
|
||||
if (currentProject) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(oldFeatures: Feature[] | undefined) => {
|
||||
if (!oldFeatures) return oldFeatures;
|
||||
return oldFeatures.map((f) =>
|
||||
f.id === feature.id ? { ...f, status: targetStatus } : f
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (targetStatus !== feature.status) {
|
||||
moveFeature(feature.id, targetStatus);
|
||||
// Must await to ensure file is written before user can restart
|
||||
@@ -1020,7 +1070,7 @@ export function useBoardActions({
|
||||
});
|
||||
}
|
||||
},
|
||||
[autoMode, moveFeature, persistFeatureUpdate]
|
||||
[autoMode, moveFeature, persistFeatureUpdate, currentProject, queryClient]
|
||||
);
|
||||
|
||||
const handleStartNextFeatures = useCallback(async () => {
|
||||
@@ -1137,6 +1187,12 @@ export function useBoardActions({
|
||||
})
|
||||
)
|
||||
);
|
||||
// Remove from all worktrees
|
||||
if (currentProject) {
|
||||
for (const feature of runningVerified) {
|
||||
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use bulk update API for a single server request instead of N individual calls
|
||||
|
||||
@@ -6,13 +6,21 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
|
||||
import {
|
||||
EnhancementMode,
|
||||
ENHANCEMENT_MODE_LABELS,
|
||||
REWRITE_MODES,
|
||||
ADDITIVE_MODES,
|
||||
isAdditiveMode,
|
||||
} from './enhancement-constants';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
const logger = createLogger('EnhanceWithAI');
|
||||
@@ -79,7 +87,10 @@ export function EnhanceWithAI({
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const originalText = value;
|
||||
const enhancedText = result.enhancedText;
|
||||
// For additive modes, prepend the original description above the AI-generated content
|
||||
const enhancedText = isAdditiveMode(enhancementMode)
|
||||
? `${originalText.trim()}\n\n${result.enhancedText.trim()}`
|
||||
: result.enhancedText;
|
||||
onChange(enhancedText);
|
||||
|
||||
// Track in history if callback provided (includes original for restoration)
|
||||
@@ -119,13 +130,19 @@ export function EnhanceWithAI({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
|
||||
([mode, label]) => (
|
||||
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
<DropdownMenuLabel>Rewrite</DropdownMenuLabel>
|
||||
{REWRITE_MODES.map((mode) => (
|
||||
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
|
||||
{ENHANCEMENT_MODE_LABELS[mode]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Append Details</DropdownMenuLabel>
|
||||
{ADDITIVE_MODES.map((mode) => (
|
||||
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
|
||||
{ENHANCEMENT_MODE_LABELS[mode]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Enhancement mode options for AI-powered prompt improvement */
|
||||
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
|
||||
import type { EnhancementMode } from '@automaker/types';
|
||||
export type { EnhancementMode } from '@automaker/types';
|
||||
|
||||
/** Labels for enhancement modes displayed in the UI */
|
||||
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
|
||||
@@ -18,3 +18,14 @@ export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
|
||||
acceptance: 'Add specific acceptance criteria and test cases',
|
||||
'ux-reviewer': 'Add user experience considerations and flows',
|
||||
};
|
||||
|
||||
/** Modes that rewrite/replace the entire description */
|
||||
export const REWRITE_MODES: EnhancementMode[] = ['improve', 'simplify'];
|
||||
|
||||
/** Modes that append additional content below the original description */
|
||||
export const ADDITIVE_MODES: EnhancementMode[] = ['technical', 'acceptance', 'ux-reviewer'];
|
||||
|
||||
/** Check if a mode appends content rather than replacing */
|
||||
export function isAdditiveMode(mode: EnhancementMode): boolean {
|
||||
return ADDITIVE_MODES.includes(mode);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
Settings2,
|
||||
ArrowLeftRight,
|
||||
Check,
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -105,6 +108,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onChangePRNumber?: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
@@ -149,6 +153,14 @@ interface WorktreeActionsDropdownProps {
|
||||
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** List of remote names that have a branch matching the current branch name */
|
||||
remotesWithBranch?: string[];
|
||||
/** Available worktrees for swapping into this slot (non-main only) */
|
||||
availableWorktreesForSwap?: WorktreeInfo[];
|
||||
/** The slot index for this tab in the pinned list (0-based, excluding main) */
|
||||
slotIndex?: number;
|
||||
/** Callback when user swaps this slot to a different worktree */
|
||||
onSwapWorktree?: (slotIndex: number, newBranch: string) => void;
|
||||
/** List of currently pinned branch names (to show which are pinned in the swap dropdown) */
|
||||
pinnedBranches?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,6 +271,7 @@ export function WorktreeActionsDropdown({
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onChangePRNumber,
|
||||
onAddressPRComments,
|
||||
onAutoAddressPRComments,
|
||||
onResolveConflicts,
|
||||
@@ -287,6 +300,10 @@ export function WorktreeActionsDropdown({
|
||||
onSyncWithRemote,
|
||||
onSetTracking,
|
||||
remotesWithBranch,
|
||||
availableWorktreesForSwap,
|
||||
slotIndex,
|
||||
onSwapWorktree,
|
||||
pinnedBranches,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
const { editors } = useAvailableEditors();
|
||||
@@ -1334,6 +1351,12 @@ export function WorktreeActionsDropdown({
|
||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||
Address PR Comments
|
||||
</DropdownMenuItem>
|
||||
{onChangePRNumber && (
|
||||
<DropdownMenuItem onClick={() => onChangePRNumber(worktree)} className="text-xs">
|
||||
<Hash className="w-3.5 h-3.5 mr-2" />
|
||||
Change PR Number
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
@@ -1359,6 +1382,36 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{/* Swap Worktree submenu - only shown for non-main slots when there are other worktrees to swap to */}
|
||||
{!worktree.isMain &&
|
||||
availableWorktreesForSwap &&
|
||||
availableWorktreesForSwap.length > 1 &&
|
||||
slotIndex !== undefined &&
|
||||
onSwapWorktree && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="text-xs">
|
||||
<ArrowLeftRight className="w-3.5 h-3.5 mr-2" />
|
||||
Swap Worktree
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-64 max-h-80 overflow-y-auto">
|
||||
{availableWorktreesForSwap
|
||||
.filter((wt) => wt.branch !== worktree.branch)
|
||||
.map((wt) => {
|
||||
const isPinned = pinnedBranches?.includes(wt.branch);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={wt.path}
|
||||
onSelect={() => onSwapWorktree(slotIndex, wt.branch)}
|
||||
className="flex items-center gap-2 cursor-pointer font-mono text-xs"
|
||||
>
|
||||
<span className="truncate flex-1">{wt.branch}</span>
|
||||
{isPinned && <Check className="w-3 h-3 shrink-0 text-muted-foreground" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface WorktreeDropdownProps {
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onChangePRNumber?: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
@@ -148,6 +149,8 @@ export interface WorktreeDropdownProps {
|
||||
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** List of remote names that have a branch matching the current branch name */
|
||||
remotesWithBranch?: string[];
|
||||
/** When false, the trigger button uses a subdued style instead of the primary highlight. Defaults to true. */
|
||||
highlightTrigger?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,6 +218,7 @@ export function WorktreeDropdown({
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onChangePRNumber,
|
||||
onAddressPRComments,
|
||||
onAutoAddressPRComments,
|
||||
onResolveConflicts,
|
||||
@@ -245,10 +249,13 @@ export function WorktreeDropdown({
|
||||
onSyncWithRemote,
|
||||
onSetTracking,
|
||||
remotesWithBranch,
|
||||
highlightTrigger = true,
|
||||
}: WorktreeDropdownProps) {
|
||||
// Find the currently selected worktree to display in the trigger
|
||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||
const displayBranch = selectedWorktree?.branch || 'Select worktree';
|
||||
const displayBranch =
|
||||
selectedWorktree?.branch ??
|
||||
(worktrees.length > 0 ? `+${worktrees.length} more` : 'Select worktree');
|
||||
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
|
||||
displayBranch,
|
||||
MAX_TRIGGER_BRANCH_NAME_LENGTH
|
||||
@@ -292,15 +299,28 @@ export function WorktreeDropdown({
|
||||
const triggerButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={selectedWorktree && highlightTrigger ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
|
||||
'h-7 px-3 gap-1.5 font-mono text-xs min-w-0',
|
||||
selectedWorktree &&
|
||||
highlightTrigger &&
|
||||
'bg-primary text-primary-foreground border-r-0 rounded-l-md rounded-r-none',
|
||||
selectedWorktree &&
|
||||
!highlightTrigger &&
|
||||
'bg-secondary/50 hover:bg-secondary border-r-0 rounded-l-md rounded-r-none',
|
||||
!selectedWorktree && 'bg-secondary/50 hover:bg-secondary rounded-md'
|
||||
)}
|
||||
disabled={isActivating}
|
||||
>
|
||||
{/* Running/Activating indicator */}
|
||||
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
|
||||
{(selectedStatus.isRunning || isActivating) && (
|
||||
<Spinner
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
variant={selectedWorktree && highlightTrigger ? 'foreground' : 'primary'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Branch icon */}
|
||||
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||
@@ -403,7 +423,14 @@ export function WorktreeDropdown({
|
||||
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
|
||||
</Button>
|
||||
),
|
||||
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
|
||||
[
|
||||
isActivating,
|
||||
selectedStatus,
|
||||
truncatedBranch,
|
||||
selectedWorktree,
|
||||
branchCardCounts,
|
||||
highlightTrigger,
|
||||
]
|
||||
);
|
||||
|
||||
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
|
||||
@@ -490,7 +517,7 @@ export function WorktreeDropdown({
|
||||
{selectedWorktree?.isMain && (
|
||||
<BranchSwitchDropdown
|
||||
worktree={selectedWorktree}
|
||||
isSelected={true}
|
||||
isSelected={highlightTrigger}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
@@ -507,7 +534,7 @@ export function WorktreeDropdown({
|
||||
{selectedWorktree && (
|
||||
<WorktreeActionsDropdown
|
||||
worktree={selectedWorktree}
|
||||
isSelected={true}
|
||||
isSelected={highlightTrigger}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
@@ -541,6 +568,7 @@ export function WorktreeDropdown({
|
||||
onDiscardChanges={onDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onChangePRNumber={onChangePRNumber}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
|
||||
@@ -66,6 +66,7 @@ interface WorktreeTabProps {
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onChangePRNumber?: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
@@ -118,6 +119,14 @@ interface WorktreeTabProps {
|
||||
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** List of remote names that have a branch matching the current branch name */
|
||||
remotesWithBranch?: string[];
|
||||
/** Available worktrees for swapping into this slot (non-main only) */
|
||||
availableWorktreesForSwap?: WorktreeInfo[];
|
||||
/** The slot index for this tab in the pinned list (0-based, excluding main) */
|
||||
slotIndex?: number;
|
||||
/** Callback when user swaps this slot to a different worktree */
|
||||
onSwapWorktree?: (slotIndex: number, newBranch: string) => void;
|
||||
/** List of currently pinned branch names (to show which are pinned in the swap dropdown) */
|
||||
pinnedBranches?: string[];
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
@@ -164,6 +173,7 @@ export function WorktreeTab({
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onChangePRNumber,
|
||||
onAddressPRComments,
|
||||
onAutoAddressPRComments,
|
||||
onResolveConflicts,
|
||||
@@ -196,6 +206,10 @@ export function WorktreeTab({
|
||||
onSyncWithRemote,
|
||||
onSetTracking,
|
||||
remotesWithBranch,
|
||||
availableWorktreesForSwap,
|
||||
slotIndex,
|
||||
onSwapWorktree,
|
||||
pinnedBranches,
|
||||
}: WorktreeTabProps) {
|
||||
// Make the worktree tab a drop target for feature cards
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -542,6 +556,7 @@ export function WorktreeTab({
|
||||
onDiscardChanges={onDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onChangePRNumber={onChangePRNumber}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
@@ -570,6 +585,10 @@ export function WorktreeTab({
|
||||
onSyncWithRemote={onSyncWithRemote}
|
||||
onSetTracking={onSetTracking}
|
||||
remotesWithBranch={remotesWithBranch}
|
||||
availableWorktreesForSwap={availableWorktreesForSwap}
|
||||
slotIndex={slotIndex}
|
||||
onSwapWorktree={onSwapWorktree}
|
||||
pinnedBranches={pinnedBranches}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface WorktreePanelProps {
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onChangePRNumber?: (worktree: WorktreeInfo) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
|
||||
@@ -26,11 +26,11 @@ import {
|
||||
} from './hooks';
|
||||
import {
|
||||
WorktreeTab,
|
||||
WorktreeDropdown,
|
||||
DevServerLogsPanel,
|
||||
WorktreeMobileDropdown,
|
||||
WorktreeActionsDropdown,
|
||||
BranchSwitchDropdown,
|
||||
WorktreeDropdown,
|
||||
} from './components';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
@@ -50,8 +50,9 @@ import type { SelectRemoteOperation } from '../dialogs';
|
||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
|
||||
const WORKTREE_DROPDOWN_THRESHOLD = 3;
|
||||
// Stable empty array to avoid creating a new [] reference on every render
|
||||
// when pinnedWorktreeBranchesByProject[projectPath] is undefined
|
||||
const EMPTY_BRANCHES: string[] = [];
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
@@ -59,6 +60,7 @@ export function WorktreePanel({
|
||||
onDeleteWorktree,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onChangePRNumber,
|
||||
onCreateBranch,
|
||||
onAddressPRComments,
|
||||
onAutoAddressPRComments,
|
||||
@@ -99,7 +101,6 @@ export function WorktreePanel({
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
trackingRemote,
|
||||
getTrackingRemote,
|
||||
remotesWithBranch,
|
||||
isLoadingBranches,
|
||||
@@ -139,13 +140,107 @@ export function WorktreePanel({
|
||||
features,
|
||||
});
|
||||
|
||||
// Pinned worktrees count from store
|
||||
const pinnedWorktreesCount = useAppStore(
|
||||
(state) => state.pinnedWorktreesCountByProject[projectPath] ?? 0
|
||||
);
|
||||
const pinnedWorktreeBranchesRaw = useAppStore(
|
||||
(state) => state.pinnedWorktreeBranchesByProject[projectPath]
|
||||
);
|
||||
const pinnedWorktreeBranches = pinnedWorktreeBranchesRaw ?? EMPTY_BRANCHES;
|
||||
const setPinnedWorktreeBranches = useAppStore((state) => state.setPinnedWorktreeBranches);
|
||||
const swapPinnedWorktreeBranch = useAppStore((state) => state.swapPinnedWorktreeBranch);
|
||||
|
||||
// Resolve pinned worktrees from explicit branch assignments
|
||||
// Shows exactly pinnedWorktreesCount slots, each with a specific worktree.
|
||||
// Main worktree is always slot 0. Other slots can be swapped by the user.
|
||||
const pinnedWorktrees = useMemo(() => {
|
||||
const mainWt = worktrees.find((w) => w.isMain);
|
||||
const otherWts = worktrees.filter((w) => !w.isMain);
|
||||
|
||||
// Slot 0 is always main worktree
|
||||
const result: WorktreeInfo[] = mainWt ? [mainWt] : [];
|
||||
|
||||
// pinnedWorktreesCount represents only non-main worktrees; main is always shown separately
|
||||
const otherSlotCount = Math.max(0, pinnedWorktreesCount);
|
||||
|
||||
if (otherSlotCount > 0 && otherWts.length > 0) {
|
||||
// Use explicit branch assignments if available
|
||||
const assignedBranches = pinnedWorktreeBranches;
|
||||
const usedBranches = new Set<string>();
|
||||
|
||||
for (let i = 0; i < otherSlotCount; i++) {
|
||||
const assignedBranch = assignedBranches[i];
|
||||
let wt: WorktreeInfo | undefined;
|
||||
|
||||
// Try to find the explicitly assigned worktree
|
||||
if (assignedBranch) {
|
||||
wt = otherWts.find((w) => w.branch === assignedBranch && !usedBranches.has(w.branch));
|
||||
}
|
||||
|
||||
// Fall back to next available worktree if assigned one doesn't exist
|
||||
if (!wt) {
|
||||
wt = otherWts.find((w) => !usedBranches.has(w.branch));
|
||||
}
|
||||
|
||||
if (wt) {
|
||||
result.push(wt);
|
||||
usedBranches.add(wt.branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [worktrees, pinnedWorktreesCount, pinnedWorktreeBranches]);
|
||||
|
||||
// All non-main worktrees available for swapping into slots
|
||||
const availableWorktreesForSwap = useMemo(() => {
|
||||
return worktrees.filter((w) => !w.isMain);
|
||||
}, [worktrees]);
|
||||
|
||||
// Handle swapping a worktree in a specific slot
|
||||
const handleSwapWorktreeSlot = useCallback(
|
||||
(slotIndex: number, newBranch: string) => {
|
||||
swapPinnedWorktreeBranch(projectPath, slotIndex, newBranch);
|
||||
},
|
||||
[projectPath, swapPinnedWorktreeBranch]
|
||||
);
|
||||
|
||||
// Initialize pinned branch assignments when worktrees change
|
||||
// This ensures new worktrees get default slot assignments
|
||||
// Read store state directly inside the effect to avoid a dependency cycle
|
||||
// (the effect writes to the same state it would otherwise depend on)
|
||||
useEffect(() => {
|
||||
const mainWt = worktrees.find((w) => w.isMain);
|
||||
const otherWts = worktrees.filter((w) => !w.isMain);
|
||||
const otherSlotCount = Math.max(0, pinnedWorktreesCount);
|
||||
|
||||
const storedBranches = useAppStore.getState().pinnedWorktreeBranchesByProject[projectPath];
|
||||
if (otherSlotCount > 0 && otherWts.length > 0) {
|
||||
const existing = storedBranches ?? [];
|
||||
if (existing.length < otherSlotCount) {
|
||||
const used = new Set(existing.filter(Boolean));
|
||||
const filled = [...existing];
|
||||
for (const wt of otherWts) {
|
||||
if (filled.length >= otherSlotCount) break;
|
||||
if (!used.has(wt.branch)) {
|
||||
filled.push(wt.branch);
|
||||
used.add(wt.branch);
|
||||
}
|
||||
}
|
||||
if (filled.length > 0) {
|
||||
setPinnedWorktreeBranches(projectPath, filled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [worktrees, pinnedWorktreesCount, projectPath, setPinnedWorktreeBranches]);
|
||||
|
||||
// Auto-mode state management using the store
|
||||
// Use separate selectors to avoid creating new object references on each render
|
||||
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
const setAutoModeRunning = useAppStore((state) => state.setAutoModeRunning);
|
||||
const getMaxConcurrencyForWorktree = useAppStore((state) => state.getMaxConcurrencyForWorktree);
|
||||
|
||||
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
|
||||
const getAutoModeWorktreeKey = useCallback(
|
||||
(projectId: string, branchName: string | null): string => {
|
||||
@@ -651,18 +746,6 @@ export function WorktreePanel({
|
||||
// Keep logPanelWorktree set for smooth close animation
|
||||
}, []);
|
||||
|
||||
// Wrap handleStartDevServer to auto-open the logs panel so the user
|
||||
// can see output immediately (including failure reasons)
|
||||
const handleStartDevServerAndShowLogs = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
// Open logs panel immediately so output is visible from the start
|
||||
setLogPanelWorktree(worktree);
|
||||
setLogPanelOpen(true);
|
||||
await handleStartDevServer(worktree);
|
||||
},
|
||||
[handleStartDevServer]
|
||||
);
|
||||
|
||||
// Handle opening the push to remote dialog
|
||||
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
|
||||
setPushToRemoteWorktree(worktree);
|
||||
@@ -887,7 +970,6 @@ export function WorktreePanel({
|
||||
);
|
||||
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||
|
||||
// Mobile view: single dropdown for all worktrees
|
||||
if (isMobile) {
|
||||
@@ -965,12 +1047,13 @@ export function WorktreePanel({
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onChangePRNumber={onChangePRNumber}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
@@ -1145,56 +1228,124 @@ export function WorktreePanel({
|
||||
);
|
||||
}
|
||||
|
||||
// Use dropdown layout when worktree count meets or exceeds the threshold
|
||||
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
|
||||
// Desktop view: pinned worktrees as individual tabs (each slot can be swapped)
|
||||
|
||||
// Desktop view: full tabs layout or dropdown layout depending on worktree count
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">
|
||||
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm text-muted-foreground mr-2 shrink-0">Worktree:</span>
|
||||
|
||||
{/* Dropdown layout for 3+ worktrees */}
|
||||
{useDropdownLayout ? (
|
||||
<>
|
||||
<WorktreeDropdown
|
||||
worktrees={worktrees}
|
||||
isWorktreeSelected={isWorktreeSelected}
|
||||
hasRunningFeatures={hasRunningFeatures}
|
||||
{/* When only 1 pinned slot (main only) and there are other worktrees,
|
||||
use a compact dropdown to switch between them without highlighting main */}
|
||||
{pinnedWorktreesCount === 0 && availableWorktreesForSwap.length > 0 ? (
|
||||
<WorktreeDropdown
|
||||
worktrees={worktrees}
|
||||
isWorktreeSelected={isWorktreeSelected}
|
||||
hasRunningFeatures={hasRunningFeatures}
|
||||
isActivating={isActivating}
|
||||
branchCardCounts={branchCardCounts}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
getDevServerInfo={getDevServerInfo}
|
||||
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
|
||||
isTestRunningForWorktree={isTestRunningForWorktree}
|
||||
getTestSessionInfo={getTestSessionInfo}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
getTrackingRemote={getTrackingRemote}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
hasInitScript={hasInitScript}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onPullWithRemote={handlePullWithSpecificRemote}
|
||||
onPushWithRemote={handlePushWithSpecificRemote}
|
||||
remotesCache={remotesCache}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onViewCommits={handleViewCommits}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onChangePRNumber={onChangePRNumber}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
highlightTrigger={false}
|
||||
/>
|
||||
) : pinnedWorktreesCount === 0 ? (
|
||||
/* Only main worktree, no others exist - render main tab without highlight */
|
||||
mainWorktree && (
|
||||
<WorktreeTab
|
||||
worktree={mainWorktree}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
hasChanges={mainWorktree.hasChanges}
|
||||
changedFilesCount={mainWorktree.changedFilesCount}
|
||||
isSelected={false}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
isActivating={isActivating}
|
||||
branchCardCounts={branchCardCounts}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
getDevServerInfo={getDevServerInfo}
|
||||
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
|
||||
isTestRunningForWorktree={isTestRunningForWorktree}
|
||||
getTestSessionInfo={getTestSessionInfo}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
// Branch switching props
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
// Action dropdown props
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={trackingRemote}
|
||||
getTrackingRemote={getTrackingRemote}
|
||||
trackingRemote={getTrackingRemote(mainWorktree.path)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
hasInitScript={hasInitScript}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
@@ -1205,7 +1356,7 @@ export function WorktreePanel({
|
||||
onSyncWithRemote={handleSyncWithSpecificRemote}
|
||||
onSetTracking={handleSetTrackingForRemote}
|
||||
remotesWithBranch={remotesWithBranch}
|
||||
remotesCache={remotesCache}
|
||||
remotes={remotesCache[mainWorktree.path]}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
@@ -1214,12 +1365,13 @@ export function WorktreePanel({
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onChangePRNumber={onChangePRNumber}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
@@ -1233,247 +1385,138 @@ export function WorktreePanel({
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
/>
|
||||
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
/* Standard tabs layout for 1-2 worktrees */
|
||||
/* Multiple pinned slots - show individual tabs */
|
||||
pinnedWorktrees.map((worktree, index) => {
|
||||
const hasOtherWorktrees = worktrees.length > 1;
|
||||
const effectiveIsSelected =
|
||||
isWorktreeSelected(worktree) && (hasOtherWorktrees || !worktree.isMain);
|
||||
|
||||
// Slot index for swap (0-based, excluding main which is always slot 0)
|
||||
const slotIndex = worktree.isMain ? -1 : index - (pinnedWorktrees[0]?.isMain ? 1 : 0);
|
||||
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={branchCardCounts?.[worktree.branch]}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={effectiveIsSelected}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={getTrackingRemote(worktree.path)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onPullWithRemote={handlePullWithSpecificRemote}
|
||||
onPushWithRemote={handlePushWithSpecificRemote}
|
||||
remotes={remotesCache[worktree.path]}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onViewCommits={handleViewCommits}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onChangePRNumber={onChangePRNumber}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
availableWorktreesForSwap={!worktree.isMain ? availableWorktreesForSwap : undefined}
|
||||
slotIndex={slotIndex >= 0 ? slotIndex : undefined}
|
||||
onSwapWorktree={slotIndex >= 0 ? handleSwapWorktreeSlot : undefined}
|
||||
pinnedBranches={pinnedWorktrees.map((w) => w.branch)}
|
||||
isSyncing={isSyncing}
|
||||
onSync={handleSyncWithRemoteSelection}
|
||||
onSyncWithRemote={handleSyncWithSpecificRemote}
|
||||
onSetTracking={handleSetTrackingForRemote}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Create and refresh buttons */}
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{mainWorktree && (
|
||||
<WorktreeTab
|
||||
key={mainWorktree.path}
|
||||
worktree={mainWorktree}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
hasChanges={mainWorktree.hasChanges}
|
||||
changedFilesCount={mainWorktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={getTrackingRemote(mainWorktree.path)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onPullWithRemote={handlePullWithSpecificRemote}
|
||||
onPushWithRemote={handlePushWithSpecificRemote}
|
||||
isSyncing={isSyncing}
|
||||
onSync={handleSyncWithRemoteSelection}
|
||||
onSyncWithRemote={handleSyncWithSpecificRemote}
|
||||
onSetTracking={handleSetTrackingForRemote}
|
||||
remotes={remotesCache[mainWorktree.path]}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onViewCommits={handleViewCommits}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
remotesWithBranch={remotesWithBranch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Worktrees section - only show if enabled and not using dropdown layout */}
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-2" />
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{nonMainWorktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
trackingRemote={getTrackingRemote(worktree.path)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onPullWithRemote={handlePullWithSpecificRemote}
|
||||
onPushWithRemote={handlePushWithSpecificRemote}
|
||||
isSyncing={isSyncing}
|
||||
onSync={handleSyncWithRemoteSelection}
|
||||
onSyncWithRemote={handleSyncWithSpecificRemote}
|
||||
onSetTracking={handleSetTrackingForRemote}
|
||||
remotes={remotesCache[worktree.path]}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onViewCommits={handleViewCommits}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
remotesWithBranch={remotesWithBranch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,34 +1,13 @@
|
||||
import { useMemo, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { EditorView, keymap, Decoration, WidgetType } from '@codemirror/view';
|
||||
import { Extension, RangeSetBuilder, StateField } from '@codemirror/state';
|
||||
import { undo as cmUndo, redo as cmRedo } from '@codemirror/commands';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { search, openSearchPanel } from '@codemirror/search';
|
||||
|
||||
// Language imports
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { java } from '@codemirror/lang-java';
|
||||
import { rust } from '@codemirror/lang-rust';
|
||||
import { cpp } from '@codemirror/lang-cpp';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { php } from '@codemirror/lang-php';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { go } from '@codemirror/legacy-modes/mode/go';
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||
import { swift } from '@codemirror/legacy-modes/mode/swift';
|
||||
|
||||
import { getLanguageExtension } from '@/lib/codemirror-languages';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
@@ -55,6 +34,8 @@ export interface CodeEditorHandle {
|
||||
undo: () => void;
|
||||
/** Redoes the last undone edit */
|
||||
redo: () => void;
|
||||
/** Returns the current text selection with line range, or null if nothing is selected */
|
||||
getSelection: () => { text: string; fromLine: number; toLine: number } | null;
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
@@ -72,133 +53,10 @@ interface CodeEditorProps {
|
||||
className?: string;
|
||||
/** When true, scrolls the cursor into view (e.g. after virtual keyboard opens) */
|
||||
scrollCursorIntoView?: boolean;
|
||||
}
|
||||
|
||||
/** Detect language extension based on file extension */
|
||||
function getLanguageExtension(filePath: string): Extension | null {
|
||||
const name = filePath.split('/').pop()?.toLowerCase() || '';
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
// Files without an extension (no dot, or dotfile with dot at position 0)
|
||||
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
|
||||
|
||||
// Handle files by name first
|
||||
switch (name) {
|
||||
case 'dockerfile':
|
||||
case 'dockerfile.dev':
|
||||
case 'dockerfile.prod':
|
||||
return StreamLanguage.define(dockerFile);
|
||||
case 'makefile':
|
||||
case 'gnumakefile':
|
||||
return StreamLanguage.define(shell);
|
||||
case '.gitignore':
|
||||
case '.dockerignore':
|
||||
case '.npmignore':
|
||||
case '.eslintignore':
|
||||
return StreamLanguage.define(shell); // close enough for ignore files
|
||||
case '.env':
|
||||
case '.env.local':
|
||||
case '.env.development':
|
||||
case '.env.production':
|
||||
return StreamLanguage.define(shell);
|
||||
}
|
||||
|
||||
switch (ext) {
|
||||
// JavaScript/TypeScript
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return javascript();
|
||||
case 'jsx':
|
||||
return javascript({ jsx: true });
|
||||
case 'ts':
|
||||
case 'mts':
|
||||
case 'cts':
|
||||
return javascript({ typescript: true });
|
||||
case 'tsx':
|
||||
return javascript({ jsx: true, typescript: true });
|
||||
|
||||
// Web
|
||||
case 'html':
|
||||
case 'htm':
|
||||
case 'svelte':
|
||||
case 'vue':
|
||||
return html();
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return css();
|
||||
case 'json':
|
||||
case 'jsonc':
|
||||
case 'json5':
|
||||
return json();
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
case 'xsl':
|
||||
case 'xslt':
|
||||
case 'plist':
|
||||
return xml();
|
||||
|
||||
// Markdown
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
case 'markdown':
|
||||
return markdown();
|
||||
|
||||
// Python
|
||||
case 'py':
|
||||
case 'pyx':
|
||||
case 'pyi':
|
||||
return python();
|
||||
|
||||
// Java/Kotlin
|
||||
case 'java':
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return java();
|
||||
|
||||
// Systems
|
||||
case 'rs':
|
||||
return rust();
|
||||
case 'c':
|
||||
case 'h':
|
||||
return cpp();
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
case 'hpp':
|
||||
case 'hxx':
|
||||
return cpp();
|
||||
case 'go':
|
||||
return StreamLanguage.define(go);
|
||||
case 'swift':
|
||||
return StreamLanguage.define(swift);
|
||||
|
||||
// Scripting
|
||||
case 'rb':
|
||||
case 'erb':
|
||||
return StreamLanguage.define(ruby);
|
||||
case 'php':
|
||||
return php();
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
case 'fish':
|
||||
return StreamLanguage.define(shell);
|
||||
|
||||
// Data
|
||||
case 'sql':
|
||||
case 'mysql':
|
||||
case 'pgsql':
|
||||
return sql();
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return StreamLanguage.define(yaml);
|
||||
case 'toml':
|
||||
return StreamLanguage.define(toml);
|
||||
|
||||
default:
|
||||
return null; // Plain text fallback
|
||||
}
|
||||
/** Raw unified diff string for the file, used to highlight added/removed lines */
|
||||
diffContent?: string | null;
|
||||
/** Fires when the text selection state changes (true = non-empty selection) */
|
||||
onSelectionChange?: (hasSelection: boolean) => void;
|
||||
}
|
||||
|
||||
/** Get a human-readable language name */
|
||||
@@ -295,6 +153,215 @@ export function getLanguageName(filePath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Inline Diff Decorations ─────────────────────────────────────────────
|
||||
|
||||
/** Parsed diff info: added line numbers and groups of deleted lines with content */
|
||||
interface DiffInfo {
|
||||
addedLines: Set<number>;
|
||||
/**
|
||||
* Groups of consecutive deleted lines keyed by the new-file line number
|
||||
* they appear before. E.g. key=3 means the deleted lines were removed
|
||||
* just before line 3 in the current file.
|
||||
*/
|
||||
deletedGroups: Map<number, string[]>;
|
||||
}
|
||||
|
||||
/** Parse a unified diff to extract added lines and groups of deleted lines */
|
||||
function parseUnifiedDiff(diffContent: string): DiffInfo {
|
||||
const addedLines = new Set<number>();
|
||||
const deletedGroups = new Map<number, string[]>();
|
||||
const lines = diffContent.split('\n');
|
||||
|
||||
let currentNewLine = 0;
|
||||
let inHunk = false;
|
||||
let pendingDeletions: string[] = [];
|
||||
|
||||
const flushDeletions = () => {
|
||||
if (pendingDeletions.length > 0) {
|
||||
const existing = deletedGroups.get(currentNewLine);
|
||||
if (existing) {
|
||||
existing.push(...pendingDeletions);
|
||||
} else {
|
||||
deletedGroups.set(currentNewLine, [...pendingDeletions]);
|
||||
}
|
||||
pendingDeletions = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@ ...
|
||||
if (line.startsWith('@@')) {
|
||||
flushDeletions();
|
||||
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (match) {
|
||||
currentNewLine = parseInt(match[1], 10);
|
||||
inHunk = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inHunk) continue;
|
||||
|
||||
// Skip diff header lines
|
||||
if (
|
||||
line.startsWith('--- ') ||
|
||||
line.startsWith('+++ ') ||
|
||||
line.startsWith('diff ') ||
|
||||
line.startsWith('index ')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
flushDeletions();
|
||||
addedLines.add(currentNewLine);
|
||||
currentNewLine++;
|
||||
} else if (line.startsWith('-')) {
|
||||
// Accumulate deleted lines to show as a group
|
||||
pendingDeletions.push(line.substring(1));
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
flushDeletions();
|
||||
currentNewLine++;
|
||||
}
|
||||
}
|
||||
|
||||
flushDeletions();
|
||||
return { addedLines, deletedGroups };
|
||||
}
|
||||
|
||||
/** Widget that renders a block of deleted lines inline in the editor */
|
||||
class DeletedLinesWidget extends WidgetType {
|
||||
constructor(readonly lines: string[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-diff-deleted-widget';
|
||||
container.style.cssText =
|
||||
'background-color: oklch(0.55 0.22 25 / 0.1); border-left: 3px solid oklch(0.55 0.22 25 / 0.5);';
|
||||
|
||||
for (const line of this.lines) {
|
||||
const lineEl = document.createElement('div');
|
||||
lineEl.style.cssText =
|
||||
'text-decoration: line-through; color: oklch(0.55 0.22 25 / 0.8); background-color: oklch(0.55 0.22 25 / 0.15); padding: 0 0.5rem; padding-left: calc(0.5rem - 3px); white-space: pre; font-family: inherit;';
|
||||
lineEl.textContent = line || ' ';
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
eq(other: WidgetType) {
|
||||
if (!(other instanceof DeletedLinesWidget)) return false;
|
||||
return (
|
||||
this.lines.length === other.lines.length && this.lines.every((l, i) => l === other.lines[i])
|
||||
);
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a CodeMirror extension that decorates lines based on diff */
|
||||
function createDiffDecorations(diffContent: string | null | undefined): Extension {
|
||||
if (!diffContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { addedLines, deletedGroups } = parseUnifiedDiff(diffContent);
|
||||
if (addedLines.size === 0 && deletedGroups.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const addedLineDecoration = Decoration.line({
|
||||
class: 'cm-diff-added-line',
|
||||
attributes: { style: 'background-color: oklch(0.65 0.2 145 / 0.15);' },
|
||||
});
|
||||
|
||||
const extensions: Extension[] = [];
|
||||
|
||||
// Line decorations for added lines
|
||||
if (addedLines.size > 0) {
|
||||
extensions.push(
|
||||
EditorView.decorations.of((view) => {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (const lineNum of addedLines) {
|
||||
if (lineNum >= 1 && lineNum <= doc.lines) {
|
||||
const linePos = doc.line(lineNum).from;
|
||||
builder.add(linePos, linePos, addedLineDecoration);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Widget decorations for deleted line groups.
|
||||
// Block decorations MUST be provided via a StateField (not a plugin/function).
|
||||
if (deletedGroups.size > 0) {
|
||||
const buildDeletedDecorations = (doc: {
|
||||
lines: number;
|
||||
line(n: number): { from: number; to: number };
|
||||
}) => {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const positions = [...deletedGroups.keys()].sort((a, b) => a - b);
|
||||
|
||||
for (const pos of positions) {
|
||||
const deletedLines = deletedGroups.get(pos)!;
|
||||
if (pos >= 1 && pos <= doc.lines) {
|
||||
const linePos = doc.line(pos).from;
|
||||
builder.add(
|
||||
linePos,
|
||||
linePos,
|
||||
Decoration.widget({
|
||||
widget: new DeletedLinesWidget(deletedLines),
|
||||
block: true,
|
||||
side: -1,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const lastLinePos = doc.line(doc.lines).to;
|
||||
builder.add(
|
||||
lastLinePos,
|
||||
lastLinePos,
|
||||
Decoration.widget({
|
||||
widget: new DeletedLinesWidget(deletedLines),
|
||||
block: true,
|
||||
side: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
};
|
||||
|
||||
extensions.push(
|
||||
StateField.define({
|
||||
create(state) {
|
||||
return buildDeletedDecorations(state.doc);
|
||||
},
|
||||
update(decorations, tr) {
|
||||
if (tr.docChanged) {
|
||||
return decorations.map(tr.changes);
|
||||
}
|
||||
return decorations;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Syntax highlighting using CSS variables for theme compatibility
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
@@ -338,6 +405,8 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
|
||||
onSave,
|
||||
className,
|
||||
scrollCursorIntoView = false,
|
||||
diffContent,
|
||||
onSelectionChange,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -347,12 +416,17 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
|
||||
// Stable refs for callbacks to avoid frequent extension rebuilds
|
||||
const onSaveRef = useRef(onSave);
|
||||
const onCursorChangeRef = useRef(onCursorChange);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
const lastHasSelectionRef = useRef(false);
|
||||
useEffect(() => {
|
||||
onSaveRef.current = onSave;
|
||||
}, [onSave]);
|
||||
useEffect(() => {
|
||||
onCursorChangeRef.current = onCursorChange;
|
||||
}, [onCursorChange]);
|
||||
useEffect(() => {
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
// Expose imperative methods to parent components
|
||||
useImperativeHandle(
|
||||
@@ -381,6 +455,16 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
|
||||
cmRedo(editorRef.current.view);
|
||||
}
|
||||
},
|
||||
getSelection: () => {
|
||||
const view = editorRef.current?.view;
|
||||
if (!view) return null;
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (from === to) return null;
|
||||
const text = view.state.sliceDoc(from, to);
|
||||
const fromLine = view.state.doc.lineAt(from).number;
|
||||
const toLine = view.state.doc.lineAt(to).number;
|
||||
return { text, fromLine, toLine };
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
@@ -537,10 +621,20 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
|
||||
editorTheme,
|
||||
search(),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet && onCursorChangeRef.current) {
|
||||
const pos = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(pos);
|
||||
onCursorChangeRef.current(line.number, pos - line.from + 1);
|
||||
if (update.selectionSet) {
|
||||
if (onCursorChangeRef.current) {
|
||||
const pos = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(pos);
|
||||
onCursorChangeRef.current(line.number, pos - line.from + 1);
|
||||
}
|
||||
if (onSelectionChangeRef.current) {
|
||||
const { from, to } = update.state.selection.main;
|
||||
const hasSelection = from !== to;
|
||||
if (hasSelection !== lastHasSelectionRef.current) {
|
||||
lastHasSelectionRef.current = hasSelection;
|
||||
onSelectionChangeRef.current(hasSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
];
|
||||
@@ -572,8 +666,13 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
|
||||
exts.push(langExt);
|
||||
}
|
||||
|
||||
// Add inline diff decorations if diff content is provided
|
||||
if (diffContent) {
|
||||
exts.push(createDiffDecorations(diffContent));
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [filePath, wordWrap, tabSize, editorTheme]);
|
||||
}, [filePath, wordWrap, tabSize, editorTheme, diffContent]);
|
||||
|
||||
return (
|
||||
<div className={cn('h-full w-full', className)}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { X, Circle, MoreHorizontal, Save } from 'lucide-react';
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { X, Circle, MoreHorizontal, Save, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { EditorTab } from '../use-file-editor-store';
|
||||
import {
|
||||
@@ -84,61 +85,105 @@ export function EditorTabs({
|
||||
isDirty,
|
||||
showSaveButton,
|
||||
}: EditorTabsProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const activeTabRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll the active tab into view when it changes
|
||||
useEffect(() => {
|
||||
if (activeTabRef.current) {
|
||||
activeTabRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}, [activeTabId]);
|
||||
|
||||
const scrollBy = useCallback((direction: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
const amount = direction === 'left' ? -200 : 200;
|
||||
scrollRef.current.scrollBy({ left: amount, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center border-b border-border bg-muted/30 overflow-x-auto"
|
||||
data-testid="editor-tabs"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const fileColor = getFileColor(tab.fileName);
|
||||
<div className="flex items-center border-b border-border bg-muted/30" data-testid="editor-tabs">
|
||||
{/* Scroll left arrow */}
|
||||
<button
|
||||
onClick={() => scrollBy('left')}
|
||||
className="shrink-0 p-1 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Scroll tabs left"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer border-r border-border min-w-0 max-w-[200px] text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-background text-foreground border-b-2 border-b-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => onTabSelect(tab.id)}
|
||||
title={tab.filePath}
|
||||
>
|
||||
{/* Dirty indicator */}
|
||||
{tab.isDirty ? (
|
||||
<Circle className="w-2 h-2 shrink-0 fill-current text-primary" />
|
||||
) : (
|
||||
<span className={cn('w-2 h-2 rounded-full shrink-0', fileColor)} />
|
||||
)}
|
||||
{/* Scrollable tab area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex items-center overflow-x-auto flex-1 min-w-0 scrollbar-none"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const fileColor = getFileColor(tab.fileName);
|
||||
|
||||
{/* File name */}
|
||||
<span className="truncate">{tab.fileName}</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tab.id);
|
||||
}}
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={isActive ? activeTabRef : undefined}
|
||||
className={cn(
|
||||
'p-0.5 rounded shrink-0 transition-colors',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
isActive && 'opacity-60',
|
||||
'hover:bg-accent'
|
||||
'group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer border-r border-border min-w-0 max-w-[200px] shrink-0 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-background text-foreground border-b-2 border-b-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
)}
|
||||
title="Close"
|
||||
onClick={() => onTabSelect(tab.id)}
|
||||
title={tab.filePath}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Dirty indicator */}
|
||||
{tab.isDirty ? (
|
||||
<Circle className="w-2 h-2 shrink-0 fill-current text-primary" />
|
||||
) : (
|
||||
<span
|
||||
className={cn('w-2 h-2 rounded-full shrink-0', fileColor.replace('text-', 'bg-'))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* File name */}
|
||||
<span className="truncate">{tab.fileName}</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
'p-0.5 rounded shrink-0 transition-colors',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
isActive && 'opacity-60',
|
||||
'hover:bg-accent'
|
||||
)}
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Scroll right arrow */}
|
||||
<button
|
||||
onClick={() => scrollBy('right')}
|
||||
className="shrink-0 p-1 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Scroll tabs right"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Tab actions: save button (mobile) + close-all dropdown */}
|
||||
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5">
|
||||
<div className="shrink-0 flex items-center px-1 gap-0.5 border-l border-border">
|
||||
{/* Save button — shown in the tab bar on mobile */}
|
||||
{showSaveButton && onSave && (
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
File,
|
||||
Folder,
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
|
||||
import {
|
||||
useFileEditorStore,
|
||||
type FileTreeNode,
|
||||
type EnhancedGitFileStatus,
|
||||
} from '../use-file-editor-store';
|
||||
import { useFileBrowser } from '@/contexts/file-browser-context';
|
||||
|
||||
interface FileTreeProps {
|
||||
@@ -105,6 +109,51 @@ function getGitStatusLabel(status: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Status priority for determining the "dominant" status on a folder (higher = more prominent) */
|
||||
const STATUS_PRIORITY: Record<string, number> = {
|
||||
U: 6, // Conflicted - highest priority
|
||||
D: 5, // Deleted
|
||||
A: 4, // Added
|
||||
M: 3, // Modified
|
||||
R: 2, // Renamed
|
||||
C: 2, // Copied
|
||||
S: 1, // Staged
|
||||
'?': 0, // Untracked
|
||||
'!': -1, // Ignored - lowest priority
|
||||
};
|
||||
|
||||
/** Compute aggregated git status info for a folder from the status maps */
|
||||
function computeFolderGitRollup(
|
||||
folderPath: string,
|
||||
gitStatusMap: Map<string, string>,
|
||||
enhancedGitStatusMap: Map<string, EnhancedGitFileStatus>
|
||||
): { count: number; dominantStatus: string | null; totalAdded: number; totalRemoved: number } {
|
||||
const prefix = folderPath + '/';
|
||||
let count = 0;
|
||||
let dominantStatus: string | null = null;
|
||||
let dominantPriority = -2;
|
||||
let totalAdded = 0;
|
||||
let totalRemoved = 0;
|
||||
|
||||
for (const [filePath, status] of gitStatusMap) {
|
||||
if (filePath.startsWith(prefix)) {
|
||||
count++;
|
||||
const priority = STATUS_PRIORITY[status] ?? -1;
|
||||
if (priority > dominantPriority) {
|
||||
dominantPriority = priority;
|
||||
dominantStatus = status;
|
||||
}
|
||||
const enhanced = enhancedGitStatusMap.get(filePath);
|
||||
if (enhanced) {
|
||||
totalAdded += enhanced.linesAdded;
|
||||
totalRemoved += enhanced.linesRemoved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { count, dominantStatus, totalAdded, totalRemoved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a file/folder name for safety.
|
||||
* Rejects names containing path separators, relative path components,
|
||||
@@ -281,6 +330,12 @@ function TreeNode({
|
||||
const linesRemoved = enhancedStatus?.linesRemoved || 0;
|
||||
const enhancedLabel = enhancedStatus?.statusLabel || statusLabel;
|
||||
|
||||
// Folder-level git status rollup
|
||||
const folderRollup = useMemo(() => {
|
||||
if (!node.isDirectory) return null;
|
||||
return computeFolderGitRollup(node.path, gitStatusMap, enhancedGitStatusMap);
|
||||
}, [node.isDirectory, node.path, gitStatusMap, enhancedGitStatusMap]);
|
||||
|
||||
// Drag state
|
||||
const isDragging = dragState.draggedPaths.includes(node.path);
|
||||
const isDropTarget = dragState.dropTargetPath === node.path && node.isDirectory;
|
||||
@@ -385,9 +440,16 @@ function TreeNode({
|
||||
|
||||
// Build tooltip with enhanced info
|
||||
let tooltip = node.name;
|
||||
if (enhancedLabel) tooltip += ` (${enhancedLabel})`;
|
||||
if (linesAdded > 0 || linesRemoved > 0) {
|
||||
tooltip += ` +${linesAdded} -${linesRemoved}`;
|
||||
if (node.isDirectory && folderRollup && folderRollup.count > 0) {
|
||||
tooltip += ` (${folderRollup.count} changed file${folderRollup.count !== 1 ? 's' : ''})`;
|
||||
if (folderRollup.totalAdded > 0 || folderRollup.totalRemoved > 0) {
|
||||
tooltip += ` +${folderRollup.totalAdded} -${folderRollup.totalRemoved}`;
|
||||
}
|
||||
} else {
|
||||
if (enhancedLabel) tooltip += ` (${enhancedLabel})`;
|
||||
if (linesAdded > 0 || linesRemoved > 0) {
|
||||
tooltip += ` +${linesAdded} -${linesRemoved}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -456,7 +518,33 @@ function TreeNode({
|
||||
{/* Name */}
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
|
||||
{/* Diff stats (lines added/removed) shown inline */}
|
||||
{/* Folder: modified file count badge and rollup indicator */}
|
||||
{node.isDirectory && folderRollup && folderRollup.count > 0 && (
|
||||
<>
|
||||
<span
|
||||
className="text-[10px] font-medium shrink-0 px-1 py-0 rounded-full bg-muted text-muted-foreground"
|
||||
title={`${folderRollup.count} changed file${folderRollup.count !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{folderRollup.count}
|
||||
</span>
|
||||
<span
|
||||
className={cn('w-1.5 h-1.5 rounded-full shrink-0', {
|
||||
'bg-yellow-500': folderRollup.dominantStatus === 'M',
|
||||
'bg-green-500':
|
||||
folderRollup.dominantStatus === 'A' || folderRollup.dominantStatus === 'S',
|
||||
'bg-red-500': folderRollup.dominantStatus === 'D',
|
||||
'bg-gray-400': folderRollup.dominantStatus === '?',
|
||||
'bg-gray-600': folderRollup.dominantStatus === '!',
|
||||
'bg-purple-500': folderRollup.dominantStatus === 'R',
|
||||
'bg-cyan-500': folderRollup.dominantStatus === 'C',
|
||||
'bg-orange-500': folderRollup.dominantStatus === 'U',
|
||||
})}
|
||||
title={`${folderRollup.dominantStatus ? getGitStatusLabel(folderRollup.dominantStatus) : 'Changed'} (${folderRollup.count})`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File: diff stats (lines added/removed) shown inline */}
|
||||
{!node.isDirectory && (linesAdded > 0 || linesRemoved > 0) && (
|
||||
<span className="flex items-center gap-1 text-[10px] shrink-0 opacity-70">
|
||||
{linesAdded > 0 && (
|
||||
@@ -474,8 +562,8 @@ function TreeNode({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Git status indicator - two-tone badge for staged+unstaged */}
|
||||
{gitStatus && (
|
||||
{/* File: git status indicator - two-tone badge for staged+unstaged */}
|
||||
{!node.isDirectory && gitStatus && (
|
||||
<span className="flex items-center gap-0 shrink-0">
|
||||
{isStaged && isUnstaged ? (
|
||||
// Two-tone badge: staged (green) + unstaged (yellow)
|
||||
|
||||
@@ -11,10 +11,17 @@ import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
Settings,
|
||||
Diff,
|
||||
FolderKanban,
|
||||
} from 'lucide-react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { cn, generateUUID, pathsEqual } from '@/lib/utils';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -23,7 +30,6 @@ import {
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -42,6 +48,7 @@ import {
|
||||
} from './components/markdown-preview';
|
||||
import { WorktreeDirectoryDropdown } from './components/worktree-directory-dropdown';
|
||||
import { GitDetailPanel } from './components/git-detail-panel';
|
||||
import { AddFeatureDialog } from '@/components/views/board-view/dialogs';
|
||||
|
||||
const logger = createLogger('FileEditorView');
|
||||
|
||||
@@ -111,10 +118,13 @@ interface FileEditorViewProps {
|
||||
}
|
||||
|
||||
export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
const { currentProject } = useAppStore();
|
||||
const { currentProject, defaultSkipTests, getCurrentWorktree, worktreesByProject } =
|
||||
useAppStore();
|
||||
const currentWorktree = useAppStore((s) =>
|
||||
currentProject?.path ? (s.currentWorktreeByProject[currentProject.path] ?? null) : null
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
// Read persisted editor font settings from app store
|
||||
const editorFontSize = useAppStore((s) => s.editorFontSize);
|
||||
const editorFontFamily = useAppStore((s) => s.editorFontFamily);
|
||||
@@ -131,6 +141,9 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
const editorRef = useRef<CodeEditorHandle>(null);
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
const [hasEditorSelection, setHasEditorSelection] = useState(false);
|
||||
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
|
||||
const [featureSelectionContext, setFeatureSelectionContext] = useState<string | undefined>();
|
||||
|
||||
// Derive the effective working path from the current worktree selection.
|
||||
// When a worktree is selected (path is non-null), use the worktree path;
|
||||
@@ -151,7 +164,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
tabSize,
|
||||
wordWrap,
|
||||
maxFileSize,
|
||||
setFileTree,
|
||||
openTab,
|
||||
closeTab,
|
||||
closeAllTabs,
|
||||
@@ -159,14 +171,11 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
markTabSaved,
|
||||
setMarkdownViewMode,
|
||||
setMobileBrowserVisible,
|
||||
setGitStatusMap,
|
||||
setExpandedFolders,
|
||||
setEnhancedGitStatusMap,
|
||||
setGitBranch,
|
||||
setActiveFileGitDetails,
|
||||
activeFileGitDetails,
|
||||
gitBranch,
|
||||
enhancedGitStatusMap,
|
||||
showInlineDiff,
|
||||
setShowInlineDiff,
|
||||
activeFileDiff,
|
||||
} = store;
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId) || null;
|
||||
@@ -217,6 +226,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
};
|
||||
|
||||
const tree = await buildTree(treePath);
|
||||
const { setFileTree, setExpandedFolders } = useFileEditorStore.getState();
|
||||
setFileTree(tree);
|
||||
|
||||
if (expandedSnapshot !== null) {
|
||||
@@ -230,12 +240,14 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
logger.error('Failed to load file tree:', error);
|
||||
}
|
||||
},
|
||||
[effectivePath, setFileTree, setExpandedFolders]
|
||||
[effectivePath]
|
||||
);
|
||||
|
||||
// ─── Load Git Status ─────────────────────────────────────────
|
||||
const loadGitStatus = useCallback(async () => {
|
||||
if (!effectivePath) return;
|
||||
const { setGitStatusMap, setEnhancedGitStatusMap, setGitBranch } =
|
||||
useFileEditorStore.getState();
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -289,7 +301,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
// Git might not be available - that's okay
|
||||
logger.debug('Git status not available:', error);
|
||||
}
|
||||
}, [effectivePath, setGitStatusMap, setEnhancedGitStatusMap, setGitBranch]);
|
||||
}, [effectivePath]);
|
||||
|
||||
// ─── Load subdirectory children lazily ───────────────────────
|
||||
const loadSubdirectory = useCallback(async (dirPath: string): Promise<FileTreeNode[]> => {
|
||||
@@ -448,6 +460,33 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
[handleFileSelect, isMobile, setMobileBrowserVisible]
|
||||
);
|
||||
|
||||
// ─── Load File Diff for Inline Display ───────────────────────────────────
|
||||
const loadFileDiff = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!effectivePath) return;
|
||||
const { setActiveFileDiff } = useFileEditorStore.getState();
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.git?.getFileDiff) return;
|
||||
|
||||
// Get relative path
|
||||
const relativePath = filePath.startsWith(effectivePath)
|
||||
? filePath.substring(effectivePath.length + 1)
|
||||
: filePath;
|
||||
|
||||
const result = await api.git.getFileDiff(effectivePath, relativePath);
|
||||
if (result.success && result.diff) {
|
||||
setActiveFileDiff(result.diff);
|
||||
} else {
|
||||
setActiveFileDiff(null);
|
||||
}
|
||||
} catch {
|
||||
setActiveFileDiff(null);
|
||||
}
|
||||
},
|
||||
[effectivePath]
|
||||
);
|
||||
|
||||
// ─── Handle Save ─────────────────────────────────────────────
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!activeTab || !activeTab.isDirty) return;
|
||||
@@ -458,15 +497,18 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
|
||||
if (result.success) {
|
||||
markTabSaved(activeTab.id, activeTab.content);
|
||||
// Refresh git status after save
|
||||
// Refresh git status and inline diff after save
|
||||
loadGitStatus();
|
||||
if (showInlineDiff) {
|
||||
loadFileDiff(activeTab.filePath);
|
||||
}
|
||||
} else {
|
||||
logger.error('Failed to save file:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to save file:', error);
|
||||
}
|
||||
}, [activeTab, markTabSaved, loadGitStatus]);
|
||||
}, [activeTab, markTabSaved, loadGitStatus, showInlineDiff, loadFileDiff]);
|
||||
|
||||
// ─── Auto Save: save a specific tab by ID ───────────────────
|
||||
const saveTabById = useCallback(
|
||||
@@ -482,6 +524,11 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
if (result.success) {
|
||||
markTabSaved(tab.id, tab.content);
|
||||
loadGitStatus();
|
||||
// Refresh inline diff for the saved file if diff is active
|
||||
const { showInlineDiff, activeTabId: currentActive } = useFileEditorStore.getState();
|
||||
if (showInlineDiff && tab.id === currentActive) {
|
||||
loadFileDiff(tab.filePath);
|
||||
}
|
||||
} else {
|
||||
logger.error('Auto-save failed:', result.error);
|
||||
}
|
||||
@@ -489,7 +536,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
logger.error('Auto-save failed:', error);
|
||||
}
|
||||
},
|
||||
[markTabSaved, loadGitStatus]
|
||||
[markTabSaved, loadGitStatus, loadFileDiff]
|
||||
);
|
||||
|
||||
// ─── Auto Save: on tab switch ──────────────────────────────
|
||||
@@ -560,6 +607,151 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── Get current branch from selected worktree ────────────
|
||||
const currentBranch = useMemo(() => {
|
||||
if (!currentProject?.path) return '';
|
||||
const currentWorktreeInfo = getCurrentWorktree(currentProject.path);
|
||||
const worktrees = worktreesByProject[currentProject.path] ?? [];
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
|
||||
const selectedWorktree =
|
||||
currentWorktreePath === null
|
||||
? worktrees.find((w) => w.isMain)
|
||||
: worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
||||
|
||||
return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || '';
|
||||
}, [currentProject?.path, getCurrentWorktree, worktreesByProject]);
|
||||
|
||||
// ─── Create Feature from Selection ─────────────────────────
|
||||
const handleCreateFeatureFromSelection = useCallback(() => {
|
||||
if (!activeTab || !editorRef.current || !effectivePath) return;
|
||||
|
||||
const selection = editorRef.current.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// Compute relative path from effectivePath
|
||||
const relativePath = activeTab.filePath.startsWith(effectivePath)
|
||||
? activeTab.filePath.substring(effectivePath.length + 1)
|
||||
: activeTab.filePath.split('/').pop() || activeTab.filePath;
|
||||
|
||||
// Get language extension for code fence
|
||||
const langName = getLanguageName(activeTab.filePath).toLowerCase();
|
||||
const langMap: Record<string, string> = {
|
||||
javascript: 'js',
|
||||
jsx: 'jsx',
|
||||
typescript: 'ts',
|
||||
tsx: 'tsx',
|
||||
python: 'py',
|
||||
ruby: 'rb',
|
||||
shell: 'sh',
|
||||
'c++': 'cpp',
|
||||
'plain text': '',
|
||||
};
|
||||
const fenceLang = langMap[langName] || langName;
|
||||
|
||||
// Truncate selection to ~200 lines
|
||||
const lines = selection.text.split('\n');
|
||||
const truncated = lines.length > 200;
|
||||
const codeText = truncated ? lines.slice(0, 200).join('\n') + '\n[...]' : selection.text;
|
||||
|
||||
const description = [
|
||||
`**File:** \`${relativePath}\` (Lines ${selection.fromLine}-${selection.toLine})`,
|
||||
'',
|
||||
`\`\`\`${fenceLang}`,
|
||||
codeText,
|
||||
'```',
|
||||
truncated ? `\n*Selection truncated (${lines.length} lines total)*` : '',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
]
|
||||
.filter((line) => line !== undefined)
|
||||
.join('\n');
|
||||
|
||||
setFeatureSelectionContext(description);
|
||||
setShowAddFeatureDialog(true);
|
||||
}, [activeTab, effectivePath]);
|
||||
|
||||
// ─── Handle feature creation from AddFeatureDialog ─────────
|
||||
const handleAddFeatureFromEditor = useCallback(
|
||||
async (featureData: {
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
priority: number;
|
||||
model: string;
|
||||
thinkingLevel: string;
|
||||
reasoningEffort: string;
|
||||
skipTests: boolean;
|
||||
branchName: string;
|
||||
planningMode: string;
|
||||
requirePlanApproval: boolean;
|
||||
excludedPipelineSteps?: string[];
|
||||
workMode: string;
|
||||
imagePaths?: Array<{ id: string; path: string; description?: string }>;
|
||||
textFilePaths?: Array<{ id: string; path: string; description?: string }>;
|
||||
}) => {
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.features?.create) {
|
||||
const feature = {
|
||||
id: `editor-${generateUUID()}`,
|
||||
title: featureData.title,
|
||||
description: featureData.description,
|
||||
category: featureData.category,
|
||||
status: 'backlog' as const,
|
||||
passes: false,
|
||||
priority: featureData.priority,
|
||||
model: resolveModelString(featureData.model),
|
||||
thinkingLevel: featureData.thinkingLevel,
|
||||
reasoningEffort: featureData.reasoningEffort,
|
||||
skipTests: featureData.skipTests,
|
||||
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
|
||||
planningMode: featureData.planningMode,
|
||||
requirePlanApproval: featureData.requirePlanApproval,
|
||||
excludedPipelineSteps: featureData.excludedPipelineSteps,
|
||||
...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}),
|
||||
...(featureData.textFilePaths?.length
|
||||
? { textFilePaths: featureData.textFilePaths }
|
||||
: {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await api.features.create(currentProject.path, feature as any);
|
||||
if (result.success) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
toast.success(
|
||||
`Created feature: ${featureData.title || featureData.description.slice(0, 50)}`,
|
||||
{
|
||||
action: {
|
||||
label: 'View Board',
|
||||
onClick: () => navigate({ to: '/board' }),
|
||||
},
|
||||
}
|
||||
);
|
||||
setShowAddFeatureDialog(false);
|
||||
setFeatureSelectionContext(undefined);
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to create feature');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Create feature from editor error:', err);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create feature');
|
||||
}
|
||||
},
|
||||
[currentProject?.path, currentBranch, queryClient, navigate]
|
||||
);
|
||||
|
||||
// ─── File Operations ─────────────────────────────────────────
|
||||
const handleCreateFile = useCallback(
|
||||
async (parentPath: string, name: string) => {
|
||||
@@ -847,6 +1039,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
const loadFileGitDetails = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!effectivePath) return;
|
||||
const { setActiveFileGitDetails } = useFileEditorStore.getState();
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.git?.getDetails) return;
|
||||
@@ -866,7 +1059,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
setActiveFileGitDetails(null);
|
||||
}
|
||||
},
|
||||
[effectivePath, setActiveFileGitDetails]
|
||||
[effectivePath]
|
||||
);
|
||||
|
||||
// Load git details when active tab changes
|
||||
@@ -874,9 +1067,24 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
if (activeTab && !activeTab.isBinary) {
|
||||
loadFileGitDetails(activeTab.filePath);
|
||||
} else {
|
||||
setActiveFileGitDetails(null);
|
||||
useFileEditorStore.getState().setActiveFileGitDetails(null);
|
||||
}
|
||||
}, [activeTab?.filePath, activeTab?.isBinary, loadFileGitDetails, setActiveFileGitDetails]);
|
||||
}, [activeTab?.filePath, activeTab?.isBinary, loadFileGitDetails]);
|
||||
|
||||
// Load file diff when inline diff is enabled and active tab changes
|
||||
useEffect(() => {
|
||||
if (showInlineDiff && activeTab && !activeTab.isBinary && !activeTab.isTooLarge) {
|
||||
loadFileDiff(activeTab.filePath);
|
||||
} else {
|
||||
useFileEditorStore.getState().setActiveFileDiff(null);
|
||||
}
|
||||
}, [
|
||||
showInlineDiff,
|
||||
activeTab?.filePath,
|
||||
activeTab?.isBinary,
|
||||
activeTab?.isTooLarge,
|
||||
loadFileDiff,
|
||||
]);
|
||||
|
||||
// ─── Handle Cursor Change ────────────────────────────────────
|
||||
// Stable callback to avoid recreating CodeMirror extensions on every render.
|
||||
@@ -938,7 +1146,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
return n;
|
||||
});
|
||||
};
|
||||
setFileTree(updateChildren(fileTree));
|
||||
useFileEditorStore.getState().setFileTree(updateChildren(fileTree));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,7 +1154,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
// on every render, which would make this useCallback's dependency unstable.
|
||||
useFileEditorStore.getState().toggleFolder(path);
|
||||
},
|
||||
[loadSubdirectory, setFileTree]
|
||||
[loadSubdirectory]
|
||||
);
|
||||
|
||||
// ─── Initial Load ────────────────────────────────────────────
|
||||
@@ -1088,6 +1296,8 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
onCursorChange={handleCursorChange}
|
||||
onSave={handleSave}
|
||||
scrollCursorIntoView={isMobile && isKeyboardOpen}
|
||||
diffContent={showInlineDiff ? activeFileDiff : null}
|
||||
onSelectionChange={setHasEditorSelection}
|
||||
/>
|
||||
</Panel>
|
||||
<PanelResizeHandle
|
||||
@@ -1119,6 +1329,8 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
onCursorChange={handleCursorChange}
|
||||
onSave={handleSave}
|
||||
scrollCursorIntoView={isMobile && isKeyboardOpen}
|
||||
diffContent={showInlineDiff ? activeFileDiff : null}
|
||||
onSelectionChange={setHasEditorSelection}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -1302,6 +1514,41 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Desktop: Inline Diff toggle */}
|
||||
{activeTab &&
|
||||
!activeTab.isBinary &&
|
||||
!activeTab.isTooLarge &&
|
||||
!(isMobile && mobileBrowserVisible) && (
|
||||
<Button
|
||||
variant={showInlineDiff ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowInlineDiff(!showInlineDiff)}
|
||||
className="hidden lg:flex"
|
||||
title={showInlineDiff ? 'Hide git diff highlighting' : 'Show git diff highlighting'}
|
||||
>
|
||||
<Diff className="w-4 h-4 mr-2" />
|
||||
Diff
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Desktop: Create Feature from selection */}
|
||||
{hasEditorSelection &&
|
||||
activeTab &&
|
||||
!activeTab.isBinary &&
|
||||
!activeTab.isTooLarge &&
|
||||
!(isMobile && mobileBrowserVisible) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateFeatureFromSelection}
|
||||
className="hidden lg:flex"
|
||||
title="Create a board feature from the selected code"
|
||||
>
|
||||
<FolderKanban className="w-4 h-4 mr-2" />
|
||||
Create Feature
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Editor Settings popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -1415,6 +1662,37 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Inline Diff toggle */}
|
||||
{activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
|
||||
<button
|
||||
onClick={() => setShowInlineDiff(!showInlineDiff)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 w-full p-2 rounded-lg border transition-colors text-sm',
|
||||
showInlineDiff
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'bg-muted/30 border-border text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Diff className="w-4 h-4" />
|
||||
<span>{showInlineDiff ? 'Hide Git Diff' : 'Show Git Diff'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Create Feature from selection */}
|
||||
{hasEditorSelection && activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
handleCreateFeatureFromSelection();
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
>
|
||||
<FolderKanban className="w-4 h-4 mr-2" />
|
||||
Create Feature from Selection
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* File info */}
|
||||
{activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-lg bg-muted/30 border border-border">
|
||||
@@ -1478,6 +1756,27 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
)}
|
||||
|
||||
{/* Add Feature Dialog - opened from code selection */}
|
||||
<AddFeatureDialog
|
||||
open={showAddFeatureDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowAddFeatureDialog(open);
|
||||
if (!open) {
|
||||
setFeatureSelectionContext(undefined);
|
||||
}
|
||||
}}
|
||||
onAdd={handleAddFeatureFromEditor}
|
||||
categorySuggestions={['From Editor']}
|
||||
branchSuggestions={[]}
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
defaultBranch={currentBranch}
|
||||
currentBranch={currentBranch || undefined}
|
||||
isMaximized={false}
|
||||
projectPath={currentProject?.path}
|
||||
prefilledDescription={featureSelectionContext}
|
||||
prefilledCategory="From Editor"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,12 @@ interface FileEditorState {
|
||||
// Git details for the currently active file (loaded on demand)
|
||||
activeFileGitDetails: GitFileDetailsInfo | null;
|
||||
|
||||
// Inline diff display
|
||||
/** Whether to show inline git diffs in the editor */
|
||||
showInlineDiff: boolean;
|
||||
/** The diff content for the active file (raw unified diff) */
|
||||
activeFileDiff: string | null;
|
||||
|
||||
// Drag and drop state
|
||||
dragState: DragState;
|
||||
|
||||
@@ -135,6 +141,9 @@ interface FileEditorState {
|
||||
setGitBranch: (branch: string) => void;
|
||||
setActiveFileGitDetails: (details: GitFileDetailsInfo | null) => void;
|
||||
|
||||
setShowInlineDiff: (show: boolean) => void;
|
||||
setActiveFileDiff: (diff: string | null) => void;
|
||||
|
||||
setDragState: (state: DragState) => void;
|
||||
setSelectedPaths: (paths: Set<string>) => void;
|
||||
toggleSelectedPath: (path: string) => void;
|
||||
@@ -159,6 +168,8 @@ const initialState = {
|
||||
enhancedGitStatusMap: new Map<string, EnhancedGitFileStatus>(),
|
||||
gitBranch: '',
|
||||
activeFileGitDetails: null as GitFileDetailsInfo | null,
|
||||
showInlineDiff: false,
|
||||
activeFileDiff: null as string | null,
|
||||
dragState: { draggedPaths: [], dropTargetPath: null } as DragState,
|
||||
selectedPaths: new Set<string>(),
|
||||
};
|
||||
@@ -206,8 +217,18 @@ export const useFileEditorStore = create<FileEditorState>()(
|
||||
|
||||
const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const newTab: EditorTab = { ...tabData, id };
|
||||
let updatedTabs = [...tabs, newTab];
|
||||
|
||||
// Enforce max open tabs – evict the oldest non-dirty tab when over the limit
|
||||
const MAX_TABS = 25;
|
||||
while (updatedTabs.length > MAX_TABS) {
|
||||
const evictIdx = updatedTabs.findIndex((t) => t.id !== id && !t.isDirty);
|
||||
if (evictIdx === -1) break; // all other tabs are dirty, keep them
|
||||
updatedTabs.splice(evictIdx, 1);
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: [...tabs, newTab],
|
||||
tabs: updatedTabs,
|
||||
activeTabId: id,
|
||||
});
|
||||
},
|
||||
@@ -282,6 +303,9 @@ export const useFileEditorStore = create<FileEditorState>()(
|
||||
setGitBranch: (branch) => set({ gitBranch: branch }),
|
||||
setActiveFileGitDetails: (details) => set({ activeFileGitDetails: details }),
|
||||
|
||||
setShowInlineDiff: (show) => set({ showInlineDiff: show }),
|
||||
setActiveFileDiff: (diff) => set({ activeFileDiff: diff }),
|
||||
|
||||
setDragState: (state) => set({ dragState: state }),
|
||||
setSelectedPaths: (paths) => set({ selectedPaths: paths }),
|
||||
toggleSelectedPath: (path) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||
import {
|
||||
GitBranch,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
Copy,
|
||||
Plus,
|
||||
FolderOpen,
|
||||
LayoutGrid,
|
||||
Pin,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -64,6 +67,10 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
const copyFiles = copyFilesFromStore ?? EMPTY_FILES;
|
||||
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
|
||||
|
||||
// Worktree display settings
|
||||
const pinnedWorktreesCount = useAppStore((s) => s.getPinnedWorktreesCount(project.path));
|
||||
const setPinnedWorktreesCount = useAppStore((s) => s.setPinnedWorktreesCount);
|
||||
|
||||
// Get effective worktrees setting (project override or global fallback)
|
||||
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
|
||||
|
||||
@@ -78,6 +85,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
const [newCopyFilePath, setNewCopyFilePath] = useState('');
|
||||
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
|
||||
|
||||
// Ref for storing previous slider value for rollback on error
|
||||
const sliderPrevRef = useRef<number | null>(null);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = scriptContent !== originalContent;
|
||||
|
||||
@@ -115,6 +125,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
if (response.settings.worktreeCopyFiles !== undefined) {
|
||||
setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles);
|
||||
}
|
||||
if (response.settings.pinnedWorktreesCount !== undefined) {
|
||||
setPinnedWorktreesCount(currentPath, response.settings.pinnedWorktreesCount);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
@@ -135,6 +148,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
setDefaultDeleteBranch,
|
||||
setAutoDismissInitScriptIndicator,
|
||||
setWorktreeCopyFiles,
|
||||
setPinnedWorktreesCount,
|
||||
]);
|
||||
|
||||
// Load init script content when project changes
|
||||
@@ -507,6 +521,78 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Worktree Display Settings */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutGrid className="w-4 h-4 text-brand-500" />
|
||||
<Label className="text-foreground font-medium">Display Settings</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Control how worktrees are presented in the panel. Pinned worktrees appear as tabs, and
|
||||
remaining worktrees are available in a combined overflow dropdown.
|
||||
</p>
|
||||
|
||||
{/* Pinned Worktrees Count */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<div className="mt-0.5">
|
||||
<Pin className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="pinned-worktrees-count"
|
||||
className="text-foreground cursor-pointer font-medium"
|
||||
>
|
||||
Pinned Worktree Tabs
|
||||
</Label>
|
||||
<span className="text-sm font-medium text-foreground tabular-nums">
|
||||
{pinnedWorktreesCount}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Number of worktree tabs to pin (excluding the main worktree, which is always shown).
|
||||
</p>
|
||||
<Slider
|
||||
id="pinned-worktrees-count"
|
||||
min={0}
|
||||
max={25}
|
||||
step={1}
|
||||
value={[pinnedWorktreesCount]}
|
||||
onValueChange={(value) => {
|
||||
// Capture previous value before mutation for potential rollback
|
||||
const prevCount = pinnedWorktreesCount;
|
||||
// Update local state immediately for visual feedback
|
||||
const newValue = value[0] ?? pinnedWorktreesCount;
|
||||
setPinnedWorktreesCount(project.path, newValue);
|
||||
// Store prev for onValueCommit rollback
|
||||
sliderPrevRef.current = prevCount;
|
||||
}}
|
||||
onValueCommit={async (value) => {
|
||||
const newValue = value[0] ?? pinnedWorktreesCount;
|
||||
const prev = sliderPrevRef.current ?? pinnedWorktreesCount;
|
||||
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(project.path, {
|
||||
pinnedWorktreesCount: newValue,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist pinnedWorktreesCount:', error);
|
||||
toast.error('Failed to save pinned worktrees setting');
|
||||
// Rollback optimistic update using captured previous value
|
||||
setPinnedWorktreesCount(project.path, prev);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Copy Files Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -906,6 +906,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
|
||||
if (result.success) {
|
||||
removeRunningTask(currentProject.id, branchName, featureId);
|
||||
|
||||
logger.info('Feature stopped successfully:', featureId);
|
||||
addAutoModeActivity({
|
||||
featureId,
|
||||
|
||||
@@ -15,6 +15,7 @@ interface UseElectronAgentOptions {
|
||||
model?: string;
|
||||
thinkingLevel?: string;
|
||||
onToolUse?: (toolName: string, toolInput: unknown) => void;
|
||||
onToolResult?: (toolName: string, result: unknown) => void;
|
||||
}
|
||||
|
||||
// Server-side queued prompt type
|
||||
@@ -72,6 +73,7 @@ export function useElectronAgent({
|
||||
model,
|
||||
thinkingLevel,
|
||||
onToolUse,
|
||||
onToolResult,
|
||||
}: UseElectronAgentOptions): UseElectronAgentResult {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -308,6 +310,12 @@ export function useElectronAgent({
|
||||
onToolUse?.(event.tool.name, event.tool.input);
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
// Tool completed - surface result via onToolResult callback
|
||||
logger.info('Tool result:', event.tool.name);
|
||||
onToolResult?.(event.tool.name, event.tool.input);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
// Agent finished processing for THIS session
|
||||
logger.info('Processing complete for session:', sessionId);
|
||||
@@ -366,7 +374,7 @@ export function useElectronAgent({
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, onToolUse]);
|
||||
}, [sessionId, onToolUse, onToolResult]);
|
||||
|
||||
// Send a message to the agent
|
||||
const sendMessage = useCallback(
|
||||
|
||||
@@ -26,6 +26,10 @@ export function useProjectSettingsLoader() {
|
||||
(state) => state.setAutoDismissInitScriptIndicator
|
||||
);
|
||||
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
|
||||
const setProjectUseWorktrees = useAppStore((state) => state.setProjectUseWorktrees);
|
||||
const setPinnedWorktreesCount = useAppStore((state) => state.setPinnedWorktreesCount);
|
||||
const setWorktreeDropdownThreshold = useAppStore((state) => state.setWorktreeDropdownThreshold);
|
||||
const setAlwaysUseWorktreeDropdown = useAppStore((state) => state.setAlwaysUseWorktreeDropdown);
|
||||
|
||||
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
|
||||
|
||||
@@ -100,6 +104,24 @@ export function useProjectSettingsLoader() {
|
||||
setWorktreeCopyFiles(projectPath, settings.worktreeCopyFiles);
|
||||
}
|
||||
|
||||
// Apply useWorktrees if present
|
||||
if (settings.useWorktrees !== undefined) {
|
||||
setProjectUseWorktrees(projectPath, settings.useWorktrees);
|
||||
}
|
||||
|
||||
// Apply worktree display settings if present
|
||||
if (settings.pinnedWorktreesCount !== undefined) {
|
||||
setPinnedWorktreesCount(projectPath, settings.pinnedWorktreesCount);
|
||||
}
|
||||
|
||||
if (settings.worktreeDropdownThreshold !== undefined) {
|
||||
setWorktreeDropdownThreshold(projectPath, settings.worktreeDropdownThreshold);
|
||||
}
|
||||
|
||||
if (settings.alwaysUseWorktreeDropdown !== undefined) {
|
||||
setAlwaysUseWorktreeDropdown(projectPath, settings.alwaysUseWorktreeDropdown);
|
||||
}
|
||||
|
||||
// Apply activeClaudeApiProfileId and phaseModelOverrides if present
|
||||
// These are stored directly on the project, so we need to update both
|
||||
// currentProject AND the projects array to keep them in sync
|
||||
@@ -167,5 +189,9 @@ export function useProjectSettingsLoader() {
|
||||
setDefaultDeleteBranch,
|
||||
setAutoDismissInitScriptIndicator,
|
||||
setWorktreeCopyFiles,
|
||||
setProjectUseWorktrees,
|
||||
setPinnedWorktreesCount,
|
||||
setWorktreeDropdownThreshold,
|
||||
setAlwaysUseWorktreeDropdown,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -750,6 +750,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
|
||||
defaultFeatureModel: migratePhaseModelEntry(settings.defaultFeatureModel) ?? {
|
||||
model: 'claude-opus',
|
||||
thinkingLevel: 'adaptive',
|
||||
},
|
||||
muteDoneSound: settings.muteDoneSound ?? false,
|
||||
disableSplashScreen: settings.disableSplashScreen ?? false,
|
||||
@@ -759,7 +760,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
|
||||
validationModel: settings.validationModel ?? 'claude-opus',
|
||||
phaseModels: { ...DEFAULT_PHASE_MODELS, ...(settings.phaseModels ?? current.phaseModels) },
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none',
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'adaptive',
|
||||
defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none',
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
cursorDefaultModel: sanitizedCursorDefaultModel,
|
||||
@@ -805,7 +806,11 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
// (error boundary reloads → restores same bad path → crash again).
|
||||
// The use-worktrees validation effect will re-discover valid worktrees
|
||||
// from the server once they load.
|
||||
currentWorktreeByProject: sanitizeWorktreeByProject(settings.currentWorktreeByProject),
|
||||
currentWorktreeByProject: Object.fromEntries(
|
||||
Object.entries(sanitizeWorktreeByProject(settings.currentWorktreeByProject)).filter(
|
||||
([, worktree]) => worktree.path === null
|
||||
)
|
||||
),
|
||||
// UI State
|
||||
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
|
||||
lastProjectDir: settings.lastProjectDir ?? '',
|
||||
|
||||
@@ -75,6 +75,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'enhancementModel',
|
||||
'validationModel',
|
||||
'phaseModels',
|
||||
'defaultThinkingLevel',
|
||||
'defaultReasoningEffort',
|
||||
'enabledCursorModels',
|
||||
'cursorDefaultModel',
|
||||
'enabledOpencodeModels',
|
||||
@@ -781,9 +783,9 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
|
||||
defaultFeatureModel: serverSettings.defaultFeatureModel
|
||||
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
||||
: { model: 'claude-opus' },
|
||||
: { model: 'claude-opus', thinkingLevel: 'adaptive' },
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 1000,
|
||||
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 10000,
|
||||
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||
@@ -793,6 +795,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
...DEFAULT_PHASE_MODELS,
|
||||
...(migratedPhaseModels ?? serverSettings.phaseModels),
|
||||
},
|
||||
defaultThinkingLevel: serverSettings.defaultThinkingLevel ?? 'adaptive',
|
||||
defaultReasoningEffort: serverSettings.defaultReasoningEffort ?? 'none',
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
cursorDefaultModel: sanitizedCursorDefault,
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
|
||||
155
apps/ui/src/lib/codemirror-languages.ts
Normal file
155
apps/ui/src/lib/codemirror-languages.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Shared CodeMirror language detection utilities.
|
||||
*
|
||||
* Extracted from code-editor.tsx so that both the file editor and
|
||||
* the diff viewer can resolve language extensions from file paths.
|
||||
*/
|
||||
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { java } from '@codemirror/lang-java';
|
||||
import { rust } from '@codemirror/lang-rust';
|
||||
import { cpp } from '@codemirror/lang-cpp';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { php } from '@codemirror/lang-php';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { go } from '@codemirror/legacy-modes/mode/go';
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||
import { swift } from '@codemirror/legacy-modes/mode/swift';
|
||||
|
||||
/** Detect language extension based on file extension */
|
||||
export function getLanguageExtension(filePath: string): Extension | null {
|
||||
const name = filePath.split(/[/\\]/).pop()?.toLowerCase() || '';
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
// Files without an extension (no dot, or dotfile with dot at position 0)
|
||||
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
|
||||
|
||||
// Handle files by name first
|
||||
switch (name) {
|
||||
case 'dockerfile':
|
||||
case 'dockerfile.dev':
|
||||
case 'dockerfile.prod':
|
||||
return StreamLanguage.define(dockerFile);
|
||||
case 'makefile':
|
||||
case 'gnumakefile':
|
||||
return StreamLanguage.define(shell);
|
||||
case '.gitignore':
|
||||
case '.dockerignore':
|
||||
case '.npmignore':
|
||||
case '.eslintignore':
|
||||
return StreamLanguage.define(shell);
|
||||
case '.env':
|
||||
case '.env.local':
|
||||
case '.env.development':
|
||||
case '.env.production':
|
||||
return StreamLanguage.define(shell);
|
||||
}
|
||||
|
||||
switch (ext) {
|
||||
// JavaScript/TypeScript
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return javascript();
|
||||
case 'jsx':
|
||||
return javascript({ jsx: true });
|
||||
case 'ts':
|
||||
case 'mts':
|
||||
case 'cts':
|
||||
return javascript({ typescript: true });
|
||||
case 'tsx':
|
||||
return javascript({ jsx: true, typescript: true });
|
||||
|
||||
// Web
|
||||
case 'html':
|
||||
case 'htm':
|
||||
case 'svelte':
|
||||
case 'vue':
|
||||
return html();
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return css();
|
||||
case 'json':
|
||||
case 'jsonc':
|
||||
case 'json5':
|
||||
return json();
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
case 'xsl':
|
||||
case 'xslt':
|
||||
case 'plist':
|
||||
return xml();
|
||||
|
||||
// Markdown
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
case 'markdown':
|
||||
return markdown();
|
||||
|
||||
// Python
|
||||
case 'py':
|
||||
case 'pyx':
|
||||
case 'pyi':
|
||||
return python();
|
||||
|
||||
// Java/Kotlin
|
||||
case 'java':
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return java();
|
||||
|
||||
// Systems
|
||||
case 'rs':
|
||||
return rust();
|
||||
case 'c':
|
||||
case 'h':
|
||||
return cpp();
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
case 'hpp':
|
||||
case 'hxx':
|
||||
return cpp();
|
||||
case 'go':
|
||||
return StreamLanguage.define(go);
|
||||
case 'swift':
|
||||
return StreamLanguage.define(swift);
|
||||
|
||||
// Scripting
|
||||
case 'rb':
|
||||
case 'erb':
|
||||
return StreamLanguage.define(ruby);
|
||||
case 'php':
|
||||
return php();
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
case 'fish':
|
||||
return StreamLanguage.define(shell);
|
||||
|
||||
// Data
|
||||
case 'sql':
|
||||
case 'mysql':
|
||||
case 'pgsql':
|
||||
return sql();
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return StreamLanguage.define(yaml);
|
||||
case 'toml':
|
||||
return StreamLanguage.define(toml);
|
||||
|
||||
default:
|
||||
return null; // Plain text fallback
|
||||
}
|
||||
}
|
||||
@@ -131,3 +131,130 @@ export function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct old (original) and new (modified) file content from a single-file
|
||||
* unified diff string. Used by the CodeMirror merge diff viewer which needs
|
||||
* both document versions to compute inline highlighting.
|
||||
*
|
||||
* For new files (entire content is additions), oldContent will be empty.
|
||||
* For deleted files (entire content is deletions), newContent will be empty.
|
||||
*/
|
||||
export function reconstructFilesFromDiff(diffText: string): {
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
} {
|
||||
if (!diffText) return { oldContent: '', newContent: '' };
|
||||
|
||||
const lines = diffText.split('\n');
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
let inHunk = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip diff header lines
|
||||
if (
|
||||
line.startsWith('diff --git') ||
|
||||
line.startsWith('index ') ||
|
||||
line.startsWith('--- ') ||
|
||||
line.startsWith('+++ ') ||
|
||||
line.startsWith('new file mode') ||
|
||||
line.startsWith('deleted file mode') ||
|
||||
line.startsWith('rename from') ||
|
||||
line.startsWith('rename to') ||
|
||||
line.startsWith('similarity index') ||
|
||||
line.startsWith('old mode') ||
|
||||
line.startsWith('new mode')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hunk header
|
||||
if (line.startsWith('@@')) {
|
||||
inHunk = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inHunk) continue;
|
||||
|
||||
// Skip trailing empty line produced by split('\n')
|
||||
if (line === '' && i === lines.length - 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// "\ No newline at end of file" marker
|
||||
if (line.startsWith('\\')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
newLines.push(line.substring(1));
|
||||
} else if (line.startsWith('-')) {
|
||||
oldLines.push(line.substring(1));
|
||||
} else {
|
||||
// Context line (starts with space or is empty within hunk)
|
||||
const content = line.startsWith(' ') ? line.substring(1) : line;
|
||||
oldLines.push(content);
|
||||
newLines.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oldContent: oldLines.join('\n'),
|
||||
newContent: newLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a combined multi-file diff string into per-file diff strings.
|
||||
* Each entry in the returned array is a complete diff block for a single file.
|
||||
*/
|
||||
export function splitDiffByFile(
|
||||
combinedDiff: string
|
||||
): { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] {
|
||||
if (!combinedDiff) return [];
|
||||
|
||||
const results: { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] = [];
|
||||
const lines = combinedDiff.split('\n');
|
||||
let currentLines: string[] = [];
|
||||
let currentFilePath = '';
|
||||
let currentIsNew = false;
|
||||
let currentIsDeleted = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('diff --git')) {
|
||||
// Push previous file if exists
|
||||
if (currentLines.length > 0 && currentFilePath) {
|
||||
results.push({
|
||||
filePath: currentFilePath,
|
||||
diff: currentLines.join('\n'),
|
||||
isNew: currentIsNew,
|
||||
isDeleted: currentIsDeleted,
|
||||
});
|
||||
}
|
||||
currentLines = [line];
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFilePath = match ? match[2] : 'unknown';
|
||||
currentIsNew = false;
|
||||
currentIsDeleted = false;
|
||||
} else {
|
||||
if (line.startsWith('new file mode')) currentIsNew = true;
|
||||
if (line.startsWith('deleted file mode')) currentIsDeleted = true;
|
||||
currentLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Push last file
|
||||
if (currentLines.length > 0 && currentFilePath) {
|
||||
results.push({
|
||||
filePath: currentFilePath,
|
||||
diff: currentLines.join('\n'),
|
||||
isNew: currentIsNew,
|
||||
isDeleted: currentIsDeleted,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -2334,6 +2334,23 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
updatePRNumber: async (worktreePath: string, prNumber: number, projectPath?: string) => {
|
||||
console.log('[Mock] Updating PR number:', { worktreePath, prNumber, projectPath });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
branch: 'feature-branch',
|
||||
prInfo: {
|
||||
number: prNumber,
|
||||
url: `https://github.com/example/repo/pull/${prNumber}`,
|
||||
title: `PR #${prNumber}`,
|
||||
state: 'OPEN',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getDiffs: async (projectPath: string, featureId: string) => {
|
||||
console.log('[Mock] Getting file diffs:', { projectPath, featureId });
|
||||
return {
|
||||
|
||||
@@ -2238,6 +2238,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/worktree/set-tracking', { worktreePath, remote, branch }),
|
||||
createPR: (worktreePath: string, options?: CreatePROptions) =>
|
||||
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
|
||||
updatePRNumber: (worktreePath: string, prNumber: number, projectPath?: string) =>
|
||||
this.post('/api/worktree/update-pr-number', { worktreePath, prNumber, projectPath }),
|
||||
getDiffs: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/worktree/diffs', { projectPath, featureId }),
|
||||
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
|
||||
@@ -2746,6 +2748,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
defaultDeleteBranchWithWorktree?: boolean;
|
||||
autoDismissInitScriptIndicator?: boolean;
|
||||
worktreeCopyFiles?: string[];
|
||||
pinnedWorktreesCount?: number;
|
||||
worktreeDropdownThreshold?: number;
|
||||
alwaysUseWorktreeDropdown?: boolean;
|
||||
lastSelectedSessionId?: string;
|
||||
testCommand?: string;
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
DEFAULT_COPILOT_MODEL,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
getThinkingLevelsForModel,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Import types from modular type files
|
||||
@@ -371,9 +372,9 @@ const initialState: AppState = {
|
||||
defaultPlanningMode: 'skip' as PlanningMode,
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
|
||||
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none',
|
||||
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'adaptive',
|
||||
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
|
||||
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 1000,
|
||||
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 10000,
|
||||
pendingPlanApproval: null,
|
||||
claudeRefreshInterval: 60,
|
||||
claudeUsage: null,
|
||||
@@ -396,6 +397,10 @@ const initialState: AppState = {
|
||||
autoDismissInitScriptIndicatorByProject: {},
|
||||
useWorktreesByProject: {},
|
||||
worktreeCopyFilesByProject: {},
|
||||
pinnedWorktreesCountByProject: {},
|
||||
pinnedWorktreeBranchesByProject: {},
|
||||
worktreeDropdownThresholdByProject: {},
|
||||
alwaysUseWorktreeDropdownByProject: {},
|
||||
worktreePanelCollapsed: false,
|
||||
lastProjectDir: '',
|
||||
recentFolders: [],
|
||||
@@ -2453,7 +2458,20 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }),
|
||||
|
||||
setDefaultThinkingLevel: async (level) => {
|
||||
set({ defaultThinkingLevel: level });
|
||||
const currentModel = get().defaultFeatureModel;
|
||||
const modelId = currentModel.model;
|
||||
const availableLevels = getThinkingLevelsForModel(modelId);
|
||||
|
||||
// Also update defaultFeatureModel's thinkingLevel if compatible
|
||||
if (availableLevels.includes(level)) {
|
||||
set({
|
||||
defaultThinkingLevel: level,
|
||||
defaultFeatureModel: { ...currentModel, thinkingLevel: level },
|
||||
});
|
||||
} else {
|
||||
set({ defaultThinkingLevel: level });
|
||||
}
|
||||
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
@@ -2478,7 +2496,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Guard against NaN/Infinity before flooring and clamping
|
||||
const safeValue = Number.isFinite(maxTurns) ? maxTurns : 1;
|
||||
// Clamp to valid range
|
||||
const clamped = Math.max(1, Math.min(2000, Math.floor(safeValue)));
|
||||
const clamped = Math.max(1, Math.min(10000, Math.floor(safeValue)));
|
||||
set({ defaultMaxTurns: clamped });
|
||||
// Sync to server
|
||||
try {
|
||||
@@ -2641,6 +2659,65 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
})),
|
||||
getWorktreeCopyFiles: (projectPath) => get().worktreeCopyFilesByProject[projectPath] ?? [],
|
||||
|
||||
// Worktree Display Settings actions
|
||||
setPinnedWorktreesCount: (projectPath, count) =>
|
||||
set((state) => ({
|
||||
pinnedWorktreesCountByProject: {
|
||||
...state.pinnedWorktreesCountByProject,
|
||||
[projectPath]: count,
|
||||
},
|
||||
})),
|
||||
getPinnedWorktreesCount: (projectPath) => get().pinnedWorktreesCountByProject[projectPath] ?? 0,
|
||||
setPinnedWorktreeBranches: (projectPath, branches) =>
|
||||
set((state) => ({
|
||||
pinnedWorktreeBranchesByProject: {
|
||||
...state.pinnedWorktreeBranchesByProject,
|
||||
[projectPath]: branches,
|
||||
},
|
||||
})),
|
||||
getPinnedWorktreeBranches: (projectPath) =>
|
||||
get().pinnedWorktreeBranchesByProject[projectPath] ?? [],
|
||||
swapPinnedWorktreeBranch: (projectPath, slotIndex, newBranch) =>
|
||||
set((state) => {
|
||||
const src = state.pinnedWorktreeBranchesByProject[projectPath] ?? [];
|
||||
// Pre-fill up to slotIndex to prevent sparse holes
|
||||
const current: string[] = Array.from(
|
||||
{ length: Math.max(src.length, slotIndex + 1) },
|
||||
(_, i) => src[i] ?? ''
|
||||
);
|
||||
// If the new branch is already in another slot, swap them (only when newBranch is non-empty)
|
||||
const existingIndex = newBranch !== '' ? current.indexOf(newBranch) : -1;
|
||||
if (existingIndex !== -1 && existingIndex !== slotIndex) {
|
||||
// Swap: put the old branch from this slot into the other slot
|
||||
current[existingIndex] = current[slotIndex];
|
||||
}
|
||||
current[slotIndex] = newBranch;
|
||||
return {
|
||||
pinnedWorktreeBranchesByProject: {
|
||||
...state.pinnedWorktreeBranchesByProject,
|
||||
[projectPath]: current,
|
||||
},
|
||||
};
|
||||
}),
|
||||
setWorktreeDropdownThreshold: (projectPath, threshold) =>
|
||||
set((state) => ({
|
||||
worktreeDropdownThresholdByProject: {
|
||||
...state.worktreeDropdownThresholdByProject,
|
||||
[projectPath]: threshold,
|
||||
},
|
||||
})),
|
||||
getWorktreeDropdownThreshold: (projectPath) =>
|
||||
get().worktreeDropdownThresholdByProject[projectPath] ?? 3,
|
||||
setAlwaysUseWorktreeDropdown: (projectPath, always) =>
|
||||
set((state) => ({
|
||||
alwaysUseWorktreeDropdownByProject: {
|
||||
...state.alwaysUseWorktreeDropdownByProject,
|
||||
[projectPath]: always,
|
||||
},
|
||||
})),
|
||||
getAlwaysUseWorktreeDropdown: (projectPath) =>
|
||||
get().alwaysUseWorktreeDropdownByProject[projectPath] ?? true,
|
||||
|
||||
// UI State actions
|
||||
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
||||
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
||||
|
||||
@@ -370,6 +370,17 @@ export interface AppState {
|
||||
// List of relative file paths to copy from project root into new worktrees
|
||||
worktreeCopyFilesByProject: Record<string, string[]>;
|
||||
|
||||
// Worktree Display Settings (per-project, keyed by project path)
|
||||
// Number of worktrees always visible (pinned) without expanding a dropdown (default: 1)
|
||||
pinnedWorktreesCountByProject: Record<string, number>;
|
||||
// Explicit list of branch names assigned to pinned slots (ordered)
|
||||
// When set, these branches are shown in the pinned slots instead of using default ordering
|
||||
pinnedWorktreeBranchesByProject: Record<string, string[]>;
|
||||
// Minimum number of worktrees before the list collapses into a dropdown (default: 3)
|
||||
worktreeDropdownThresholdByProject: Record<string, number>;
|
||||
// Always use dropdown layout regardless of worktree count (default: false)
|
||||
alwaysUseWorktreeDropdownByProject: Record<string, boolean>;
|
||||
|
||||
// UI State (previously in localStorage, now synced via API)
|
||||
/** Whether worktree panel is collapsed in board view */
|
||||
worktreePanelCollapsed: boolean;
|
||||
@@ -814,6 +825,17 @@ export interface AppActions {
|
||||
setWorktreeCopyFiles: (projectPath: string, files: string[]) => void;
|
||||
getWorktreeCopyFiles: (projectPath: string) => string[];
|
||||
|
||||
// Worktree Display Settings actions (per-project)
|
||||
setPinnedWorktreesCount: (projectPath: string, count: number) => void;
|
||||
getPinnedWorktreesCount: (projectPath: string) => number;
|
||||
setPinnedWorktreeBranches: (projectPath: string, branches: string[]) => void;
|
||||
getPinnedWorktreeBranches: (projectPath: string) => string[];
|
||||
swapPinnedWorktreeBranch: (projectPath: string, slotIndex: number, newBranch: string) => void;
|
||||
setWorktreeDropdownThreshold: (projectPath: string, threshold: number) => void;
|
||||
getWorktreeDropdownThreshold: (projectPath: string) => number;
|
||||
setAlwaysUseWorktreeDropdown: (projectPath: string, always: boolean) => void;
|
||||
getAlwaysUseWorktreeDropdown: (projectPath: string) => boolean;
|
||||
|
||||
// UI State actions (previously in localStorage, now synced via API)
|
||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||
setLastProjectDir: (dir: string) => void;
|
||||
|
||||
34
apps/ui/src/types/electron.d.ts
vendored
34
apps/ui/src/types/electron.d.ts
vendored
@@ -33,6 +33,14 @@ export interface ToolUse {
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
name: string;
|
||||
input: {
|
||||
toolUseId?: string;
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| {
|
||||
type: 'message';
|
||||
@@ -51,6 +59,11 @@ export type StreamEvent =
|
||||
sessionId: string;
|
||||
tool: ToolUse;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
sessionId: string;
|
||||
tool: ToolResult;
|
||||
}
|
||||
| {
|
||||
type: 'complete';
|
||||
sessionId: string;
|
||||
@@ -1075,6 +1088,27 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Update the tracked PR number for a worktree branch
|
||||
updatePRNumber: (
|
||||
worktreePath: string,
|
||||
prNumber: number,
|
||||
projectPath?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
branch: string;
|
||||
prInfo: {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
};
|
||||
ghCliUnavailable?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get file diffs for a feature worktree
|
||||
getDiffs: (projectPath: string, featureId: string) => Promise<FileDiffsResult>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user