diff --git a/src/components/appearance-provider.test.tsx b/src/components/appearance-provider.test.tsx new file mode 100644 index 000000000..1faea72e9 --- /dev/null +++ b/src/components/appearance-provider.test.tsx @@ -0,0 +1,70 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" +import { useMarkdownPreviewPreferences } from "@/hooks/use-appearance" +import { STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS } from "@/lib/appearance-script" +import { AppearanceProvider } from "./appearance-provider" + +function PreferenceProbe() { + const { + markdownPreviewPreserveLineBreaks, + setMarkdownPreviewPreserveLineBreaks, + } = useMarkdownPreviewPreferences() + + return ( + + ) +} + +describe("AppearanceProvider markdown preview preferences", () => { + beforeEach(() => { + localStorage.clear() + }) + + it("defaults to disabled and persists an enabled preference", () => { + const { unmount } = render( + + + + ) + + expect(screen.getByRole("button")).toHaveTextContent("false") + fireEvent.click(screen.getByRole("button")) + expect(screen.getByRole("button")).toHaveTextContent("true") + expect( + localStorage.getItem(STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS) + ).toBe("1") + + unmount() + render( + + + + ) + expect(screen.getByRole("button")).toHaveTextContent("true") + }) + + it("follows preference changes from another window", () => { + render( + + + + ) + + localStorage.setItem(STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS, "1") + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { + key: STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS, + newValue: "1", + }) + ) + }) + + expect(screen.getByRole("button")).toHaveTextContent("true") + }) +}) diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 4b3e462be..4f9bf387f 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -24,6 +24,7 @@ import { STORAGE_KEY_THEME_COLOR, STORAGE_KEY_ZOOM_LEVEL, STORAGE_KEY_WELCOME_QUICK_ACTIONS, + STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS, STORAGE_KEY_UI_FONT, STORAGE_KEY_UI_FONT_CUSTOM, STORAGE_KEY_UI_FONT_STACK, @@ -64,6 +65,9 @@ type AppearanceContextValue = { /** 新会话欢迎页是否显示「模式选择区域」(QuickActions 快捷卡片),默认开启 */ showWelcomeQuickActions: boolean setShowWelcomeQuickActions: (on: boolean) => void + /** Markdown 文件预览是否将单个物理换行显示为可见换行,默认关闭 */ + markdownPreviewPreserveLineBreaks: boolean + setMarkdownPreviewPreserveLineBreaks: (on: boolean) => void /** 界面字体(普通组件,驱动 --font-sans) */ uiFont: FontSelection setUiFont: (id: string, custom?: string) => void @@ -181,6 +185,12 @@ export function AppearanceProvider({ // QuickActions 仅在欢迎态客户端渲染,此处同步读 localStorage 不会造成首帧闪烁。 const [showWelcomeQuickActions, setShowWelcomeQuickActionsState] = useState(() => readBool(STORAGE_KEY_WELCOME_QUICK_ACTIONS, true)) + const [ + markdownPreviewPreserveLineBreaks, + setMarkdownPreviewPreserveLineBreaksState, + ] = useState(() => + readBool(STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS, false) + ) // 字体偏好的初始值从 localStorage 读 id/custom(视觉已由 inline 脚本就位, // 这里只是回填选中态,不会造成闪烁)。 @@ -239,6 +249,11 @@ export function AppearanceProvider({ persist(STORAGE_KEY_WELCOME_QUICK_ACTIONS, on ? "1" : "0") }, []) + const setMarkdownPreviewPreserveLineBreaks = useCallback((on: boolean) => { + setMarkdownPreviewPreserveLineBreaksState(on) + persist(STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS, on ? "1" : "0") + }, []) + const setUiFont = useCallback((id: string, custom = "") => { setUiFontState({ id, custom }) const stack = resolveFontStack(id, custom, "sans") @@ -384,6 +399,11 @@ export function AppearanceProvider({ readBool(STORAGE_KEY_WELCOME_QUICK_ACTIONS, true) ) } + if (e.key === STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS) { + setMarkdownPreviewPreserveLineBreaksState( + readBool(STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS, false) + ) + } if (e.key && FONT_KEYS.has(e.key)) { rehydrateFonts() } @@ -405,6 +425,8 @@ export function AppearanceProvider({ setZoomLevel, showWelcomeQuickActions, setShowWelcomeQuickActions, + markdownPreviewPreserveLineBreaks, + setMarkdownPreviewPreserveLineBreaks, uiFont, setUiFont, editorFont, diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index 66eac9d5c..15bf02713 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -31,6 +31,7 @@ import { import { ImagePreview } from "@/components/files/image-preview" import { HtmlPreview } from "@/components/files/html-preview" import { OfficePreview } from "@/components/files/office-preview" +import { getMarkdownPreviewRemarkPlugins } from "@/components/files/markdown-preview-remark-plugins" import { isHtmlPreviewable, isOfficePreviewable } from "@/lib/language-detect" import { DiffViewer } from "@/components/diff/diff-viewer" import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" @@ -49,7 +50,11 @@ import { MONACO_UNICODE_HIGHLIGHT_OPTIONS, useMonacoThemeSync, } from "@/lib/monaco-themes" -import { useZoomLevel, useEditorFont } from "@/hooks/use-appearance" +import { + useZoomLevel, + useEditorFont, + useMarkdownPreviewPreferences, +} from "@/hooks/use-appearance" import { useImeSafeEditorValue } from "@/hooks/use-ime-safe-editor-value" import { ScrollArea } from "@/components/ui/scroll-area" @@ -260,10 +265,15 @@ function MarkdownDocumentPreview({ openFilePreview: (path: string) => void }) { const plugins = useStreamdownPlugins(content) + const { markdownPreviewPreserveLineBreaks } = useMarkdownPreviewPreferences() + const remarkPlugins = getMarkdownPreviewRemarkPlugins( + markdownPreviewPreserveLineBreaks + ) return (
( diff --git a/src/components/files/markdown-preview-remark-plugins.test.tsx b/src/components/files/markdown-preview-remark-plugins.test.tsx new file mode 100644 index 000000000..d8bb4d272 --- /dev/null +++ b/src/components/files/markdown-preview-remark-plugins.test.tsx @@ -0,0 +1,56 @@ +import { render, waitFor } from "@testing-library/react" +import { Streamdown } from "streamdown" +import { describe, expect, it } from "vitest" +import { getMarkdownPreviewRemarkPlugins } from "./markdown-preview-remark-plugins" + +function renderMarkdown(content: string, preserveLineBreaks: boolean) { + return render( + + {content} + + ) +} + +describe("Markdown preview line break policy", () => { + it("keeps CommonMark soft-break behavior when disabled", async () => { + const { container } = renderMarkdown("first\nsecond", false) + + await waitFor(() => expect(container.querySelector("p")).not.toBeNull()) + expect(container.querySelector("br")).toBeNull() + expect(container.querySelector("p")).toHaveTextContent("first second") + }) + + it("renders a soft break as a visible line break when enabled", async () => { + const { container } = renderMarkdown("first\nsecond", true) + + await waitFor(() => expect(container.querySelector("br")).not.toBeNull()) + expect(container.querySelectorAll("br")).toHaveLength(1) + }) + + it("preserves existing Markdown block structures when enabled", async () => { + const content = [ + "first ", + "second", + "", + "| A | B |", + "| --- | --- |", + "| 1 | 2 |", + "", + "```text", + "code line 1", + "code line 2", + "```", + ].join("\n") + const { container } = renderMarkdown(content, true) + + await waitFor(() => + expect(container).toHaveTextContent("code line 1code line 2") + ) + expect(container.querySelectorAll("p")).toHaveLength(1) + expect(container.querySelectorAll("p br")).toHaveLength(1) + expect(container.querySelectorAll("table")).toHaveLength(1) + expect(container.querySelectorAll("code br")).toHaveLength(0) + }) +}) diff --git a/src/components/files/markdown-preview-remark-plugins.ts b/src/components/files/markdown-preview-remark-plugins.ts new file mode 100644 index 000000000..d44c77581 --- /dev/null +++ b/src/components/files/markdown-preview-remark-plugins.ts @@ -0,0 +1,11 @@ +import remarkBreaks from "remark-breaks" +import { defaultRemarkPlugins } from "streamdown" + +const standardRemarkPlugins = Object.values(defaultRemarkPlugins) +const preserveLineBreaksRemarkPlugins = [...standardRemarkPlugins, remarkBreaks] + +export function getMarkdownPreviewRemarkPlugins(preserveLineBreaks: boolean) { + return preserveLineBreaks + ? preserveLineBreaksRemarkPlugins + : standardRemarkPlugins +} diff --git a/src/components/settings/appearance-settings.tsx b/src/components/settings/appearance-settings.tsx index 7b2bbb324..fc6a71e0b 100644 --- a/src/components/settings/appearance-settings.tsx +++ b/src/components/settings/appearance-settings.tsx @@ -28,6 +28,7 @@ import { } from "@/lib/theme-presets" import { PetManagerSection } from "./pet-manager-section" import { FontSettingsSection } from "./font-settings-section" +import { MarkdownPreviewSettingsSection } from "./markdown-preview-settings-section" type ThemeMode = "system" | "light" | "dark" @@ -206,6 +207,9 @@ export function AppearanceSettings() { {/* ===== Fonts ===== */} + {/* This entry can move independently; rendering reads the preference hook. */} + + {/* ===== New conversation — mode selection area ===== */}
diff --git a/src/components/settings/markdown-preview-settings-section.tsx b/src/components/settings/markdown-preview-settings-section.tsx new file mode 100644 index 000000000..0f4f7c28d --- /dev/null +++ b/src/components/settings/markdown-preview-settings-section.tsx @@ -0,0 +1,37 @@ +"use client" + +import { FileText } from "lucide-react" +import { useTranslations } from "next-intl" +import { Switch } from "@/components/ui/switch" +import { useMarkdownPreviewPreferences } from "@/hooks/use-appearance" + +export function MarkdownPreviewSettingsSection() { + const t = useTranslations("AppearanceSettings.markdownPreview") + const { + markdownPreviewPreserveLineBreaks, + setMarkdownPreviewPreserveLineBreaks, + } = useMarkdownPreviewPreferences() + + return ( +
+
+ +

{t("sectionTitle")}

+
+ +

+ {t("sectionDescription")} +

+ + +
+ ) +} diff --git a/src/hooks/use-appearance.ts b/src/hooks/use-appearance.ts index e33a873bb..845fbdf16 100644 --- a/src/hooks/use-appearance.ts +++ b/src/hooks/use-appearance.ts @@ -31,6 +31,18 @@ export function useWelcomeQuickActions() { return { showWelcomeQuickActions, setShowWelcomeQuickActions } } +/** Markdown 文件预览的显示偏好,不影响源码或 Agent 消息渲染。 */ +export function useMarkdownPreviewPreferences() { + const { + markdownPreviewPreserveLineBreaks, + setMarkdownPreviewPreserveLineBreaks, + } = useAppearance() + return { + markdownPreviewPreserveLineBreaks, + setMarkdownPreviewPreserveLineBreaks, + } +} + /** 界面字体(普通组件)。stack 已解析,可直接用于 style 或 CSS 变量。 */ export function useUiFont() { const { uiFont, setUiFont } = useAppearance() diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 18044c1da..b1b5a43e5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "تنطبق الحروف المتصلة في الطرفية على خطوط البرمجة المضمّنة فقط.", "preview": "معاينة" }, + "markdownPreview": { + "sectionTitle": "معاينة Markdown", + "sectionDescription": "تحكم في كيفية ظهور فواصل أسطر المصدر عند معاينة ملفات Markdown.", + "preserveLineBreaks": "الاحتفاظ بفواصل الأسطر المفردة" + }, "welcomePanel": { "sectionTitle": "منطقة اختيار الوضع", "sectionDescription": "بطاقات الاختصار «تطوير الكود / الأعمال المكتبية» التي تظهر أعلى مربع الإدخال في صفحة المحادثة الجديدة.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 953805e00..468852ff9 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "Terminal-Ligaturen gelten nur für mitgelieferte Programmierschriftarten.", "preview": "Vorschau" }, + "markdownPreview": { + "sectionTitle": "Markdown-Vorschau", + "sectionDescription": "Legt fest, wie Zeilenumbrüche aus dem Quelltext in der Markdown-Vorschau erscheinen.", + "preserveLineBreaks": "Einfache Zeilenumbrüche beibehalten" + }, "welcomePanel": { "sectionTitle": "Modusauswahlbereich", "sectionDescription": "Die Shortcut-Karten „Code-Entwicklung / Büroarbeit“ über dem Eingabefeld auf der Seite für eine neue Konversation.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 16cef83ec..3a4ff7abb 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "Terminal ligatures apply only to bundled coding fonts.", "preview": "Preview" }, + "markdownPreview": { + "sectionTitle": "Markdown preview", + "sectionDescription": "Control how source line breaks appear when previewing Markdown files.", + "preserveLineBreaks": "Preserve single line breaks" + }, "welcomePanel": { "sectionTitle": "Mode selection area", "sectionDescription": "The Code Development / Office Work shortcut cards shown above the composer on the new conversation page.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1e946a224..60a6b4c87 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "Las ligaduras del terminal solo se aplican a las fuentes de programación incluidas.", "preview": "Vista previa" }, + "markdownPreview": { + "sectionTitle": "Vista previa de Markdown", + "sectionDescription": "Controla cómo aparecen los saltos de línea del código fuente al previsualizar archivos Markdown.", + "preserveLineBreaks": "Conservar saltos de línea simples" + }, "welcomePanel": { "sectionTitle": "Área de selección de modo", "sectionDescription": "Las tarjetas de acceso rápido «Desarrollo de código / Ofimática» que se muestran sobre el editor en la página de nueva conversación.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8747ac281..d30b27085 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "Les ligatures du terminal ne s’appliquent qu’aux polices de programmation intégrées.", "preview": "Aperçu" }, + "markdownPreview": { + "sectionTitle": "Aperçu Markdown", + "sectionDescription": "Contrôle l’affichage des sauts de ligne du code source dans l’aperçu des fichiers Markdown.", + "preserveLineBreaks": "Conserver les sauts de ligne simples" + }, "welcomePanel": { "sectionTitle": "Zone de sélection du mode", "sectionDescription": "Les cartes de raccourci « Développement / Bureautique » affichées au-dessus du champ de saisie sur la page de nouvelle conversation.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e6e9a0d0e..1df139ad1 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "ターミナルの合字は内蔵のコーディングフォントにのみ適用されます。", "preview": "プレビュー" }, + "markdownPreview": { + "sectionTitle": "Markdown プレビュー", + "sectionDescription": "Markdown ファイルのプレビューでソースの改行を表示する方法を設定します。", + "preserveLineBreaks": "単一の改行を保持" + }, "welcomePanel": { "sectionTitle": "モード選択エリア", "sectionDescription": "新しい会話ページで入力欄の上に表示される「コード開発 / 日常業務」のショートカットカードです。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 70e6c3844..628917452 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "터미널 합자는 내장 코딩 글꼴에만 적용됩니다.", "preview": "미리 보기" }, + "markdownPreview": { + "sectionTitle": "Markdown 미리보기", + "sectionDescription": "Markdown 파일 미리보기에서 소스 줄바꿈을 표시하는 방식을 설정합니다.", + "preserveLineBreaks": "단일 줄바꿈 유지" + }, "welcomePanel": { "sectionTitle": "모드 선택 영역", "sectionDescription": "새 대화 페이지에서 입력창 위에 표시되는 '코드 개발 / 일상 업무' 바로가기 카드입니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 15941ece7..54bc52e67 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "As ligaduras do terminal aplicam-se apenas às fontes de programação incluídas.", "preview": "Pré-visualização" }, + "markdownPreview": { + "sectionTitle": "Pré-visualização de Markdown", + "sectionDescription": "Controla como as quebras de linha do código-fonte aparecem ao pré-visualizar arquivos Markdown.", + "preserveLineBreaks": "Preservar quebras de linha simples" + }, "welcomePanel": { "sectionTitle": "Área de seleção de modo", "sectionDescription": "Os cartões de atalho «Desenvolvimento / Escritório» exibidos acima do campo de entrada na página de nova conversa.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 656c04865..c1430e5ae 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "终端连字仅对内置编程字体生效。", "preview": "预览" }, + "markdownPreview": { + "sectionTitle": "Markdown 预览", + "sectionDescription": "控制 Markdown 文件预览如何显示源码中的换行。", + "preserveLineBreaks": "保留单换行" + }, "welcomePanel": { "sectionTitle": "模式选择区域", "sectionDescription": "新会话页面输入框上方显示的「代码开发 / 日常办公」快捷卡片区域。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 34a7d3d2b..3fe2a2dae 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -113,6 +113,11 @@ "terminalLigaturesHint": "終端機連字僅對內建程式字型生效。", "preview": "預覽" }, + "markdownPreview": { + "sectionTitle": "Markdown 預覽", + "sectionDescription": "控制 Markdown 檔案預覽如何顯示原始碼中的換行。", + "preserveLineBreaks": "保留單一換行" + }, "welcomePanel": { "sectionTitle": "模式選擇區域", "sectionDescription": "新會話頁面輸入框上方顯示的「程式開發 / 日常辦公」快捷卡片區域。", diff --git a/src/lib/appearance-script.ts b/src/lib/appearance-script.ts index b555b5d82..1aa0e4aff 100644 --- a/src/lib/appearance-script.ts +++ b/src/lib/appearance-script.ts @@ -11,6 +11,11 @@ export const STORAGE_KEY_ZOOM_LEVEL = "codeg-zoom-level" // 缺省即回退为开启(保持历史行为);仅在欢迎态客户端渲染,无需预水合。 export const STORAGE_KEY_WELCOME_QUICK_ACTIONS = "codeg-welcome-quick-actions" +// Markdown 文件预览是否将 soft break 显示为可见换行。 +// 默认关闭以保持 CommonMark/GFM 行为;无需预水合。 +export const STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS = + "codeg-markdown-preview-preserve-line-breaks" + // 字体偏好(界面 / 编辑器 / 终端)。 // 只有界面字体需要 *_STACK(已解析的 CSS font-family 栈),供 inline 脚本零依赖地 // 预水合写入 --font-sans;编辑器/终端字体只走各自的 Monaco/xterm 选项,水合后才挂载,