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:
gsxdsm
2026-02-23 20:31:25 -08:00
committed by GitHub
parent e7504b247f
commit 0330c70261
72 changed files with 3667 additions and 1173 deletions

View File

@@ -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)}>

View File

@@ -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

View File

@@ -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)

View File

@@ -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>
);
}

View File

@@ -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) => {