feat: 预览区划选文字浮窗「添加到对话」(md 预览 + 兜底 code 查看器)
This commit is contained in:
+3
-13
@@ -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
@@ -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,
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user