diff --git a/README.md b/README.md index 2508e02..49bc46b 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/src/client/EditorView.tsx b/src/client/EditorView.tsx index 4a7fffa..dbd5d2e 100644 --- a/src/client/EditorView.tsx +++ b/src/client/EditorView.tsx @@ -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({ status: 'loading' }) + const [mode, setMode] = useState('preview') + /** The editor's current text (null while clean); preview renders this. */ + const [draft, setDraft] = useState(null) const [dirty, setDirty] = useState(false) const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle') const hostRef = useRef(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 (
{title} + {previewable && ( +
+ + +
+ )} {dirty && } - {load.status === 'ready' && load.kind === 'text' && ( + {editable && (
} {load.status === 'error' &&
{load.message}
} {load.status === 'binary' &&
{t('binary')}
} - {load.status === 'ready' && load.kind === 'text' && ( + {editable && ( <> - {load.truncated &&
{t('truncation')}
} -
+ {load.truncated && mode === 'edit' &&
{t('truncation')}
} +
)} {load.status === 'ready' && load.kind === 'image' && ( @@ -156,9 +201,9 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st {title}
)} - {load.status === 'ready' && load.kind === 'md' && ( + {previewable && mode === 'preview' && (
- +
)}
diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 1a486f6..38a1a17 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -123,8 +123,11 @@ export function TabBar(props: {
))} - -
+ {/* + 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). + */} { setMenuOpen(false) }} @@ -142,7 +145,7 @@ export function TabBar(props: { anchor={( + ))} +
+ ) +} + /** 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 ? (
{activeTab !== undefined ? renderTab(activeTab) : null}
) : ( -
{t('emptyPane')}
+ )} ) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 2e9c216..44c1ebf 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -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()