ui: preview/edit toggle for markdown, empty-pane open cards, + flush to the rightmost tab

- markdown tabs open in rendered preview with a 预览/编辑 segmented toggle;
  edit mode uses the CodeMirror source editor (markdown highlighting), the
  preview renders the live unsaved draft, and the editor stays mounted while
  previewing so edits survive the toggle.
- an empty pane shows openable-type cards (explorer / git / terminal, the
  same options as the + menu, terminal dimmed at the cap) instead of the
  bare 'this pane is empty' message; clicking a card opens the tab.
- the + button sits immediately right of the last tab (sticky at the
  scrollport's right edge when the tab row overflows, so it stays
  reachable).
This commit is contained in:
Menghuan1918
2026-08-08 15:16:09 +08:00
parent 47ebffcf9b
commit 97e1f60e47
8 changed files with 190 additions and 38 deletions
+2 -2
View File
@@ -6,12 +6,12 @@ control panel — **isolated per conversation session**.
- 右侧侧边栏(文件夹预览 / 文件编辑与预览 / 终端 / Git)——按会话记忆与隔离
- Explorer: 懒加载目录树,根目录 = 当前会话工作目录
- Editor: CodeMirror 文本编辑(自动换行 + 按扩展名语法高亮、脏点 + Ctrl/Cmd+S 保存)、图片查看、Markdown 预览
- Editor: CodeMirror 文本编辑(自动换行 + 按扩展名语法高亮、脏点 + Ctrl/Cmd+S 保存)、图片查看;Markdown 支持预览/编辑切换(预览渲染未保存草稿)
- Terminal: xterm.js + node-pty(每会话最多 3 个,进程跨刷新存活)
- Git: status / diff / stage / commit / branch / history(基础集)
- 分栏工作台: VSCode 式拖拽分栏(拖 Tab 到分栏边缘即左右/上下拆分,拖到中间合并)、
分隔线拖拽、面板宽度拖拽;折叠后右缘展开按钮常驻
- Tab 栏: 最大宽度 160px、横向滚动、`+` 菜单新建 Tab
- Tab 栏: 最大宽度 160px、横向滚动、`+` 紧贴最右 Tab(溢出时吸附右缘)、`+` 菜单新建 Tab;空分栏显示可打开类型卡片(点击即开)
- 拦截: 对话"产出文件"行点击改为在侧边栏打开
- 样式完全复用 `--dsw-*` 主题变量与 `@deepseek-ai/dsh-client-ui-primitives`
+58 -13
View File
@@ -1,9 +1,11 @@
/**
* The file editor tab: a CodeMirror 6 editor with line wrapping and syntax
* highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save,
* image viewing through the media route, Markdown preview through the shared
* MarkdownText component, and a plain notice for binary files. Reads cap at
* the host's 512KB bound (a banner marks truncation).
* image viewing through the media route, and a plain notice for binary
* files. Markdown files open in rendered preview with a preview/edit toggle:
* edit mode shows the source in the editor, preview renders the live draft,
* and unsaved edits survive the toggle. Reads cap at the host's 512KB bound
* (a banner marks truncation).
*/
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -26,9 +28,15 @@ type EditorLoad =
| { status: 'ready'; kind: 'text' | 'image' | 'md'; content: string; truncated: boolean }
| { status: 'binary' }
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
export function EditorView(props: { scope: SessionScope; path: string; title: string }) {
const { scope, path, title } = props
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
const [mode, setMode] = useState<ViewMode>('preview')
/** The editor's current text (null while clean); preview renders this. */
const [draft, setDraft] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const hostRef = useRef<HTMLDivElement>(null)
@@ -38,6 +46,8 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
useEffect(() => {
let cancelled = false
setLoad({ status: 'loading' })
setMode('preview')
setDraft(null)
setDirty(false)
setSaveState('idle')
api.fsRead(scope, path).then((result) => {
@@ -64,10 +74,12 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
return () => { cancelled = true }
}, [scope.sessionId, scope.cwd, path])
// Create the CodeMirror editor once the text is loaded. The view owns the
// document; React only tracks dirty/save state through the update listener.
// Create the CodeMirror editor once the file is loaded. The view owns the
// document; React only tracks dirty/draft state through the update
// listener. For markdown the view stays mounted while previewing (hidden),
// so unsaved edits survive the preview/edit toggle.
useEffect(() => {
if (load.status !== 'ready' || load.kind !== 'text') return
if (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) return
const host = hostRef.current
if (host === null) return
const language = languageForPath(path)
@@ -86,7 +98,10 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
...(language !== null ? [language] : []),
oneDark,
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) setDirty(true)
if (update.docChanged) {
setDraft(update.state.doc.toString())
setDirty(true)
}
}),
keymap.of([
{
@@ -107,6 +122,12 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
}
}, [load])
// The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal).
useEffect(() => {
if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode])
const save = (): void => {
const view = viewRef.current
if (view === null || savingRef.current) return
@@ -114,6 +135,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
setSaveState('saving')
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
savingRef.current = false
setDraft(null)
setDirty(false)
setSaveState('saved')
}).catch(() => {
@@ -122,14 +144,34 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
})
}
const previewable = load.status === 'ready' && load.kind === 'md'
const editable = load.status === 'ready' && (load.kind === 'text' || load.kind === 'md')
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={path}>{title}</span>
{previewable && (
<div className={css.editorModeToggle}>
<button
type="button"
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
onClick={() => { setMode('preview') }}
>
{t('preview')}
</button>
<button
type="button"
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
onClick={() => { setMode('edit') }}
>
{t('edit')}
</button>
</div>
)}
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
{load.status === 'ready' && load.kind === 'text' && (
{editable && (
<button
type="button"
className={css.iconButton}
@@ -145,10 +187,13 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'binary' && <div className={css.editorPlaceholder}>{t('binary')}</div>}
{load.status === 'ready' && load.kind === 'text' && (
{editable && (
<>
{load.truncated && <div className={css.editorBanner}>{t('truncation')}</div>}
<div className={css.editorCm} ref={hostRef} />
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
<div
className={clsx(css.editorCm, previewable && mode === 'preview' && css.editorCmHidden)}
ref={hostRef}
/>
</>
)}
{load.status === 'ready' && load.kind === 'image' && (
@@ -156,9 +201,9 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
</div>
)}
{load.status === 'ready' && load.kind === 'md' && (
{previewable && mode === 'preview' && (
<div className={css.editorMd}>
<MarkdownText text={load.content} />
<MarkdownText text={draft ?? load.content} />
</div>
)}
</div>
+6 -3
View File
@@ -123,8 +123,11 @@ export function TabBar(props: {
</button>
</div>
))}
</div>
<div className={css.tabBarActions}>
{/*
The + sits immediately after the rightmost tab (sticky at the
right edge of the scrollport when the tabs overflow, so it stays
reachable no matter how many tabs are open).
*/}
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
@@ -142,7 +145,7 @@ export function TabBar(props: {
anchor={(
<button
type="button"
className={css.tabBarButton}
className={css.tabBarPlus}
aria-label={t('newTab')}
title={t('newTab')}
onClick={() => { setMenuOpen(v => !v) }}
+3
View File
@@ -6,6 +6,7 @@
import { Language, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { markdown } from '@codemirror/lang-markdown'
import { python } from '@codemirror/lang-python'
import { html } from '@codemirror/lang-html'
import { css } from '@codemirror/lang-css'
@@ -39,6 +40,7 @@ export function languageKeyForExt(ext: string): string | null {
case 'ts': case 'mts': case 'cts': return 'ts'
case 'tsx': return 'tsx'
case 'json': case 'jsonc': return 'json'
case 'md': case 'markdown': return 'md'
case 'py': case 'pyw': return 'python'
case 'html': case 'htm': return 'html'
case 'css': return 'css'
@@ -66,6 +68,7 @@ const FACTORIES: Record<string, () => Language | LanguageSupport> = {
ts: () => javascript({ typescript: true }),
tsx: () => javascript({ typescript: true, jsx: true }),
json: () => json(),
md: () => markdown(),
python: () => python(),
html: () => html(),
css: () => css(),
+4 -2
View File
@@ -23,6 +23,8 @@ const zh = {
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
preview: '预览',
edit: '编辑',
refresh: '刷新',
save: '保存',
saved: '已保存',
@@ -33,7 +35,6 @@ const zh = {
loading: '加载中…',
error: '加载失败',
retry: '重试',
emptyPane: '此分栏为空 — 从资源管理器或 + 菜单打开内容',
splitLeft: '向左分栏',
splitRight: '向右分栏',
splitUp: '向上分栏',
@@ -85,6 +86,8 @@ const en: Record<keyof typeof zh, string> = {
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
preview: 'Preview',
edit: 'Edit',
refresh: 'Refresh',
save: 'Save',
saved: 'Saved',
@@ -95,7 +98,6 @@ const en: Record<keyof typeof zh, string> = {
loading: 'Loading…',
error: 'Failed to load',
retry: 'Retry',
emptyPane: 'This pane is empty — open something from the explorer or the + menu',
splitLeft: 'Split left',
splitRight: 'Split right',
splitUp: 'Split up',
+74 -16
View File
@@ -269,15 +269,39 @@
overflow: hidden;
}
.paneEmpty {
.paneEmptyCards {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 16px;
}
.paneCard {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
text-align: left;
}
.paneCard:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
border-color: var(--dsw-alias-border-l2);
}
.paneCard:disabled {
opacity: 0.45;
cursor: default;
}
/* ── Tab strip ─────────────────────────────────────────────────────────── */
@@ -362,29 +386,28 @@
color: var(--dsw-alias-label-primary);
}
.tabBarActions {
.tabBarPlus {
flex: none;
display: flex;
align-items: center;
gap: 2px;
padding: 0 6px;
}
.tabBarButton {
align-self: center;
/* Adjacent to the rightmost tab; sticky so overflowing tab rows keep it
visible at the right edge of the scrollport. */
position: sticky;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin: 0 6px;
padding: 0;
border: none;
border-radius: 5px;
background: transparent;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.tabBarButton:hover {
.tabBarPlus:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
@@ -545,6 +568,10 @@
background: transparent;
}
.editorCmHidden {
display: none;
}
/* CodeMirror fills the pane; the theme supplies its own token palette
(a designed dark theme, same policy as the xterm palette). */
.editorCm :global(.cm-editor) {
@@ -555,6 +582,37 @@
outline: none;
}
/* Preview / edit segmented toggle (markdown and other previewable files). */
.editorModeToggle {
flex: none;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
}
.editorModeButton {
padding: 2px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.editorModeButton:hover {
color: var(--dsw-alias-label-primary);
}
.editorModeActive {
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
}
.editorImageWrap {
flex: 1;
min-height: 0;
+42 -2
View File
@@ -12,9 +12,11 @@
import { Fragment, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import {
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import type { DropZone } from './state.ts'
import { t } from './locales.ts'
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import css from './sidebar.module.css'
@@ -79,6 +81,44 @@ function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
return 'center'
}
/** The icon of one openable type card (mirror of the + menu options). */
function optionIcon(optionId: string): ReactNode {
switch (optionId) {
case 'explorer': return <IconFolderOpen16 size={16} />
case 'git': return <IconBranchOutline16 size={16} />
case 'terminal': return <IconCodeOutline16 size={16} />
default: return <IconCodeOutline16 size={16} />
}
}
/**
* An empty pane's welcome cards: the openable types as cards, clicked to
* open (instead of a bare "this pane is empty" message).
*/
function PaneEmptyCards(props: {
newTabOptions: NewTabOption[]
onNewTab: (optionId: string) => void
}) {
const { newTabOptions, onNewTab } = props
return (
<div className={css.paneEmptyCards}>
{newTabOptions.map(option => (
<button
key={option.id}
type="button"
className={css.paneCard}
disabled={option.disabled === true}
title={option.label}
onClick={() => { onNewTab(option.id) }}
>
{optionIcon(option.id)}
<span>{option.label}</span>
</button>
))}
</div>
)
}
/** A leaf: tab strip + active content + VSCode-style drop target for tabs. */
function LeafView(props: {
leaf: { id: string; tabs: SidebarTab[]; active: string | null }
@@ -135,7 +175,7 @@ function LeafView(props: {
{leaf.tabs.length > 0 ? (
<div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</div>
) : (
<div className={css.paneEmpty}>{t('emptyPane')}</div>
<PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
)}
</div>
)
+1
View File
@@ -310,6 +310,7 @@ describe('editor language mapping', () => {
expect(languageKeyForExt('py')).toBe('python')
expect(languageKeyForExt('yaml')).toBe('yaml')
expect(languageKeyForExt('sh')).toBe('shell')
expect(languageKeyForExt('md')).toBe('md')
expect(languageKeyForExt('txt')).toBeNull()
expect(languageKeyForExt('log')).toBeNull()
expect(languageKeyForExt('')).toBeNull()