fix: cancel pending uploads when composer chips are removed
This commit is contained in:
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { FeatureInvocationDescriptor, Segment } from "#lib/generated/protocol.ts";
|
||||
import { undo } from "@codemirror/commands";
|
||||
import { redo, undo } from "@codemirror/commands";
|
||||
import ComposerInput from "./ComposerInput.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -188,6 +188,52 @@ test("restored chip descriptor edits reject same-draft cursor ABA", async () =>
|
||||
expect(ui.component.snapshot().segments).toEqual([invocationSegment]);
|
||||
});
|
||||
|
||||
test.each(["Backspace", "Delete", "range", "cut", "replace"])("pending upload %s cancels its reservation before late completion and Undo", async (operation) => {
|
||||
const oncancelupload = vi.fn();
|
||||
const ui = render(ComposerInput, { historyScope: `upload-delete-${operation}`, oncancelupload });
|
||||
const cm = editor(ui.container);
|
||||
type(cm, "before after", 7);
|
||||
const reservation = ui.component.reserveUpload("report.md")!;
|
||||
const from = 7, to = cm.state.selection.main.head;
|
||||
if (operation === "Backspace" || operation === "Delete") {
|
||||
cm.dispatch({ selection: { anchor: operation === "Backspace" ? to : from } });
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: operation });
|
||||
} else if (operation === "replace") {
|
||||
cm.dispatch({ changes: { from, to, insert: "replacement" }, userEvent: "input" });
|
||||
} else {
|
||||
// happy-dom emits selectionchange synchronously while CodeMirror updates a
|
||||
// focused DOM range. Exercise the transaction/cut handler unfocused here;
|
||||
// the Chromium suite covers focused keyboard range deletion.
|
||||
cm.contentDOM.blur();
|
||||
cm.dispatch({ selection: { anchor: from, head: to } });
|
||||
if (operation === "cut") await fireEvent.cut(cm.contentDOM, { clipboardData: { setData: vi.fn() } });
|
||||
else await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
|
||||
}
|
||||
expect(oncancelupload).toHaveBeenCalledExactlyOnceWith(reservation);
|
||||
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
|
||||
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "unknown" || segment.kind === "uploaded_file")).toBe(false);
|
||||
undo(cm);
|
||||
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
|
||||
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "unknown" || segment.kind === "uploaded_file")).toBe(false);
|
||||
redo(cm);
|
||||
ui.component.clear();
|
||||
expect(oncancelupload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.each(["clear", "restore", "unmount"])("pending upload %s boundary cancels once without acquiring late staged resources", (boundary) => {
|
||||
const oncancelupload = vi.fn();
|
||||
const onremoveatom = vi.fn();
|
||||
const ui = render(ComposerInput, { historyScope: `upload-boundary-${boundary}`, oncancelupload, onremoveatom });
|
||||
const reservation = ui.component.reserveUpload("report.md")!;
|
||||
const complete = ui.component.completeUpload;
|
||||
if (boundary === "clear") ui.component.clear();
|
||||
else if (boundary === "restore") ui.component.restoreSegments([invocationSegment]);
|
||||
else ui.unmount();
|
||||
expect(oncancelupload).toHaveBeenCalledExactlyOnceWith(reservation);
|
||||
expect(complete(reservation, uploadedSegment)).toBe(false);
|
||||
expect(onremoveatom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cancelled upload cannot become sendable via late completion or Undo", async () => {
|
||||
const onremoveatom = vi.fn();
|
||||
const ui = render(ComposerInput, { historyScope: "upload-cancel-late", onremoveatom });
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
signal: AbortSignal,
|
||||
) => Promise<ComposerCompletionEntry[]>;
|
||||
onremoveatom?: (segment: Segment) => void;
|
||||
oncancelupload?: (reservation: number) => void;
|
||||
onclientadapter?: (descriptor: FeatureInvocationDescriptor) => void;
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@
|
||||
resolveFeatureCompletions,
|
||||
resolveFeatureArgumentCompletions,
|
||||
onremoveatom,
|
||||
oncancelupload,
|
||||
onclientadapter,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -105,6 +107,7 @@
|
||||
let view: EditorView | null = null;
|
||||
let editorExtensions: Extension[] = [];
|
||||
const stagedTypedAtoms = new Map<number, Segment>();
|
||||
const pendingUploads = new Set<number>();
|
||||
let composerHistory = new ComposerHistory();
|
||||
let restoringHistory = false;
|
||||
let nextPasteId = 1;
|
||||
@@ -411,9 +414,19 @@
|
||||
create: () => new Map(),
|
||||
update(registry, transaction) {
|
||||
const additions = transaction.effects.filter((effect) => effect.is(registerTypedAtom));
|
||||
if (!additions.length) return registry;
|
||||
if (!additions.length && !transaction.docChanged) return registry;
|
||||
const next = new Map(registry);
|
||||
for (const addition of additions) next.set(addition.value.key, addition.value.atom);
|
||||
if (transaction.docChanged) {
|
||||
const live = new Set(composerTypedAtoms(transaction.newDoc.toString(), next).map((atom) => atom.key));
|
||||
for (const atom of composerTypedAtoms(transaction.startState.doc.toString(), registry)) {
|
||||
if (atom.uploadReservation && !live.has(atom.key)) {
|
||||
// Cancellation is part of the editor transaction, not a later callback.
|
||||
// Undo restores text only; it cannot regain this upload reservation.
|
||||
next.set(atom.key, { segment: { kind: "text", content: "" }, label: "Removed upload" });
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
},
|
||||
});
|
||||
@@ -457,7 +470,14 @@
|
||||
EditorView.decorations.of((currentView) => typedDecorations(currentView.state)),
|
||||
EditorView.atomicRanges.of((currentView) => typedDecorations(currentView.state)),
|
||||
];
|
||||
// Deleted uploads remain staged while Undo can still restore their atoms.
|
||||
function releasePendingUploads(): void {
|
||||
const cancelled = [...pendingUploads];
|
||||
pendingUploads.clear();
|
||||
for (const key of cancelled) oncancelupload?.(key);
|
||||
}
|
||||
|
||||
// Completed uploads remain staged while Undo can still restore their atoms.
|
||||
// Pending reservations are cancelled immediately, not retained as Undo leases.
|
||||
// Release abandoned resources at a draft boundary; accepted live atoms belong to the host.
|
||||
function releaseStagedAtoms(accepted = false): void {
|
||||
const live = accepted && view ? new Set(composerTypedAtoms(
|
||||
@@ -481,7 +501,7 @@
|
||||
|
||||
function insertTypedSegment(
|
||||
segment: Segment,
|
||||
options: { from?: number; to?: number; cleanup?: boolean; label?: string } = {},
|
||||
options: { from?: number; to?: number; cleanup?: boolean; label?: string; uploadReservation?: boolean } = {},
|
||||
): number | null {
|
||||
if (!view || view.state.readOnly) return null;
|
||||
const selection = view.state.selection.main;
|
||||
@@ -489,13 +509,14 @@
|
||||
const to = options.to ?? selection.to;
|
||||
const key = nextTypedKey++;
|
||||
if (options.cleanup) stagedTypedAtoms.set(key, segment);
|
||||
if (options.uploadReservation) pendingUploads.add(key);
|
||||
const token = composerTypedToken(key);
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: token },
|
||||
selection: EditorSelection.cursor(from + token.length),
|
||||
effects: registerTypedAtom.of({
|
||||
key,
|
||||
atom: { segment, label: options.label ?? typedAtomLabel(segment), cleanup: options.cleanup },
|
||||
atom: { segment, label: options.label ?? typedAtomLabel(segment), cleanup: options.cleanup, uploadReservation: options.uploadReservation },
|
||||
}),
|
||||
annotations: isolateHistory.of("full"),
|
||||
userEvent: "input.complete",
|
||||
@@ -784,6 +805,14 @@
|
||||
spellcheck: "true",
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
// StateField tombstones deleted reservations before notifying the
|
||||
// adapter. The callback must not dispatch a nested editor update.
|
||||
for (const key of pendingUploads) {
|
||||
if (!update.state.field(typedRegistry).get(key)?.uploadReservation) {
|
||||
pendingUploads.delete(key);
|
||||
oncancelupload?.(key);
|
||||
}
|
||||
}
|
||||
if (update.docChanged || update.selectionSet) {
|
||||
editorGeneration++;
|
||||
// Invalidate synchronously: an A→B→A edit/cursor cycle in one
|
||||
@@ -881,6 +910,7 @@
|
||||
|
||||
return () => {
|
||||
closeCompletion();
|
||||
releasePendingUploads();
|
||||
releaseStagedAtoms();
|
||||
editVersion++;
|
||||
view?.destroy();
|
||||
@@ -940,11 +970,14 @@
|
||||
}
|
||||
|
||||
export function reserveUpload(fileName: string): number | null {
|
||||
return insertTypedSegment({ kind: "unknown" }, { label: `Uploading ${fileName}` });
|
||||
return insertTypedSegment({ kind: "unknown" }, { label: `Uploading ${fileName}`, uploadReservation: true });
|
||||
}
|
||||
|
||||
export function completeUpload(key: number, segment: Segment): boolean {
|
||||
if (!view || view.state.field(typedRegistry).get(key)?.segment.kind !== "unknown") return false;
|
||||
if (!view || !pendingUploads.has(key) ||
|
||||
!view.state.field(typedRegistry).get(key)?.uploadReservation ||
|
||||
!composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry)).some((atom) => atom.key === key)) return false;
|
||||
pendingUploads.delete(key);
|
||||
stagedTypedAtoms.set(key, segment);
|
||||
view.dispatch({ effects: registerTypedAtom.of({
|
||||
key, atom: { segment, label: typedAtomLabel(segment), cleanup: true },
|
||||
@@ -968,6 +1001,7 @@
|
||||
|
||||
export function clear(acceptedTypedResources = false, retainStagedResources = false): void {
|
||||
if (!view) return;
|
||||
releasePendingUploads();
|
||||
if (!retainStagedResources) releaseStagedAtoms(acceptedTypedResources);
|
||||
editVersion++;
|
||||
closeCompletion();
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ComposerTypedSegment {
|
||||
label: string;
|
||||
/** Whether deleting this unsent atom must release client-staged state. */
|
||||
cleanup?: boolean;
|
||||
/** Client-only reservation whose removal cancels an in-progress upload. */
|
||||
uploadReservation?: boolean;
|
||||
}
|
||||
|
||||
export interface ComposerTypedAtom extends ComposerTypedSegment {
|
||||
|
||||
@@ -1091,6 +1091,17 @@
|
||||
);
|
||||
}
|
||||
|
||||
function cancelComposerUpload(reservation: number): void {
|
||||
const attachment = attachments.find((candidate) =>
|
||||
uploadReservations.get(candidate.id) === reservation);
|
||||
if (!attachment) return;
|
||||
// The editor already cancelled/tombstoned its reservation. Do not call
|
||||
// cancelUpload here: this may run inside a CodeMirror update listener.
|
||||
uploadReservations.delete(attachment.id);
|
||||
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
|
||||
attachment.request?.abort();
|
||||
}
|
||||
|
||||
function completeAttachmentUpload(
|
||||
attachment: ComposerAttachment,
|
||||
reference: NonNullable<ComposerAttachment["reference"]>,
|
||||
@@ -2177,6 +2188,7 @@
|
||||
resolveFeatureArgumentCompletions={(context, prefix, signal) =>
|
||||
featureArgumentCompletionLane(context).request(prefix, signal)}
|
||||
onremoveatom={(segment) => void releaseComposerAtom(segment)}
|
||||
oncancelupload={cancelComposerUpload}
|
||||
onclientadapter={(descriptor) => {
|
||||
if (descriptor.client_adapter === "attachment") fileInput?.click();
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user