Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/components/appearance-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={() => setMarkdownPreviewPreserveLineBreaks(true)}
>
{String(markdownPreviewPreserveLineBreaks)}
</button>
)
}

describe("AppearanceProvider markdown preview preferences", () => {
beforeEach(() => {
localStorage.clear()
})

it("defaults to disabled and persists an enabled preference", () => {
const { unmount } = render(
<AppearanceProvider>
<PreferenceProbe />
</AppearanceProvider>
)

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(
<AppearanceProvider>
<PreferenceProbe />
</AppearanceProvider>
)
expect(screen.getByRole("button")).toHaveTextContent("true")
})

it("follows preference changes from another window", () => {
render(
<AppearanceProvider>
<PreferenceProbe />
</AppearanceProvider>
)

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")
})
})
22 changes: 22 additions & 0 deletions src/components/appearance-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -181,6 +185,12 @@ export function AppearanceProvider({
// QuickActions 仅在欢迎态客户端渲染,此处同步读 localStorage 不会造成首帧闪烁。
const [showWelcomeQuickActions, setShowWelcomeQuickActionsState] =
useState<boolean>(() => readBool(STORAGE_KEY_WELCOME_QUICK_ACTIONS, true))
const [
markdownPreviewPreserveLineBreaks,
setMarkdownPreviewPreserveLineBreaksState,
] = useState<boolean>(() =>
readBool(STORAGE_KEY_MARKDOWN_PREVIEW_PRESERVE_LINE_BREAKS, false)
)

// 字体偏好的初始值从 localStorage 读 id/custom(视觉已由 inline 脚本就位,
// 这里只是回填选中态,不会造成闪烁)。
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
}
Expand All @@ -405,6 +425,8 @@ export function AppearanceProvider({
setZoomLevel,
showWelcomeQuickActions,
setShowWelcomeQuickActions,
markdownPreviewPreserveLineBreaks,
setMarkdownPreviewPreserveLineBreaks,
uiFont,
setUiFont,
editorFont,
Expand Down
12 changes: 11 additions & 1 deletion src/components/files/file-workspace-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -260,10 +265,15 @@ function MarkdownDocumentPreview({
openFilePreview: (path: string) => void
}) {
const plugins = useStreamdownPlugins(content)
const { markdownPreviewPreserveLineBreaks } = useMarkdownPreviewPreferences()
const remarkPlugins = getMarkdownPreviewRemarkPlugins(
markdownPreviewPreserveLineBreaks
)
return (
<div className="h-full overflow-auto p-6 [&_a_img]:inline [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-6 [&_ul]:pl-6">
<Streamdown
plugins={plugins}
remarkPlugins={remarkPlugins}
components={{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
img: ({ node, ...imgProps }) => (
Expand Down
56 changes: 56 additions & 0 deletions src/components/files/markdown-preview-remark-plugins.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Streamdown
remarkPlugins={getMarkdownPreviewRemarkPlugins(preserveLineBreaks)}
>
{content}
</Streamdown>
)
}

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)
})
})
11 changes: 11 additions & 0 deletions src/components/files/markdown-preview-remark-plugins.ts
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions src/components/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -206,6 +207,9 @@ export function AppearanceSettings() {
{/* ===== Fonts ===== */}
<FontSettingsSection />

{/* This entry can move independently; rendering reads the preference hook. */}
<MarkdownPreviewSettingsSection />

{/* ===== New conversation — mode selection area ===== */}
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
Expand Down
37 changes: 37 additions & 0 deletions src/components/settings/markdown-preview-settings-section.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t("sectionTitle")}</h2>
</div>

<p className="text-xs text-muted-foreground leading-5">
{t("sectionDescription")}
</p>

<label className="flex items-center gap-2">
<Switch
checked={markdownPreviewPreserveLineBreaks}
onCheckedChange={setMarkdownPreviewPreserveLineBreaks}
/>
<span className="text-xs text-muted-foreground">
{t("preserveLineBreaks")}
</span>
</label>
</section>
)
}
12 changes: 12 additions & 0 deletions src/hooks/use-appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@
"terminalLigaturesHint": "تنطبق الحروف المتصلة في الطرفية على خطوط البرمجة المضمّنة فقط.",
"preview": "معاينة"
},
"markdownPreview": {
"sectionTitle": "معاينة Markdown",
"sectionDescription": "تحكم في كيفية ظهور فواصل أسطر المصدر عند معاينة ملفات Markdown.",
"preserveLineBreaks": "الاحتفاظ بفواصل الأسطر المفردة"
},
"welcomePanel": {
"sectionTitle": "منطقة اختيار الوضع",
"sectionDescription": "بطاقات الاختصار «تطوير الكود / الأعمال المكتبية» التي تظهر أعلى مربع الإدخال في صفحة المحادثة الجديدة.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@
"terminalLigaturesHint": "ターミナルの合字は内蔵のコーディングフォントにのみ適用されます。",
"preview": "プレビュー"
},
"markdownPreview": {
"sectionTitle": "Markdown プレビュー",
"sectionDescription": "Markdown ファイルのプレビューでソースの改行を表示する方法を設定します。",
"preserveLineBreaks": "単一の改行を保持"
},
"welcomePanel": {
"sectionTitle": "モード選択エリア",
"sectionDescription": "新しい会話ページで入力欄の上に表示される「コード開発 / 日常業務」のショートカットカードです。",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@
"terminalLigaturesHint": "터미널 합자는 내장 코딩 글꼴에만 적용됩니다.",
"preview": "미리 보기"
},
"markdownPreview": {
"sectionTitle": "Markdown 미리보기",
"sectionDescription": "Markdown 파일 미리보기에서 소스 줄바꿈을 표시하는 방식을 설정합니다.",
"preserveLineBreaks": "단일 줄바꿈 유지"
},
"welcomePanel": {
"sectionTitle": "모드 선택 영역",
"sectionDescription": "새 대화 페이지에서 입력창 위에 표시되는 '코드 개발 / 일상 업무' 바로가기 카드입니다.",
Expand Down
Loading
Loading