feat: 预览区划选文字浮窗「添加到对话」(md 预览 + 兜底 code 查看器)

This commit is contained in:
Menghuan1918
2026-08-13 01:39:59 +08:00
parent a2bb04bce0
commit ce052faee8
7 changed files with 381 additions and 16 deletions
+3 -13
View File
@@ -29,7 +29,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import type { Context, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts'
import { import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals, reconcileAgentTerminals,
@@ -563,18 +564,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
*/ */
const referenceInChat = useCallback((path: string): void => { const referenceInChat = useCallback((path: string): void => {
if (sessionId === undefined) return if (sessionId === undefined) return
try { appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return
const input = conversation.input.for(actx)
const mention = `@${relativeTo(cwd ?? '', path)}`
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
} catch (error) {
console.warn('[dsh-better-sidebar] reference insert failed:', error)
}
}, [ctx, sessionId, cwd]) }, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) { if (state === undefined || sessionId === undefined) {
+141 -3
View File
@@ -10,6 +10,7 @@
* row below the host's title bar, VSCode-style. * row below the host's title bar, VSCode-style.
*/ */
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx' import clsx from 'clsx'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view' import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
@@ -20,6 +21,8 @@ import { languageForPath } from './lang.ts'
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts' import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { t } from './locales.ts' import { t } from './locales.ts'
import type { FileViewerProps } from './service.ts' import type { FileViewerProps } from './service.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
@@ -27,6 +30,13 @@ import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */ /** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit' type ViewMode = 'preview' | 'edit'
/** The floating "add to conversation" action: payload + viewport anchor. */
interface SelectionPopup {
insert: string
left: number
top: number
}
/** /**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the * The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it * preview must stay in an opaque origin — with the route's own origin it
@@ -37,7 +47,7 @@ type ViewMode = 'preview' | 'edit'
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals' export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
export function TextEditor(props: FileViewerProps) { export function TextEditor(props: FileViewerProps) {
const { scope, path, viewerId, content, truncated } = props const { ctx, scope, path, viewerId, content, truncated } = props
const [mode, setMode] = useState<ViewMode>('preview') const [mode, setMode] = useState<ViewMode>('preview')
/** The editor's current text (null while clean); preview renders this. */ /** The editor's current text (null while clean); preview renders this. */
const [draft, setDraft] = useState<string | null>(null) const [draft, setDraft] = useState<string | null>(null)
@@ -50,6 +60,36 @@ export function TextEditor(props: FileViewerProps) {
const themeCompRef = useRef<CmThemeCompartment | null>(null) const themeCompRef = useRef<CmThemeCompartment | null>(null)
/** The app's resolved color scheme; the editor re-themes in place on flips. */ /** The app's resolved color scheme; the editor re-themes in place on flips. */
const [dark, setDark] = useState(() => isDarkScheme()) const [dark, setDark] = useState(() => isDarkScheme())
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
const [popup, setPopup] = useState<SelectionPopup | null>(null)
/** Live mirror of the popup state for click-time reads (no re-render race). */
const popupRef = useRef<SelectionPopup | null>(null)
/** The markdown preview container (selection-containment + line lookup). */
const mdRef = useRef<HTMLDivElement>(null)
const hidePopup = (): void => {
popupRef.current = null
setPopup(null)
}
/** Anchor the popup above the selection center; clamp inside the viewport. */
const showPopup = (insert: string, left: number, top: number): void => {
const next: SelectionPopup = {
insert,
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
top,
}
popupRef.current = next
setPopup(next)
}
/** The popup button's click: insert the stored payload into the draft. */
const commitPopup = (): void => {
const current = popupRef.current
if (current === null) return
appendToDraft(ctx, scope.sessionId, current.insert)
hidePopup()
}
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), []) useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
@@ -59,6 +99,7 @@ export function TextEditor(props: FileViewerProps) {
setDraft(null) setDraft(null)
setDirty(false) setDirty(false)
setSaveState('idle') setSaveState('idle')
hidePopup()
}, [content]) }, [content])
// Create the CodeMirror editor once the content is loaded. The view owns // Create the CodeMirror editor once the content is loaded. The view owns
@@ -100,6 +141,49 @@ export function TextEditor(props: FileViewerProps) {
...defaultKeymap, ...defaultKeymap,
...historyKeymap, ...historyKeymap,
]), ]),
// Selection popup (the catch-all code viewer only): a non-empty
// selection anchors the floating "add to conversation" button above
// its head. Scrolling (geometry/viewport change) or losing focus
// hides it; typing collapses the selection and hides it too.
...(viewerId === 'code' ? [
CodeMirrorView.updateListener.of((update) => {
if (update.geometryChanged || update.viewportChanged) {
hidePopup()
return
}
if (!update.view.hasFocus) {
hidePopup()
return
}
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
const sel = update.state.selection.main
if (sel.empty) {
hidePopup()
return
}
const text = update.state.sliceDoc(sel.from, sel.to)
if (text.trim() === '') {
hidePopup()
return
}
// Page coordinates (the document root may scroll); the popup is
// position:fixed, so convert to viewport coordinates.
const rect = update.view.coordsAtPos(sel.head)
if (rect === null) {
hidePopup()
return
}
const doc = update.state.doc
showPopup(
buildSelectionInsert(path, scope.cwd, {
start: doc.lineAt(sel.from).number,
end: doc.lineAt(sel.to).number,
}, text),
rect.left - window.scrollX + (rect.right - rect.left) / 2,
rect.top - window.scrollY,
)
}),
] : []),
], ],
}) })
const view = new CodeMirrorView({ state, parent: host }) const view = new CodeMirrorView({ state, parent: host })
@@ -124,8 +208,10 @@ export function TextEditor(props: FileViewerProps) {
}, [dark]) }, [dark])
// The editor may have been display:none while previewing; re-measure when // The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal). // it becomes visible again (CodeMirror sizes itself on reveal). A mode
// flip also invalidates any anchored selection popup.
useEffect(() => { useEffect(() => {
hidePopup()
if (mode === 'edit') viewRef.current?.requestMeasure() if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode]) }, [mode])
@@ -147,6 +233,39 @@ export function TextEditor(props: FileViewerProps) {
const markdown = viewerId === 'markdown' const markdown = viewerId === 'markdown'
const html = viewerId === 'html' const html = viewerId === 'html'
/**
* Selection popup for the markdown preview: a mouse-up inside the preview
* container anchors the floating "add to conversation" button above the
* selection. Line numbers come from a best-effort reverse-search of the
* selected text in the source ({@link linesOfSelection} — an ambiguous or
* missing hit omits them). The button's own mousedown preventDefaults so
* the selection survives until the click commits.
*/
const handlePreviewMouseUp = (): void => {
const sel = window.getSelection()
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
hidePopup()
return
}
const host = mdRef.current
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
hidePopup()
return
}
const text = sel.toString()
if (text.trim() === '') {
hidePopup()
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(draft ?? content ?? '', text)
showPopup(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
rect.top,
)
}
const editable = content !== undefined const editable = content !== undefined
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : '' const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
// Per-feature sandbox escape hatch: the global side card setting (warned) // Per-feature sandbox escape hatch: the global side card setting (warned)
@@ -204,7 +323,12 @@ export function TextEditor(props: FileViewerProps) {
</> </>
)} )}
{markdown && mode === 'preview' && ( {markdown && mode === 'preview' && (
<div className={css.editorMd}> <div
className={css.editorMd}
ref={mdRef}
onMouseUp={handlePreviewMouseUp}
onScroll={hidePopup}
>
<MarkdownText text={draft ?? content ?? ''} /> <MarkdownText text={draft ?? content ?? ''} />
</div> </div>
)} )}
@@ -231,6 +355,20 @@ export function TextEditor(props: FileViewerProps) {
/> />
</> </>
)} )}
{popup !== null && createPortal(
<button
type="button"
className={css.selectionPopup}
style={{ left: popup.left, top: popup.top }}
// Keep the selection (and CodeMirror focus) alive until the click
// commits — without this the popup unmounts before click lands.
onMouseDown={(event) => { event.preventDefault() }}
onClick={commitPopup}
>
{t('addToConversation')}
</button>,
document.body,
)}
</> </>
) )
} }
+29
View File
@@ -0,0 +1,29 @@
/**
* Append text to the current session's composer draft through the
* conversation service — the shared path behind the explorer's @-reference
* button and the viewer selection popup. The service is resolved lazily
* through `ctx.get` (the inject-free read the app's own plugins use); a
* missing service or scope degrades to a logged no-op, never a crash.
*/
import type { Context, SidebarConversation } from '../context-types.ts'
/**
* Append `text` to the session's composer draft (space-separated, like the
* @-mentions). Returns false — and logs — when the conversation service or
* the session scope is unavailable.
*/
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return false
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return false
const input = conversation.input.for(actx)
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
return true
} catch (error) {
console.warn('[dsh-better-sidebar] draft insert failed:', error)
return false
}
}
+2
View File
@@ -101,6 +101,7 @@ export const zh = {
openEditor: '打开编辑器', openEditor: '打开编辑器',
gitDetail: '查看变更详情', gitDetail: '查看变更详情',
referenceFile: '@文件', referenceFile: '@文件',
addToConversation: '添加到对话',
copyRelative: '复制相对地址', copyRelative: '复制相对地址',
copyAbsolute: '复制绝对地址', copyAbsolute: '复制绝对地址',
download: '下载', download: '下载',
@@ -301,6 +302,7 @@ export const en: Record<keyof typeof zh, string> = {
openEditor: 'Open editor', openEditor: 'Open editor',
gitDetail: 'View change details', gitDetail: 'View change details',
referenceFile: '@file', referenceFile: '@file',
addToConversation: 'Add to conversation',
copyRelative: 'Copy relative path', copyRelative: 'Copy relative path',
copyAbsolute: 'Copy absolute path', copyAbsolute: 'Copy absolute path',
download: 'Download', download: 'Download',
+86
View File
@@ -0,0 +1,86 @@
/**
* Pure payload builders for the "add selection to conversation" popup in the
* text viewers (markdown preview + the catch-all code viewer). Everything
* here is string math — no React, no ctx — so the unit tests cover it
* directly.
*
* Insert shape (agreed with the product owner):
* - Selection ≤ SELECTION_LIMIT characters: a fenced code block whose info
* line is `相对路径:起止行` and whose body is the selected text.
* - Selection over the limit: a single plain-text line `相对路径:起止行`
* (no fence, no content).
* - The path is relative to the session cwd (the same projection the
* explorer's @ button uses); an unknown cwd falls back to the absolute
* path.
* - Line numbers: single-line selections write `path:12`, multi-line write
* `path:12-15`. The markdown preview cannot map rendered DOM back to
* source lines directly, so it reverse-searches the selected text in the
* source and only reports lines on an unambiguous hit (see
* {@link linesOfSelection}).
*/
import { relativeTo } from './paths.ts'
/** Max inserted selection length (UTF-16 code units, i.e. JS `.length`). */
export const SELECTION_LIMIT = 500
/** The source line span a selection maps to (1-based, inclusive). */
export interface SelectionLines {
start: number
end: number
}
/**
* The fence info line: `rel[:start[-end]]` — lines are omitted entirely
* when unknown (the preview reverse-search missed).
*/
export function headerOf(path: string, cwd: string | undefined, lines?: SelectionLines): string {
const rel = cwd !== undefined ? relativeTo(cwd, path) : path
if (lines === undefined) return rel
if (lines.end > lines.start) return `${rel}:${lines.start}-${lines.end}`
return `${rel}:${lines.start}`
}
/**
* The full text appended to the composer draft for one selection.
* Over the limit the content is dropped: the plain path line is the whole
* payload (an empty fenced block would just occupy the draft).
*/
export function buildSelectionInsert(
path: string,
cwd: string | undefined,
lines: SelectionLines | undefined,
selected: string,
): string {
const header = headerOf(path, cwd, lines)
if (selected.length > SELECTION_LIMIT) return header
return `\`\`\`${header}\n${selected}\n\`\`\``
}
/** 1-based line number of a character index in a text. */
function lineAt(source: string, index: number): number {
let line = 1
for (let i = 0; i < index && i < source.length; i++) {
if (source[i] === '\n') line++
}
return line
}
/**
* Reverse-map a rendered-DOM selection back to source line numbers. The
* preview selection is plain text (block boundaries come out as `\n`), so
* this is a best-effort substring search: a single trailing newline is
* stripped first (DOM block selections tend to carry one), and only an
* EXACTLY-ONE occurrence yields lines — an ambiguous or missing match
* returns null (the header then carries the path without line numbers).
*/
export function linesOfSelection(source: string, selected: string): SelectionLines | null {
const text = selected.endsWith('\n') ? selected.slice(0, -1) : selected
if (text === '') return null
const at = source.indexOf(text)
if (at === -1) return null
if (source.indexOf(text, at + 1) !== -1) return null
return {
start: lineAt(source, at),
end: lineAt(source, at + Math.max(text.length - 1, 0)),
}
}
+26
View File
@@ -1067,6 +1067,32 @@
font: var(--dsw-font-xs-13); font: var(--dsw-font-xs-13);
} }
/* The floating "add to conversation" button anchored above a text
selection in the preview/code viewers. Portalled to document.body and
position:fixed, so the editor's overflow clips cannot crop it; flat
chrome like the rest of the sidebar (hairline border, layer-2 fill,
hover fill — no shadow). */
.selectionPopup {
position: fixed;
z-index: 60;
transform: translate(-50%, calc(-100% - 8px));
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
cursor: pointer;
}
.selectionPopup:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Word preview: the document scrolls independently while the zoom control /* Word preview: the document scrolls independently while the zoom control
stays pinned to the bottom edge. */ stays pinned to the bottom edge. */
.editorDocx { .editorDocx {
+94
View File
@@ -0,0 +1,94 @@
/**
* Pure payload logic for the viewer selection popup ("add to
* conversation"): the fence header (relative path + line span), the
* over-limit plain-line fallback, and the best-effort reverse-search that
* maps a preview selection back to source lines.
*/
import { describe, expect, it } from 'vitest'
import {
SELECTION_LIMIT,
buildSelectionInsert,
headerOf,
linesOfSelection,
} from '../src/client/selection-payload.ts'
describe('headerOf', () => {
it('projects the path relative to the session cwd', () => {
expect(headerOf('/p/docs/plan.md', '/p', { start: 12, end: 12 })).toBe('docs/plan.md:12')
})
it('falls back to the absolute path when the cwd is unknown', () => {
expect(headerOf('/p/docs/plan.md', undefined, { start: 3, end: 3 })).toBe('/p/docs/plan.md:3')
})
it('keeps the absolute path for files outside the cwd', () => {
expect(headerOf('/other/x.md', '/p', { start: 1, end: 1 })).toBe('/other/x.md:1')
})
it('writes a single line number for single-line selections', () => {
expect(headerOf('/p/a.md', '/p', { start: 5, end: 5 })).toBe('a.md:5')
})
it('writes the start-end range for multi-line selections', () => {
expect(headerOf('/p/a.md', '/p', { start: 5, end: 9 })).toBe('a.md:5-9')
})
it('omits the line suffix entirely when the lines are unknown', () => {
expect(headerOf('/p/a.md', '/p', undefined)).toBe('a.md')
})
})
describe('buildSelectionInsert', () => {
it('wraps a small selection in a fenced block with the path:line info line', () => {
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 2, end: 4 }, 'const x = 1')).toBe(
'```a.ts:2-4\nconst x = 1\n```',
)
})
it('accepts a selection exactly at the limit (fenced)', () => {
const text = 'x'.repeat(SELECTION_LIMIT)
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 1, end: 1 }, text)).toBe(
`\`\`\`a.ts:1\n${text}\n\`\`\``,
)
})
it('drops the content past the limit: one plain path line, no fence', () => {
const text = 'x'.repeat(SELECTION_LIMIT + 1)
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 7, end: 7 }, text)).toBe('a.ts:7')
})
it('keeps the plain-line form when past the limit without line numbers', () => {
const text = 'x'.repeat(SELECTION_LIMIT + 1)
expect(buildSelectionInsert('/p/a.ts', '/p', undefined, text)).toBe('a.ts')
})
})
describe('linesOfSelection', () => {
const source = 'alpha\nbeta\ngamma alpha\ndelta\n'
it('maps a unique hit to its source line span', () => {
expect(linesOfSelection(source, 'beta')).toEqual({ start: 2, end: 2 })
expect(linesOfSelection(source, 'gamma alpha')).toEqual({ start: 3, end: 3 })
})
it('spans the end line when the selection crosses line breaks', () => {
expect(linesOfSelection(source, 'beta\ngamma')).toEqual({ start: 2, end: 3 })
})
it('returns null when the text is missing from the source', () => {
expect(linesOfSelection(source, 'nope')).toBeNull()
})
it('returns null on an ambiguous (multi-hit) match', () => {
expect(linesOfSelection(source, 'alpha')).toBeNull()
})
it('strips a single trailing newline before searching (DOM block selections)', () => {
expect(linesOfSelection(source, 'delta\n')).toEqual({ start: 4, end: 4 })
})
it('returns null for an empty selection', () => {
expect(linesOfSelection(source, '')).toBeNull()
expect(linesOfSelection(source, '\n')).toBeNull()
})
})