feat(composer): offer to format pasted code as a code block (#107)
CI / Build & Quality Checks (push) Successful in 1m52s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m45s

When a multi-line paste looks like code, a chip above the composer asks
"That looks like code. Format it as a code block (js)?" with Format as code /
No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and
Settings → Editor → "Offer to Format Pasted Code" turns it off.

- utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation
  with depth changes, statement terminators, operators + brackets, keywords,
  monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted
  replies, URL lists, long sentence-punctuated lines, plain-word lines,
  markdown lists. Language guess only when fairly sure (js/ts/python/sql/
  rust/c/php).
- Accept rebuilds the pasted paragraphs as one code block from their plain
  text (code lines hold text only) and leaves the caret after it.
- Code blocks carry an optional lang → <code class="language-js"> (whitelisted
  identifier only).

Verified in Chromium: pasting a JS function shows the chip; Format as code →
sent formatted_body is <pre><code class="language-js"> with indentation intact;
a three-line prose paste shows no chip; typing after a paste dismisses it.
Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-25 11:05:49 -04:00
co-authored by Claude Opus 5.5
parent e91b5fe10e
commit 75d55e861b
8 changed files with 391 additions and 4 deletions
+113 -3
View File
@@ -13,8 +13,9 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import { Transforms, Editor, Element as SlateElement, Node as SlateNode, Range } from 'slate';
import {
Button,
Box,
Dialog,
Icon,
@@ -58,6 +59,7 @@ import {
getBeginCommand,
trimCommand,
getMentions,
BlockType,
} from '../../components/editor';
import { EmojiBoardTab } from '../../components/emoji-board/types';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -99,6 +101,7 @@ import { filesToUploadItems } from '../../utils/uploadItems';
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { looksLikeCode } from '../../utils/looksLikeCode';
import { useAlive } from '../../hooks/useAlive';
import {
ComposerToolbarButtonKey,
@@ -297,6 +300,48 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
// [Gitea #107] "Paste as code block?" offer for the top-level blocks a
// paste just filled. Dismissed by typing, by No, or after 8 s.
const [offerCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
const [codeOffer, setCodeOffer] = useState<{ start: number; end: number; lang?: string }>();
useEffect(() => {
if (!codeOffer) return undefined;
const t = window.setTimeout(() => setCodeOffer(undefined), 8000);
return () => window.clearTimeout(t);
}, [codeOffer]);
const acceptCodeOffer = useCallback(() => {
if (!codeOffer) return;
const { start, end, lang } = codeOffer;
setCodeOffer(undefined);
if (end >= editor.children.length || start > end) return;
const blocks = editor.children.slice(start, end + 1);
// Only plain paragraphs (what a paste produces); anything else, leave it.
if (blocks.some((b) => !SlateElement.isElement(b) || b.type !== BlockType.Paragraph)) return;
// Rebuild from plain text: code lines hold text only, and a paste may
// have produced links/mentions.
const lines = blocks.map((b) => SlateNode.string(b));
Editor.withoutNormalizing(editor, () => {
for (let i = end; i >= start; i -= 1) Transforms.removeNodes(editor, { at: [i] });
Transforms.insertNodes(
editor,
{
type: BlockType.CodeBlock,
lang,
children: lines.map((text) => ({ type: BlockType.CodeLine, children: [{ text }] })),
},
{ at: [start] },
);
if (start + 1 >= editor.children.length) {
Transforms.insertNodes(
editor,
{ type: BlockType.Paragraph, children: [{ text: '' }] },
{ at: [start + 1] },
);
}
});
Transforms.select(editor, Editor.start(editor, [start + 1]));
ReactEditor.focus(editor);
}, [codeOffer, editor]);
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
const showLocation = composerToolbarButtons?.showLocation ?? true;
@@ -411,7 +456,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handlePaste = useCallback<React.ClipboardEventHandler>(
(evt) => {
handleFilePaste(evt);
if (evt.defaultPrevented || !stripTracking) return;
if (evt.defaultPrevented) return;
if (offerCodePaste && editor.selection) {
const pasted = evt.clipboardData?.getData('text/plain') ?? '';
const startBlock = Range.start(editor.selection).path[0];
const startNode = editor.children[startBlock];
const inCode =
SlateElement.isElement(startNode) && startNode.type === BlockType.CodeBlock;
const guess =
!inCode && pasted
? looksLikeCode(pasted, evt.clipboardData?.getData('text/html'))
: undefined;
if (guess?.isCode) {
// Slate inserts after this handler returns; read where it ended.
window.setTimeout(() => {
const endBlock = editor.selection ? Range.end(editor.selection).path[0] : startBlock;
setCodeOffer({ start: startBlock, end: endBlock, lang: guess.lang });
}, 0);
}
}
if (!stripTracking) return;
const text = evt.clipboardData?.getData('text/plain');
if (!text) return;
const cleaned = stripTrackingParamsInText(text);
@@ -424,7 +488,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
dt.setData('text/plain', cleaned);
ReactEditor.insertData(editor, dt);
},
[handleFilePaste, stripTracking, editor],
[handleFilePaste, stripTracking, editor, offerCodePaste],
);
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const { gifApiKey } = useClientConfig();
@@ -853,6 +917,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
// Typing (not Tab/arrows/modifiers) dismisses the paste-as-code offer.
if (
evt.key.length === 1 ||
evt.key === 'Backspace' ||
evt.key === 'Delete' ||
evt.key === 'Enter'
) {
setCodeOffer(undefined);
}
if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!isComposing(evt)
@@ -1149,6 +1222,43 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
</Box>
)}
{codeOffer && (
<Box
role="group"
aria-label="Paste as code block"
alignItems="Center"
gap="200"
wrap="Wrap"
style={{
margin: `0 ${config.space.S300} ${config.space.S100}`,
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<Icon size="100" src={Icons.BlockCode} style={{ flexShrink: 0 }} />
<Text size="T200" style={{ flexGrow: 1 }}>
That looks like code. Format it as a code block
{codeOffer.lang ? ` (${codeOffer.lang})` : ''}?
</Text>
<Button size="300" variant="Primary" radii="300" onClick={acceptCodeOffer}>
<Text size="B300">Format as code</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="None"
radii="300"
onClick={() => {
setCodeOffer(undefined);
ReactEditor.focus(editor);
}}
>
<Text size="B300">No thanks</Text>
</Button>
</Box>
)}
<ScheduledMessagesTray roomId={roomId} />
<CustomEditor
editableName={editableName}
@@ -1405,6 +1405,7 @@ function ComposerToolbarReorder({
function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [offerCodePaste, setOfferCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
settingsAtom,
@@ -1455,6 +1456,13 @@ function Editor() {
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Offer to Format Pasted Code"
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Formatting Toolbar"