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:
committed by
GitHub
parent
1f23b63c0b
commit
d97181bcd2
@@ -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) && (
|
||||
|
||||
Reference in New Issue
Block a user