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
+130
View File
@@ -0,0 +1,130 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { looksLikeCode } from './looksLikeCode';
const code = (text: string, html?: string) => looksLikeCode(text, html);
describe('looksLikeCode: code', () => {
it('JavaScript', () => {
const r = code(`function add(a, b) {
const sum = a + b;
if (sum > 10) {
return sum;
}
return 0;
}`);
assert.equal(r.isCode, true);
assert.equal(r.lang, 'js');
});
it('TypeScript', () => {
const r = code(`interface User {
id: string;
name: string;
}
const u: User = { id: '1', name: 'a' };`);
assert.deepEqual(r, { isCode: true, lang: 'ts' });
});
it('Python', () => {
const r = code(`def greet(name):
if not name:
return "hi"
return f"hi {name}"
print(greet("bob"))`);
assert.deepEqual(r, { isCode: true, lang: 'python' });
});
it('SQL', () => {
const r = code(`SELECT id, name
FROM users
WHERE active = 1
ORDER BY name;`);
assert.deepEqual(r, { isCode: true, lang: 'sql' });
});
it('Rust', () => {
const r = code(`pub fn main() {
let mut total = 0;
for i in 0..10 {
total += i;
}
}`);
assert.deepEqual(r, { isCode: true, lang: 'rust' });
});
it('config-ish JSON without a language guess', () => {
const r = code(`{
"name": "lotus",
"version": "1.0.0",
"private": true
}`);
assert.equal(r.isCode, true);
});
it('an editor paste carrying monospace HTML counts as a signal', () => {
const text = `server {
listen 443 ssl;
server_name chat.example.org;
}`;
assert.equal(code(text, '<div style="font-family: Menlo, monospace">x</div>').isCode, true);
});
});
describe('looksLikeCode: not code', () => {
it('fewer than three lines', () => {
assert.equal(code('const a = 1;\nconst b = 2;').isCode, false);
});
it('prose with line breaks', () => {
assert.equal(
code(`Hey, are we still on for tonight?
I can bring snacks if someone else handles drinks.
Let me know what time works, I'm free after six.`).isCode,
false,
);
});
it('a long paragraph pasted as lines', () => {
const line =
'This is a long sentence about the plan. It keeps going for a while. And then some more words follow here.';
assert.equal(code([line, line, line].join('\n')).isCode, false);
});
it('a markdown list', () => {
assert.equal(
code(`- milk (2 litres)
- eggs: a dozen
- bread, the good kind
- coffee!`).isCode,
false,
);
});
it('a quoted reply', () => {
assert.equal(
code(`> const a = 1;
> if (a) { run(); }
> return a;`).isCode,
false,
);
});
it('a list of links', () => {
assert.equal(
code(`https://example.org/a?b=1
https://example.org/c
https://example.org/d;e`).isCode,
false,
);
});
it('a log excerpt', () => {
assert.equal(
code(`2026-09-25 10:00:01 INFO server started
2026-09-25 10:00:02 INFO listening on 8008
2026-09-25 10:00:05 WARN slow request 1200ms`).isCode,
false,
);
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* [Gitea #107] Decide whether a multi-line paste looks like source code, so the
* composer can OFFER to turn it into a code block (never converts on its own).
* Pure and unit-tested; tuned to keep prose, lists, quotes and URL lists out.
*/
export type CodeGuess = {
/** Offer "Paste as code block?" */
isCode: boolean;
/** Language for `class="language-…"`, only when fairly sure. */
lang?: string;
};
const KEYWORDS = [
/\bfunction\b/,
/\bconst\b/,
/\blet\b/,
/\bvar\b/,
/\breturn\b/,
/\bimport\b/,
/\bexport\b/,
/\bclass\b/,
/\bdef\b/,
/\bfn\b/,
/\bpub\b/,
/\bstruct\b/,
/\bif \(/,
/\bfor \(/,
/\bwhile \(/,
/#include\b/,
/\bSELECT\b/i,
/\bFROM\b/i,
/<\?php/,
/<\//,
];
const MONO_HTML = /font-family:[^;"]*(mono|courier|consolas|menlo)|<pre[\s>]|<code[\s>]/i;
const indentOf = (line: string): number => {
const m = /^[ \t]*/.exec(line);
return m ? m[0].replace(/\t/g, ' ').length : 0;
};
export function guessLanguage(text: string): string | undefined {
if (
/^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE)\b/im.test(text) &&
/\bFROM\b|\bINTO\b|\bSET\b|\(/i.test(text)
)
return 'sql';
if (/\bpub fn\b|\bfn \w+\(.*\)\s*(->|\{)|\blet mut\b/.test(text)) return 'rust';
if (/^\s*#include\s*[<"]/m.test(text)) return 'c';
if (
/^\s*def \w+\(.*\):\s*$/m.test(text) ||
(/^\s*(from \w+ )?import \w+\s*$/m.test(text) && /:\s*$/m.test(text))
)
return 'python';
if (/:\s*(string|number|boolean)\b|\binterface \w+|\btype \w+ =/.test(text) && /[{;]/.test(text))
return 'ts';
if (/\b(const|let|var)\b|=>/.test(text) && /[{;]/.test(text)) return 'js';
if (/^\s*<\?php/m.test(text)) return 'php';
return undefined;
}
export function looksLikeCode(text: string, html?: string): CodeGuess {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
const nonEmpty = lines.filter((l) => l.trim() !== '');
if (nonEmpty.length < 3) return { isCode: false };
const n = nonEmpty.length;
const share = (pred: (l: string) => boolean) => nonEmpty.filter(pred).length / n;
// ── Prose guards ──
if (share((l) => /^\s*>/.test(l)) > 0.5) return { isCode: false }; // quoted reply
if (nonEmpty.every((l) => /^\s*(<)?https?:\/\/\S+(>)?\s*$/.test(l))) return { isCode: false };
const avgLen = nonEmpty.reduce((sum, l) => sum + l.length, 0) / n;
if (avgLen > 90 && share((l) => (l.match(/[.!?] /g) ?? []).length >= 2) > 0.5) {
return { isCode: false };
}
if (share((l) => /^[\p{L}\p{N}\s'’",.!?-]*$/u.test(l)) > 0.6) return { isCode: false };
if (share((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)) > 0.6) return { isCode: false }; // list
// ── Signals: need two ──
let signals = 0;
const indented = share((l) => /^( {2,}|\t)/.test(l));
let depthChanges = 0;
for (let i = 1; i < nonEmpty.length; i += 1) {
if (indentOf(nonEmpty[i]) !== indentOf(nonEmpty[i - 1])) depthChanges += 1;
}
if (indented >= 0.4 && depthChanges >= 2) signals += 1;
if (share((l) => /[;{}):,]\s*$/.test(l)) >= 0.3) signals += 1;
if (
nonEmpty.filter((l) => /=>|->|::|\(\)|[^=!<>]=[^=]/.test(l)).length >= 2 &&
/[{}[\]]/.test(text)
) {
signals += 1;
}
if (KEYWORDS.filter((k) => k.test(text)).length >= 2) signals += 1;
if (html && MONO_HTML.test(html)) signals += 1;
// SQL has few symbols: lines that open with a clause keyword are its shape.
if (
share((l) =>
/^\s*(SELECT|FROM|WHERE|(LEFT |RIGHT |INNER )?JOIN|ORDER BY|GROUP BY|HAVING|INSERT|VALUES|UPDATE|SET|DELETE|LIMIT|AND|OR)\b/.test(
l,
),
) >= 0.5
) {
signals += 1;
}
if (signals < 2) return { isCode: false };
return { isCode: true, lang: guessLanguage(text) };
}