feat(UI): Introduce new UI components for file attachments and related views (#191)

Add file management API and UI components
Implement directory listing, search and CRUD operations in desktop backend
Expose new Files API on frontend to list, search, and modify files
This commit is contained in:
Bohdan Triapitsyn
2026-01-22 12:21:19 +02:00
committed by GitHub
parent 1f23b63c0b
commit d97181bcd2
22 changed files with 1611 additions and 1223 deletions
+280 -33
View File
@@ -16,9 +16,22 @@ import {
RiSave3Line,
RiSendPlane2Line,
RiTextWrap,
RiMore2Fill,
RiFileAddLine,
RiFolderAddLine,
RiDeleteBinLine,
RiEditLine,
RiFileCopyLine,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -273,6 +286,24 @@ export const FilesView: React.FC = () => {
const pendingTabRef = React.useRef<import('@/stores/useUIStore').MainTab | null>(null);
const skipDirtyOnceRef = React.useRef(false);
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
const [dialogInputValue, setDialogInputValue] = React.useState('');
const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false);
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
const canCreateFile = Boolean(files.writeFile);
const canCreateFolder = Boolean(files.createDirectory);
const canRename = Boolean(files.rename);
const canDelete = Boolean(files.delete);
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
setActiveDialog(type);
setDialogData(data);
setDialogInputValue(type === 'rename' ? data.name || '' : '');
setIsDialogSubmitting(false);
}, []);
// Line selection state for commenting
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = React.useState('');
@@ -509,6 +540,78 @@ export const FilesView: React.FC = () => {
void refreshRoot();
}, [currentDirectory, refreshRoot, showGitignored]);
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
if (!dialogData || !activeDialog) return;
setIsDialogSubmitting(true);
try {
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) throw new Error('Filename is required');
const parentPath = dialogData.path;
// Handle root path or empty path
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
if (!files.writeFile) throw new Error('Write not supported');
const result = await files.writeFile(newPath, '');
if (result.success) {
toast.success('File created');
await refreshRoot();
}
} else if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) throw new Error('Folder name is required');
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
const result = await files.createDirectory(newPath);
if (result.success) {
toast.success('Folder created');
await refreshRoot();
}
} else if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) throw new Error('Name is required');
const oldPath = dialogData.path;
const parentDir = oldPath.split('/').slice(0, -1).join('/');
const prefix = parentDir ? `${parentDir}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
if (files.rename) {
const result = await files.rename(oldPath, newPath);
if (result.success) {
toast.success('Renamed successfully');
await refreshRoot();
if (selectedFile?.path === oldPath) {
setSelectedFile(null);
}
}
} else {
toast.error("Rename not supported");
}
} else if (activeDialog === 'delete') {
if (files.delete) {
const result = await files.delete(dialogData.path);
if (result.success) {
toast.success('Deleted successfully');
await refreshRoot();
if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) {
setSelectedFile(null);
setFileContent('');
}
}
} else {
toast.error("Delete not supported");
}
}
setActiveDialog(null);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Operation failed');
} finally {
setIsDialogSubmitting(false);
}
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, selectedFile]);
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
if (!q) {
@@ -853,56 +956,129 @@ export const FilesView: React.FC = () => {
const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => {
const nodes = childrenByDir[dirPath] ?? [];
return nodes.map((node) => {
return nodes.map((node, index) => {
const isDir = node.type === 'directory';
const isExpanded = isDir && expandedDirs.has(node.path);
const isActive = selectedFile?.path === node.path;
const isLoading = isDir && inFlightDirsRef.current.has(node.path);
const isLast = index === nodes.length - 1;
return (
<li key={node.path}>
<button
type="button"
onClick={() => {
if (isDir) {
void toggleDirectory(node.path);
} else {
void handleSelectFile(node);
<li key={node.path} className="relative">
{depth > 0 && (
<>
<span className="absolute top-3.5 left-[-12px] w-3 h-px bg-border/40" />
{isLast && (
<span className="absolute top-3.5 bottom-0 left-[-13px] w-[2px] bg-background" />
)}
</>
)}
<div
className="group relative flex items-center"
onContextMenu={(event) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) {
return;
}
event.preventDefault();
setContextMenuPath(node.path);
}}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors',
isActive ? 'bg-accent/70' : 'hover:bg-accent/40'
)}
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
{isDir ? (
isLoading ? (
<RiLoader4Line className="h-4 w-4 flex-shrink-0 animate-spin" />
) : isExpanded ? (
<RiFolderOpenFill className="h-4 w-4 flex-shrink-0 text-primary/60" />
) : (
<RiFolder3Fill className="h-4 w-4 flex-shrink-0 text-primary/60" />
)
) : (
getFileIcon(node.extension)
)}
<span
className="min-w-0 flex-1 truncate typography-meta"
title={node.path}
<button
type="button"
onClick={() => {
if (isDir) {
void toggleDirectory(node.path);
} else {
void handleSelectFile(node);
}
}}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
isActive ? 'bg-accent/70' : 'hover:bg-accent/40'
)}
>
{node.name}
</span>
</button>
{isDir ? (
isLoading ? (
<RiLoader4Line className="h-4 w-4 flex-shrink-0 animate-spin" />
) : isExpanded ? (
<RiFolderOpenFill className="h-4 w-4 flex-shrink-0 text-primary/60" />
) : (
<RiFolder3Fill className="h-4 w-4 flex-shrink-0 text-primary/60" />
)
) : (
getFileIcon(node.extension)
)}
<span
className="min-w-0 flex-1 truncate typography-meta"
title={node.path}
>
{node.name}
</span>
</button>
{(canRename || canCreateFile || canCreateFolder || canDelete) && (
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 focus-within:opacity-100">
<DropdownMenu
open={contextMenuPath === node.path}
onOpenChange={(open) => setContextMenuPath(open ? node.path : null)}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<RiMore2Fill className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onCloseAutoFocus={() => setContextMenuPath(null)}>
{canRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleOpenDialog('rename', node); }}>
<RiEditLine className="mr-2 h-4 w-4" /> Rename
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void navigator.clipboard.writeText(node.path);
toast.success('Path copied');
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
</DropdownMenuItem>
{isDir && (canCreateFile || canCreateFolder) && (
<>
<DropdownMenuSeparator />
{canCreateFile && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleOpenDialog('createFile', node); }}>
<RiFileAddLine className="mr-2 h-4 w-4" /> New File
</DropdownMenuItem>
)}
{canCreateFolder && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleOpenDialog('createFolder', node); }}>
<RiFolderAddLine className="mr-2 h-4 w-4" /> New Folder
</DropdownMenuItem>
)}
</>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); handleOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{isDir && isExpanded && (
<ul className="flex flex-col gap-1">
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
{renderTree(node.path, depth + 1)}
</ul>
)}
</li>
);
});
}, [childrenByDir, expandedDirs, handleSelectFile, selectedFile?.path, toggleDirectory]);
}, [childrenByDir, expandedDirs, handleSelectFile, selectedFile?.path, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, setContextMenuPath]);
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
@@ -1000,6 +1176,58 @@ export const FilesView: React.FC = () => {
};
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path]);
const renderDialogs = () => (
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{activeDialog === 'createFile' && 'Create File'}
{activeDialog === 'createFolder' && 'Create Folder'}
{activeDialog === 'rename' && 'Rename'}
{activeDialog === 'delete' && 'Delete'}
</DialogTitle>
<DialogDescription>
{activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'rename' && `Rename ${dialogData?.name}`}
{activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`}
</DialogDescription>
</DialogHeader>
{activeDialog !== 'delete' && (
<div className="py-4">
<Input
value={dialogInputValue}
onChange={(e) => setDialogInputValue(e.target.value)}
placeholder={activeDialog === 'rename' ? 'New name' : 'Name'}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleDialogSubmit();
}
}}
autoFocus
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
Cancel
</Button>
<Button
variant={activeDialog === 'delete' ? 'destructive' : 'default'}
onClick={() => void handleDialogSubmit()}
disabled={isDialogSubmitting || (activeDialog !== 'delete' && !dialogInputValue.trim())}
>
{isDialogSubmitting ? <RiLoader4Line className="animate-spin" /> : (
activeDialog === 'delete' ? 'Delete' : 'Confirm'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
// Comment UI component
const renderCommentUI = () => {
if (!lineSelection || !selectedFile) return null;
@@ -1369,6 +1597,24 @@ export const FilesView: React.FC = () => {
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog('createFile', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New File"
>
<RiFileAddLine className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog('createFolder', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New Folder"
>
<RiFolderAddLine className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0">
<RiRefreshLine className="h-4 w-4" />
</Button>
@@ -1419,6 +1665,7 @@ export const FilesView: React.FC = () => {
return (
<div className="flex h-full min-h-0 overflow-hidden bg-background">
{renderDialogs()}
{isMobile ? (
showMobilePageContent ? (
fileViewer
@@ -2,7 +2,7 @@ import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'
import { createPortal } from 'react-dom';
import { FileDiff } from '@pierre/diffs/react';
import { parseDiffFromFile, type FileContents, type FileDiffMetadata, type SelectedLineRange } from '@pierre/diffs';
import { RiArrowDownSLine, RiEyeLine, RiSendPlane2Line } from '@remixicon/react';
import { RiSendPlane2Line } from '@remixicon/react';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -141,7 +141,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = useState('');
const commentContainerRef = useRef<HTMLDivElement>(null);
// Calculate initial center synchronously to avoid flicker
const getMainContentCenter = useCallback(() => {
@@ -212,8 +212,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const target = e.target as HTMLElement;
// Check if click is inside the comment UI portal
const commentUI = document.querySelector('[data-comment-ui]');
if (commentUI?.contains(target)) return;
if (commentContainerRef.current?.contains(target)) return;
// Check if click is inside toast (sonner)
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
@@ -302,21 +301,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
fileDiff: FileDiffMetadata;
} | null>(null);
// Threshold for lazy loading (total lines > 1500 or content size > 150KB)
const isLargeDiff = useMemo(() => {
const totalLines = (original || '').split('\n').length + (modified || '').split('\n').length;
const totalSize = (original?.length || 0) + (modified?.length || 0);
return totalLines > 1500 || totalSize > 150 * 1024;
}, [original, modified]);
// State for large diff loading
const [shouldLoad, setShouldLoad] = useState(false);
// Parse diff when loaded (for large diffs) or always (for small diffs)
// Pre-parse the diff with cacheKey for worker pool caching
const fileDiff = useMemo(() => {
// For large diffs, only parse if manually triggered
if (isLargeDiff && !shouldLoad) return null;
const cacheKey = getCacheKey(fileName, original, modified);
// Return cached diff if inputs haven't changed
@@ -344,7 +330,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
diffCacheRef.current = { key: cacheKey, fileDiff: diff };
return diff;
}, [fileName, original, modified, language, isLargeDiff, shouldLoad]);
}, [fileName, original, modified, language]);
const options = useMemo(() => ({
theme: {
@@ -363,45 +349,11 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
onLineSelected: handleSelectionChange,
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
}), [isDark, renderSideBySide, wrapLines, handleSelectionChange]);
// Show placeholder for large diffs
if (isLargeDiff && !fileDiff) {
const originalLines = (original || '').split('\n').length;
const modifiedLines = (modified || '').split('\n').length;
return (
<div className="flex flex-col items-center justify-center h-full min-h-[200px] p-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="p-3 rounded-full bg-muted/50">
<RiEyeLine className="size-6 text-muted-foreground" />
</div>
<div>
<div className="typography-ui-header font-medium text-foreground">Large Diff</div>
<div className="typography-meta text-muted-foreground mt-1">
{originalLines + modifiedLines} lines
</div>
</div>
<button
type="button"
onClick={() => setShouldLoad(true)}
className="flex items-center gap-2 px-4 py-2 mt-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors typography-meta font-medium"
>
<RiArrowDownSLine className="size-4" />
Load Diff
</button>
</div>
</div>
);
}
if (typeof window === 'undefined') {
return null;
}
// fileDiff should not be null here (handled by large diff placeholder above)
if (!fileDiff) {
return null;
}
// Extracted Comment Interface Content for reuse in Portal or In-Flow
const renderCommentContent = () => {
if (!selection) return null;
@@ -521,6 +473,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
: '16px'
}}
data-keyboard-avoid="true"
data-comment-ui="true"
ref={commentContainerRef}
>
{commentContent}
</div>
@@ -566,6 +520,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
}}
data-keyboard-avoid="true"
data-comment-ui="true"
ref={commentContainerRef}
>
{commentContent}
</div>
@@ -325,7 +325,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return '';
}, [isDesktopApp, isMacPlatform]);
const showLeadingDivider = isDesktopApp && isMacPlatform;
return (
<div ref={containerRef} className={cn('flex h-full flex-col overflow-hidden', isDesktopApp ? 'bg-transparent' : 'bg-background')}>
@@ -356,48 +355,46 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{isMobile && <div className="flex-1" />}
<div className={cn('flex items-center', isMobile ? 'gap-1' : 'h-full')}>
{/* Leading divider before first tab - only on Mac desktop */}
{!isMobile && showLeadingDivider && <div className="h-full w-px bg-border" aria-hidden="true" />}
{settingsSections.map(({ id, label, icon: Icon }) => {
const isActive = activeTab === id;
const PhosphorIcon = Icon as React.ComponentType<{ className?: string; weight?: string }>;
<div className={cn('flex items-center gap-1', !isMobile && 'p-1 bg-background/50 rounded-lg')}>
{settingsSections.map(({ id, label, icon: Icon }) => {
const isActive = activeTab === id;
const PhosphorIcon = Icon as React.ComponentType<{ className?: string; weight?: string }>;
if (isMobile) {
// Mobile: icon-only buttons
if (isMobile) {
// Mobile: icon-only buttons
return (
<Tooltip key={id} delayDuration={500}>
<TooltipTrigger asChild>
<button
onClick={() => handleTabChange(id)}
className={cn(
'relative flex h-9 w-9 items-center justify-center rounded-md transition-colors',
'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isActive ? 'bg-secondary text-foreground shadow-sm' : 'text-muted-foreground'
)}
aria-pressed={isActive}
aria-label={label}
>
<PhosphorIcon className="h-5 w-5" weight="regular" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}
// Desktop: pill tabs like main header
return (
<Tooltip key={id} delayDuration={500}>
<TooltipTrigger asChild>
<button
onClick={() => handleTabChange(id)}
className={cn(
'relative flex h-9 w-9 items-center justify-center rounded-md transition-colors',
'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}
aria-pressed={isActive}
aria-label={label}
>
<PhosphorIcon className="h-5 w-5" weight="regular" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}
// Desktop: full tabs with text and dividers
return (
<React.Fragment key={id}>
<button
key={id}
onClick={() => handleTabChange(id)}
onMouseDown={isActive ? handleActiveTabDragStart : undefined}
className={cn(
'relative flex h-full items-center gap-2 px-4 typography-ui-label font-medium transition-colors',
isActive ? 'app-region-drag' : 'app-region-no-drag',
'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
isActive ? 'text-foreground' : 'text-muted-foreground'
'relative flex h-8 items-center gap-2 px-3 rounded-md typography-ui-label font-medium transition-colors',
isActive ? 'app-region-drag bg-secondary text-foreground shadow-sm' : 'app-region-no-drag text-muted-foreground hover:bg-secondary/50 hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
aria-pressed={isActive}
aria-label={label}
@@ -405,11 +402,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
<PhosphorIcon className="h-4 w-4" weight="regular" />
{showTabLabels && <span>{label}</span>}
</button>
{/* Vertical divider after each tab */}
<div className="h-full w-px bg-border" aria-hidden="true" />
</React.Fragment>
);
})}
);
})}
</div>
</div>
{(onClose || showProjectSwitcher) && (