mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
fix(desktop): support pasted file attachments #1086
This commit is contained in:
parent
8e9c104514
commit
c2774fc170
@ -1,9 +1,12 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
import { createElectronHost } from '../src/lib/desktopHost/electronHost'
|
||||
import type { DesktopHostUnlisten } from '../src/lib/desktopHost/types'
|
||||
import type { ElectronEventChannel, ElectronIpcChannel } from './ipc/channels'
|
||||
|
||||
const electronHost = createElectronHost({
|
||||
getPathForFile(file) {
|
||||
return webUtils.getPathForFile(file)
|
||||
},
|
||||
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
|
||||
return ipcRenderer.invoke(channel, payload) as Promise<T>
|
||||
},
|
||||
|
||||
@ -1292,7 +1292,53 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores pasted images that finish loading after the prompt was sent', async () => {
|
||||
it('pastes copied desktop files into the active session as path-only attachments', async () => {
|
||||
installElectronFileHost()
|
||||
const copiedFile = new File(['# Project notes'], 'ignored-name.md', { type: 'text/markdown' })
|
||||
Object.defineProperty(copiedFile, 'path', {
|
||||
configurable: true,
|
||||
value: 'C:\\Users\\Nanmi\\Desktop\\project-notes.md',
|
||||
})
|
||||
|
||||
render(<ChatInput compact />)
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [{
|
||||
kind: 'file',
|
||||
type: 'text/markdown',
|
||||
getAsFile: () => copiedFile,
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('project-notes.md')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
value: 'review this document',
|
||||
selectionStart: 'review this document'.length,
|
||||
},
|
||||
})
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||
type: 'user_message',
|
||||
content: 'review this document',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
name: 'project-notes.md',
|
||||
path: 'C:\\Users\\Nanmi\\Desktop\\project-notes.md',
|
||||
data: undefined,
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores pasted files that finish loading after the prompt was sent', async () => {
|
||||
class DeferredFileReader {
|
||||
result: string | ArrayBuffer | null = null
|
||||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null
|
||||
@ -1311,7 +1357,9 @@ describe('ChatInput file mentions', () => {
|
||||
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [{
|
||||
kind: 'file',
|
||||
type: 'image/png',
|
||||
getAsFile: () => file,
|
||||
}],
|
||||
|
||||
@ -38,6 +38,7 @@ import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||
import { isDesktopRuntime } from '../../lib/desktopRuntime'
|
||||
import {
|
||||
filesToComposerAttachments,
|
||||
getDataTransferFiles,
|
||||
selectNativeFileAttachments,
|
||||
type ComposerAttachment,
|
||||
} from '../../lib/composerAttachments'
|
||||
@ -835,42 +836,22 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent) => {
|
||||
if (isMemberSession) return
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
const files = getDataTransferFiles(event.clipboardData)
|
||||
if (files.length === 0) return
|
||||
|
||||
let hasImage = false
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i]
|
||||
if (!item || !item.type.startsWith('image/')) continue
|
||||
|
||||
hasImage = true
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const pasteGeneration = pasteGenerationRef.current
|
||||
const pastedSessionId = activeTabId
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
event.preventDefault()
|
||||
const pasteGeneration = pasteGenerationRef.current
|
||||
const pastedSessionId = activeTabId
|
||||
void filesToComposerAttachments(files)
|
||||
.then((nextAttachments) => {
|
||||
if (pasteGeneration !== pasteGenerationRef.current) return
|
||||
if (pastedSessionId !== useTabStore.getState().activeTabId) return
|
||||
setComposerAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
name: `pasted-image-${Date.now()}.png`,
|
||||
type: 'image',
|
||||
mimeType: file.type || 'image/png',
|
||||
previewUrl: reader.result as string,
|
||||
data: reader.result as string,
|
||||
},
|
||||
])
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
if (!hasImage) return
|
||||
if (nextAttachments.length === 0) return
|
||||
setComposerAttachments((prev) => [...prev, ...nextAttachments])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[attachments] Failed to read pasted files', error)
|
||||
})
|
||||
}
|
||||
|
||||
const appendFiles = useCallback((files: FileList | File[]) => {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { browserHost } from './desktopHost/browserHost'
|
||||
import { pathToComposerAttachment, selectNativeFileAttachments } from './composerAttachments'
|
||||
import {
|
||||
filesToComposerAttachments,
|
||||
getDataTransferFiles,
|
||||
pathToComposerAttachment,
|
||||
selectNativeFileAttachments,
|
||||
} from './composerAttachments'
|
||||
|
||||
describe('composer attachment payloads', () => {
|
||||
afterEach(() => {
|
||||
@ -61,4 +66,61 @@ describe('composer attachment payloads', () => {
|
||||
'/workspace/b.log',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps pasted desktop files as native path attachments across common file types', async () => {
|
||||
const nativePaths = new Map<File, string>()
|
||||
const files = [
|
||||
new File(['# Notes'], 'notes.md', { type: 'text/markdown' }),
|
||||
new File(['pdf'], 'brief.pdf', { type: 'application/pdf' }),
|
||||
new File(['docx'], 'proposal.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
}),
|
||||
]
|
||||
nativePaths.set(files[0]!, 'C:\\Users\\Nanmi\\Desktop\\notes.md')
|
||||
nativePaths.set(files[1]!, 'C:\\Users\\Nanmi\\Desktop\\brief.pdf')
|
||||
nativePaths.set(files[2]!, 'C:\\Users\\Nanmi\\Desktop\\proposal.docx')
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
files: {
|
||||
getPathForFile: file => nativePaths.get(file) ?? '',
|
||||
},
|
||||
}
|
||||
|
||||
const attachments = await filesToComposerAttachments(files)
|
||||
|
||||
expect(attachments.map(({ name, path, data }) => ({ name, path, data }))).toEqual([
|
||||
{ name: 'notes.md', path: 'C:\\Users\\Nanmi\\Desktop\\notes.md', data: undefined },
|
||||
{ name: 'brief.pdf', path: 'C:\\Users\\Nanmi\\Desktop\\brief.pdf', data: undefined },
|
||||
{ name: 'proposal.docx', path: 'C:\\Users\\Nanmi\\Desktop\\proposal.docx', data: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('reads clipboard files from DataTransfer items when the files list is empty', () => {
|
||||
const markdown = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
|
||||
const dataTransfer = {
|
||||
files: [],
|
||||
items: [
|
||||
{ kind: 'string', getAsFile: () => null },
|
||||
{ kind: 'file', getAsFile: () => markdown },
|
||||
],
|
||||
} as unknown as DataTransfer
|
||||
|
||||
expect(getDataTransferFiles(dataTransfer)).toEqual([markdown])
|
||||
})
|
||||
|
||||
it('keeps every clipboard item when the browser files list is incomplete', () => {
|
||||
const markdown = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
|
||||
const spreadsheet = new File(['name,total'], 'budget.csv', { type: 'text/csv' })
|
||||
const dataTransfer = {
|
||||
files: [markdown],
|
||||
items: [
|
||||
{ kind: 'file', getAsFile: () => markdown },
|
||||
{ kind: 'file', getAsFile: () => spreadsheet },
|
||||
],
|
||||
} as unknown as DataTransfer
|
||||
|
||||
expect(getDataTransferFiles(dataTransfer)).toEqual([markdown, spreadsheet])
|
||||
})
|
||||
})
|
||||
|
||||
@ -44,11 +44,26 @@ export function pathsToComposerAttachments(filePaths: string[]): ComposerAttachm
|
||||
|
||||
export function dataTransferHasFiles(dataTransfer: DataTransfer): boolean {
|
||||
const types = Array.from(dataTransfer.types ?? [])
|
||||
return types.includes('Files') || dataTransfer.files.length > 0
|
||||
const items = Array.from(dataTransfer.items ?? [])
|
||||
return (
|
||||
types.includes('Files') ||
|
||||
dataTransfer.files.length > 0 ||
|
||||
items.some((item) => item.kind === 'file')
|
||||
)
|
||||
}
|
||||
|
||||
export function getDataTransferFiles(dataTransfer: DataTransfer): File[] {
|
||||
const files = Array.from(dataTransfer.files ?? [])
|
||||
const itemFiles = Array.from(dataTransfer.items ?? []).flatMap((item) => {
|
||||
if (item.kind !== 'file') return []
|
||||
const file = item.getAsFile()
|
||||
return file ? [file] : []
|
||||
})
|
||||
return itemFiles.length > files.length ? itemFiles : files
|
||||
}
|
||||
|
||||
export async function dataTransferToComposerAttachments(dataTransfer: DataTransfer): Promise<ComposerAttachment[]> {
|
||||
return filesToComposerAttachments(dataTransfer.files)
|
||||
return filesToComposerAttachments(getDataTransferFiles(dataTransfer))
|
||||
}
|
||||
|
||||
export async function selectNativeFileAttachments(): Promise<ComposerAttachment[] | null> {
|
||||
@ -81,8 +96,8 @@ function normalizeDialogSelection(selected: string | string[] | null): string[]
|
||||
}
|
||||
|
||||
function getNativeFilePath(file: File): string | undefined {
|
||||
const path = (file as File & { path?: unknown }).path
|
||||
return typeof path === 'string' && path.length > 0 ? path : undefined
|
||||
const path = getDesktopHost().files.getPathForFile(file)
|
||||
return path.length > 0 ? path : undefined
|
||||
}
|
||||
|
||||
async function fileToComposerAttachment(file: File): Promise<ComposerAttachment | null> {
|
||||
|
||||
@ -72,6 +72,12 @@ export const browserHost: DesktopHost = {
|
||||
unsupported('Writing clipboard text')
|
||||
},
|
||||
},
|
||||
files: {
|
||||
getPathForFile(file) {
|
||||
const legacyPath = (file as File & { path?: unknown }).path
|
||||
return typeof legacyPath === 'string' ? legacyPath : ''
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async listen() {
|
||||
return noopUnlisten
|
||||
|
||||
@ -37,6 +37,19 @@ describe('electron desktop host', () => {
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, ELECTRON_IPC_CHANNELS.clipboardWriteText, 'to clipboard')
|
||||
})
|
||||
|
||||
it('resolves native paths for renderer File objects through the preload bridge', () => {
|
||||
const file = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
|
||||
const getPathForFile = vi.fn().mockReturnValue('C:\\Users\\Nanmi\\Desktop\\notes.md')
|
||||
const host = createElectronHost({
|
||||
getPathForFile,
|
||||
invoke: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
})
|
||||
|
||||
expect(host.files.getPathForFile(file)).toBe('C:\\Users\\Nanmi\\Desktop\\notes.md')
|
||||
expect(getPathForFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('rejects invalid preload payloads before invoking Electron IPC', async () => {
|
||||
const invoke = vi.fn()
|
||||
const host = createElectronHost({
|
||||
|
||||
@ -14,6 +14,7 @@ import { validateElectronIpcPayload } from '../../../electron/ipc/capabilities'
|
||||
|
||||
export type ElectronHostBridge = {
|
||||
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T>
|
||||
getPathForFile?(file: File): string
|
||||
subscribe<T>(
|
||||
channel: ElectronEventChannel,
|
||||
handler: (payload: T) => void,
|
||||
@ -87,6 +88,14 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
||||
readText: () => invoke(ELECTRON_IPC_CHANNELS.clipboardReadText),
|
||||
writeText: text => invoke(ELECTRON_IPC_CHANNELS.clipboardWriteText, text),
|
||||
},
|
||||
files: {
|
||||
getPathForFile(file) {
|
||||
const nativePath = bridge.getPathForFile?.(file)
|
||||
if (nativePath) return nativePath
|
||||
const legacyPath = (file as File & { path?: unknown }).path
|
||||
return typeof legacyPath === 'string' ? legacyPath : ''
|
||||
},
|
||||
},
|
||||
events: {
|
||||
listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler),
|
||||
},
|
||||
|
||||
@ -212,6 +212,9 @@ export type DesktopHost = {
|
||||
readText(): Promise<string>
|
||||
writeText(text: string): Promise<void>
|
||||
}
|
||||
files: {
|
||||
getPathForFile(file: File): string
|
||||
}
|
||||
events: {
|
||||
listen<T>(eventName: string, handler: (payload: T) => void): Promise<DesktopHostUnlisten>
|
||||
}
|
||||
|
||||
@ -149,6 +149,7 @@ import { useTabStore } from '../stores/tabStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { usePluginStore } from '../stores/pluginStore'
|
||||
import type { RepositoryContextResult } from '../api/sessions'
|
||||
import { browserHost } from '../lib/desktopHost/browserHost'
|
||||
|
||||
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
|
||||
return {
|
||||
@ -725,6 +726,62 @@ describe('EmptySession', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pastes copied desktop files into a new-session draft as path attachments', async () => {
|
||||
mocks.isTauriRuntime = true
|
||||
const copiedFile = new File(['{\"name\":\"cc-haha\"}'], 'ignored-name.json', {
|
||||
type: 'application/json',
|
||||
})
|
||||
Object.defineProperty(copiedFile, 'path', {
|
||||
configurable: true,
|
||||
value: 'C:\\Users\\Nanmi\\Desktop\\project-context.json',
|
||||
})
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
webview: {
|
||||
...browserHost.webview,
|
||||
onDragDropEvent: vi.fn().mockResolvedValue(mocks.webviewUnlisten),
|
||||
},
|
||||
}
|
||||
|
||||
render(<EmptySession />)
|
||||
|
||||
fireEvent.paste(screen.getByRole('textbox'), {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [{
|
||||
kind: 'file',
|
||||
type: 'application/json',
|
||||
getAsFile: () => copiedFile,
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('project-context.json')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'use this context', selectionStart: 'use this context'.length },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
|
||||
})
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
|
||||
type: 'user_message',
|
||||
content: 'use this context',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
name: 'project-context.json',
|
||||
path: 'C:\\Users\\Nanmi\\Desktop\\project-context.json',
|
||||
data: undefined,
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps slash and @ popovers visible above the empty-session drop target', async () => {
|
||||
mocks.search.mockResolvedValueOnce({
|
||||
currentPath: '/workspace/project',
|
||||
|
||||
@ -25,6 +25,7 @@ import { publicAssetPath } from '../lib/publicAsset'
|
||||
import { resolveActiveProviderRuntimeSelection } from '../lib/runtimeSelection'
|
||||
import {
|
||||
filesToComposerAttachments,
|
||||
getDataTransferFiles,
|
||||
selectNativeFileAttachments,
|
||||
type ComposerAttachment,
|
||||
} from '../lib/composerAttachments'
|
||||
@ -472,37 +473,18 @@ export function EmptySession() {
|
||||
}
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
const files = getDataTransferFiles(event.clipboardData)
|
||||
if (files.length === 0) return
|
||||
|
||||
let hasImage = false
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i]
|
||||
if (!item || !item.type.startsWith('image/')) continue
|
||||
|
||||
hasImage = true
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
name: `pasted-image-${Date.now()}.png`,
|
||||
type: 'image',
|
||||
mimeType: file.type || undefined,
|
||||
previewUrl: reader.result as string,
|
||||
data: reader.result as string,
|
||||
},
|
||||
])
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
if (!hasImage) return
|
||||
event.preventDefault()
|
||||
void filesToComposerAttachments(files)
|
||||
.then((nextAttachments) => {
|
||||
if (nextAttachments.length === 0) return
|
||||
setAttachments((prev) => [...prev, ...nextAttachments])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[attachments] Failed to read pasted files', error)
|
||||
})
|
||||
}
|
||||
|
||||
const appendFiles = useCallback((files: FileList | File[]) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user