Feature: Add PR review comments and resolution, improve AI prompt handling (#790)

* feat: Add PR review comments and resolution endpoints, improve prompt handling

* Feature: File Editor (#789)

* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component

* ```
fix: Improve error handling and stabilize async operations

- Add error event handlers to GraphQL process spawns to prevent unhandled rejections
- Replace execAsync with execFile for safer command execution and better control
- Fix timeout cleanup in withTimeout generator to prevent memory leaks
- Improve outdated comment detection logic by removing redundant condition
- Use resolveModelString for consistent model string handling
- Replace || with ?? for proper falsy value handling in dialog initialization
- Add comments clarifying branch name resolution logic for local branches with slashes
- Add catch handler for project selection to handle async errors gracefully
```

* refactor: Extract PR review comments logic to dedicated service

* fix: Improve robustness and UX for PR review and file operations

* fix: Consolidate exec utilities and improve type safety

* refactor: Replace ScrollArea with div and improve file tree layout
This commit is contained in:
gsxdsm
2026-02-20 21:34:40 -08:00
committed by GitHub
parent 0e020f7e4a
commit c81ea768a7
60 changed files with 4568 additions and 681 deletions

View File

@@ -1,4 +1,4 @@
import { X, Circle, MoreHorizontal } from 'lucide-react';
import { X, Circle, MoreHorizontal, Save } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EditorTab } from '../use-file-editor-store';
import {
@@ -14,6 +14,12 @@ interface EditorTabsProps {
onTabSelect: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onCloseAll: () => void;
/** Called when the save button is clicked (mobile only) */
onSave?: () => void;
/** Whether there are unsaved changes (controls enabled state of save button) */
isDirty?: boolean;
/** Whether to show the save button in the tab bar (intended for mobile) */
showSaveButton?: boolean;
}
/** Get a file icon color based on extension */
@@ -74,6 +80,9 @@ export function EditorTabs({
onTabSelect,
onTabClose,
onCloseAll,
onSave,
isDirty,
showSaveButton,
}: EditorTabsProps) {
if (tabs.length === 0) return null;
@@ -128,8 +137,26 @@ export function EditorTabs({
);
})}
{/* Tab actions dropdown (close all, etc.) */}
<div className="ml-auto shrink-0 flex items-center px-1">
{/* Tab actions: save button (mobile) + close-all dropdown */}
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5">
{/* Save button — shown in the tab bar on mobile */}
{showSaveButton && onSave && (
<button
onClick={onSave}
disabled={!isDirty}
className={cn(
'p-1 rounded transition-colors',
isDirty
? 'text-primary hover:text-primary hover:bg-muted/50'
: 'text-muted-foreground/40 cursor-not-allowed'
)}
title="Save file (Ctrl+S)"
aria-label="Save file"
>
<Save className="w-4 h-4" />
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button

View File

@@ -32,6 +32,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
import { useFileBrowser } from '@/contexts/file-browser-context';
interface FileTreeProps {
onFileSelect: (path: string) => void;
@@ -104,6 +105,21 @@ function getGitStatusLabel(status: string | undefined): string {
}
}
/**
* Validate a file/folder name for safety.
* Rejects names containing path separators, relative path components,
* or names that are just dots (which resolve to parent/current directory).
*/
function isValidFileName(name: string): boolean {
// Reject names containing path separators
if (name.includes('/') || name.includes('\\')) return false;
// Reject current/parent directory references
if (name === '.' || name === '..') return false;
// Reject empty or whitespace-only names
if (!name.trim()) return false;
return true;
}
/** Inline input for creating/renaming items */
function InlineInput({
defaultValue,
@@ -117,6 +133,7 @@ function InlineInput({
placeholder?: string;
}) {
const [value, setValue] = useState(defaultValue || '');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Guard against double-submission: pressing Enter triggers onKeyDown AND may
// immediately trigger onBlur (e.g. when the component unmounts after submit).
@@ -125,7 +142,9 @@ function InlineInput({
useEffect(() => {
inputRef.current?.focus();
if (defaultValue) {
// Select name without extension for rename
// Select name without extension for rename.
// For dotfiles (e.g. ".gitignore"), lastIndexOf('.') returns 0,
// so we fall through to select() which selects the entire name.
const dotIndex = defaultValue.lastIndexOf('.');
if (dotIndex > 0) {
inputRef.current?.setSelectionRange(0, dotIndex);
@@ -135,97 +154,62 @@ function InlineInput({
}
}, [defaultValue]);
const handleSubmit = useCallback(() => {
if (submittedRef.current) return;
const trimmed = value.trim();
if (!trimmed) {
onCancel();
return;
}
if (!isValidFileName(trimmed)) {
// Invalid name — surface error, keep editing so the user can fix it
setErrorMessage('Invalid name: avoid /, \\, ".", or ".."');
inputRef.current?.focus();
return;
}
setErrorMessage(null);
submittedRef.current = true;
onSubmit(trimmed);
}, [value, onSubmit, onCancel]);
return (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && value.trim()) {
<div className="flex flex-col gap-0.5">
<input
ref={inputRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (errorMessage) setErrorMessage(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSubmit();
} else if (e.key === 'Escape') {
onCancel();
}
}}
onBlur={() => {
// Prevent duplicate submission if onKeyDown already triggered onSubmit
if (submittedRef.current) return;
submittedRef.current = true;
onSubmit(value.trim());
} else if (e.key === 'Escape') {
onCancel();
}
}}
onBlur={() => {
// Prevent duplicate submission if onKeyDown already triggered onSubmit
if (submittedRef.current) return;
if (value.trim()) {
submittedRef.current = true;
onSubmit(value.trim());
} else {
onCancel();
}
}}
placeholder={placeholder}
className="text-sm bg-muted border border-border rounded px-1 py-0.5 w-full outline-none focus:border-primary"
/>
);
}
/** Destination path picker dialog for copy/move operations */
function DestinationPicker({
onSubmit,
onCancel,
defaultPath,
action,
}: {
onSubmit: (path: string) => void;
onCancel: () => void;
defaultPath: string;
action: 'Copy' | 'Move';
}) {
const [path, setPath] = useState(defaultPath);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium">{action} To...</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Enter the destination path for the {action.toLowerCase()} operation
</p>
</div>
<div className="px-4 py-3">
<input
ref={inputRef}
value={path}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && path.trim()) {
onSubmit(path.trim());
} else if (e.key === 'Escape') {
onCancel();
}
}}
placeholder="Enter destination path..."
className="w-full text-sm bg-muted border border-border rounded px-3 py-2 outline-none focus:border-primary font-mono"
/>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded hover:bg-muted transition-colors"
>
Cancel
</button>
<button
onClick={() => path.trim() && onSubmit(path.trim())}
disabled={!path.trim()}
className="px-3 py-1.5 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{action}
</button>
</div>
</div>
const trimmed = value.trim();
if (trimmed && isValidFileName(trimmed)) {
submittedRef.current = true;
onSubmit(trimmed);
}
// If the name is empty or invalid, do NOT call onCancel — keep the
// input open so the user can correct the value (mirrors handleSubmit).
// Optionally re-focus so the user can continue editing.
else {
inputRef.current?.focus();
}
}}
placeholder={placeholder}
className={cn(
'text-sm bg-muted border rounded px-1 py-0.5 w-full outline-none focus:border-primary',
errorMessage ? 'border-red-500' : 'border-border'
)}
/>
{errorMessage && <span className="text-[10px] text-red-500 px-0.5">{errorMessage}</span>}
</div>
);
}
@@ -276,12 +260,11 @@ function TreeNode({
selectedPaths,
toggleSelectedPath,
} = useFileEditorStore();
const { openFileBrowser } = useFileBrowser();
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [showCopyPicker, setShowCopyPicker] = useState(false);
const [showMovePicker, setShowMovePicker] = useState(false);
const isExpanded = expandedFolders.has(node.path);
const isActive = activeFilePath === node.path;
@@ -409,30 +392,6 @@ function TreeNode({
return (
<div key={node.path}>
{/* Destination picker dialogs */}
{showCopyPicker && onCopyItem && (
<DestinationPicker
action="Copy"
defaultPath={node.path}
onSubmit={async (destPath) => {
setShowCopyPicker(false);
await onCopyItem(node.path, destPath);
}}
onCancel={() => setShowCopyPicker(false)}
/>
)}
{showMovePicker && onMoveItem && (
<DestinationPicker
action="Move"
defaultPath={node.path}
onSubmit={async (destPath) => {
setShowMovePicker(false);
await onMoveItem(node.path, destPath);
}}
onCancel={() => setShowMovePicker(false)}
/>
)}
{isRenaming ? (
<div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2">
<InlineInput
@@ -630,9 +589,21 @@ function TreeNode({
{/* Copy To... */}
{onCopyItem && (
<DropdownMenuItem
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
setShowCopyPicker(true);
try {
const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/';
const destPath = await openFileBrowser({
title: `Copy "${node.name}" To...`,
description: 'Select the destination folder for the copy operation',
initialPath: parentPath,
});
if (destPath) {
await onCopyItem(node.path, destPath);
}
} catch (err) {
console.error('Copy operation failed:', err);
}
}}
className="gap-2"
>
@@ -644,9 +615,21 @@ function TreeNode({
{/* Move To... */}
{onMoveItem && (
<DropdownMenuItem
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
setShowMovePicker(true);
try {
const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/';
const destPath = await openFileBrowser({
title: `Move "${node.name}" To...`,
description: 'Select the destination folder for the move operation',
initialPath: parentPath,
});
if (destPath) {
await onMoveItem(node.path, destPath);
}
} catch (err) {
console.error('Move operation failed:', err);
}
}}
className="gap-2"
>
@@ -775,8 +758,15 @@ export function FileTree({
onDragDropMove,
effectivePath,
}: FileTreeProps) {
const { fileTree, showHiddenFiles, setShowHiddenFiles, gitStatusMap, setDragState, gitBranch } =
useFileEditorStore();
const {
fileTree,
showHiddenFiles,
setShowHiddenFiles,
gitStatusMap,
dragState,
setDragState,
gitBranch,
} = useFileEditorStore();
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
@@ -791,10 +781,13 @@ export function FileTree({
e.preventDefault();
if (effectivePath) {
e.dataTransfer.dropEffect = 'move';
setDragState({ draggedPaths: [], dropTargetPath: effectivePath });
// Skip redundant state update if already targeting the same path
if (dragState.dropTargetPath !== effectivePath) {
setDragState({ ...dragState, dropTargetPath: effectivePath });
}
}
},
[effectivePath, setDragState]
[effectivePath, dragState, setDragState]
);
const handleRootDrop = useCallback(
@@ -818,47 +811,54 @@ export function FileTree({
return (
<div className="flex flex-col h-full" data-testid="file-tree">
{/* Tree toolbar */}
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Explorer
</span>
{gitBranch && (
<span className="text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded">
<div className="px-2 py-1.5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Explorer
</span>
</div>
<div className="flex items-center gap-0.5">
<button
onClick={() => setIsCreatingFile(true)}
className="p-1 hover:bg-accent rounded"
title="New file"
>
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setIsCreatingFolder(true)}
className="p-1 hover:bg-accent rounded"
title="New folder"
>
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
className="p-1 hover:bg-accent rounded"
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
>
{showHiddenFiles ? (
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div>
{gitBranch && (
<div className="mt-1 min-w-0">
<span
className="inline-block max-w-full truncate whitespace-nowrap text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded"
title={gitBranch}
>
{gitBranch}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
<button
onClick={() => setIsCreatingFile(true)}
className="p-1 hover:bg-accent rounded"
title="New file"
>
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setIsCreatingFolder(true)}
className="p-1 hover:bg-accent rounded"
title="New folder"
>
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
className="p-1 hover:bg-accent rounded"
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
>
{showHiddenFiles ? (
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div>
)}
</div>
{/* Tree content */}

View File

@@ -650,6 +650,12 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const handleRenameItem = useCallback(
async (oldPath: string, newName: string) => {
// Extract the current file/folder name from the old path
const oldName = oldPath.split('/').pop() || '';
// If the name hasn't changed, skip the rename entirely (no-op)
if (newName === oldName) return;
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
const newPath = `${parentPath}/${newName}`;
@@ -1028,6 +1034,9 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
onTabSelect={setActiveTab}
onTabClose={handleTabClose}
onCloseAll={handleCloseAll}
onSave={handleSave}
isDirty={activeTab?.isDirty && !activeTab?.isBinary && !activeTab?.isTooLarge}
showSaveButton={isMobile && !!activeTab && !activeTab.isBinary && !activeTab.isTooLarge}
/>
{/* Editor content */}
@@ -1320,24 +1329,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</PopoverContent>
</Popover>
{/* Mobile: Save button in main toolbar */}
{activeTab &&
!activeTab.isBinary &&
!activeTab.isTooLarge &&
isMobile &&
!mobileBrowserVisible && (
<Button
variant="outline"
size="icon-sm"
onClick={handleSave}
disabled={!activeTab.isDirty}
className="lg:hidden"
title={editorAutoSave ? 'Auto-save enabled (Ctrl+S)' : 'Save file (Ctrl+S)'}
>
<Save className="w-4 h-4" />
</Button>
)}
{/* Tablet/Mobile: actions panel trigger */}
<HeaderActionsPanelTrigger
isOpen={showActionsPanel}