From ce052faee89202178efad0215d3b67fc62dacb9c Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 13 Aug 2026 01:39:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A2=84=E8=A7=88=E5=8C=BA=E5=88=92?= =?UTF-8?q?=E9=80=89=E6=96=87=E5=AD=97=E6=B5=AE=E7=AA=97=E3=80=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=88=B0=E5=AF=B9=E8=AF=9D=E3=80=8D=EF=BC=88md=20?= =?UTF-8?q?=E9=A2=84=E8=A7=88=20+=20=E5=85=9C=E5=BA=95=20code=20=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=99=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/Sidebar.tsx | 16 +--- src/client/TextEditor.tsx | 144 ++++++++++++++++++++++++++++++- src/client/conversation-draft.ts | 29 +++++++ src/client/locales.ts | 2 + src/client/selection-payload.ts | 86 ++++++++++++++++++ src/client/sidebar.module.css | 26 ++++++ tests/selection-payload.spec.ts | 94 ++++++++++++++++++++ 7 files changed, 381 insertions(+), 16 deletions(-) create mode 100644 src/client/conversation-draft.ts create mode 100644 src/client/selection-payload.ts create mode 100644 tests/selection-payload.spec.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index aee479e..503c65a 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -29,7 +29,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { useSyncExternalStore } from 'react' import clsx from 'clsx' 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 { BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, reconcileAgentTerminals, @@ -563,18 +564,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { */ const referenceInChat = useCallback((path: string): void => { if (sessionId === undefined) return - try { - 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) - } + appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`) }, [ctx, sessionId, cwd]) if (state === undefined || sessionId === undefined) { diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 5bfc96d..9035599 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -10,6 +10,7 @@ * row below the host's title bar, VSCode-style. */ import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { EditorState } from '@codemirror/state' 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 { isDarkScheme, subscribeColorScheme } from './theme.ts' import { SandboxStatusBar } from './SandboxStatusBar.tsx' +import { appendToDraft } from './conversation-draft.ts' +import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts' import { t } from './locales.ts' import type { FileViewerProps } from './service.ts' import css from './sidebar.module.css' @@ -27,6 +30,13 @@ import css from './sidebar.module.css' /** Previewable files (rendered output vs source editing). */ 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 * 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 function TextEditor(props: FileViewerProps) { - const { scope, path, viewerId, content, truncated } = props + const { ctx, scope, path, viewerId, content, truncated } = props const [mode, setMode] = useState('preview') /** The editor's current text (null while clean); preview renders this. */ const [draft, setDraft] = useState(null) @@ -50,6 +60,36 @@ export function TextEditor(props: FileViewerProps) { const themeCompRef = useRef(null) /** The app's resolved color scheme; the editor re-themes in place on flips. */ const [dark, setDark] = useState(() => isDarkScheme()) + /** The floating "add to conversation" popup (viewport-anchored; null = hidden). */ + const [popup, setPopup] = useState(null) + /** Live mirror of the popup state for click-time reads (no re-render race). */ + const popupRef = useRef(null) + /** The markdown preview container (selection-containment + line lookup). */ + const mdRef = useRef(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()) }), []) @@ -59,6 +99,7 @@ export function TextEditor(props: FileViewerProps) { setDraft(null) setDirty(false) setSaveState('idle') + hidePopup() }, [content]) // Create the CodeMirror editor once the content is loaded. The view owns @@ -100,6 +141,49 @@ export function TextEditor(props: FileViewerProps) { ...defaultKeymap, ...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 }) @@ -124,8 +208,10 @@ export function TextEditor(props: FileViewerProps) { }, [dark]) // 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(() => { + hidePopup() if (mode === 'edit') viewRef.current?.requestMeasure() }, [mode]) @@ -147,6 +233,39 @@ export function TextEditor(props: FileViewerProps) { const markdown = viewerId === 'markdown' 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 saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : '' // Per-feature sandbox escape hatch: the global side card setting (warned) @@ -204,7 +323,12 @@ export function TextEditor(props: FileViewerProps) { )} {markdown && mode === 'preview' && ( -
+
)} @@ -231,6 +355,20 @@ export function TextEditor(props: FileViewerProps) { /> )} + {popup !== null && createPortal( + , + document.body, + )} ) } diff --git a/src/client/conversation-draft.ts b/src/client/conversation-draft.ts new file mode 100644 index 0000000..9047054 --- /dev/null +++ b/src/client/conversation-draft.ts @@ -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 + } +} diff --git a/src/client/locales.ts b/src/client/locales.ts index 494c912..2f6e906 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -101,6 +101,7 @@ export const zh = { openEditor: '打开编辑器', gitDetail: '查看变更详情', referenceFile: '@文件', + addToConversation: '添加到对话', copyRelative: '复制相对地址', copyAbsolute: '复制绝对地址', download: '下载', @@ -301,6 +302,7 @@ export const en: Record = { openEditor: 'Open editor', gitDetail: 'View change details', referenceFile: '@file', + addToConversation: 'Add to conversation', copyRelative: 'Copy relative path', copyAbsolute: 'Copy absolute path', download: 'Download', diff --git a/src/client/selection-payload.ts b/src/client/selection-payload.ts new file mode 100644 index 0000000..0f6e7e0 --- /dev/null +++ b/src/client/selection-payload.ts @@ -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)), + } +} diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 8334e4b..c94436c 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -1067,6 +1067,32 @@ 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 stays pinned to the bottom edge. */ .editorDocx { diff --git a/tests/selection-payload.spec.ts b/tests/selection-payload.spec.ts new file mode 100644 index 0000000..2095640 --- /dev/null +++ b/tests/selection-payload.spec.ts @@ -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() + }) +})