style(ui): 美化编辑器代码显示

- 添加 CodeMirror 编辑器全局样式(字体、行号、高亮、搜索面板等)
- 添加 Diff 编辑器样式(删除/新增行高亮、行内变更等)
- CodeEditor 优化:Tab 栏动画、文件图标着色、底部状态栏
- DiffEditor 优化:头部布局、变更统计、操作类型标签
This commit is contained in:
2025-12-17 21:35:34 +08:00
parent fea5442d53
commit c6dd3695e5
3 changed files with 411 additions and 69 deletions
+104 -39
View File
@@ -13,7 +13,7 @@ import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { X, Save, Circle, FileCode, MousePointerClick } from 'lucide-react';
import { X, Save, Circle, FileCode, MousePointerClick, Code2 } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn.js';
@@ -154,6 +154,32 @@ export function CodeEditor({
return [getLanguageExtension(activeTab.language)].flat();
}, [activeTab]);
// 获取文件图标颜色
const getFileIconColor = (language: string) => {
switch (language) {
case 'typescript':
case 'tsx':
return 'text-blue-500';
case 'javascript':
case 'jsx':
return 'text-yellow-500';
case 'python':
return 'text-green-500';
case 'json':
return 'text-orange-500';
case 'html':
return 'text-red-500';
case 'css':
case 'scss':
case 'less':
return 'text-purple-500';
case 'markdown':
return 'text-cyan-500';
default:
return 'text-fg-muted';
}
};
if (tabs.length === 0) {
return (
<div className={cn('flex flex-col items-center justify-center h-full bg-surface-base', className)}>
@@ -163,14 +189,14 @@ export function CodeEditor({
transition={{ duration: 0.3 }}
className="text-center max-w-xs"
>
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-surface-subtle flex items-center justify-center">
<FileCode size={32} className="text-fg-subtle" />
<div className="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-surface-subtle to-surface-muted flex items-center justify-center shadow-inner">
<Code2 size={36} className="text-fg-subtle" />
</div>
<h3 className="text-fg font-medium mb-2">No files open</h3>
<p className="text-fg-muted text-sm mb-4">
<h3 className="text-fg font-semibold text-lg mb-2">No files open</h3>
<p className="text-fg-muted text-sm mb-5 leading-relaxed">
Select a file from the explorer to view and edit its contents
</p>
<div className="flex items-center justify-center gap-2 text-xs text-fg-subtle">
<div className="flex items-center justify-center gap-2 text-xs text-fg-subtle bg-surface-subtle px-4 py-2 rounded-full">
<MousePointerClick size={14} />
<span>Click a file to open</span>
</div>
@@ -182,61 +208,77 @@ export function CodeEditor({
return (
<div className={cn('flex flex-col h-full bg-surface-base', className)}>
{/* Tab Bar */}
<div className="flex items-center border-b border-line bg-surface-subtle overflow-x-auto">
<div className="flex items-center border-b border-line bg-surface-subtle/80 backdrop-blur-sm overflow-x-auto scrollbar-hide">
<AnimatePresence mode="popLayout">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const isModified = hasUnsavedChanges(tab);
const isSaving = saving === tab.id;
const iconColor = getFileIconColor(tab.language);
return (
<motion.div
key={tab.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10, scale: 0.95 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className={cn(
'flex items-center gap-1.5 px-3 py-2 border-r border-line cursor-pointer group min-w-0',
'hover:bg-surface-muted transition-colors',
isActive && 'bg-surface-base border-b-2 border-b-primary-500'
'relative flex items-center gap-2 px-3.5 py-2.5 cursor-pointer group min-w-0',
'hover:bg-surface-muted/50 transition-all duration-150',
isActive
? 'bg-surface-base shadow-sm'
: 'border-r border-line/50'
)}
onClick={() => onTabChange(tab.id)}
>
{/* 修改指示 */}
{isModified && !isSaving && (
<Circle size={8} className="fill-orange-400 text-orange-400 flex-shrink-0" />
)}
{isSaving && (
<div className="w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin flex-shrink-0" />
{/* 活动标签顶部指示 */}
{isActive && (
<motion.div
layoutId="activeTabIndicator"
className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-primary-500 to-primary-400"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
{/* 文件图标 */}
<FileCode size={14} className={cn('flex-shrink-0', isActive ? iconColor : 'text-fg-subtle')} />
{/* 文件名 */}
<span
className={cn(
'text-sm truncate max-w-[150px]',
isActive ? 'text-fg' : 'text-fg-muted'
'text-sm truncate max-w-[140px] transition-colors',
isActive ? 'text-fg font-medium' : 'text-fg-muted'
)}
title={tab.path}
>
{tab.name}
</span>
{/* 关闭按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
className={cn(
'p-0.5 rounded hover:bg-surface-emphasis transition-colors flex-shrink-0',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-100'
)}
>
<X size={14} className="text-fg-muted" />
</button>
{/* 修改指示器 / 保存中 / 关闭按钮 */}
<div className="flex items-center gap-1 flex-shrink-0">
{isSaving ? (
<div className="w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
) : isModified ? (
<Circle size={8} className="fill-orange-400 text-orange-400" />
) : null}
{/* 关闭按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
className={cn(
'p-1 rounded-md hover:bg-surface-emphasis/80 transition-all duration-150 flex-shrink-0',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-60 hover:opacity-100'
)}
>
<X size={12} className="text-fg-muted" />
</button>
</div>
</motion.div>
);
})}
@@ -251,10 +293,11 @@ export function CodeEditor({
whileTap={{ scale: 0.95 }}
onClick={() => handleSave(activeTab)}
disabled={saving === activeTab.id}
className="ml-auto mr-2 p-1.5 rounded hover:bg-surface-muted transition-colors"
className="ml-auto mr-3 px-3 py-1.5 rounded-md bg-primary-500/10 hover:bg-primary-500/20 transition-colors flex items-center gap-1.5"
title="Save (Cmd+S)"
>
<Save size={16} className="text-fg-muted" />
<Save size={14} className="text-primary-500" />
<span className="text-xs text-primary-500 font-medium">Save</span>
</motion.button>
)}
</div>
@@ -294,10 +337,32 @@ export function CodeEditor({
completionKeymap: true,
lintKeymap: true,
}}
className="h-full text-sm"
className="h-full"
/>
)}
</div>
{/* Status Bar */}
{activeTab && (
<div className="flex items-center justify-between px-4 py-1.5 border-t border-line bg-surface-subtle/60 text-xs">
<div className="flex items-center gap-4">
{/* 语言 */}
<span className="text-fg-muted capitalize">{activeTab.language}</span>
{/* 行数 */}
<span className="text-fg-subtle">
{activeTab.content.split('\n').length} lines
</span>
</div>
<div className="flex items-center gap-4">
{/* 编码 */}
<span className="text-fg-subtle">UTF-8</span>
{/* 路径 */}
<span className="text-fg-subtle truncate max-w-[300px]" title={activeTab.path}>
{activeTab.path}
</span>
</div>
</div>
)}
</div>
);
}
+71 -30
View File
@@ -16,7 +16,7 @@ import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { X, FileCode, GitCompare } from 'lucide-react';
import { X, FileCode, GitCompare, ChevronLeft, ChevronRight, FileEdit, FilePlus } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '../utils/cn.js';
import { useTheme } from '../hooks/useTheme.js';
@@ -159,51 +159,86 @@ export function DiffEditor({ diff, onClose, className }: DiffEditorProps) {
};
}, [diff.originalContent, diff.newContent, languageExtension, resolvedTheme]);
// 获取操作类型的图标和颜色
const operationInfo = diff.operation === 'write'
? { icon: FilePlus, label: 'New File', color: 'text-green-500', bgColor: 'bg-green-500/10' }
: { icon: FileEdit, label: 'Modified', color: 'text-blue-500', bgColor: 'bg-blue-500/10' };
const OperationIcon = operationInfo.icon;
return (
<div className={cn('flex flex-col h-full bg-surface-base', className)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-line bg-surface-subtle">
<div className="flex items-center gap-3">
{/* 文件图标和名称 */}
<div className="flex items-center gap-2">
<GitCompare size={16} className="text-primary-500" />
<span className="font-medium text-fg">{diff.name}</span>
<span className="text-xs text-fg-muted">({diff.operation === 'write' ? 'Write' : 'Edit'})</span>
<div className="flex items-center justify-between px-4 py-3 border-b border-line bg-gradient-to-r from-surface-subtle to-surface-subtle/80">
<div className="flex items-center gap-4">
{/* Diff 图标 */}
<div className="p-2 rounded-lg bg-primary-500/10">
<GitCompare size={18} className="text-primary-500" />
</div>
{/* 变更统计 */}
<div className="flex items-center gap-2 text-xs">
<span className="text-green-500">+{stats.additions}</span>
<span className="text-red-500">-{stats.deletions}</span>
{/* 文件名和操作类型 */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="font-semibold text-fg">{diff.name}</span>
<span className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
operationInfo.bgColor, operationInfo.color
)}>
<OperationIcon size={12} />
{operationInfo.label}
</span>
</div>
<span className="text-xs text-fg-subtle truncate max-w-[400px]" title={diff.path}>
{diff.path}
</span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2">
{/* 变更统计和操作按钮 */}
<div className="flex items-center gap-4">
{/* 变更统计 */}
<div className="flex items-center gap-3 px-3 py-1.5 rounded-lg bg-surface-muted/50">
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-500">+{stats.additions}</span>
</div>
<div className="w-px h-4 bg-line" />
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm font-medium text-red-500">-{stats.deletions}</span>
</div>
</div>
{/* 关闭按钮 */}
{onClose && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onClose}
className="p-1.5 rounded hover:bg-surface-muted transition-colors"
title="关闭 Diff 视图"
className="p-2 rounded-lg hover:bg-surface-muted transition-colors group"
title="关闭 Diff 视图 (ESC)"
>
<X size={16} className="text-fg-muted" />
<X size={18} className="text-fg-muted group-hover:text-fg transition-colors" />
</motion.button>
)}
</div>
</div>
{/* 标签栏 */}
<div className="flex border-b border-line bg-surface-subtle">
<div className="flex-1 px-4 py-2 text-sm text-fg-muted border-r border-line">
<FileCode size={14} className="inline mr-2" />
Original
{/* 标签栏 */}
<div className="flex border-b border-line bg-surface-subtle/60">
<div className="flex-1 flex items-center gap-2 px-4 py-2 text-sm text-fg-muted border-r border-line">
<ChevronLeft size={14} className="text-red-400" />
<FileCode size={14} className="text-fg-subtle" />
<span className="font-medium">Original</span>
{diff.operation === 'write' && (
<span className="text-xs text-fg-subtle">(empty)</span>
)}
</div>
<div className="flex-1 px-4 py-2 text-sm text-fg-muted">
<FileCode size={14} className="inline mr-2" />
Modified
<div className="flex-1 flex items-center gap-2 px-4 py-2 text-sm text-fg-muted">
<ChevronRight size={14} className="text-green-400" />
<FileCode size={14} className="text-fg-subtle" />
<span className="font-medium">Modified</span>
<span className="text-xs text-fg-subtle">({stats.totalLines} lines)</span>
</div>
</div>
@@ -214,9 +249,15 @@ export function DiffEditor({ diff, onClose, className }: DiffEditorProps) {
style={{ minHeight: 0 }}
/>
{/* 路径提示 */}
<div className="px-4 py-1.5 border-t border-line bg-surface-subtle text-xs text-fg-muted truncate">
{diff.path}
{/* 底部状态栏 */}
<div className="flex items-center justify-between px-4 py-2 border-t border-line bg-surface-subtle/60 text-xs">
<div className="flex items-center gap-2 text-fg-subtle">
<kbd className="px-1.5 py-0.5 rounded bg-surface-muted font-mono">ESC</kbd>
<span>to close</span>
</div>
<div className="text-fg-muted">
Side-by-side comparison
</div>
</div>
</div>
);
+236
View File
@@ -93,6 +93,16 @@ html {
background: var(--scrollbar-thumb-hover);
}
/* 隐藏滚动条(保留滚动功能) */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* ============ Message content ============ */
.message-content {
@@ -119,6 +129,232 @@ html {
padding: 0;
}
/* ============ CodeMirror Editor Styles ============ */
/* 编辑器容器 */
.cm-editor {
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace !important;
font-size: 13px !important;
line-height: 1.6 !important;
font-feature-settings: 'liga' 1, 'calt' 1; /* 启用连字 */
}
/* 编辑器聚焦时的边框 */
.cm-editor.cm-focused {
outline: none !important;
}
/* 内容区域 */
.cm-scroller {
overflow: auto !important;
font-family: inherit !important;
}
/* 行号栏 */
.cm-gutters {
border-right: 1px solid rgb(var(--color-border-default)) !important;
background: rgb(var(--color-bg-subtle)) !important;
}
.dark .cm-gutters {
background: rgba(0, 0, 0, 0.2) !important;
border-right-color: rgb(var(--color-border-default)) !important;
}
/* 行号 */
.cm-lineNumbers .cm-gutterElement {
padding: 0 12px 0 8px !important;
min-width: 40px !important;
color: rgb(var(--color-text-subtle)) !important;
font-size: 12px !important;
}
/* 活动行高亮 */
.cm-activeLine {
background: rgba(var(--color-bg-emphasis), 0.5) !important;
}
.dark .cm-activeLine {
background: rgba(255, 255, 255, 0.04) !important;
}
/* 活动行号高亮 */
.cm-activeLineGutter {
background: rgba(var(--color-bg-emphasis), 0.5) !important;
}
.dark .cm-activeLineGutter {
background: rgba(255, 255, 255, 0.06) !important;
}
/* 选区样式 */
.cm-selectionBackground {
background: rgba(59, 130, 246, 0.2) !important;
}
.dark .cm-selectionBackground {
background: rgba(59, 130, 246, 0.3) !important;
}
/* 匹配的括号 */
.cm-matchingBracket {
background: rgba(255, 215, 0, 0.3) !important;
outline: 1px solid rgba(255, 215, 0, 0.5) !important;
}
/* 折叠占位符 */
.cm-foldPlaceholder {
background: rgb(var(--color-bg-muted)) !important;
border: 1px solid rgb(var(--color-border-default)) !important;
border-radius: 4px !important;
padding: 0 4px !important;
margin: 0 2px !important;
color: rgb(var(--color-text-muted)) !important;
}
/* 搜索面板 */
.cm-panel.cm-search {
background: rgb(var(--color-bg-subtle)) !important;
border-bottom: 1px solid rgb(var(--color-border-default)) !important;
padding: 8px !important;
}
.cm-panel.cm-search input {
background: rgb(var(--color-bg-base)) !important;
border: 1px solid rgb(var(--color-border-default)) !important;
border-radius: 4px !important;
padding: 4px 8px !important;
color: rgb(var(--color-text-primary)) !important;
}
.cm-panel.cm-search button {
background: rgb(var(--color-bg-muted)) !important;
border: 1px solid rgb(var(--color-border-default)) !important;
border-radius: 4px !important;
padding: 4px 8px !important;
color: rgb(var(--color-text-secondary)) !important;
cursor: pointer !important;
}
.cm-panel.cm-search button:hover {
background: rgb(var(--color-bg-emphasis)) !important;
}
/* 自动补全下拉框 */
.cm-tooltip-autocomplete {
background: rgb(var(--color-bg-base)) !important;
border: 1px solid rgb(var(--color-border-default)) !important;
border-radius: 6px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
overflow: hidden !important;
}
.dark .cm-tooltip-autocomplete {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
.cm-tooltip-autocomplete > ul {
font-family: inherit !important;
max-height: 200px !important;
}
.cm-tooltip-autocomplete > ul > li {
padding: 4px 10px !important;
color: rgb(var(--color-text-primary)) !important;
}
.cm-tooltip-autocomplete > ul > li[aria-selected] {
background: rgb(var(--color-bg-emphasis)) !important;
color: rgb(var(--color-text-primary)) !important;
}
.dark .cm-tooltip-autocomplete > ul > li[aria-selected] {
background: rgba(59, 130, 246, 0.2) !important;
}
/* 光标样式 */
.cm-cursor {
border-left: 2px solid rgb(var(--color-text-primary)) !important;
}
.dark .cm-cursor {
border-left-color: #fff !important;
}
/* 行内容 */
.cm-line {
padding: 0 8px !important;
}
/* ============ Diff Editor Styles ============ */
/* Merge View 容器 */
.cm-mergeView {
height: 100% !important;
}
/* Diff 视图中的变更高亮 */
.cm-changedLine {
background: rgba(255, 215, 0, 0.1) !important;
}
.cm-deletedChunk {
background: rgba(239, 68, 68, 0.15) !important;
}
.dark .cm-deletedChunk {
background: rgba(239, 68, 68, 0.2) !important;
}
.cm-insertedChunk {
background: rgba(34, 197, 94, 0.15) !important;
}
.dark .cm-insertedChunk {
background: rgba(34, 197, 94, 0.2) !important;
}
/* Diff 行内变更 */
.cm-changedText {
background: rgba(255, 215, 0, 0.3) !important;
border-radius: 2px !important;
}
.cm-deletedText {
background: rgba(239, 68, 68, 0.3) !important;
text-decoration: line-through !important;
border-radius: 2px !important;
}
.cm-insertedText {
background: rgba(34, 197, 94, 0.3) !important;
border-radius: 2px !important;
}
/* Gutter 中的变更标记 */
.cm-changeGutter {
width: 4px !important;
}
.cm-changeGutter .cm-gutterElement {
padding: 0 !important;
}
/* 折叠的未变更区域 */
.cm-collapsedLines {
background: rgb(var(--color-bg-subtle)) !important;
color: rgb(var(--color-text-muted)) !important;
font-size: 12px !important;
padding: 4px 12px !important;
margin: 4px 0 !important;
border-radius: 4px !important;
cursor: pointer !important;
}
.cm-collapsedLines:hover {
background: rgb(var(--color-bg-muted)) !important;
}
/* ============ Typing indicator ============ */
.typing-indicator {