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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user