feat(files): comment on selection in read-only markdown previews
Selecting text in a rendered markdown preview shows a Comment pill; attaching stores a file-quote context draft carrying the file path, the selected fragment (not whole lines), the user's comment, and a best-effort source line range resolved by anchoring the fragment's first and last lines in the raw content — a partially located fragment gets no range rather than a misleading one. The fragment stays highlighted while the comment input is open, using the selection overlay rects shared with chat quote comments, and the preview's native selection color now matches chat messages. file-quote flows through the same context contract: composer chip previews, the message context card, and the metadata round-trip.
This commit is contained in:
@@ -40,14 +40,14 @@ type ChipGroup = {
|
||||
drafts: InlineCommentDraft[];
|
||||
};
|
||||
|
||||
const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan'];
|
||||
const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan', 'file-quote'];
|
||||
|
||||
/** Sources whose drafts carry a user-written comment that can be edited. */
|
||||
const editableSource = (source: InlineCommentSource): boolean => source !== 'terminal';
|
||||
|
||||
/** Captured code/output kinds read better monospaced; quoted prose does not. */
|
||||
const monoSource = (source: InlineCommentSource): boolean =>
|
||||
source !== 'chat-quote' && source !== 'preview-annotation';
|
||||
source !== 'chat-quote' && source !== 'preview-annotation' && source !== 'file-quote';
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
@@ -259,6 +259,12 @@ export function ComposerContextChips({ draftTarget, colors }: ComposerContextChi
|
||||
return t('chat.message.context.prCheck', { label: draft.fileLabel });
|
||||
case 'chat-quote':
|
||||
return t('chat.message.context.chatQuote');
|
||||
case 'file-quote':
|
||||
return draft.startLine > 0 && draft.endLine > 0
|
||||
? (draft.startLine === draft.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
|
||||
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine }))
|
||||
: t('chat.message.context.fileQuote', { file: basename(draft.fileLabel) });
|
||||
default:
|
||||
return draft.startLine === draft.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -67,56 +68,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
return;
|
||||
}
|
||||
|
||||
const textRects: DOMRect[] = [];
|
||||
const pushNodeRects = (node: Text) => {
|
||||
const nodeRange = document.createRange();
|
||||
nodeRange.selectNodeContents(node);
|
||||
if (node === range.startContainer) nodeRange.setStart(node, range.startOffset);
|
||||
if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset);
|
||||
// Text rects cover only the glyph box; the native selection paints the
|
||||
// full line box, so each rect is stretched to its element's line-height.
|
||||
const lineHeight = node.parentElement
|
||||
? Number.parseFloat(window.getComputedStyle(node.parentElement).lineHeight)
|
||||
: Number.NaN;
|
||||
for (const rect of nodeRange.getClientRects()) {
|
||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||
if (Number.isFinite(lineHeight) && lineHeight > rect.height) {
|
||||
const expand = (lineHeight - rect.height) / 2;
|
||||
textRects.push(new DOMRect(rect.left, rect.top - expand, rect.width, lineHeight));
|
||||
} else {
|
||||
textRects.push(rect);
|
||||
}
|
||||
}
|
||||
};
|
||||
const root = range.commonAncestorContainer;
|
||||
if (root instanceof Text) {
|
||||
pushNodeRects(root);
|
||||
} else {
|
||||
// SAFETY: the walker is created with SHOW_TEXT, so every node it
|
||||
// yields is a Text node.
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (range.intersectsNode(node)) pushNodeRects(node as Text);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge rects that sit on the same visual line into one strip, the way
|
||||
// the native selection paints a line box.
|
||||
const lines: Array<{ left: number; right: number; top: number; bottom: number }> = [];
|
||||
for (const rect of textRects) {
|
||||
const line = lines.find((candidate) => (
|
||||
Math.abs(candidate.top - rect.top) < 6 && Math.abs(candidate.bottom - rect.bottom) < 6
|
||||
));
|
||||
if (line) {
|
||||
line.left = Math.min(line.left, rect.left);
|
||||
line.right = Math.max(line.right, rect.right);
|
||||
line.top = Math.min(line.top, rect.top);
|
||||
line.bottom = Math.max(line.bottom, rect.bottom);
|
||||
} else {
|
||||
lines.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom });
|
||||
}
|
||||
}
|
||||
setCommentRects(lines.map((line) => new DOMRect(line.left, line.top, line.right - line.left, line.bottom - line.top)));
|
||||
setCommentRects(collectSelectionOverlayRects(range));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -113,6 +113,15 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'file-quote': {
|
||||
const file = basename(payload.fileLabel);
|
||||
const summary = payload.startLine != null && payload.endLine != null
|
||||
? (payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine }))
|
||||
: t('chat.message.context.fileQuote', { file });
|
||||
return <ContextCard icon="chat-1" summary={summary} title={payload.fileLabel} body={payload.quote} text={payload.text} />;
|
||||
}
|
||||
case 'chat-quote':
|
||||
return (
|
||||
<ContextCard
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
|
||||
import { InlineCommentInput } from './InlineCommentInput';
|
||||
|
||||
interface FilePreviewCommentMenuProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
filePath: string;
|
||||
/** Raw file source, used to locate the selected fragment's line range. */
|
||||
fileContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment-on-selection for read-only file previews (rendered markdown and the
|
||||
* like). Selecting text shows a small Comment pill above the selection;
|
||||
* choosing it opens the shared comment input, and attaching stores a
|
||||
* file-quote context draft carrying the selected fragment (with a best-effort
|
||||
* source line range) plus the user's comment.
|
||||
*/
|
||||
export function FilePreviewCommentMenu({ containerRef, filePath, fileContent }: FilePreviewCommentMenuProps) {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
|
||||
const [anchor, setAnchor] = React.useState<{ x: number; y: number } | null>(null);
|
||||
const [selectedText, setSelectedText] = React.useState('');
|
||||
const [commentMode, setCommentMode] = React.useState(false);
|
||||
const commentModeRef = React.useRef(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const selectionRangeRef = React.useRef<Range | null>(null);
|
||||
// The selected fragment stays visibly highlighted while the comment input
|
||||
// owns focus, same as chat quote comments.
|
||||
const [highlightRects, setHighlightRects] = React.useState<DOMRect[] | null>(null);
|
||||
|
||||
const hide = React.useCallback(() => {
|
||||
setAnchor(null);
|
||||
setSelectedText('');
|
||||
setCommentMode(false);
|
||||
commentModeRef.current = false;
|
||||
selectionRangeRef.current = null;
|
||||
setHighlightRects(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
if (commentModeRef.current) return;
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString().trim() ?? '';
|
||||
if (!selection || !text || selection.rangeCount === 0) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!container.contains(range.commonAncestorContainer)) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
const rect = range.getBoundingClientRect();
|
||||
selectionRangeRef.current = range.cloneRange();
|
||||
setSelectedText(text);
|
||||
setAnchor({ x: rect.left + rect.width / 2, y: rect.top - 10 });
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
// SAFETY: a pointer event target inside the document is always a Node;
|
||||
// `contains` only needs that.
|
||||
if (menuRef.current?.contains(event.target as Node)) return;
|
||||
if (commentModeRef.current) {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('selectionchange', handleSelectionChange);
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
};
|
||||
}, [containerRef, hide]);
|
||||
|
||||
/**
|
||||
* Best-effort mapping of the rendered-text selection back to source lines:
|
||||
* an exact match of the fragment (or its first line) in the raw content
|
||||
* yields a range; markdown syntax usually breaks the match, in which case
|
||||
* the draft simply carries no line numbers.
|
||||
*/
|
||||
const resolveLineRange = React.useCallback((fragment: string): { start: number; end: number } | null => {
|
||||
const lineAt = (index: number): number => fileContent.slice(0, index).split('\n').length;
|
||||
// Inline markdown syntax (emphasis markers and such) breaks long matches,
|
||||
// so each anchor line is tried at decreasing lengths.
|
||||
const candidatesFor = (line: string): string[] => {
|
||||
const trimmed = line.trim();
|
||||
return [trimmed, trimmed.slice(0, 32), trimmed.split(' ').slice(0, 4).join(' ')]
|
||||
.filter((candidate) => candidate.length >= 8);
|
||||
};
|
||||
const locate = (line: string, from: number): number => {
|
||||
for (const candidate of candidatesFor(line)) {
|
||||
const index = fileContent.indexOf(candidate, from);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const exact = fragment.length >= 8 ? fileContent.indexOf(fragment) : -1;
|
||||
if (exact >= 0) {
|
||||
const start = lineAt(exact);
|
||||
return { start, end: start + fragment.split('\n').length - 1 };
|
||||
}
|
||||
|
||||
const fragmentLines = fragment.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
if (fragmentLines.length === 0) return null;
|
||||
const startIndex = locate(fragmentLines[0], 0);
|
||||
if (startIndex < 0) return null;
|
||||
const start = lineAt(startIndex);
|
||||
if (fragmentLines.length === 1) return { start, end: start };
|
||||
const endIndex = locate(fragmentLines[fragmentLines.length - 1], startIndex);
|
||||
// A partially located multi-line fragment gets no range rather than a
|
||||
// misleading single-line one.
|
||||
if (endIndex < 0) return null;
|
||||
return { start, end: Math.max(start, lineAt(endIndex)) };
|
||||
}, [fileContent]);
|
||||
|
||||
const updateHighlightRects = React.useCallback(() => {
|
||||
const range = selectionRangeRef.current;
|
||||
if (!range) {
|
||||
setHighlightRects(null);
|
||||
return;
|
||||
}
|
||||
setHighlightRects(collectSelectionOverlayRects(range));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!commentMode) return;
|
||||
let frame: number | null = null;
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== null) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
updateHighlightRects();
|
||||
});
|
||||
};
|
||||
document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
return () => {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
document.removeEventListener('scroll', scheduleUpdate, { capture: true });
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, [commentMode, updateHighlightRects]);
|
||||
|
||||
const openComment = React.useCallback(() => {
|
||||
if (!selectedText) return;
|
||||
setCommentMode(true);
|
||||
commentModeRef.current = true;
|
||||
updateHighlightRects();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, updateHighlightRects]);
|
||||
|
||||
const saveComment = React.useCallback((text: string) => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!selectedText || !sessionKey || !effectiveDirectory) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
const lineRange = resolveLineRange(selectedText);
|
||||
addDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'file-quote',
|
||||
fileLabel: filePath,
|
||||
startLine: lineRange?.start ?? 0,
|
||||
endLine: lineRange?.end ?? 0,
|
||||
code: selectedText,
|
||||
language: '',
|
||||
text: text.trim(),
|
||||
});
|
||||
hide();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [addDraft, currentSessionId, effectiveDirectory, filePath, hide, newSessionDraftOpen, resolveLineRange, selectedText]);
|
||||
|
||||
if (!anchor) return null;
|
||||
|
||||
const lineRange = commentMode ? resolveLineRange(selectedText) : null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50"
|
||||
style={{
|
||||
left: anchor.x,
|
||||
top: anchor.y,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
{commentMode && highlightRects && highlightRects.length > 0
|
||||
? createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-40">
|
||||
{highlightRects.map((rect, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="oc-chat-comment-rect absolute"
|
||||
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{commentMode ? (
|
||||
<div className="w-[min(420px,80vw)]">
|
||||
<InlineCommentInput
|
||||
fileLabel={filePath}
|
||||
lineRange={lineRange ? { start: lineRange.start, end: lineRange.end } : undefined}
|
||||
onSave={saveComment}
|
||||
onCancel={hide}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center whitespace-nowrap',
|
||||
'rounded-full border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'p-1',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openComment}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.commentOnSelection')}
|
||||
>
|
||||
{t('chat.textSelection.actions.comment')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './useInlineCommentController';
|
||||
export * from './CodeMirrorCommentWidgets';
|
||||
export * from './PierreDiffCommentUtils';
|
||||
export * from './PierreDiffCommentOverlays';
|
||||
export * from './FilePreviewCommentMenu';
|
||||
|
||||
@@ -60,7 +60,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments';
|
||||
import { buildCodeMirrorCommentWidgets, FilePreviewCommentMenu, normalizeLineRange, useInlineCommentController } from '@/components/comments';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
@@ -1071,6 +1071,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return lines.slice(startLine - 1, endLine).join('\n');
|
||||
}, []);
|
||||
|
||||
const markdownPreviewRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const fileCommentController = useInlineCommentController<SelectedLineRange>({
|
||||
source: 'file',
|
||||
fileLabel: selectedFile?.path ?? null,
|
||||
@@ -3943,7 +3945,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
|
||||
<div className="h-full overflow-auto p-3">
|
||||
<div className="oc-file-preview h-full overflow-auto p-3" ref={markdownPreviewRef}>
|
||||
<FilePreviewCommentMenu
|
||||
containerRef={markdownPreviewRef}
|
||||
filePath={selectedFile.path}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
{fileContent.length > 500 * 1024 && (
|
||||
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
|
||||
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
|
||||
@@ -4309,7 +4316,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
) : null}
|
||||
</div>
|
||||
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="oc-file-preview h-full overflow-auto p-4" ref={markdownPreviewRef}>
|
||||
{selectedFile ? (
|
||||
<FilePreviewCommentMenu
|
||||
containerRef={markdownPreviewRef}
|
||||
filePath={selectedFile.path}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
) : null}
|
||||
{fileContent.length > 500 * 1024 && (
|
||||
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
|
||||
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
|
||||
|
||||
Reference in New Issue
Block a user