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
@@ -221,7 +221,7 @@ export const ChatContainer: React.FC = () => {
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="flex-1 overflow-y-auto p-4 bg-background">
<div className="chat-column space-y-4">
<div className="chat-message-column space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 p-4">
<Skeleton className="h-8 w-8 rounded-full" />
@@ -827,17 +827,17 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
<div
className={cn(
'group w-full',
shouldShowHeader ? 'pt-2' : 'pt-0',
isUser ? 'pb-2' : isFollowedByAssistant ? 'pb-0' : 'pb-2'
shouldShowHeader ? 'pt-6' : 'pt-0',
isUser ? 'pb-4' : isFollowedByAssistant ? 'pb-0' : 'pb-8'
)}
data-message-id={message.info.id}
ref={messageContainerRef}
>
<div className="chat-column">
<div className="chat-message-column relative">
{isUser ? (
<FadeInOnReveal>
<div className="flex justify-end">
<div className="max-w-[85%] rounded-xl rounded-br-xs bg-primary/10 dark:bg-primary/8 px-3.5 pt-2.5 pb-1.5">
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-primary/10 dark:bg-primary/10 px-5 py-3 shadow-sm border border-primary/5">
<MessageBody
messageId={message.info.id}
parts={visibleParts}
@@ -870,7 +870,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
</div>
</FadeInOnReveal>
) : (
<div>
<div className="relative pl-4 ml-1">
{shouldShowHeader && (
<MessageHeader
isUser={isUser}
@@ -168,9 +168,11 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
)}
</div>
{getFileIcon()}
<span title={file.serverPath || displayName}>
{displayName}
</span>
<div className="overflow-hidden max-w-[200px]">
<span className="marquee-text" title={file.serverPath || displayName}>
{displayName}
</span>
</div>
<span className="text-muted-foreground flex-shrink-0">
({formatFileSize(file.size)})
</span>
@@ -289,9 +291,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta"
>
{getFileIcon(file.mime)}
<span>
{extractFilename(file.filename)}
</span>
<div className="overflow-hidden max-w-[200px]">
<span className="marquee-text">
{extractFilename(file.filename)}
</span>
</div>
</div>
))}
</div>
@@ -2049,13 +2049,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<span
key={`${currentProviderId}-${currentModelId}`}
className={cn(
'model-controls__model-label',
'model-controls__model-label overflow-hidden',
controlTextSize,
'font-medium whitespace-nowrap text-foreground truncate min-w-0',
'font-medium whitespace-nowrap text-foreground min-w-0',
'max-w-[260px]'
)}
>
{getCurrentModelDisplayName()}
<span className="marquee-text">
{getCurrentModelDisplayName()}
</span>
</span>
</div>
</DropdownMenuTrigger>
@@ -2169,11 +2171,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
)}
<span
className={cn(
'model-controls__model-label typography-micro font-medium truncate min-w-0',
'model-controls__model-label typography-micro font-medium overflow-hidden min-w-0',
isMobile ? 'max-w-[120px]' : 'max-w-[220px]',
)}
>
{getCurrentModelDisplayName()}
<span className="marquee-text">
{getCurrentModelDisplayName()}
</span>
</span>
</button>
)}
@@ -155,7 +155,7 @@ const UserMessageBody: React.FC<{
style={{ contain: 'layout', transform: 'translateZ(0)' }}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
>
<div className="leading-normal overflow-hidden text-foreground/85">
<div className="leading-relaxed overflow-hidden text-foreground/90 text-base">
{textParts.map((part, index) => {
let mentionForPart: AgentMentionInfo | undefined;
if (agentMention && mentionToken && !mentionInjected) {
@@ -180,8 +180,7 @@ const UserMessageBody: React.FC<{
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
<div className={cn(
"mt-1 flex items-center justify-end gap-2 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/message:opacity-100 group-hover/message:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto",
copyHintVisible && "opacity-100 pointer-events-auto"
"mt-1 flex items-center justify-end gap-2"
)}>
{onRevert && (
<Tooltip delayDuration={1000}>
@@ -284,7 +283,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
errorMessage,
}) => {
void _streamPhase;
void _allowAnimation;
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
@@ -899,11 +897,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
}}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
>
<div
className="px-3"
>
<div>
<div
className="leading-normal overflow-hidden text-foreground/90 [&_p:last-child]:mb-0 [&_ul:last-child]:mb-0 [&_ol:last-child]:mb-0"
className="leading-relaxed overflow-hidden text-foreground/90 [&_p:last-child]:mb-0 [&_ul:last-child]:mb-0 [&_ol:last-child]:mb-0"
>
{renderedParts}
{showErrorMessage && (
@@ -19,7 +19,7 @@ const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agent
return (
<FadeInOnReveal>
<div className={cn('pl-3', 'mb-2')}>
<div className={cn('mb-2')}>
<div className={cn('flex items-center justify-between gap-2')}>
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
+55 -90
View File
@@ -59,7 +59,7 @@ export const FixedSessionsButton: React.FC = () => {
}
}, [isMobile, setSessionSwitcherOpen, toggleSidebar]);
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground';
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-secondary/50 transition-colors';
if (isMobile || !isDesktopApp || !isMacPlatform) {
return null;
@@ -89,7 +89,6 @@ export const Header: React.FC = () => {
const toggleCommandPalette = useUIStore((state) => state.toggleCommandPalette);
const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
@@ -168,12 +167,11 @@ export const Header: React.FC = () => {
setSettingsDialogOpen(true);
}, [blurActiveElement, isMobile, setSessionSwitcherOpen, setSettingsDialogOpen]);
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground';
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-secondary/50 transition-colors';
const desktopPaddingClass = React.useMemo(() => {
if (isDesktopApp && isMacPlatform) {
return isSidebarOpen ? 'pl-0' : 'pl-[8.0rem]';
return isSidebarOpen ? 'pl-2' : 'pl-[8.0rem]';
}
return 'pl-3';
}, [isDesktopApp, isMacPlatform, isSidebarOpen]);
@@ -221,15 +219,12 @@ export const Header: React.FC = () => {
}, [updateHeaderHeight, isMobile]);
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
return;
}
if (e.button !== 0) {
return;
}
if (isDesktopApp) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
@@ -242,11 +237,9 @@ export const Header: React.FC = () => {
}, [isDesktopApp]);
const handleActiveTabDragStart = React.useCallback(async (e: React.MouseEvent) => {
if (e.button !== 0) {
return;
}
if (isDesktopApp) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
@@ -278,7 +271,6 @@ export const Header: React.FC = () => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= tabs.length) {
@@ -287,82 +279,58 @@ export const Header: React.FC = () => {
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [tabs, setActiveMainTab]);
const renderTab = (tab: TabConfig, isLast: boolean) => {
const renderTab = (tab: TabConfig) => {
const isActive = activeMainTab === tab.id;
const Icon = tab.icon;
const isChatTab = tab.id === 'chat';
const isGitTab = tab.id === 'git';
return (
<React.Fragment key={tab.id}>
<button
type="button"
onClick={() => setActiveMainTab(tab.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',
isActive && 'after:absolute after:bottom-[-1px] after:left-0 after:right-0 after:h-[2px] after:bg-background',
isActive && isChatTab && isSidebarOpen && 'before:absolute before:bottom-[-1px] before:right-full before:h-px before:bg-[var(--interactive-border)] before:w-[var(--sidebar-w)]',
isChatTab && !(isDesktopApp && isMacPlatform && isSidebarOpen) && 'border-l',
isGitTab && 'border-r',
isChatTab && !isMobile && 'min-w-[165px] max-[1024px]:min-w-0'
)}
style={{
...(isActive && isChatTab && isSidebarOpen ? {
['--sidebar-w' as string]: (isDesktopApp && isMacPlatform) ? `${sidebarWidth}px` : '64px'
} : {}),
...((isChatTab && !(isDesktopApp && isMacPlatform && isSidebarOpen)) || isGitTab ? { borderColor: 'var(--interactive-border)' } : {}),
}}
aria-label={tab.label}
aria-selected={isActive}
role="tab"
>
{isMobile ? (
<Icon size={20} />
) : (
<>
<Icon size={16} />
<span className="header-tab-label">{tab.label}</span>
</>
)}
{}
{isChatTab && !isMobile && contextUsage && contextUsage.totalTokens > 0 && (
<span className="ml-1">
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
/>
</span>
)}
{}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="text-xs font-semibold text-primary">
{tab.badge}
</span>
)}
</button>
{}
{!isLast && (
<div className="h-full w-px bg-border" aria-hidden="true" />
<button
key={tab.id}
type="button"
onClick={() => setActiveMainTab(tab.id)}
onMouseDown={isActive ? handleActiveTabDragStart : undefined}
className={cn(
'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',
isChatTab && !isMobile && 'min-w-[100px] justify-center'
)}
</React.Fragment>
aria-label={tab.label}
aria-selected={isActive}
role="tab"
>
{isMobile ? (
<Icon size={20} />
) : (
<>
<Icon size={16} />
<span className="header-tab-label">{tab.label}</span>
</>
)}
{isChatTab && !isMobile && contextUsage && contextUsage.totalTokens > 0 && (
<span className="ml-1">
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
/>
</span>
)}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="ml-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/10 px-1 text-[10px] font-bold text-primary">
{tab.badge}
</span>
)}
</button>
);
};
@@ -376,29 +344,25 @@ export const Header: React.FC = () => {
role="tablist"
aria-label="Main navigation"
>
{}
{!(isDesktopApp && isMacPlatform) && (
<>
<button
type="button"
onClick={handleOpenSessionSwitcher}
aria-label="Open sessions"
className={`${headerIconButtonClass} mr-2.5`}
className={`${headerIconButtonClass} mr-2`}
>
<RiLayoutLeftLine className="h-5 w-5" />
</button>
</>
)}
{}
<div className="flex h-full items-center">
{tabs.map((tab, index) => renderTab(tab, index === tabs.length - 1))}
<div className="flex items-center gap-1 p-1 bg-background/50 rounded-lg">
{tabs.map((tab) => renderTab(tab))}
</div>
{}
<div className="flex-1" />
{}
<div className="flex items-center gap-1 pr-3">
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
@@ -442,7 +406,7 @@ export const Header: React.FC = () => {
<div className="flex items-center gap-2">
<button
onClick={handleOpenSessionSwitcher}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md active:bg-secondary"
aria-label="Open sessions"
>
<RiPlayListAddLine className="h-5 w-5" />
@@ -459,9 +423,10 @@ export const Header: React.FC = () => {
)}
</div>
<div className="app-region-no-drag flex items-center gap-1.5">
<div className="app-region-no-drag flex items-center gap-1">
<div className="flex items-center gap-0.5" role="tablist" aria-label="Main navigation">
<div className="flex items-center" role="tablist" aria-label="Main navigation">
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
const Icon = tab.icon;
@@ -482,7 +447,7 @@ export const Header: React.FC = () => {
className={cn(
headerIconButtonClass,
'relative',
isActive && 'text-foreground'
isActive && 'text-foreground bg-secondary'
)}
>
<Icon className="h-5 w-5" />
@@ -535,7 +500,7 @@ export const Header: React.FC = () => {
);
const headerClassName = cn(
'header-safe-area border-b relative z-10',
'header-safe-area border-b border-border/50 relative z-10',
isDesktopApp ? 'bg-background' : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
);
@@ -543,7 +508,7 @@ export const Header: React.FC = () => {
<header
ref={headerRef}
className={headerClassName}
style={{ borderColor: 'var(--interactive-border)', ['--padding-scale' as string]: '1' } as React.CSSProperties}
style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties}
>
{isMobile ? renderMobile() : renderDesktop()}
</header>
+30 -31
View File
@@ -168,19 +168,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
return (
<aside
className={cn(
'relative flex h-full overflow-hidden border-r',
'relative flex h-full overflow-hidden border-r border-border',
isDesktopApp
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
? 'bg-sidebar/95 backdrop-blur supports-[backdrop-filter]:bg-sidebar/80'
: 'bg-sidebar',
isResizing ? 'transition-none' : '',
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
!isOpen && 'border-r-0'
)}
style={{
width: `${appliedWidth}px`,
minWidth: `${appliedWidth}px`,
maxWidth: `${appliedWidth}px`,
pointerEvents: !isOpen ? 'none' : undefined,
borderColor: 'var(--interactive-border)',
overflowX: 'clip',
}}
aria-hidden={!isOpen || appliedWidth === 0}
@@ -188,8 +186,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
{isOpen && (
<div
className={cn(
'absolute right-0 top-0 z-20 h-full w-[6px] -mr-[3px] cursor-col-resize',
isResizing ? 'bg-primary/30' : 'bg-transparent hover:bg-primary/20'
'absolute right-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
)}
onPointerDown={handlePointerDown}
role="separator"
@@ -199,7 +197,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
)}
<div
className={cn(
'relative z-10 flex h-full flex-col transition-opacity duration-200 ease-in-out',
'relative z-10 flex h-full flex-col transition-opacity duration-300 ease-in-out',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
style={{ width: `${appliedWidth}px`, overflowX: 'hidden' }}
@@ -216,44 +214,45 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
<div className="flex-1 overflow-hidden">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
<div className="flex-shrink-0 border-t border-border p-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-shrink-0 border-t border-border h-12 px-2 bg-sidebar-accent/10">
<div className="flex h-full items-center justify-between gap-2">
<button
onClick={() => setSettingsDialogOpen(true)}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2',
'text-sm font-semibold text-muted-foreground',
'hover:text-foreground',
'transition-colors'
'flex h-8 items-center gap-2 rounded-md px-2',
'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-sidebar-accent',
'transition-all duration-200'
)}
>
<RiSettings3Line className="h-4 w-4" />
<span>Settings</span>
</button>
{(available || downloaded) ? (
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1.5',
'text-xs font-medium',
'bg-primary/10 text-primary',
'hover:bg-primary/20',
'transition-colors'
)}
>
<RiDownloadLine className="h-3.5 w-3.5" />
<span>Update</span>
</button>
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1',
'text-xs font-semibold',
'bg-primary/10 text-primary',
'hover:bg-primary/20',
'transition-colors'
)}
>
<RiDownloadLine className="h-3.5 w-3.5" />
<span>Update</span>
</button>
) : !isDesktopApp && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setAboutDialogOpen(true)}
className={cn(
'flex items-center justify-center rounded-md p-1.5',
'text-muted-foreground',
'hover:text-foreground hover:bg-muted/50',
'transition-colors'
'flex h-8 w-8 items-center justify-center rounded-md',
'text-sidebar-foreground/70',
'hover:text-sidebar-foreground hover:bg-sidebar-accent',
'transition-all duration-200'
)}
>
<RiInformationLine className="h-4 w-4" />
@@ -80,7 +80,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
onTouchStart={isMobile ? handleLongPressStart : undefined}
onTouchEnd={isMobile ? handleLongPressEnd : undefined}
>
<RiDonutChartLine className="h-4 w-4 flex-shrink-0" />
{!isMobile && <RiDonutChartLine className="h-4 w-4 flex-shrink-0" />}
<span className={cn(getPercentageColor(percentage), 'font-medium')}>
{Math.min(percentage, 999).toFixed(1)}%
</span>
+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) && (