feat: merge Composer paste handling from develop
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
@@ -16,6 +16,7 @@
|
||||
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
||||
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
||||
"@codemirror/commands": "npm:@codemirror/commands@6.9.0",
|
||||
"@codemirror/language": "npm:@codemirror/language@6.12.4",
|
||||
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
||||
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
||||
|
||||
Generated
+11
@@ -4,6 +4,7 @@
|
||||
"jsr:@std/assert@*": "1.0.19",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.14",
|
||||
"npm:@codemirror/autocomplete@6.20.0": "6.20.0",
|
||||
"npm:@codemirror/commands@6.9.0": "6.9.0",
|
||||
"npm:@codemirror/language@6.12.4": "6.12.4",
|
||||
"npm:@codemirror/state@6.7.1": "6.7.1",
|
||||
"npm:@codemirror/view@6.43.8": "6.43.8",
|
||||
@@ -47,6 +48,15 @@
|
||||
"@lezer/common"
|
||||
]
|
||||
},
|
||||
"@codemirror/commands@6.9.0": {
|
||||
"integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==",
|
||||
"dependencies": [
|
||||
"@codemirror/language",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common"
|
||||
]
|
||||
},
|
||||
"@codemirror/language@6.12.4": {
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"dependencies": [
|
||||
@@ -1012,6 +1022,7 @@
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"npm:@codemirror/autocomplete@6.20.0",
|
||||
"npm:@codemirror/commands@6.9.0",
|
||||
"npm:@codemirror/language@6.12.4",
|
||||
"npm:@codemirror/state@6.7.1",
|
||||
"npm:@codemirror/view@6.43.8",
|
||||
|
||||
@@ -133,7 +133,18 @@ message: string,
|
||||
*/
|
||||
timestamp_ms: number, };
|
||||
|
||||
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
|
||||
export type PasteArtifactMediaType = "text_plain_utf8";
|
||||
|
||||
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
|
||||
|
||||
export type PasteArtifactRef = { artifact_id: string, created_at_ms: number, media_type: PasteArtifactMediaType,
|
||||
/**
|
||||
* Availability observed when this immutable reference was committed.
|
||||
* Reads revalidate storage and integrity rather than trusting this field.
|
||||
*/
|
||||
availability: PasteArtifactAvailability, byte_len: number, char_count: number, line_count: number, sha256: string, source_entry_id: string, };
|
||||
|
||||
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
|
||||
|
||||
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Compartment,
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
Prec,
|
||||
StateEffect,
|
||||
StateField,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
keymap,
|
||||
WidgetType,
|
||||
type DecorationSet,
|
||||
} from "@codemirror/view";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
invertedEffects,
|
||||
isolateHistory,
|
||||
} from "@codemirror/commands";
|
||||
import { onMount } from "svelte";
|
||||
import type { Segment } from "$lib/generated/protocol.ts";
|
||||
import {
|
||||
measureComposerPaste,
|
||||
type ComposerPasteMeasurement,
|
||||
} from "$lib/workspace/console/composer-paste.ts";
|
||||
import {
|
||||
composerDeletionRange,
|
||||
composerPasteAtoms,
|
||||
composerPasteToken,
|
||||
pasteChipLabel,
|
||||
snapshotComposerDraft,
|
||||
type ComposerDraftSnapshot,
|
||||
type ComposerPaste,
|
||||
type ComposerTextPaste,
|
||||
} from "$lib/workspace/console/composer-draft.ts";
|
||||
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaKeyShortcuts?: string;
|
||||
onchange?: (snapshot: ComposerDraftSnapshot) => void;
|
||||
onkeydown?: (event: KeyboardEvent) => void;
|
||||
onsubmit?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
ariaLabel = "Message",
|
||||
ariaKeyShortcuts = "Meta+Enter Control+Enter",
|
||||
onchange,
|
||||
onkeydown,
|
||||
onsubmit,
|
||||
}: Props = $props();
|
||||
|
||||
let mountElement: HTMLDivElement;
|
||||
let view: EditorView | null = null;
|
||||
let nextPasteId = 1;
|
||||
let nextPasteKey = 1;
|
||||
const editable = new Compartment();
|
||||
|
||||
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
|
||||
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
|
||||
create: () => new Map(),
|
||||
update(registry, transaction) {
|
||||
const additions = transaction.effects.filter((effect) => effect.is(registerPaste));
|
||||
if (additions.length === 0) return registry;
|
||||
const next = new Map(registry);
|
||||
for (const addition of additions) {
|
||||
next.set(addition.value.key, addition.value.paste);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
constructor(paste: ComposerPaste) {
|
||||
super();
|
||||
this.paste = paste;
|
||||
}
|
||||
|
||||
override eq(other: PasteChipWidget): boolean {
|
||||
return other.paste.id === this.paste.id &&
|
||||
other.paste.content === this.paste.content &&
|
||||
other.paste.chars === this.paste.chars &&
|
||||
other.paste.lines === this.paste.lines;
|
||||
}
|
||||
|
||||
override toDOM(): HTMLElement {
|
||||
const chip = document.createElement("span");
|
||||
const label = pasteChipLabel(this.paste);
|
||||
chip.className = "composer-paste-chip";
|
||||
chip.textContent = label;
|
||||
chip.title = label;
|
||||
chip.setAttribute("role", "note");
|
||||
chip.setAttribute("aria-label", label);
|
||||
return chip;
|
||||
}
|
||||
|
||||
override ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pasteDecorations(state: EditorState): DecorationSet {
|
||||
const registry = state.field(pasteRegistry);
|
||||
return Decoration.set(
|
||||
composerPasteAtoms(state.doc.toString(), registry).map((paste) =>
|
||||
Decoration.replace({
|
||||
widget: new PasteChipWidget(paste),
|
||||
inclusive: false,
|
||||
}).range(paste.from, paste.to)
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
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: [], textPastes: [] };
|
||||
}
|
||||
return snapshotComposerDraft(
|
||||
state.doc.toString(),
|
||||
state.field(pasteRegistry),
|
||||
state.field(textPasteState),
|
||||
);
|
||||
}
|
||||
|
||||
function emitChange(): void {
|
||||
onchange?.(currentSnapshot());
|
||||
}
|
||||
|
||||
function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void {
|
||||
if (!view) return;
|
||||
const selection = view.state.selection.main;
|
||||
const key = nextPasteKey++;
|
||||
const paste: ComposerPaste = {
|
||||
id: nextPasteId++,
|
||||
content,
|
||||
chars: measurement.charCount,
|
||||
lines: measurement.logicalLineCount,
|
||||
};
|
||||
const token = composerPasteToken(key);
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: token },
|
||||
selection: EditorSelection.cursor(selection.from + token.length),
|
||||
effects: registerPaste.of({ key, paste }),
|
||||
annotations: isolateHistory.of("full"),
|
||||
userEvent: "input.paste",
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
if (disabled || view?.state.readOnly) return false;
|
||||
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;
|
||||
const document = state.doc.sliceString(selection.from, selection.to);
|
||||
const registry = state.field(pasteRegistry);
|
||||
const selectedRegistry = new Map<number, ComposerPaste>();
|
||||
for (const atom of composerPasteAtoms(state.doc.toString(), registry)) {
|
||||
if (atom.from >= selection.from && atom.to <= selection.to) {
|
||||
selectedRegistry.set(atom.key, atom);
|
||||
}
|
||||
}
|
||||
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(
|
||||
currentView: EditorView,
|
||||
direction: "backward" | "forward",
|
||||
): boolean {
|
||||
if (currentView.state.readOnly) return false;
|
||||
const selection = currentView.state.selection.main;
|
||||
const pastes = composerPasteAtoms(
|
||||
currentView.state.doc.toString(),
|
||||
currentView.state.field(pasteRegistry),
|
||||
);
|
||||
const deletion = composerDeletionRange(selection, pastes, direction);
|
||||
if (!deletion) return false;
|
||||
currentView.dispatch({
|
||||
changes: deletion,
|
||||
selection: EditorSelection.cursor(deletion.from),
|
||||
annotations: isolateHistory.of("full"),
|
||||
userEvent: "delete",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
view = new EditorView({
|
||||
parent: mountElement,
|
||||
state: EditorState.create({
|
||||
extensions: [
|
||||
history(),
|
||||
Prec.highest(keymap.of([
|
||||
{
|
||||
key: "Mod-z",
|
||||
run: (currentView) => currentView.state.readOnly,
|
||||
},
|
||||
{
|
||||
key: "Mod-Shift-z",
|
||||
run: (currentView) => currentView.state.readOnly,
|
||||
},
|
||||
{
|
||||
key: "Mod-y",
|
||||
run: (currentView) => currentView.state.readOnly,
|
||||
},
|
||||
{
|
||||
key: "Backspace",
|
||||
run: (currentView) =>
|
||||
deleteAdjacentPasteFromView(currentView, "backward"),
|
||||
},
|
||||
{
|
||||
key: "Delete",
|
||||
run: (currentView) =>
|
||||
deleteAdjacentPasteFromView(currentView, "forward"),
|
||||
},
|
||||
])),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
pasteChips,
|
||||
editable.of([
|
||||
EditorView.editable.of(!disabled),
|
||||
EditorState.readOnly.of(disabled),
|
||||
]),
|
||||
EditorState.allowMultipleSelections.of(false),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({
|
||||
"aria-label": ariaLabel,
|
||||
"aria-keyshortcuts": ariaKeyShortcuts,
|
||||
"aria-multiline": "true",
|
||||
role: "textbox",
|
||||
spellcheck: "true",
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
|
||||
emitChange();
|
||||
}
|
||||
}),
|
||||
Prec.high(EditorView.domEventHandlers({
|
||||
paste(event) {
|
||||
return handlePasteEvent(event);
|
||||
},
|
||||
copy(event, currentView) {
|
||||
const content = selectedClipboardContent(currentView.state);
|
||||
if (content === null || !event.clipboardData) return false;
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", content);
|
||||
return true;
|
||||
},
|
||||
cut(event, currentView) {
|
||||
if (disabled) return false;
|
||||
const content = selectedClipboardContent(currentView.state);
|
||||
if (content === null || !event.clipboardData) return false;
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", content);
|
||||
const selection = currentView.state.selection.main;
|
||||
currentView.dispatch({
|
||||
changes: { from: selection.from, to: selection.to },
|
||||
selection: EditorSelection.cursor(selection.from),
|
||||
userEvent: "delete.cut",
|
||||
});
|
||||
return true;
|
||||
},
|
||||
keydown(event) {
|
||||
onkeydown?.(event);
|
||||
if (event.defaultPrevented) return true;
|
||||
if (
|
||||
shouldSubmitChatKey(event, {
|
||||
mode: "mod-enter",
|
||||
modKey: "auto",
|
||||
enabled: !disabled,
|
||||
})
|
||||
) {
|
||||
event.preventDefault();
|
||||
onsubmit?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
})),
|
||||
EditorView.theme({
|
||||
"&": { backgroundColor: "transparent" },
|
||||
".cm-scroller": { fontFamily: "inherit" },
|
||||
".cm-content": { caretColor: "var(--text-strong)" },
|
||||
"&.cm-focused": { outline: "none" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
emitChange();
|
||||
|
||||
return () => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const isDisabled = disabled;
|
||||
view?.dispatch({
|
||||
effects: editable.reconfigure([
|
||||
EditorView.editable.of(!isDisabled),
|
||||
EditorState.readOnly.of(isDisabled),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
export function snapshot(): ComposerDraftSnapshot {
|
||||
return currentSnapshot();
|
||||
}
|
||||
|
||||
export function focus(): void {
|
||||
view?.focus();
|
||||
}
|
||||
|
||||
export function containsTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Node && Boolean(view?.dom.contains(target));
|
||||
}
|
||||
|
||||
export function cursor(): number {
|
||||
return view?.state.selection.main.head ?? 0;
|
||||
}
|
||||
|
||||
export function replaceRange(from: number, to: number, content: string): void {
|
||||
if (!view || view.state.readOnly) return;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: content },
|
||||
selection: EditorSelection.cursor(from + content.length),
|
||||
userEvent: "input.complete",
|
||||
});
|
||||
}
|
||||
|
||||
export function clear(): void {
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: "" },
|
||||
selection: EditorSelection.cursor(0),
|
||||
userEvent: "input",
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreSegments(
|
||||
segments: readonly Segment[],
|
||||
preserveExactText = false,
|
||||
): void {
|
||||
if (!view) return;
|
||||
let document = "";
|
||||
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
|
||||
const textEffects: StateEffect<ComposerTextPaste>[] = [];
|
||||
let highestPasteId = nextPasteId - 1;
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === "text") {
|
||||
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 = {
|
||||
id: segment.id,
|
||||
content: segment.content,
|
||||
chars: segment.chars,
|
||||
lines: segment.lines,
|
||||
};
|
||||
highestPasteId = Math.max(highestPasteId, paste.id);
|
||||
document += composerPasteToken(key);
|
||||
pasteEffects.push(registerPaste.of({ key, paste }));
|
||||
} else if (segment.kind === "file_ref") {
|
||||
document += `@${segment.path}`;
|
||||
}
|
||||
}
|
||||
nextPasteId = highestPasteId + 1;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: document },
|
||||
selection: EditorSelection.cursor(document.length),
|
||||
effects: [...pasteEffects, ...textEffects],
|
||||
userEvent: "input.restore",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="composer-input" class:disabled bind:this={mountElement}></div>
|
||||
|
||||
<style>
|
||||
.composer-input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: var(--text-strong);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.composer-input.disabled {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.composer-input :global(.cm-editor) {
|
||||
min-height: 5.35rem;
|
||||
max-height: 10rem;
|
||||
}
|
||||
|
||||
.composer-input :global(.cm-scroller) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.composer-input :global(.cm-content) {
|
||||
min-height: 5.35rem;
|
||||
padding: 0.55rem 3.4rem 3rem 0.65rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.composer-input :global(.cm-line) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.composer-input :global(.composer-paste-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: min(26rem, 70vw);
|
||||
margin: 0 0.15rem;
|
||||
padding: 0.08rem 0.42rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-subtle));
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -80,6 +80,85 @@ 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." };
|
||||
}
|
||||
const leadingText = sourceSegments[0]?.kind === "text"
|
||||
? sourceSegments[0].content
|
||||
: "";
|
||||
if (leadingText.trimStart().startsWith(":")) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"Commands cannot include a paste chip. Remove the chip or send it as a message.",
|
||||
};
|
||||
}
|
||||
|
||||
const segments: Segment[] = [];
|
||||
for (const segment of sourceSegments) {
|
||||
if (segment.kind === "text") {
|
||||
segments.push(...parseSigilSegments(segment.content));
|
||||
} else {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
request: {
|
||||
kind: "user",
|
||||
content,
|
||||
segments: coalesceTextSegments(segments),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function segmentContent(segment: Segment): string {
|
||||
switch (segment.kind) {
|
||||
case "text":
|
||||
case "paste":
|
||||
return segment.content;
|
||||
case "file_ref":
|
||||
return `@${segment.path}`;
|
||||
case "flow":
|
||||
return segment.selector;
|
||||
case "paste_artifact":
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildColonCommand(commandLine: string): ComposerCommandResult {
|
||||
const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean);
|
||||
if (!name) {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Segment } from "$lib/generated/protocol.ts";
|
||||
import {
|
||||
composerDeletionRange,
|
||||
type ComposerPaste,
|
||||
composerPasteToken,
|
||||
pasteChipLabel,
|
||||
snapshotComposerDraft,
|
||||
} from "$lib/workspace/console/composer-draft.ts";
|
||||
import { buildComposerSegmentsRequest } from "$lib/workspace/console/composer-command.ts";
|
||||
import { measureComposerPaste } from "$lib/workspace/console/composer-paste.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assert(
|
||||
condition: unknown,
|
||||
message = "assertion failed",
|
||||
): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
function paste(id: number, content: string): ComposerPaste {
|
||||
const measurement = measureComposerPaste(content);
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
chars: measurement.charCount,
|
||||
lines: measurement.logicalLineCount,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("composer draft preserves mixed Text and Paste order exactly", () => {
|
||||
const unicodeCrlf = "🙂界\r\nsecond\r\n";
|
||||
const trailingNewline = `${"x".repeat(51)}\n`;
|
||||
const registry = new Map<number, ComposerPaste>([
|
||||
[11, paste(1, unicodeCrlf)],
|
||||
[12, paste(2, trailingNewline)],
|
||||
]);
|
||||
const document = `before ${composerPasteToken(11)} middle ${
|
||||
composerPasteToken(12)
|
||||
} after`;
|
||||
|
||||
const snapshot = snapshotComposerDraft(document, registry);
|
||||
|
||||
assertEquals(
|
||||
snapshot.content,
|
||||
`before ${unicodeCrlf} middle ${trailingNewline} after`,
|
||||
);
|
||||
assertEquals(snapshot.segments, [
|
||||
{ kind: "text", content: "before " },
|
||||
{
|
||||
kind: "paste",
|
||||
id: 1,
|
||||
content: unicodeCrlf,
|
||||
chars: 12,
|
||||
lines: 3,
|
||||
},
|
||||
{ kind: "text", content: " middle " },
|
||||
{
|
||||
kind: "paste",
|
||||
id: 2,
|
||||
content: trailingNewline,
|
||||
chars: 52,
|
||||
lines: 2,
|
||||
},
|
||||
{ kind: "text", content: " after" },
|
||||
]);
|
||||
assertEquals(snapshot.pastes.map((entry) => entry.key), [11, 12]);
|
||||
});
|
||||
|
||||
Deno.test("selection deletion covers mixed Text and every selected paste chip", () => {
|
||||
const pastes = [
|
||||
{ ...paste(1, "first"), key: 10, from: 2, to: 5 },
|
||||
{ ...paste(2, "second"), key: 11, from: 8, to: 11 },
|
||||
];
|
||||
|
||||
assertEquals(
|
||||
composerDeletionRange({ from: 1, to: 12, head: 12 }, pastes, "backward"),
|
||||
{ from: 1, to: 12 },
|
||||
);
|
||||
assertEquals(
|
||||
composerDeletionRange({ from: 2, to: 11, head: 2 }, pastes, "forward"),
|
||||
{ from: 2, to: 11 },
|
||||
);
|
||||
assertEquals(
|
||||
composerDeletionRange({ from: 5, to: 5, head: 5 }, pastes, "backward"),
|
||||
{ from: 2, to: 5 },
|
||||
);
|
||||
assertEquals(
|
||||
composerDeletionRange({ from: 8, to: 8, head: 8 }, pastes, "forward"),
|
||||
{ from: 8, to: 11 },
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("composer paste chip label is compact and accessible", () => {
|
||||
assertEquals(
|
||||
pasteChipLabel({ id: 4, content: "payload", chars: 7, lines: 1 }),
|
||||
"Clipboard #4 · 7 chars · 1 line",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("typed composer restoration retains Paste ids and metadata", () => {
|
||||
const original: Segment[] = [
|
||||
{ kind: "text", content: "prefix\n" },
|
||||
{
|
||||
kind: "paste",
|
||||
id: 9,
|
||||
content: "alpha\r\nbeta\r\n",
|
||||
chars: 13,
|
||||
lines: 3,
|
||||
},
|
||||
{ kind: "text", content: "\nsuffix" },
|
||||
];
|
||||
const registry = new Map<number, ComposerPaste>([
|
||||
[31, original[1] as Extract<Segment, { kind: "paste" }>],
|
||||
]);
|
||||
const restored = snapshotComposerDraft(
|
||||
`prefix\n${composerPasteToken(31)}\nsuffix`,
|
||||
registry,
|
||||
);
|
||||
|
||||
assertEquals(restored.segments, original);
|
||||
});
|
||||
|
||||
Deno.test("mixed composer request preserves Paste and parsed file-ref boundaries", () => {
|
||||
const segments: Segment[] = [
|
||||
{ kind: "text", content: "inspect @src/main.rs then " },
|
||||
{
|
||||
kind: "paste",
|
||||
id: 2,
|
||||
content: "a\r\nb\r\n",
|
||||
chars: 6,
|
||||
lines: 3,
|
||||
},
|
||||
{ kind: "text", content: " exactly" },
|
||||
];
|
||||
|
||||
const result = buildComposerSegmentsRequest(segments);
|
||||
assert(result.ok);
|
||||
assertEquals(result.request, {
|
||||
kind: "user",
|
||||
content: "inspect @src/main.rs then a\r\nb\r\n exactly",
|
||||
segments: [
|
||||
{ kind: "text", content: "inspect " },
|
||||
{ kind: "file_ref", path: "src/main.rs" },
|
||||
{ kind: "text", content: " then " },
|
||||
segments[1],
|
||||
segments[2],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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 " },
|
||||
]);
|
||||
assert(result.ok);
|
||||
assertEquals(result.request, {
|
||||
kind: "user",
|
||||
content: "short\r\npaste",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Segment } from "$lib/generated/protocol.ts";
|
||||
|
||||
const PASTE_TOKEN_PREFIX = "\uFFF9";
|
||||
const PASTE_TOKEN_SUFFIX = "\uFFFB";
|
||||
const PASTE_TOKEN_PATTERN = /\uFFF9(\d+)\uFFFB/g;
|
||||
|
||||
export interface ComposerPaste {
|
||||
id: number;
|
||||
content: string;
|
||||
chars: number;
|
||||
lines: number;
|
||||
}
|
||||
|
||||
export interface ComposerPasteAtom extends ComposerPaste {
|
||||
key: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface ComposerTextPaste {
|
||||
from: number;
|
||||
to: number;
|
||||
rendered: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ComposerSelection {
|
||||
from: number;
|
||||
to: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
export function composerDeletionRange(
|
||||
selection: ComposerSelection,
|
||||
pastes: readonly ComposerPasteAtom[],
|
||||
direction: "backward" | "forward",
|
||||
): { from: number; to: number } | null {
|
||||
if (selection.from !== selection.to) {
|
||||
return { from: selection.from, to: selection.to };
|
||||
}
|
||||
const paste = direction === "backward"
|
||||
? pastes.find((candidate) => candidate.to === selection.head)
|
||||
: pastes.find((candidate) => candidate.from === selection.head);
|
||||
return paste ? { from: paste.from, to: paste.to } : null;
|
||||
}
|
||||
|
||||
export interface ComposerDraftSnapshot {
|
||||
document: string;
|
||||
content: string;
|
||||
segments: Segment[];
|
||||
pastes: ComposerPasteAtom[];
|
||||
textPastes: ComposerTextPaste[];
|
||||
}
|
||||
|
||||
export function composerPasteToken(key: number): string {
|
||||
return `${PASTE_TOKEN_PREFIX}${key}${PASTE_TOKEN_SUFFIX}`;
|
||||
}
|
||||
|
||||
export function composerPasteAtoms(
|
||||
document: string,
|
||||
registry: ReadonlyMap<number, ComposerPaste>,
|
||||
): ComposerPasteAtom[] {
|
||||
const atoms: ComposerPasteAtom[] = [];
|
||||
for (const match of document.matchAll(PASTE_TOKEN_PATTERN)) {
|
||||
const key = Number(match[1]);
|
||||
const paste = registry.get(key);
|
||||
if (!paste || match.index === undefined) continue;
|
||||
atoms.push({
|
||||
...paste,
|
||||
key,
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
return atoms;
|
||||
}
|
||||
|
||||
function appendTextSegment(segments: Segment[], content: string): void {
|
||||
if (content.length === 0) return;
|
||||
const previous = segments.at(-1);
|
||||
if (previous?.kind === "text") {
|
||||
previous.content += content;
|
||||
} else {
|
||||
segments.push({ kind: "text", content });
|
||||
}
|
||||
}
|
||||
|
||||
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 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: event.paste.id,
|
||||
content: event.paste.content,
|
||||
chars: event.paste.chars,
|
||||
lines: event.paste.lines,
|
||||
});
|
||||
} 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, textPastes };
|
||||
}
|
||||
|
||||
export function pasteChipLabel(paste: ComposerPaste): string {
|
||||
const chars = paste.chars === 1 ? "char" : "chars";
|
||||
const lines = paste.lines === 1 ? "line" : "lines";
|
||||
return `Clipboard #${paste.id} · ${paste.chars} ${chars} · ${paste.lines} ${lines}`;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export const MAX_PLAIN_TEXT_PASTE_CHARS = 50;
|
||||
export const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES = 3;
|
||||
|
||||
export type ComposerPastePresentation = "text" | "chip";
|
||||
|
||||
export interface ComposerPasteMeasurement {
|
||||
charCount: number;
|
||||
logicalLineCount: number;
|
||||
presentation: ComposerPastePresentation;
|
||||
}
|
||||
|
||||
export interface ComposerPasteEvent {
|
||||
clipboardData: { getData(format: string): string } | null;
|
||||
preventDefault(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count user-visible Unicode scalar values rather than UTF-16 code units.
|
||||
* JavaScript string iteration combines a valid surrogate pair into one value.
|
||||
*/
|
||||
export function unicodeScalarCount(content: string): number {
|
||||
let count = 0;
|
||||
for (const _value of content) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty content has zero logical lines. Otherwise each LF, lone CR, or CRLF
|
||||
* advances one line; CRLF is one break rather than two.
|
||||
*/
|
||||
export function logicalLineCount(content: string): number {
|
||||
if (content.length === 0) return 0;
|
||||
|
||||
let count = 1;
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const codeUnit = content.charCodeAt(index);
|
||||
if (codeUnit === 0x0d) {
|
||||
if (content.charCodeAt(index + 1) === 0x0a) index += 1;
|
||||
count += 1;
|
||||
} else if (codeUnit === 0x0a) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function measureComposerPaste(
|
||||
content: string,
|
||||
): ComposerPasteMeasurement {
|
||||
const charCount = unicodeScalarCount(content);
|
||||
const logicalLineCountValue = logicalLineCount(content);
|
||||
const presentation = charCount <= MAX_PLAIN_TEXT_PASTE_CHARS &&
|
||||
logicalLineCountValue <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||
? "text"
|
||||
: "chip";
|
||||
|
||||
return {
|
||||
charCount,
|
||||
logicalLineCount: logicalLineCountValue,
|
||||
presentation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Browser paste without disrupting native short-text editing.
|
||||
*
|
||||
* Returning false means the caller must leave the event untouched, preserving
|
||||
* the Browser's cursor, selection replacement, and undo behavior. A chip paste
|
||||
* is consumed exactly once and handed to the compact-paste implementation.
|
||||
*/
|
||||
export function handleComposerPaste(
|
||||
event: ComposerPasteEvent,
|
||||
insertCompactPaste: (
|
||||
content: string,
|
||||
measurement: ComposerPasteMeasurement,
|
||||
) => void,
|
||||
): boolean {
|
||||
if (!event.clipboardData) return false;
|
||||
|
||||
const content = event.clipboardData.getData("text/plain");
|
||||
const measurement = measureComposerPaste(content);
|
||||
if (measurement.presentation === "text") return false;
|
||||
|
||||
event.preventDefault();
|
||||
insertCompactPaste(content, measurement);
|
||||
return true;
|
||||
}
|
||||
@@ -137,6 +137,30 @@ function snapshotEvent(cwd: string, entries: unknown[] = []): Event {
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("large paste segments project compact artifact metadata", () => {
|
||||
const body = "secret pasted body";
|
||||
const text = segmentsToText([{
|
||||
kind: "paste_artifact",
|
||||
artifact: {
|
||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b2",
|
||||
created_at_ms: 1_700_000_000_000,
|
||||
media_type: "text_plain_utf8",
|
||||
availability: "available",
|
||||
byte_len: 65536,
|
||||
char_count: 65530,
|
||||
line_count: 200,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: "entry-1",
|
||||
},
|
||||
}]);
|
||||
assert(text.includes("019ca7c8-57b6-7f05-8edf-524147aba7b2"), "artifact id is visible");
|
||||
assert(text.includes("65536 bytes"), "bounded size metadata is visible");
|
||||
assert(text.includes("text_plain_utf8"), "media type is visible");
|
||||
assert(text.includes("available"), "availability is visible");
|
||||
assert(text.includes("1700000000000"), "creation time is visible");
|
||||
assert(!text.includes(body), "artifact body is not projected");
|
||||
});
|
||||
|
||||
Deno.test("console routing projects live errors but not completion replies", () => {
|
||||
const errorEvent = {
|
||||
event: "error",
|
||||
|
||||
@@ -1069,6 +1069,8 @@ export function segmentsToText(segments: Segment[]): string {
|
||||
case "paste":
|
||||
return segment.content ||
|
||||
`[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`;
|
||||
case "paste_artifact":
|
||||
return `[Large paste artifact ${segment.artifact.artifact_id}: ${segment.artifact.byte_len} bytes, ${segment.artifact.media_type}, ${segment.artifact.availability}, created ${segment.artifact.created_at_ms} ms, sha256 ${segment.artifact.sha256}]`;
|
||||
case "file_ref":
|
||||
return `@file ${segment.path}`;
|
||||
case "unknown":
|
||||
|
||||
@@ -554,18 +554,22 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console composer fits to content without manual resize", async () => {
|
||||
Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const composerInput = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
|
||||
consolePage.includes("<ComposerInput") &&
|
||||
consolePage.includes('<div class="composer-input-shell">') &&
|
||||
!consolePage.includes("handleComposerShellClick") &&
|
||||
consolePage.includes("bind:this={composerTextareaElement}") &&
|
||||
consolePage.includes("bind:this={composerInputElement}") &&
|
||||
consolePage.includes("onchange={handleComposerChange}") &&
|
||||
consolePage.includes(
|
||||
'event.key === "PageUp" || event.key === "PageDown"',
|
||||
) &&
|
||||
@@ -577,10 +581,46 @@ Deno.test("Worker Console composer fits to content without manual resize", async
|
||||
consolePage.includes("pointer-events: auto") &&
|
||||
consolePage.includes('class="composer-send-icon"') &&
|
||||
consolePage.includes('d="M8 6L12 2L16 6"') &&
|
||||
consolePage.includes(".console-composer textarea") &&
|
||||
consolePage.includes("resize: none") &&
|
||||
consolePage.includes("overflow-y: hidden"),
|
||||
"Console composer should autosize to content, cap at ten rows, wrap input and icon send button, and disable manual resize",
|
||||
composerInput.includes("max-height: 10rem") &&
|
||||
composerInput.includes("EditorView.lineWrapping") &&
|
||||
composerInput.includes("overflow-y: auto"),
|
||||
"Console composer should use the bounded chip-capable editor with wrapping, page scrolling, and the icon send button",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const composerInput = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
composerInput.includes('measurement.presentation === "chip"') &&
|
||||
composerInput.includes("registerTextPaste") &&
|
||||
composerInput.includes("EditorView.atomicRanges") &&
|
||||
composerInput.includes('key: "Backspace"') &&
|
||||
composerInput.includes('key: "Delete"') &&
|
||||
composerInput.includes(
|
||||
"composerDeletionRange(selection, pastes, direction)",
|
||||
) &&
|
||||
composerInput.includes("EditorState.readOnly.of(isDisabled)") &&
|
||||
composerInput.includes('key: "Mod-z"') &&
|
||||
composerInput.includes("if (!view || view.state.readOnly) return") &&
|
||||
composerInput.includes("if (currentView.state.readOnly) return false") &&
|
||||
consolePage.includes("activeComposerTargetKey !== targetKey") &&
|
||||
consolePage.includes("if (!composerEditable) return") &&
|
||||
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
||||
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")'),
|
||||
"Paste chips should use shared threshold classification, atomic keyboard behavior, accessible labels, typed restore, and per-Worker draft authority",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -742,7 +782,7 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
||||
'const composerEditable = $derived(protocolState === "open" && !sending);',
|
||||
) &&
|
||||
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
|
||||
consolePage.includes("enabled: canSubmitDraft") &&
|
||||
consolePage.includes("onsubmit={handleComposerSubmit}") &&
|
||||
consolePage.includes("disabled={!composerEditable}") &&
|
||||
consolePage.includes("class:stop={workerRunning}") &&
|
||||
consolePage.includes('"Stop Worker"') &&
|
||||
|
||||
+115
-61
@@ -1,22 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from "svelte";
|
||||
import { tick, untrack, type SvelteComponent } from "svelte";
|
||||
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
|
||||
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
||||
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
|
||||
import { chatSubmit } from "$lib/workspace/console/chat-submit";
|
||||
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
|
||||
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
|
||||
import {
|
||||
buildComposerRequest,
|
||||
buildComposerSegmentsRequest,
|
||||
type WorkerConsoleInputRequest,
|
||||
} from "$lib/workspace/console/composer-command";
|
||||
import {
|
||||
applyCompletion,
|
||||
completionTokenAt,
|
||||
localCommandCompletions,
|
||||
type ComposerCompletionEntry,
|
||||
type ComposerCompletionToken,
|
||||
} from "$lib/workspace/console/composer-completion";
|
||||
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
|
||||
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
|
||||
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
|
||||
import {
|
||||
consoleWorkerViews,
|
||||
@@ -103,7 +102,33 @@
|
||||
untrack(() => data.worker?.state ?? null),
|
||||
);
|
||||
let workerError = $state<string | null>(untrack(() => data.workerError));
|
||||
let draft = $state("");
|
||||
type ComposerInputHandle = {
|
||||
snapshot(): ComposerDraftSnapshot;
|
||||
focus(): void;
|
||||
containsTarget(target: EventTarget | null): boolean;
|
||||
cursor(): number;
|
||||
replaceRange(from: number, to: number, content: string): void;
|
||||
clear(): void;
|
||||
restoreSegments(
|
||||
segments: readonly Segment[],
|
||||
preserveExactText?: boolean,
|
||||
): void;
|
||||
};
|
||||
|
||||
type ComposerDraftCache = {
|
||||
segments: Segment[];
|
||||
preserveExactText: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: ComposerDraftSnapshot = {
|
||||
document: "",
|
||||
content: "",
|
||||
segments: [],
|
||||
pastes: [],
|
||||
textPastes: [],
|
||||
};
|
||||
|
||||
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
|
||||
let completionEntries = $state<ComposerCompletionEntry[]>([]);
|
||||
let completionToken = $state<ComposerCompletionToken | null>(null);
|
||||
let completionBusy = $state(false);
|
||||
@@ -130,7 +155,13 @@
|
||||
let timelineOpen = $state(false);
|
||||
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
||||
let consoleBodyElement: HTMLElement | null = null;
|
||||
let composerTextareaElement: HTMLTextAreaElement | null = null;
|
||||
let composerInputElement = $state<
|
||||
(SvelteComponent & ComposerInputHandle) | null
|
||||
>(null);
|
||||
const composerDrafts = new Map<string, ComposerDraftCache>();
|
||||
let activeComposerTargetKey = untrack(
|
||||
() => `${workspaceId}:${runtimeId}:${workerId}`,
|
||||
);
|
||||
let timelineRailDragCleanup: (() => void) | null = null;
|
||||
let autoFollowConsole = $state(true);
|
||||
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
|
||||
@@ -196,7 +227,7 @@
|
||||
const inputReady = $derived(workerState === "idle");
|
||||
const composerEditable = $derived(protocolState === "open" && !sending);
|
||||
const canSubmitDraft = $derived(inputReady && composerEditable);
|
||||
const canSend = $derived(canSubmitDraft && draft.trim().length > 0);
|
||||
const canSend = $derived(canSubmitDraft && draft.content.trim().length > 0);
|
||||
const canStopFromComposer = $derived(workerRunning && composerEditable);
|
||||
const composerSubmitDisabled = $derived(
|
||||
workerRunning ? !canStopFromComposer : !canSend,
|
||||
@@ -321,14 +352,14 @@
|
||||
scheduleObservationFlush();
|
||||
}
|
||||
|
||||
async function applyComposerCompletion(event: KeyboardEvent) {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLTextAreaElement)) {
|
||||
return;
|
||||
}
|
||||
async function applyComposerCompletion() {
|
||||
if (!composerEditable || !composerInputElement) return;
|
||||
const input = composerInputElement;
|
||||
const targetKey = activeComposerTargetKey;
|
||||
const document = draft.document;
|
||||
const token = completionTokenAt(
|
||||
draft,
|
||||
target.selectionStart ?? draft.length,
|
||||
document,
|
||||
input.cursor(),
|
||||
);
|
||||
completionToken = token;
|
||||
completionError = null;
|
||||
@@ -340,15 +371,24 @@
|
||||
completionBusy = true;
|
||||
try {
|
||||
const entries = await resolveCompletionEntries(token);
|
||||
if (
|
||||
!composerEditable ||
|
||||
composerInputElement !== input ||
|
||||
activeComposerTargetKey !== targetKey ||
|
||||
draft.document !== document
|
||||
) {
|
||||
return;
|
||||
}
|
||||
completionEntries = entries;
|
||||
if (entries.length === 0) {
|
||||
completionError = `No completions for ${token.sigil}${token.prefix}`;
|
||||
return;
|
||||
}
|
||||
const applied = applyCompletion(draft, token, entries[0]);
|
||||
draft = applied.value;
|
||||
await tick();
|
||||
target.setSelectionRange(applied.cursor, applied.cursor);
|
||||
input.replaceRange(
|
||||
token.start,
|
||||
token.end,
|
||||
`${entries[0].value} `,
|
||||
);
|
||||
composerNotice =
|
||||
entries.length > 1
|
||||
? `Completed ${token.sigil}${entries[0].value}; ${entries.length - 1} more candidate(s)`
|
||||
@@ -404,7 +444,8 @@
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void applyComposerCompletion(event);
|
||||
if (!composerEditable) return;
|
||||
void applyComposerCompletion();
|
||||
}
|
||||
|
||||
function scrollConsoleByPage(direction: 1 | -1) {
|
||||
@@ -465,13 +506,13 @@
|
||||
}
|
||||
|
||||
function handleWorkerControlShortcut(event: KeyboardEvent) {
|
||||
const composerFocused = event.target === composerTextareaElement;
|
||||
const composerFocused = composerInputElement?.containsTarget(event.target) ?? false;
|
||||
const command = resolveWorkerControlShortcut(event, {
|
||||
protocolOpen: protocolState === "open",
|
||||
running: workerRunning,
|
||||
paused: workerPaused,
|
||||
composerFocused,
|
||||
draftBlank: draft.trim().length === 0,
|
||||
draftBlank: draft.content.trim().length === 0,
|
||||
editableTarget: isEditableTarget(event.target) && !composerFocused,
|
||||
hasSelection: targetHasSelection(event.target),
|
||||
});
|
||||
@@ -529,16 +570,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleComposerSubmit(value = draft) {
|
||||
function cachedComposerDraft(snapshot: ComposerDraftSnapshot): ComposerDraftCache {
|
||||
return {
|
||||
segments: [...snapshot.segments],
|
||||
preserveExactText: snapshot.textPastes.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
|
||||
draft = snapshot;
|
||||
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
|
||||
}
|
||||
|
||||
function switchComposerTarget(target: ConsoleTarget) {
|
||||
const nextKey = `${target.workspaceId}:${target.runtimeId}:${target.workerId}`;
|
||||
if (nextKey === activeComposerTargetKey) return;
|
||||
if (composerInputElement) {
|
||||
composerDrafts.set(
|
||||
activeComposerTargetKey,
|
||||
cachedComposerDraft(composerInputElement.snapshot()),
|
||||
);
|
||||
}
|
||||
activeComposerTargetKey = nextKey;
|
||||
const restored = composerDrafts.get(nextKey) ?? {
|
||||
segments: [],
|
||||
preserveExactText: false,
|
||||
};
|
||||
void tick().then(() => {
|
||||
if (activeComposerTargetKey !== nextKey) return;
|
||||
composerInputElement?.restoreSegments(
|
||||
restored.segments,
|
||||
restored.preserveExactText,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function handleComposerSubmit() {
|
||||
if (workerRunning) {
|
||||
sendControl({ method: "cancel" }, "Stop");
|
||||
return;
|
||||
}
|
||||
void submitDraft(value);
|
||||
void submitDraft(composerInputElement?.snapshot() ?? draft);
|
||||
}
|
||||
|
||||
async function submitDraft(value = draft) {
|
||||
const command = buildComposerRequest(value);
|
||||
async function submitDraft(value: ComposerDraftSnapshot) {
|
||||
const command = buildComposerSegmentsRequest(value.segments, {
|
||||
preserveExactText: value.textPastes.length > 0,
|
||||
});
|
||||
if (!command.ok) {
|
||||
composerNotice = null;
|
||||
sendError = command.message;
|
||||
@@ -546,7 +624,7 @@
|
||||
}
|
||||
composerNotice = command.notice ?? null;
|
||||
if (!command.request) {
|
||||
draft = "";
|
||||
composerInputElement?.clear();
|
||||
return;
|
||||
}
|
||||
if (sending || !inputReady) {
|
||||
@@ -558,7 +636,7 @@
|
||||
try {
|
||||
const method = composerRequestToProtocolMethod(command.request);
|
||||
sendProtocolMethod(method);
|
||||
draft = "";
|
||||
composerInputElement?.clear();
|
||||
if (method.method === "run" || method.method === "notify") {
|
||||
liveWorkerState = "running";
|
||||
}
|
||||
@@ -1242,6 +1320,7 @@
|
||||
|
||||
$effect(() => {
|
||||
const target = consoleTarget;
|
||||
switchComposerTarget(target);
|
||||
const targetWorker = data.worker;
|
||||
const targetWorkerError = data.workerError;
|
||||
workerViewSelectionGeneration += 1;
|
||||
@@ -1521,19 +1600,15 @@
|
||||
|
||||
<form class="console-composer" onsubmit={sendMessage}>
|
||||
<div class="composer-input-shell">
|
||||
<textarea
|
||||
id="worker-console-message"
|
||||
aria-label="Console input"
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
bind:this={composerTextareaElement}
|
||||
bind:value={draft}
|
||||
use:chatSubmit={{
|
||||
enabled: canSubmitDraft,
|
||||
onSubmit: (value) => handleComposerSubmit(value),
|
||||
}}
|
||||
use:fitTextarea={{ value: draft, maxRows: 10 }}
|
||||
<ComposerInput
|
||||
bind:this={composerInputElement}
|
||||
ariaLabel="Console input"
|
||||
ariaKeyShortcuts="Meta+Enter Control+Enter"
|
||||
disabled={!composerEditable}
|
||||
onchange={handleComposerChange}
|
||||
onkeydown={handleComposerKeydown}
|
||||
disabled={!composerEditable}></textarea>
|
||||
onsubmit={handleComposerSubmit}
|
||||
/>
|
||||
<div class="composer-input-footer">
|
||||
<div class="composer-footer-slot">
|
||||
{#if completionBusy || completionError || completionEntries.length > 0}
|
||||
@@ -1875,27 +1950,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.console-composer textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 5.35rem;
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
padding: 0.55rem 3.4rem 3rem 0.65rem;
|
||||
font: inherit;
|
||||
line-height: 1.45;
|
||||
color: var(--text-strong);
|
||||
outline: none;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.console-composer textarea:disabled {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.composer-send-button {
|
||||
display: inline-grid;
|
||||
width: 2.35rem;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import fixtureJson from "../../../tests/fixtures/composer-paste-policy.json" with {
|
||||
type: "json",
|
||||
};
|
||||
|
||||
import {
|
||||
type ComposerPasteMeasurement,
|
||||
handleComposerPaste,
|
||||
MAX_PLAIN_TEXT_PASTE_CHARS,
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||
measureComposerPaste,
|
||||
} from "../src/lib/workspace/console/composer-paste.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assertEquals(
|
||||
actual: unknown,
|
||||
expected: unknown,
|
||||
message?: string,
|
||||
): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(
|
||||
`${
|
||||
message ? `${message}: ` : ""
|
||||
}expected ${expectedJson}, got ${actualJson}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FixturePart {
|
||||
value: string;
|
||||
repeat: number;
|
||||
}
|
||||
|
||||
interface FixtureCase {
|
||||
name: string;
|
||||
parts: FixturePart[];
|
||||
char_count: number;
|
||||
logical_line_count: number;
|
||||
presentation: "text" | "chip";
|
||||
}
|
||||
|
||||
interface PastePolicyFixture {
|
||||
max_plain_text_chars: number;
|
||||
max_plain_text_logical_lines: number;
|
||||
cases: FixtureCase[];
|
||||
}
|
||||
|
||||
const fixture = fixtureJson as PastePolicyFixture;
|
||||
|
||||
function fixtureContent(testCase: FixtureCase): string {
|
||||
return testCase.parts.map(({ value, repeat }) => value.repeat(repeat)).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("Browser composer follows the shared paste presentation contract", () => {
|
||||
assertEquals(MAX_PLAIN_TEXT_PASTE_CHARS, fixture.max_plain_text_chars);
|
||||
assertEquals(
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||
fixture.max_plain_text_logical_lines,
|
||||
);
|
||||
|
||||
for (const testCase of fixture.cases) {
|
||||
assertEquals(
|
||||
measureComposerPaste(fixtureContent(testCase)),
|
||||
{
|
||||
charCount: testCase.char_count,
|
||||
logicalLineCount: testCase.logical_line_count,
|
||||
presentation: testCase.presentation,
|
||||
},
|
||||
testCase.name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("short paste remains a native Browser edit", () => {
|
||||
let prevented = false;
|
||||
let inserted = 0;
|
||||
const handled = handleComposerPaste(
|
||||
{
|
||||
clipboardData: { getData: () => "replace the selection" },
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
},
|
||||
() => {
|
||||
inserted += 1;
|
||||
},
|
||||
);
|
||||
|
||||
assertEquals(handled, false);
|
||||
assertEquals(prevented, false);
|
||||
assertEquals(inserted, 0);
|
||||
});
|
||||
|
||||
Deno.test("chip paste is prevented and routed exactly once", () => {
|
||||
const content = "🦀".repeat(51);
|
||||
let prevented = 0;
|
||||
const inserted: Array<[string, ComposerPasteMeasurement]> = [];
|
||||
const handled = handleComposerPaste(
|
||||
{
|
||||
clipboardData: { getData: () => content },
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
},
|
||||
(paste, measurement) => inserted.push([paste, measurement]),
|
||||
);
|
||||
|
||||
assertEquals(handled, true);
|
||||
assertEquals(prevented, 1);
|
||||
assertEquals(inserted, [[content, {
|
||||
charCount: 51,
|
||||
logicalLineCount: 1,
|
||||
presentation: "chip",
|
||||
}]]);
|
||||
});
|
||||
Reference in New Issue
Block a user