fix: preserve exact short paste text
This commit is contained in:
@@ -18,12 +18,13 @@
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
invertedEffects,
|
||||
isolateHistory,
|
||||
} from "@codemirror/commands";
|
||||
import { onMount } from "svelte";
|
||||
import type { Segment } from "$lib/generated/protocol.ts";
|
||||
import {
|
||||
handleComposerPaste,
|
||||
measureComposerPaste,
|
||||
type ComposerPasteMeasurement,
|
||||
} from "$lib/workspace/console/composer-paste.ts";
|
||||
import {
|
||||
@@ -33,6 +34,7 @@
|
||||
snapshotComposerDraft,
|
||||
type ComposerDraftSnapshot,
|
||||
type ComposerPaste,
|
||||
type ComposerTextPaste,
|
||||
} from "$lib/workspace/console/composer-draft.ts";
|
||||
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
||||
|
||||
@@ -74,6 +76,37 @@
|
||||
},
|
||||
});
|
||||
|
||||
const registerTextPaste = StateEffect.define<ComposerTextPaste>();
|
||||
const restoreTextPastes = StateEffect.define<readonly ComposerTextPaste[]>();
|
||||
const textPasteState = StateField.define<readonly ComposerTextPaste[]>({
|
||||
create: () => [],
|
||||
update(textPastes, transaction) {
|
||||
const restored = transaction.effects.find((effect) =>
|
||||
effect.is(restoreTextPastes)
|
||||
);
|
||||
if (restored) return restored.value;
|
||||
const retained: ComposerTextPaste[] = [];
|
||||
for (const paste of textPastes) {
|
||||
let touched = false;
|
||||
transaction.changes.iterChangedRanges((from, to) => {
|
||||
const replacesContent = from < paste.to && to > paste.from;
|
||||
const insertsInside = from === to && from > paste.from && from < paste.to;
|
||||
if (replacesContent || insertsInside) touched = true;
|
||||
});
|
||||
if (touched) continue;
|
||||
retained.push({
|
||||
...paste,
|
||||
from: transaction.changes.mapPos(paste.from, 1),
|
||||
to: transaction.changes.mapPos(paste.to, -1),
|
||||
});
|
||||
}
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(registerTextPaste)) retained.push(effect.value);
|
||||
}
|
||||
return retained;
|
||||
},
|
||||
});
|
||||
|
||||
class PasteChipWidget extends WidgetType {
|
||||
readonly paste: ComposerPaste;
|
||||
|
||||
@@ -120,15 +153,25 @@
|
||||
|
||||
const pasteChips = [
|
||||
pasteRegistry,
|
||||
textPasteState,
|
||||
invertedEffects.of((transaction) =>
|
||||
transaction.docChanged
|
||||
? [restoreTextPastes.of(transaction.startState.field(textPasteState))]
|
||||
: []
|
||||
),
|
||||
EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)),
|
||||
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
|
||||
];
|
||||
|
||||
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
|
||||
if (!state) {
|
||||
return { document: "", content: "", segments: [], pastes: [] };
|
||||
return { document: "", content: "", segments: [], pastes: [], textPastes: [] };
|
||||
}
|
||||
return snapshotComposerDraft(state.doc.toString(), state.field(pasteRegistry));
|
||||
return snapshotComposerDraft(
|
||||
state.doc.toString(),
|
||||
state.field(pasteRegistry),
|
||||
state.field(textPasteState),
|
||||
);
|
||||
}
|
||||
|
||||
function emitChange(): void {
|
||||
@@ -155,6 +198,37 @@
|
||||
});
|
||||
}
|
||||
|
||||
function insertTextPaste(content: string): void {
|
||||
if (!view) return;
|
||||
const selection = view.state.selection.main;
|
||||
const rendered = view.state.toText(content).toString();
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: rendered },
|
||||
selection: EditorSelection.cursor(selection.from + rendered.length),
|
||||
effects: registerTextPaste.of({
|
||||
from: selection.from,
|
||||
to: selection.from + rendered.length,
|
||||
rendered,
|
||||
content,
|
||||
}),
|
||||
annotations: isolateHistory.of("full"),
|
||||
userEvent: "input.paste",
|
||||
});
|
||||
}
|
||||
|
||||
function handlePasteEvent(event: ClipboardEvent): boolean {
|
||||
const content = event.clipboardData?.getData("text/plain");
|
||||
if (!content) return false;
|
||||
const measurement = measureComposerPaste(content);
|
||||
event.preventDefault();
|
||||
if (measurement.presentation === "chip") {
|
||||
insertPasteChip(content, measurement);
|
||||
} else {
|
||||
insertTextPaste(content);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectedClipboardContent(state: EditorState): string | null {
|
||||
const selection = state.selection.main;
|
||||
if (selection.empty) return null;
|
||||
@@ -166,7 +240,14 @@
|
||||
selectedRegistry.set(atom.key, atom);
|
||||
}
|
||||
}
|
||||
return snapshotComposerDraft(document, selectedRegistry).content;
|
||||
const selectedTextPastes = state.field(textPasteState)
|
||||
.filter((paste) => paste.from >= selection.from && paste.to <= selection.to)
|
||||
.map((paste) => ({
|
||||
...paste,
|
||||
from: paste.from - selection.from,
|
||||
to: paste.to - selection.from,
|
||||
}));
|
||||
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes).content;
|
||||
}
|
||||
|
||||
function deleteAdjacentPasteFromView(
|
||||
@@ -229,7 +310,7 @@
|
||||
}),
|
||||
Prec.high(EditorView.domEventHandlers({
|
||||
paste(event) {
|
||||
return handleComposerPaste(event, insertPasteChip);
|
||||
return handlePasteEvent(event);
|
||||
},
|
||||
copy(event, currentView) {
|
||||
const content = selectedClipboardContent(currentView.state);
|
||||
@@ -327,14 +408,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreSegments(segments: readonly Segment[]): void {
|
||||
export function restoreSegments(
|
||||
segments: readonly Segment[],
|
||||
preserveExactText = false,
|
||||
): void {
|
||||
if (!view) return;
|
||||
let document = "";
|
||||
const effects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
|
||||
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
|
||||
const textEffects: StateEffect<ComposerTextPaste>[] = [];
|
||||
let highestPasteId = nextPasteId - 1;
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === "text") {
|
||||
document += segment.content;
|
||||
const rendered = view.state.toText(segment.content).toString();
|
||||
const from = document.length;
|
||||
document += rendered;
|
||||
if (preserveExactText) {
|
||||
textEffects.push(registerTextPaste.of({
|
||||
from,
|
||||
to: from + rendered.length,
|
||||
rendered,
|
||||
content: segment.content,
|
||||
}));
|
||||
}
|
||||
} else if (segment.kind === "paste") {
|
||||
const key = nextPasteKey++;
|
||||
const paste: ComposerPaste = {
|
||||
@@ -345,7 +440,7 @@
|
||||
};
|
||||
highestPasteId = Math.max(highestPasteId, paste.id);
|
||||
document += composerPasteToken(key);
|
||||
effects.push(registerPaste.of({ key, paste }));
|
||||
pasteEffects.push(registerPaste.of({ key, paste }));
|
||||
} else if (segment.kind === "file_ref") {
|
||||
document += `@${segment.path}`;
|
||||
}
|
||||
@@ -354,7 +449,7 @@
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: document },
|
||||
selection: EditorSelection.cursor(document.length),
|
||||
effects,
|
||||
effects: [...pasteEffects, ...textEffects],
|
||||
userEvent: "input.restore",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,20 +80,44 @@ export function buildComposerRequest(value: string): ComposerCommandResult {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposerSegmentsRequestOptions {
|
||||
preserveExactText?: boolean;
|
||||
}
|
||||
|
||||
export function buildComposerSegmentsRequest(
|
||||
sourceSegments: readonly Segment[],
|
||||
options: ComposerSegmentsRequestOptions = {},
|
||||
): ComposerCommandResult {
|
||||
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
|
||||
if (!hasPaste) {
|
||||
const content = sourceSegments.map(segmentContent).join("");
|
||||
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
|
||||
return buildComposerRequest(content);
|
||||
}
|
||||
if (!content.trim()) {
|
||||
return { ok: false, message: "Input is empty." };
|
||||
}
|
||||
const segments = coalesceTextSegments(
|
||||
sourceSegments.flatMap((segment) =>
|
||||
segment.kind === "text"
|
||||
? parseSigilSegments(segment.content)
|
||||
: [segment]
|
||||
),
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
request: { kind: "user", content, segments },
|
||||
};
|
||||
}
|
||||
|
||||
const content = sourceSegments.map(segmentContent).join("");
|
||||
if (!content.trim()) {
|
||||
return { ok: false, message: "Input is empty." };
|
||||
}
|
||||
if (content.trimStart().startsWith(":")) {
|
||||
const leadingText = sourceSegments[0]?.kind === "text"
|
||||
? sourceSegments[0].content
|
||||
: "";
|
||||
if (leadingText.trimStart().startsWith(":")) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
|
||||
@@ -134,6 +134,70 @@ Deno.test("mixed composer request preserves Paste and parsed file-ref boundaries
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("short-paste Text preserves CRLF, trailing newline, and surrounding whitespace", () => {
|
||||
const original = " short\r\npaste\r\n ";
|
||||
const rendered = " short\npaste\n ";
|
||||
const snapshot = snapshotComposerDraft(rendered, new Map(), [{
|
||||
from: 0,
|
||||
to: rendered.length,
|
||||
rendered,
|
||||
content: original,
|
||||
}]);
|
||||
|
||||
assertEquals(snapshot.content, original);
|
||||
assertEquals(snapshot.segments, [{ kind: "text", content: original }]);
|
||||
assertEquals(snapshot.textPastes.length, 1);
|
||||
|
||||
const result = buildComposerSegmentsRequest(snapshot.segments, {
|
||||
preserveExactText: snapshot.textPastes.length > 0,
|
||||
});
|
||||
assert(result.ok);
|
||||
assertEquals(result.request, {
|
||||
kind: "user",
|
||||
content: original,
|
||||
segments: [{ kind: "text", content: original }],
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("edited short-paste provenance falls back to visible Text", () => {
|
||||
const snapshot = snapshotComposerDraft("changed", new Map(), [{
|
||||
from: 0,
|
||||
to: 5,
|
||||
rendered: "short",
|
||||
content: "short\r\n",
|
||||
}]);
|
||||
|
||||
assertEquals(snapshot.content, "changed");
|
||||
assertEquals(snapshot.segments, [{ kind: "text", content: "changed" }]);
|
||||
assertEquals(snapshot.textPastes, []);
|
||||
});
|
||||
|
||||
Deno.test("Paste content beginning with a colon remains opaque user input", () => {
|
||||
const directPaste: Segment = {
|
||||
kind: "paste",
|
||||
id: 3,
|
||||
content: ":not-a-command\r\n",
|
||||
chars: 16,
|
||||
lines: 2,
|
||||
};
|
||||
const direct = buildComposerSegmentsRequest([directPaste]);
|
||||
assert(direct.ok);
|
||||
assertEquals(direct.request, {
|
||||
kind: "user",
|
||||
content: ":not-a-command\r\n",
|
||||
segments: [directPaste],
|
||||
});
|
||||
|
||||
const afterWhitespace = buildComposerSegmentsRequest([
|
||||
{ kind: "text", content: " " },
|
||||
directPaste,
|
||||
]);
|
||||
assert(afterWhitespace.ok);
|
||||
assert(afterWhitespace.request);
|
||||
assertEquals(afterWhitespace.request.kind, "user");
|
||||
assertEquals(afterWhitespace.request.content, " :not-a-command\r\n");
|
||||
});
|
||||
|
||||
Deno.test("plain short-paste Text retains the existing composer request path", () => {
|
||||
const result = buildComposerSegmentsRequest([
|
||||
{ kind: "text", content: " short\r\npaste\r\n " },
|
||||
|
||||
@@ -17,11 +17,19 @@ export interface ComposerPasteAtom extends ComposerPaste {
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface ComposerTextPaste {
|
||||
from: number;
|
||||
to: number;
|
||||
rendered: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ComposerDraftSnapshot {
|
||||
document: string;
|
||||
content: string;
|
||||
segments: Segment[];
|
||||
pastes: ComposerPasteAtom[];
|
||||
textPastes: ComposerTextPaste[];
|
||||
}
|
||||
|
||||
export function composerPasteToken(key: number): string {
|
||||
@@ -60,32 +68,60 @@ function appendTextSegment(segments: Segment[], content: string): void {
|
||||
export function snapshotComposerDraft(
|
||||
document: string,
|
||||
registry: ReadonlyMap<number, ComposerPaste>,
|
||||
candidateTextPastes: readonly ComposerTextPaste[] = [],
|
||||
): ComposerDraftSnapshot {
|
||||
const pastes = composerPasteAtoms(document, registry);
|
||||
const textPastes = candidateTextPastes
|
||||
.filter((paste) =>
|
||||
paste.from >= 0 &&
|
||||
paste.to <= document.length &&
|
||||
document.slice(paste.from, paste.to) === paste.rendered
|
||||
)
|
||||
.sort((left, right) => left.from - right.from);
|
||||
const events = [
|
||||
...pastes.map((paste) => ({
|
||||
kind: "paste" as const,
|
||||
from: paste.from,
|
||||
to: paste.to,
|
||||
paste,
|
||||
})),
|
||||
...textPastes.map((paste) => ({
|
||||
kind: "text_paste" as const,
|
||||
from: paste.from,
|
||||
to: paste.to,
|
||||
paste,
|
||||
})),
|
||||
].sort((left, right) => left.from - right.from);
|
||||
const segments: Segment[] = [];
|
||||
let content = "";
|
||||
let cursor = 0;
|
||||
|
||||
for (const paste of pastes) {
|
||||
const text = document.slice(cursor, paste.from);
|
||||
for (const event of events) {
|
||||
if (event.from < cursor) continue;
|
||||
const text = document.slice(cursor, event.from);
|
||||
appendTextSegment(segments, text);
|
||||
content += text;
|
||||
|
||||
if (event.kind === "paste") {
|
||||
segments.push({
|
||||
kind: "paste",
|
||||
id: paste.id,
|
||||
content: paste.content,
|
||||
chars: paste.chars,
|
||||
lines: paste.lines,
|
||||
id: event.paste.id,
|
||||
content: event.paste.content,
|
||||
chars: event.paste.chars,
|
||||
lines: event.paste.lines,
|
||||
});
|
||||
content += paste.content;
|
||||
cursor = paste.to;
|
||||
} else {
|
||||
appendTextSegment(segments, event.paste.content);
|
||||
}
|
||||
content += event.paste.content;
|
||||
cursor = event.to;
|
||||
}
|
||||
|
||||
const trailingText = document.slice(cursor);
|
||||
appendTextSegment(segments, trailingText);
|
||||
content += trailingText;
|
||||
|
||||
return { document, content, segments, pastes };
|
||||
return { document, content, segments, pastes, textPastes };
|
||||
}
|
||||
|
||||
export function pasteChipLabel(paste: ComposerPaste): string {
|
||||
|
||||
@@ -599,13 +599,15 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
composerInput.includes("handleComposerPaste(event, insertPasteChip)") &&
|
||||
composerInput.includes('measurement.presentation === "chip"') &&
|
||||
composerInput.includes("registerTextPaste") &&
|
||||
composerInput.includes("EditorView.atomicRanges") &&
|
||||
composerInput.includes('key: "Backspace"') &&
|
||||
composerInput.includes('key: "Delete"') &&
|
||||
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
||||
composerInput.includes("restoreSegments(segments: readonly Segment[])") &&
|
||||
consolePage.includes("buildComposerSegmentsRequest(value.segments)") &&
|
||||
composerInput.includes("preserveExactText = false") &&
|
||||
consolePage.includes("buildComposerSegmentsRequest(value.segments, {") &&
|
||||
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
|
||||
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
|
||||
consolePage.includes("switchComposerTarget(target)") &&
|
||||
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
|
||||
|
||||
+31
-7
@@ -109,7 +109,15 @@
|
||||
cursor(): number;
|
||||
replaceRange(from: number, to: number, content: string): void;
|
||||
clear(): void;
|
||||
restoreSegments(segments: readonly Segment[]): void;
|
||||
restoreSegments(
|
||||
segments: readonly Segment[],
|
||||
preserveExactText?: boolean,
|
||||
): void;
|
||||
};
|
||||
|
||||
type ComposerDraftCache = {
|
||||
segments: Segment[];
|
||||
preserveExactText: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: ComposerDraftSnapshot = {
|
||||
@@ -117,6 +125,7 @@
|
||||
content: "",
|
||||
segments: [],
|
||||
pastes: [],
|
||||
textPastes: [],
|
||||
};
|
||||
|
||||
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
|
||||
@@ -149,7 +158,7 @@
|
||||
let composerInputElement = $state<
|
||||
(SvelteComponent & ComposerInputHandle) | null
|
||||
>(null);
|
||||
const composerDrafts = new Map<string, Segment[]>();
|
||||
const composerDrafts = new Map<string, ComposerDraftCache>();
|
||||
let activeComposerTargetKey = untrack(
|
||||
() => `${workspaceId}:${runtimeId}:${workerId}`,
|
||||
);
|
||||
@@ -549,9 +558,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function cachedComposerDraft(snapshot: ComposerDraftSnapshot): ComposerDraftCache {
|
||||
return {
|
||||
segments: [...snapshot.segments],
|
||||
preserveExactText: snapshot.textPastes.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
|
||||
draft = snapshot;
|
||||
composerDrafts.set(activeComposerTargetKey, [...snapshot.segments]);
|
||||
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
|
||||
}
|
||||
|
||||
function switchComposerTarget(target: ConsoleTarget) {
|
||||
@@ -560,14 +576,20 @@
|
||||
if (composerInputElement) {
|
||||
composerDrafts.set(
|
||||
activeComposerTargetKey,
|
||||
[...composerInputElement.snapshot().segments],
|
||||
cachedComposerDraft(composerInputElement.snapshot()),
|
||||
);
|
||||
}
|
||||
activeComposerTargetKey = nextKey;
|
||||
const restored = composerDrafts.get(nextKey) ?? [];
|
||||
const restored = composerDrafts.get(nextKey) ?? {
|
||||
segments: [],
|
||||
preserveExactText: false,
|
||||
};
|
||||
void tick().then(() => {
|
||||
if (activeComposerTargetKey !== nextKey) return;
|
||||
composerInputElement?.restoreSegments(restored);
|
||||
composerInputElement?.restoreSegments(
|
||||
restored.segments,
|
||||
restored.preserveExactText,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -580,7 +602,9 @@
|
||||
}
|
||||
|
||||
async function submitDraft(value: ComposerDraftSnapshot) {
|
||||
const command = buildComposerSegmentsRequest(value.segments);
|
||||
const command = buildComposerSegmentsRequest(value.segments, {
|
||||
preserveExactText: value.textPastes.length > 0,
|
||||
});
|
||||
if (!command.ok) {
|
||||
composerNotice = null;
|
||||
sendError = command.message;
|
||||
|
||||
Reference in New Issue
Block a user