Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions src/renderer/src/components/chat/ChatInputBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
'w-full overflow-hidden rounded-xl border bg-card/30 shadow-sm backdrop-blur-lg',
props.maxWidthClass
]"
@dragover.prevent
@drop.prevent="onDrop"
@dragover="onDragOver"
@drop="onDrop"
>
<input ref="fileInput" type="file" class="hidden" multiple @change="files.handleFileSelect" />

Expand Down Expand Up @@ -83,6 +83,10 @@ import { TextSelection } from '@tiptap/pm/state'
import { Icon } from '@iconify/vue'
import type { MessageFile } from '@shared/types/agent-interface'
import { useI18n } from 'vue-i18n'
import {
buildChatInputWorkspaceReferenceText,
getChatInputWorkspaceItemDragData
} from '@/lib/chatInputWorkspaceReference'
import { useChatInputMentions } from './composables/useChatInputMentions'
import { useChatInputFiles } from './composables/useChatInputFiles'
import { useSkillsData } from '@/components/chat-input/composables/useSkillsData'
Expand Down Expand Up @@ -329,7 +333,44 @@ function onPaste(event: ClipboardEvent) {
void files.handlePaste(event, true)
}

function onDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
}
}

function insertWorkspaceReference(targetPath: string) {
const referenceText = buildChatInputWorkspaceReferenceText(
targetPath,
props.workspacePath,
targetPath.split(/[/\\]/).pop()
)
if (!referenceText) {
return false
}

const { from, to } = editor.state.selection
const docSize = editor.state.doc.content.size
const before =
from > 0 ? editor.state.doc.textBetween(Math.max(0, from - 1), from, '\n', '\n') : ''
const after =
to < docSize ? editor.state.doc.textBetween(to, Math.min(docSize, to + 1), '\n', '\n') : ''
const prefix = before && !/\s/.test(before) ? ' ' : ''
const suffix = after && /\s/.test(after) ? '' : ' '

editor.chain().focus().insertContent(`${prefix}${referenceText}${suffix}`).run()
return true
}

function onDrop(event: DragEvent) {
event.preventDefault()

const workspaceItem = getChatInputWorkspaceItemDragData(event.dataTransfer)
if (workspaceItem && insertWorkspaceReference(workspaceItem.path)) {
return
}

if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) {
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { ACP_WORKSPACE_EVENTS } from '@/events'
import { usePresenter } from '@/composables/usePresenter'
import { useMcpStore } from '@/stores/mcp'
import { useSkillsStore } from '@/stores/skillsStore'
import {
buildChatInputWorkspaceReferenceText,
resolveChatInputWorkspaceReferencePath
} from '@/lib/chatInputWorkspaceReference'
import SuggestionList from '../mentions/SuggestionList.vue'
import {
buildCommandText,
Expand Down Expand Up @@ -154,16 +158,19 @@ export function useChatInputMentions(options: UseChatInputMentionsOptions) {
([] as WorkspaceFileNode[])

return result.slice(0, 20).map((file) => {
const relativePath = window.api.toRelativePath?.(file.path, workspacePath) ?? ''
const displayPath = relativePath || file.name
const displayPath = resolveChatInputWorkspaceReferencePath(
file.path,
workspacePath,
file.name
)
return {
id: `file:${file.path}`,
category: 'file' as const,
label: displayPath,
description: file.path,
payload: {
path: file.path,
insertText: `@${displayPath} `
insertText: `${buildChatInputWorkspaceReferenceText(file.path, workspacePath, file.name)} `
}
}
})
Expand Down
12 changes: 11 additions & 1 deletion src/renderer/src/components/workspace/WorkspaceFileNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
<ContextMenu>
<ContextMenuTrigger as-child>
<button
class="flex w-full items-center gap-1.5 px-4 py-1 text-left text-xs transition hover:bg-muted/40"
class="flex w-full cursor-grab items-center gap-1.5 px-4 py-1 text-left text-xs transition hover:bg-muted/40 active:cursor-grabbing"
:style="{ paddingLeft: `${16 + depth * 12}px` }"
type="button"
draggable="true"
@click="handleClick"
@dragstart="handleDragStart"
>
<!-- Expand/collapse icon for directories -->
<Icon
Expand Down Expand Up @@ -62,6 +64,7 @@ import { computed } from 'vue'
import { Icon } from '@iconify/vue'
import { useI18n } from 'vue-i18n'
import { usePresenter } from '@/composables/usePresenter'
import { setChatInputWorkspaceItemDragData } from '@/lib/chatInputWorkspaceReference'
import {
ContextMenu,
ContextMenuContent,
Expand Down Expand Up @@ -156,6 +159,13 @@ const handleRevealInFolder = async () => {
const handleAppendFromMenu = () => {
emitAppendPath()
}

const handleDragStart = (event: DragEvent) => {
setChatInputWorkspaceItemDragData(event.dataTransfer, {
path: props.node.path,
isDirectory: props.node.isDirectory
})
}
</script>

<style scoped>
Expand Down
123 changes: 123 additions & 0 deletions src/renderer/src/lib/chatInputWorkspaceReference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
export const CHAT_INPUT_WORKSPACE_ITEM_MIME = 'application/x-deepchat-workspace-item'

export interface ChatInputWorkspaceItemDragPayload {
path: string
isDirectory: boolean
}

const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null
}

const normalizeTrimmedString = (value: unknown): string => {
return typeof value === 'string' ? value.trim() : ''
}

const resolveRelativePathFallback = (targetPath: string, workspacePath: string): string => {
const normalizedTarget = targetPath.replace(/\\/g, '/')
const normalizedWorkspace = workspacePath.replace(/\\/g, '/').replace(/\/+$/, '')

if (!normalizedWorkspace) {
return targetPath
}

if (normalizedTarget === normalizedWorkspace) {
return ''
}

const expectedPrefix = `${normalizedWorkspace}/`
if (!normalizedTarget.startsWith(expectedPrefix)) {
return targetPath
}

return normalizedTarget.slice(expectedPrefix.length)
}

export const setChatInputWorkspaceItemDragData = (
dataTransfer: DataTransfer | null | undefined,
payload: ChatInputWorkspaceItemDragPayload
) => {
const path = normalizeTrimmedString(payload.path)
if (!dataTransfer || !path) {
return
}

dataTransfer.setData(
CHAT_INPUT_WORKSPACE_ITEM_MIME,
JSON.stringify({
path,
isDirectory: Boolean(payload.isDirectory)
})
)
dataTransfer.effectAllowed = 'copy'
}

export const getChatInputWorkspaceItemDragData = (
dataTransfer: DataTransfer | null | undefined
): ChatInputWorkspaceItemDragPayload | null => {
const dragTypes = dataTransfer?.types ? Array.from(dataTransfer.types) : []
if (!dragTypes.includes(CHAT_INPUT_WORKSPACE_ITEM_MIME)) {
return null
}

try {
const raw = dataTransfer?.getData(CHAT_INPUT_WORKSPACE_ITEM_MIME)
const parsed = JSON.parse(raw || '{}') as unknown
if (!isRecord(parsed)) {
return null
}

const path = normalizeTrimmedString(parsed.path)
if (!path) {
return null
}

return {
path,
isDirectory: Boolean(parsed.isDirectory)
}
} catch (error) {
console.warn('[ChatInputWorkspaceReference] Failed to parse drag payload:', error)
return null
}
}

export const resolveChatInputWorkspaceReferencePath = (
targetPath: string,
workspacePath?: string | null,
fallbackName?: string | null
): string => {
const normalizedTargetPath = normalizeTrimmedString(targetPath)
const normalizedFallbackName = normalizeTrimmedString(fallbackName)
if (!normalizedTargetPath) {
return normalizedFallbackName
}

const normalizedWorkspacePath = normalizeTrimmedString(workspacePath)
if (!normalizedWorkspacePath) {
return normalizedFallbackName || normalizedTargetPath
}

const relativePath =
window.api?.toRelativePath?.(normalizedTargetPath, normalizedWorkspacePath) ??
resolveRelativePathFallback(normalizedTargetPath, normalizedWorkspacePath)
const normalizedRelativePath = normalizeTrimmedString(relativePath)
if (normalizedRelativePath && normalizedRelativePath !== '.') {
return normalizedRelativePath
}

return normalizedFallbackName || normalizedTargetPath
}

export const buildChatInputWorkspaceReferenceText = (
targetPath: string,
workspacePath?: string | null,
fallbackName?: string | null
): string => {
const referencePath = resolveChatInputWorkspaceReferencePath(
targetPath,
workspacePath,
fallbackName
)
return referencePath ? `@${referencePath}` : ''
}
48 changes: 45 additions & 3 deletions test/renderer/components/ChatInputBox.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, ref, nextTick } from 'vue'
import { CHAT_INPUT_WORKSPACE_ITEM_MIME } from '@/lib/chatInputWorkspaceReference'

const handlePasteMock = vi.fn().mockResolvedValue(undefined)
const handleDropMock = vi.fn().mockResolvedValue(undefined)
const openFilePickerMock = vi.fn()
const deleteFileMock = vi.fn()
const insertContentMock = vi.fn()
const selectedFilesRef = ref<any[]>([])
const activeSkillsRef = ref<string[]>([])
const pendingSkillsRef = ref<string[]>([])
Expand All @@ -25,13 +27,23 @@ vi.mock('@tiptap/vue-3', () => {
setContent: vi.fn()
}
public state = {
doc: {},
doc: {
content: {
size: 0
},
textBetween: vi.fn(() => '')
},
selection: {
from: 0,
to: 0
},
tr: {
setSelection: vi.fn()
}
}
public view = {
dispatch: vi.fn()
dispatch: vi.fn(),
updateState: vi.fn()
}
constructor(options: any) {
lastEditorOptions = options
Expand All @@ -40,13 +52,22 @@ vi.mock('@tiptap/vue-3', () => {
return ''
}
chain() {
return {
const api = {
focus: () => api,
insertContent: (content: string) => {
insertContentMock(content)
return api
},
run: () => true,
setHardBreak: () => ({
scrollIntoView: () => ({
run: () => true
})
})
}
return {
...api
}
}
destroy() {}
}
Expand Down Expand Up @@ -195,6 +216,27 @@ describe('ChatInputBox attachments', () => {
expect(handleDropMock).toHaveBeenCalledWith(files)
})

it('inserts workspace references for internal workspace drops', async () => {
const wrapper = await mountComponent()
const dataTransfer = {
types: [CHAT_INPUT_WORKSPACE_ITEM_MIME],
getData: vi.fn(() =>
JSON.stringify({
path: '/repo/src/App.vue',
isDirectory: false
})
)
} as unknown as DataTransfer

await wrapper.setProps({
workspacePath: '/repo'
})
await wrapper.trigger('drop', { dataTransfer })

expect(insertContentMock).toHaveBeenCalledWith('@src/App.vue ')
expect(handleDropMock).not.toHaveBeenCalled()
})

it('handles remove attached file', async () => {
const wrapper = await mountComponent({
files: [{ name: 'a.txt', path: '/tmp/a.txt' }]
Expand Down
Loading
Loading