refactor(web): extract attachment upload into a composable (#1034)

Move the image/video attachment state, the file-picker / paste / drag-drop
handlers, the upload machinery, the preview lightbox, and the paste-listener
+ object-URL cleanup lifecycle out of Composer into useAttachmentUpload.

The composer keeps handleSubmit / handleSteer (which read the attachments to
build the payload) and the hasUpload toolbar flag; it consumes the returned
refs and handlers directly. The destructured names match the originals so the
template bindings are unchanged. handleSubmit / handleSteer now call the
composable's clearAfterSubmit() to revoke object URLs and drop the list.

Composer.vue: 1937 -> 1787 lines. Adds unit tests for useAttachmentUpload. No
behavior change.
This commit is contained in:
qer 2026-06-23 19:52:03 +08:00 committed by GitHub
parent 2bfd6860e4
commit 603a7679de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 357 additions and 174 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Extract the composer's image/video attachment handling into a reusable composable.

View file

@ -13,27 +13,7 @@ import { useInputHistory } from '../composables/useInputHistory';
import { useSlashMenu } from '../composables/useSlashMenu';
import { useMentionMenu } from '../composables/useMentionMenu';
import { useComposerDraft } from '../composables/useComposerDraft';
// ---------------------------------------------------------------------------
// Attachment state
// ---------------------------------------------------------------------------
interface Attachment {
/** Unique local id (used as :key) */
localId: string;
/** File name */
name: string;
/** image or video — drives the chip preview and the content-block type. */
kind: 'image' | 'video';
/** Object URL for the thumbnail preview */
previewUrl: string;
/** True while uploading */
uploading: boolean;
/** Resolved daemon file id (set after upload completes) */
fileId?: string;
/** True if upload failed */
error?: boolean;
}
import { useAttachmentUpload } from '../composables/useAttachmentUpload';
// ---------------------------------------------------------------------------
// Props & emits
@ -165,160 +145,36 @@ function handleInput(): void {
}
// ---------------------------------------------------------------------------
// Attachments
// Attachments see useAttachmentUpload. The composer keeps handleSubmit /
// handleSteer (which read the attachments to build the payload) and the
// `hasUpload` toolbar flag.
// ---------------------------------------------------------------------------
const {
attachments,
previewAttachment,
fileInputRef,
isDragOver,
removeAttachment,
openAttachmentPreview,
closeAttachmentPreview,
openFilePicker,
handleFileInputChange,
handleDragOver,
handleDragLeave,
handleDrop,
clearAfterSubmit,
} = useAttachmentUpload({ uploadImage: () => props.uploadImage });
const attachments = ref<Attachment[]>([]);
const previewAttachment = ref<Attachment | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const isDragOver = ref(false);
let localIdCounter = 0;
function nextLocalId(): string {
return `att_${++localIdCounter}`;
}
function revokeAttachment(att: Attachment): void {
try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ }
}
function mediaKind(mime: string): 'image' | 'video' | null {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
return null;
}
async function addFiles(files: File[]): Promise<void> {
if (!props.uploadImage) return;
const media = files
.map((file) => ({ file, kind: mediaKind(file.type) }))
.filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null);
if (media.length === 0) return;
for (const { file, kind } of media) {
const localId = nextLocalId();
const previewUrl = URL.createObjectURL(file);
const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true };
attachments.value = [...attachments.value, att];
// Upload in background; update the attachment when done
props.uploadImage(file, file.name).then((result) => {
attachments.value = attachments.value.map((a) =>
a.localId === localId
? { ...a, uploading: false, fileId: result?.fileId, error: result === null }
: a,
);
}).catch(() => {
attachments.value = attachments.value.map((a) =>
a.localId === localId ? { ...a, uploading: false, error: true } : a,
);
});
}
}
function removeAttachment(localId: string): void {
const att = attachments.value.find((a) => a.localId === localId);
if (previewAttachment.value?.localId === localId) previewAttachment.value = null;
if (att) revokeAttachment(att);
attachments.value = attachments.value.filter((a) => a.localId !== localId);
}
function openAttachmentPreview(att: Attachment): void {
previewAttachment.value = att;
}
function closeAttachmentPreview(): void {
previewAttachment.value = null;
}
function openFilePicker(): void {
fileInputRef.value?.click();
}
function handleFileInputChange(e: Event): void {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
void addFiles(files);
// Reset so re-selecting the same file fires change again
input.value = '';
}
// Global document-level paste handler captures Ctrl+V anywhere the composer is mounted.
function handleDocumentPaste(e: ClipboardEvent): void {
if (!props.uploadImage) return;
const cd = e.clipboardData;
if (!cd) return;
// Collect image files from both .items and .files to cover all browsers/OS.
const files: File[] = [];
const seenKeys = new Set<string>();
const addBlob = (blob: File | Blob, name: string): void => {
const key = `${blob.size}:${blob.type}:${name}`;
if (seenKeys.has(key)) return;
seenKeys.add(key);
const ext = blob.type.split('/')[1] ?? 'png';
const safeName = name.includes('.') ? name : `paste-${Date.now()}.${ext}`;
files.push(blob instanceof File ? blob : new File([blob], safeName, { type: blob.type }));
};
// From DataTransferItemList
for (const item of Array.from(cd.items)) {
if (item.kind === 'file' && mediaKind(item.type)) {
const blob = item.getAsFile();
if (blob) addBlob(blob, blob.name || `paste-${Date.now()}.${item.type.split('/')[1] ?? 'png'}`);
}
}
// From FileList (some browsers/OS put screenshots here directly)
for (const file of Array.from(cd.files)) {
if (mediaKind(file.type)) {
addBlob(file, file.name);
}
}
if (files.length === 0) return; // No media let normal text paste proceed unmodified.
e.preventDefault();
void addFiles(files);
}
// Drag-drop handlers
function handleDragOver(e: DragEvent): void {
if (!props.uploadImage) return;
const hasFiles = Array.from(e.dataTransfer?.items ?? []).some((item) => item.kind === 'file');
if (!hasFiles) return;
e.preventDefault();
isDragOver.value = true;
}
function handleDragLeave(): void {
isDragOver.value = false;
}
function handleDrop(e: DragEvent): void {
isDragOver.value = false;
if (!props.uploadImage) return;
e.preventDefault();
const files = Array.from(e.dataTransfer?.files ?? []);
void addFiles(files);
}
// Silence noUnusedLocals: fileInputRef is used as a template ref (ref="fileInputRef").
void fileInputRef;
onMounted(() => {
document.addEventListener('paste', handleDocumentPaste);
// Fit the box to a restored draft on first render.
if (text.value) void nextTick(autosize);
});
// Revoke all object URLs and remove global listener on unmount
onUnmounted(() => {
document.removeEventListener('paste', handleDocumentPaste);
document.removeEventListener('mousedown', onModesDocClick);
for (const att of attachments.value) {
revokeAttachment(att);
}
previewAttachment.value = null;
clearCompositionEndTimer();
});
@ -369,12 +225,9 @@ function handleSubmit(): void {
attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })),
};
// Revoke object URLs for submitted attachments
// Revoke object URLs and drop the submitted attachments.
previewAttachment.value = null;
for (const att of attachments.value) {
revokeAttachment(att);
}
attachments.value = [];
clearAfterSubmit();
text.value = '';
slashOpen.value = false;
@ -399,10 +252,7 @@ function handleSteer(): void {
text: trimmed,
attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })),
};
for (const att of attachments.value) {
revokeAttachment(att);
}
attachments.value = [];
clearAfterSubmit();
history.push(trimmed);
text.value = '';
slashOpen.value = false;

View file

@ -0,0 +1,218 @@
// apps/kimi-web/src/composables/useAttachmentUpload.ts
import { onMounted, onUnmounted, ref } from 'vue';
export interface Attachment {
/** Unique local id (used as :key) */
localId: string;
/** File name */
name: string;
/** image or video — drives the chip preview and the content-block type. */
kind: 'image' | 'video';
/** Object URL for the thumbnail preview */
previewUrl: string;
/** True while uploading */
uploading: boolean;
/** Resolved daemon file id (set after upload completes) */
fileId?: string;
/** True if upload failed */
error?: boolean;
}
type UploadImage = (
file: Blob,
name?: string,
) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
export interface AttachmentUploadDeps {
/** Upload a blob; resolves to the daemon file id, or null on failure.
Getter so a prop change is picked up. Undefined disables attaching. */
uploadImage: () => UploadImage | undefined;
}
/**
* Image/video attachment handling for the composer: file picker, paste, drag &
* drop, the upload machinery, the chip strip, and the preview lightbox.
*
* The composer keeps `handleSubmit`/`handleSteer` (which read the attachments to
* build the payload) and the `hasUpload` toolbar flag; this composable owns the
* attachment state, all the file-input UI handlers, and the paste listener +
* object-URL cleanup lifecycle.
*/
export function useAttachmentUpload(deps: AttachmentUploadDeps) {
const { uploadImage } = deps;
const attachments = ref<Attachment[]>([]);
const previewAttachment = ref<Attachment | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const isDragOver = ref(false);
let localIdCounter = 0;
function nextLocalId(): string {
return `att_${++localIdCounter}`;
}
function revokeAttachment(att: Attachment): void {
try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ }
}
function mediaKind(mime: string): 'image' | 'video' | null {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
return null;
}
async function addFiles(files: File[]): Promise<void> {
const upload = uploadImage();
if (!upload) return;
const media = files
.map((file) => ({ file, kind: mediaKind(file.type) }))
.filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null);
if (media.length === 0) return;
for (const { file, kind } of media) {
const localId = nextLocalId();
const previewUrl = URL.createObjectURL(file);
const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true };
attachments.value = [...attachments.value, att];
// Upload in background; update the attachment when done.
upload(file, file.name).then((result) => {
attachments.value = attachments.value.map((a) =>
a.localId === localId
? { ...a, uploading: false, fileId: result?.fileId, error: result === null }
: a,
);
}).catch(() => {
attachments.value = attachments.value.map((a) =>
a.localId === localId ? { ...a, uploading: false, error: true } : a,
);
});
}
}
function removeAttachment(localId: string): void {
const att = attachments.value.find((a) => a.localId === localId);
if (previewAttachment.value?.localId === localId) previewAttachment.value = null;
if (att) revokeAttachment(att);
attachments.value = attachments.value.filter((a) => a.localId !== localId);
}
function openAttachmentPreview(att: Attachment): void {
previewAttachment.value = att;
}
function closeAttachmentPreview(): void {
previewAttachment.value = null;
}
function openFilePicker(): void {
fileInputRef.value?.click();
}
function handleFileInputChange(e: Event): void {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
void addFiles(files);
// Reset so re-selecting the same file fires change again.
input.value = '';
}
// Global document-level paste handler — captures Ctrl+V anywhere the composer is mounted.
function handleDocumentPaste(e: ClipboardEvent): void {
if (!uploadImage()) return;
const cd = e.clipboardData;
if (!cd) return;
// Collect image files from both .items and .files to cover all browsers/OS.
const files: File[] = [];
const seenKeys = new Set<string>();
const addBlob = (blob: File | Blob, name: string): void => {
const key = `${blob.size}:${blob.type}:${name}`;
if (seenKeys.has(key)) return;
seenKeys.add(key);
const ext = blob.type.split('/')[1] ?? 'png';
const safeName = name.includes('.') ? name : `paste-${Date.now()}.${ext}`;
files.push(blob instanceof File ? blob : new File([blob], safeName, { type: blob.type }));
};
// From DataTransferItemList.
for (const item of Array.from(cd.items)) {
if (item.kind === 'file' && mediaKind(item.type)) {
const blob = item.getAsFile();
if (blob) addBlob(blob, blob.name || `paste-${Date.now()}.${item.type.split('/')[1] ?? 'png'}`);
}
}
// From FileList (some browsers/OS put screenshots here directly).
for (const file of Array.from(cd.files)) {
if (mediaKind(file.type)) {
addBlob(file, file.name);
}
}
if (files.length === 0) return; // No media — let normal text paste proceed unmodified.
e.preventDefault();
void addFiles(files);
}
// Drag-drop handlers.
function handleDragOver(e: DragEvent): void {
if (!uploadImage()) return;
const hasFiles = Array.from(e.dataTransfer?.items ?? []).some((item) => item.kind === 'file');
if (!hasFiles) return;
e.preventDefault();
isDragOver.value = true;
}
function handleDragLeave(): void {
isDragOver.value = false;
}
function handleDrop(e: DragEvent): void {
isDragOver.value = false;
if (!uploadImage()) return;
e.preventDefault();
const files = Array.from(e.dataTransfer?.files ?? []);
void addFiles(files);
}
/** Revoke every object URL and drop all attachments (called after submit/steer). */
function clearAfterSubmit(): void {
for (const att of attachments.value) {
revokeAttachment(att);
}
attachments.value = [];
}
onMounted(() => {
document.addEventListener('paste', handleDocumentPaste);
});
// Revoke all object URLs and remove the global listener on unmount.
onUnmounted(() => {
document.removeEventListener('paste', handleDocumentPaste);
for (const att of attachments.value) {
revokeAttachment(att);
}
previewAttachment.value = null;
});
return {
attachments,
previewAttachment,
fileInputRef,
isDragOver,
removeAttachment,
openAttachmentPreview,
closeAttachmentPreview,
openFilePicker,
handleFileInputChange,
handleDragOver,
handleDragLeave,
handleDrop,
clearAfterSubmit,
};
}

View file

@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAttachmentUpload, type Attachment } from '../src/composables/useAttachmentUpload';
// The composable registers its paste listener and cleanup via onMounted /
// onUnmounted. Outside a component (unit test) there is no active instance, so
// Vue would warn; stub the two hooks since these tests don't exercise the
// lifecycle itself.
vi.mock('vue', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue')>();
return { ...actual, onMounted: vi.fn(), onUnmounted: vi.fn() };
});
type UploadImage = (
file: Blob,
name?: string,
) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
function setup(uploadImage?: UploadImage) {
return useAttachmentUpload({ uploadImage: () => uploadImage });
}
function imageFile(name: string): File {
return { name, type: 'image/png' } as unknown as File;
}
function inputEvent(files: File[]): Event {
return { target: { files, value: 'x' } } as unknown as Event;
}
describe('useAttachmentUpload', () => {
let createObjectURL: ReturnType<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;
beforeEach(() => {
createObjectURL = vi.fn().mockReturnValue('blob:mock-url');
revokeObjectURL = vi.fn();
(globalThis.URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL;
(globalThis.URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('adds an uploading attachment via the file input', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f1', name: 'a.png', mediaType: 'image/png' });
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
expect(att.attachments.value).toHaveLength(1);
expect(att.attachments.value[0]).toMatchObject({ name: 'a.png', kind: 'image', uploading: true });
expect(createObjectURL).toHaveBeenCalledOnce();
});
it('ignores non-media files', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([{ name: 'a.txt', type: 'text/plain' } as unknown as File]));
expect(att.attachments.value).toHaveLength(0);
});
it('is a no-op when uploadImage is not provided', () => {
const att = setup(undefined);
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
expect(att.attachments.value).toHaveLength(0);
});
it('removeAttachment drops the entry and revokes its object URL', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
const localId = att.attachments.value[0].localId;
att.removeAttachment(localId);
expect(att.attachments.value).toHaveLength(0);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('removeAttachment also closes the preview when it shows the removed entry', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
const added = att.attachments.value[0];
att.openAttachmentPreview(added);
expect(att.previewAttachment.value).not.toBeNull();
att.removeAttachment(added.localId);
expect(att.previewAttachment.value).toBeNull();
});
it('openAttachmentPreview / closeAttachmentPreview toggle the preview', () => {
const att = setup(undefined);
const item: Attachment = { localId: 'x', name: 'a.png', kind: 'image', previewUrl: 'blob:x', uploading: false };
att.openAttachmentPreview(item);
expect(att.previewAttachment.value?.localId).toBe('x');
att.closeAttachmentPreview();
expect(att.previewAttachment.value).toBeNull();
});
it('clearAfterSubmit revokes every object URL and empties the list', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('a.png'), imageFile('b.png')]));
expect(att.attachments.value).toHaveLength(2);
att.clearAfterSubmit();
expect(att.attachments.value).toHaveLength(0);
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
});
});