体验: toast改macOS通知风圆徽毛玻璃卡片,移除明暗主题切换仅保留亮色

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-12 20:15:53 +08:00
co-authored by Cursor
parent fe8b23225a
commit 08bb38b188
12 changed files with 75 additions and 267 deletions
+1 -20
View File
@@ -1,20 +1,12 @@
import { FolderCog, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Sun, Unplug } from "lucide-react"
import { FolderCog, PanelBottom, PanelLeft, PanelRight, Unplug } from "lucide-react"
import { Button } from "~/components/ui/button"
import { Separator } from "~/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
import { useTheme, type ThemePreference } from "~/hooks/use-theme"
import { isMacElectron } from "~/lib/platform"
import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils"
// 主题三态对应的图标与说明文案。
const THEME_VISUALS: Record<ThemePreference, { icon: typeof Monitor; label: string }> = {
system: { icon: Monitor, label: "主题:跟随系统" },
light: { icon: Sun, label: "主题:亮色" },
dark: { icon: Moon, label: "主题:暗色" },
}
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
export interface PanelVisibility {
tree: boolean
@@ -41,9 +33,6 @@ export function TopBar({
}) {
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
const macInset = isMacElectron()
const theme = useTheme()
const themeVisual = THEME_VISUALS[theme.preference]
const ThemeIcon = themeVisual.icon
return (
<header
className={cn(
@@ -91,14 +80,6 @@ export function TopBar({
<PanelBottom />
</PanelToggle>
<Separator orientation="vertical" className="mx-1 h-4!" />
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={theme.cycle} aria-label={themeVisual.label}>
<ThemeIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{themeVisual.label}(点击切换)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
@@ -167,7 +167,7 @@ export function ConflictDialog({
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700">
{conflicts.length} 个冲突
</Badge>
<span className="text-xs text-muted-foreground">
@@ -46,13 +46,6 @@ function splitDiffSides(raw: string[]): { original: string; modified: string } {
return { original: original.join("\n"), modified: modified.join("\n") }
}
/**
* 判断当前应用是否处于暗色主题(class 方案),用于选择 Monaco 主题。
*/
function isDarkTheme(): boolean {
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
}
/**
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
@@ -126,7 +119,6 @@ export function DiffDialog({
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
let originalModel: MonacoEditor.ITextModel | null = null
let modifiedModel: MonacoEditor.ITextModel | null = null
let themeObserver: MutationObserver | null = null
void (async () => {
const { monaco, detectLanguage } = await import("~/lib/monaco")
if (disposed || !containerRef.current) {
@@ -144,7 +136,7 @@ export function DiffDialog({
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
compactMode: viewMode === "unified",
hideUnchangedRegions: { enabled: onlyChanges },
theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light",
theme: "mactfs-light",
fontSize: 12,
lineHeight: 20,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
@@ -168,16 +160,10 @@ export function DiffDialog({
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
})
editorRef.current = diffEditor
// 弹窗打开期间应用切换明暗主题时,同步更新 Monaco 主题。
themeObserver = new MutationObserver(() => {
monaco.editor.setTheme(isDarkTheme() ? "mactfs-dark" : "mactfs-light")
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
})()
return () => {
disposed = true
editorRef.current = null
themeObserver?.disconnect()
diffEditor?.dispose()
originalModel?.dispose()
modifiedModel?.dispose()
@@ -257,10 +243,10 @@ export function DiffDialog({
<div className="flex shrink-0 flex-wrap items-center gap-2 text-xs">
{diff && (
<>
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700">
− {diff.sourceLabel}
</Badge>
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700 dark:text-green-400">
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700">
+ {diff.targetLabel}
</Badge>
<span className="text-muted-foreground">{changedCount} 行差异</span>
@@ -21,42 +21,42 @@ const FILE_ICON_RULES: { exts: string[]; icon: LucideIcon; className: string }[]
{
exts: ["ts", "tsx", "js", "jsx", "java", "cs", "py", "go", "rs", "c", "cpp", "h", "vue", "css", "scss", "html"],
icon: FileCode,
className: "text-blue-600 dark:text-blue-400",
className: "text-blue-600",
},
{
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
icon: FileJson,
className: "text-amber-600 dark:text-amber-400",
className: "text-amber-600",
},
{
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
icon: FileText,
className: "text-zinc-500 dark:text-zinc-400",
className: "text-zinc-500",
},
{
exts: ["sql", "db"],
icon: Database,
className: "text-violet-600 dark:text-violet-400",
className: "text-violet-600",
},
{
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
icon: FileImage,
className: "text-emerald-600 dark:text-emerald-400",
className: "text-emerald-600",
},
{
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
icon: FileArchive,
className: "text-orange-600 dark:text-orange-400",
className: "text-orange-600",
},
{
exts: ["xls", "xlsx", "csv"],
icon: FileSpreadsheet,
className: "text-green-600 dark:text-green-400",
className: "text-green-600",
},
{
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
icon: FileTerminal,
className: "text-zinc-600 dark:text-zinc-300",
className: "text-zinc-600",
},
]
@@ -91,7 +91,7 @@ export function FileIcon({
<Icon
className={cn(
"size-3.5 shrink-0",
mapped ? "text-primary" : "text-sky-600 dark:text-sky-400",
mapped ? "text-primary" : "text-sky-600",
className,
)}
/>
@@ -185,7 +185,7 @@ export function WorkspaceManageDialog({
) : (
<Badge
variant="secondary"
className="bg-red-500/10 text-red-700 dark:text-red-400"
className="bg-red-500/10 text-red-700"
>
不存在
</Badge>
+42 -45
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"
import { Check, Loader2, X } from "lucide-react"
import { cn } from "~/lib/utils"
@@ -70,34 +70,15 @@ export function showToast(input: { id?: string; kind: ToastKind; text: string })
return id
}
// toast 类型对应的图标、强调条与边框配色:彩色左侧条 + 着色边框提高辨识度。
const KIND_VISUALS: Record<
ToastKind,
{ icon: typeof CheckCircle2; iconClass: string; barClass: string; borderClass: string }
> = {
success: {
icon: CheckCircle2,
iconClass: "text-emerald-600 dark:text-emerald-400",
barClass: "bg-emerald-500",
borderClass: "border-emerald-500/35",
},
error: {
icon: AlertCircle,
iconClass: "text-destructive",
barClass: "bg-destructive",
borderClass: "border-destructive/40",
},
loading: {
icon: Loader2,
iconClass: "animate-spin text-primary",
barClass: "bg-primary",
borderClass: "border-primary/35",
},
// toast 类型对应的状态徽标:macOS 通知风的彩色小圆徽(成功绿 / 失败红),loading 用旋转图标。
const KIND_BADGES: Record<"success" | "error", { icon: typeof Check; chipClass: string }> = {
success: { icon: Check, chipClass: "bg-emerald-500" },
error: { icon: X, chipClass: "bg-destructive" },
}
/**
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
* 彩色强调条 + 着色边框 + 毛玻璃底,保证进度与结果提示足够显眼。
* 视觉对齐 macOS 系统通知:白色毛玻璃圆角卡片 + 彩色状态圆徽 + 柔和投影,无彩色边框。
*/
export function Toaster() {
const [items, setItems] = useState<ToastItem[]>([])
@@ -116,26 +97,42 @@ export function Toaster() {
}
return (
<div className="pointer-events-none fixed top-14 right-3 z-[60] flex w-[380px] flex-col gap-2">
{items.map((item) => {
const visual = KIND_VISUALS[item.kind]
const Icon = visual.icon
return (
<button
key={item.id}
type="button"
onClick={() => dismissToast(item.id)}
className={cn(
"animate-in fade-in slide-in-from-top-3 ease-out-quart pointer-events-auto relative flex items-start gap-2.5 overflow-hidden rounded-xl border bg-popover/95 py-3 pr-3.5 pl-4 text-left text-sm font-medium shadow-overlay backdrop-blur-md duration-250",
visual.borderClass,
)}
>
<span className={cn("absolute inset-y-0 left-0 w-1", visual.barClass)} />
<Icon className={cn("mt-0.5 size-4.5 shrink-0", visual.iconClass)} />
<span className="min-w-0 break-words">{item.text}</span>
</button>
)
})}
<div className="pointer-events-none fixed top-14 right-4 z-[60] flex flex-col items-end gap-2.5">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => dismissToast(item.id)}
className="animate-in fade-in slide-in-from-top-2 ease-out-quart pointer-events-auto flex w-fit max-w-[360px] min-w-[200px] items-center gap-2.5 rounded-xl border border-black/8 bg-popover/90 py-2.5 pr-4 pl-3 text-left shadow-[0_8px_24px_rgb(0_0_0/0.12),0_2px_6px_rgb(0_0_0/0.06)] backdrop-blur-xl duration-300"
>
{item.kind === "loading" ? (
<Loader2 className="size-4.5 shrink-0 animate-spin text-primary" />
) : (
<ToastBadge kind={item.kind} />
)}
<span className="min-w-0 text-[13px] leading-snug font-medium break-words text-foreground">
{item.text}
</span>
</button>
))}
</div>
)
}
/**
* 成功 / 失败的彩色圆形徽标:实色圆底 + 白色粗描边图标。
*/
function ToastBadge({ kind }: { kind: "success" | "error" }) {
const badge = KIND_BADGES[kind]
const Icon = badge.icon
return (
<span
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-full",
badge.chipClass,
)}
>
<Icon className="size-3 text-white" strokeWidth={3.2} />
</span>
)
}