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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user