Skip to content
Open
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
87 changes: 87 additions & 0 deletions src/__tests__/unit/file-tree-dnd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import {
appendPathMention,
FILE_TREE_DRAG_FALLBACK_MIME,
FILE_TREE_DRAG_MIME,
hasFileTreeDragType,
parseFileTreeDragPayload,
readFileTreeDragPayload,
serializeFileTreeDragPayload,
} from '../../lib/file-tree-dnd';

describe('file-tree drag payload helpers', () => {
const makeTypes = (...types: string[]) => types as unknown as readonly string[];

it('serializes and parses file payload', () => {
const raw = serializeFileTreeDragPayload({
path: 'src/app/page.tsx',
name: 'page.tsx',
type: 'file',
});

assert.deepEqual(parseFileTreeDragPayload(raw), {
path: 'src/app/page.tsx',
name: 'page.tsx',
type: 'file',
});
});

it('normalizes unknown type to file', () => {
assert.deepEqual(
parseFileTreeDragPayload('{"path":"src","name":"src","type":"weird"}'),
{ path: 'src', name: 'src', type: 'file' },
);
});

it('rejects invalid payloads', () => {
assert.equal(parseFileTreeDragPayload(''), null);
assert.equal(parseFileTreeDragPayload('not-json'), null);
assert.equal(parseFileTreeDragPayload('{"name":"src"}'), null);
});

it('detects supported drag MIME types', () => {
assert.equal(hasFileTreeDragType({ types: makeTypes(FILE_TREE_DRAG_MIME) }), true);
assert.equal(hasFileTreeDragType({ types: makeTypes(FILE_TREE_DRAG_FALLBACK_MIME) }), true);
assert.equal(hasFileTreeDragType({ types: makeTypes('text/plain') }), false);
});

it('reads payload from preferred MIME first', () => {
const payload = readFileTreeDragPayload({
types: makeTypes(FILE_TREE_DRAG_MIME, FILE_TREE_DRAG_FALLBACK_MIME),
getData(type: string) {
if (type === FILE_TREE_DRAG_MIME) {
return '{"path":"src/components","name":"components","type":"directory"}';
}
return '';
},
});

assert.deepEqual(payload, {
path: 'src/components',
name: 'components',
type: 'directory',
});
});
});

describe('appendPathMention', () => {
it('appends mention with trailing space', () => {
assert.equal(appendPathMention('', 'src/app/page.tsx'), '@src/app/page.tsx ');
});

it('inserts spacing between existing text and mention', () => {
assert.equal(
appendPathMention('请看这个', 'src/app/page.tsx'),
'请看这个 @src/app/page.tsx ',
);
});

it('does not duplicate an existing mention', () => {
assert.equal(
appendPathMention('请看 @src/app/page.tsx ', 'src/app/page.tsx'),
'请看 @src/app/page.tsx ',
);
});
});
31 changes: 31 additions & 0 deletions src/components/ai-elements/file-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
useMemo,
useState,
} from "react";
import {
FILE_TREE_DRAG_FALLBACK_MIME,
FILE_TREE_DRAG_MIME,
serializeFileTreeDragPayload,
} from "@/lib/file-tree-dnd";

interface FileTreeContextType {
expandedPaths: Set<string>;
Expand Down Expand Up @@ -131,6 +136,17 @@ export const FileTreeFolder = ({
togglePath(path);
}, [togglePath, path]);

const handleDragStart = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
const payload = serializeFileTreeDragPayload({ path, name, type: "directory" });
e.dataTransfer.setData(FILE_TREE_DRAG_MIME, payload);
e.dataTransfer.setData(FILE_TREE_DRAG_FALLBACK_MIME, payload);
e.dataTransfer.setData("text/plain", path);
e.dataTransfer.effectAllowed = "copy";
},
[name, path]
);

const folderContextValue = useMemo(
() => ({ isExpanded, name, path }),
[isExpanded, name, path]
Expand All @@ -147,6 +163,8 @@ export const FileTreeFolder = ({
<CollapsibleTrigger asChild>
<div
className="flex w-full cursor-pointer items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50"
draggable
onDragStart={handleDragStart}
role="button"
tabIndex={0}
onKeyDown={(e) => {
Expand Down Expand Up @@ -232,6 +250,17 @@ export const FileTreeFile = ({
[onAdd, path]
);

const handleDragStart = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
const payload = serializeFileTreeDragPayload({ path, name, type: "file" });
e.dataTransfer.setData(FILE_TREE_DRAG_MIME, payload);
e.dataTransfer.setData(FILE_TREE_DRAG_FALLBACK_MIME, payload);
e.dataTransfer.setData("text/plain", path);
e.dataTransfer.effectAllowed = "copy";
},
[name, path]
);

const fileContextValue = useMemo(() => ({ name, path }), [name, path]);

return (
Expand All @@ -243,7 +272,9 @@ export const FileTreeFile = ({
className
)}
onClick={handleClick}
onDragStart={handleDragStart}
onKeyDown={handleKeyDown}
draggable
role="treeitem"
tabIndex={0}
{...props}
Expand Down
56 changes: 46 additions & 10 deletions src/components/chat/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent } from 'react';
import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent, type DragEvent as ReactDragEvent } from 'react';
import { Terminal } from "@/components/ui/icon";
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
Expand Down Expand Up @@ -35,6 +35,8 @@ import { useCliToolsFetch } from '@/hooks/useCliToolsFetch';
import { useSlashCommands } from '@/hooks/useSlashCommands';
import { resolveKeyAction, cycleIndex, resolveDirectSlash, dispatchBadge, buildCliAppend } from '@/lib/message-input-logic';
import { QuickActions } from './QuickActions';
import { appendPathMention, hasFileTreeDragType, readFileTreeDragPayload } from '@/lib/file-tree-dnd';
import { cn } from '@/lib/utils';

interface MessageInputProps {
onSend: (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => void;
Expand Down Expand Up @@ -87,6 +89,7 @@ export function MessageInput({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const cliSearchRef = useRef<HTMLInputElement>(null);
const [isFileTreeDragOver, setIsFileTreeDragOver] = useState(false);
// Persist draft per session so switching chats doesn't lose typed text.
const draftKey = `codepilot:draft:${sessionId || 'new'}`;
const [inputValue, setInputValueRaw] = useState(() => {
Expand Down Expand Up @@ -165,21 +168,46 @@ export function MessageInput({
}
}, [onAssistantTrigger]);

const handleInsertPathMention = useCallback((filePath: string) => {
if (!filePath) return;
setInputValue((prev) => appendPathMention(prev, filePath));
setTimeout(() => textareaRef.current?.focus(), 0);
}, [setInputValue]);

// Listen for file tree "+" button: insert @filepath into textarea
useEffect(() => {
const handler = (e: Event) => {
const filePath = (e as CustomEvent<{ path: string }>).detail?.path;
if (!filePath) return;
const mention = `@${filePath} `;
setInputValue((prev) => {
const needsSpace = prev.length > 0 && !prev.endsWith(' ') && !prev.endsWith('\n');
return prev + (needsSpace ? ' ' : '') + mention;
});
setTimeout(() => textareaRef.current?.focus(), 0);
handleInsertPathMention(filePath);
};
window.addEventListener('insert-file-mention', handler);
return () => window.removeEventListener('insert-file-mention', handler);
}, [setInputValue]);
}, [handleInsertPathMention]);

const handleFileTreeDragOver = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
if (!hasFileTreeDragType(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setIsFileTreeDragOver(true);
}, []);

const handleFileTreeDragLeave = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsFileTreeDragOver(false);
}, []);

const handleFileTreeDrop = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
const payload = readFileTreeDragPayload(e.dataTransfer);
if (!payload) return;

e.preventDefault();
setIsFileTreeDragOver(false);
handleInsertPathMention(payload.path);

if (payload.type === 'file') {
window.dispatchEvent(new CustomEvent('attach-file-to-chat', { detail: { path: payload.path } }));
}
}, [handleInsertPathMention]);

const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
Expand Down Expand Up @@ -367,7 +395,15 @@ export function MessageInput({
return (
<div className="bg-background/80 backdrop-blur-lg px-4 pt-2 pb-1">
<div className="mx-auto">
<div className="relative">
<div
className={cn(
"relative",
isFileTreeDragOver && "rounded-xl ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
onDragLeave={handleFileTreeDragLeave}
onDragOver={handleFileTreeDragOver}
onDrop={handleFileTreeDrop}
>
{/* Slash Command / File Popover */}
<SlashCommandPopover
popoverMode={popover.popoverMode}
Expand Down
58 changes: 58 additions & 0 deletions src/lib/file-tree-dnd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export const FILE_TREE_DRAG_MIME = 'application/x-codepilot-path';
export const FILE_TREE_DRAG_FALLBACK_MIME = 'text/x-codepilot-path';

export type FileTreeDragPayload = {
path: string;
name: string;
type: 'file' | 'directory';
};

export function serializeFileTreeDragPayload(payload: FileTreeDragPayload): string {
return JSON.stringify(payload);
}

export function parseFileTreeDragPayload(raw: string | null | undefined): FileTreeDragPayload | null {
if (!raw) return null;

try {
const parsed = JSON.parse(raw) as Partial<FileTreeDragPayload>;
if (!parsed.path || typeof parsed.path !== 'string') return null;

return {
path: parsed.path,
name: typeof parsed.name === 'string' ? parsed.name : '',
type: parsed.type === 'directory' ? 'directory' : 'file',
};
} catch {
return null;
}
}

export function hasFileTreeDragType(
dataTransfer: Pick<DataTransfer, 'types'> | null | undefined,
): boolean {
if (!dataTransfer) return false;

const types = Array.from(dataTransfer.types as ArrayLike<string>);
return types.includes(FILE_TREE_DRAG_MIME) || types.includes(FILE_TREE_DRAG_FALLBACK_MIME);
}

export function readFileTreeDragPayload(
dataTransfer: Pick<DataTransfer, 'getData' | 'types'> | null | undefined,
): FileTreeDragPayload | null {
if (!dataTransfer) return null;
if (!hasFileTreeDragType(dataTransfer)) return null;

return parseFileTreeDragPayload(
dataTransfer.getData(FILE_TREE_DRAG_MIME)
|| dataTransfer.getData(FILE_TREE_DRAG_FALLBACK_MIME),
);
}

export function appendPathMention(inputValue: string, path: string): string {
const mention = `@${path}`;
if (inputValue.includes(mention)) return inputValue;

const needsSpace = inputValue.length > 0 && !inputValue.endsWith(' ') && !inputValue.endsWith('\n');
return `${inputValue}${needsSpace ? ' ' : ''}${mention} `;
}
Loading