Unverified 提交 2b3a4427 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Feat: referencing files from the code editor (#3146)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3146" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 0e436bc7
......@@ -18,25 +18,21 @@ test("file tree search finds content matches and surfaces line numbers", async (
const searchInput = po.page.getByTestId("file-tree-search");
await expect(searchInput).toBeVisible({ timeout: Timeout.MEDIUM });
// Scope searches to the file tree to avoid matching elements in the chat area
const fileTree = po.page.locator(".file-tree");
// Content search should find files whose contents match the query and show line info
await searchInput.fill("import");
const resultItem = po.page.getByText("main.tsx").first();
const resultItem = fileTree.getByText("src/main.tsx").first();
await expect(resultItem).toBeVisible({ timeout: Timeout.MEDIUM });
// Files are collapsed by default in the new accordion UI, so we need to click to expand
// Find the file name container (the clickable div that toggles expansion)
const fileContainer = resultItem
.locator("xpath=ancestor::div[contains(@class, 'cursor-pointer')]")
.first();
await expect(fileContainer).toBeVisible({ timeout: Timeout.MEDIUM });
// Click on the file name to expand the accordion and show snippets
await fileContainer.click();
// Click on the file path text to expand the accordion and show snippets.
// Clicking the text bubbles to the parent div's handleFileClick handler.
await resultItem.click();
// Now the snippets should be visible - find the snippet container
// The snippet is a div with class "ml-12" that contains the code snippet
// Find it by looking for text containing "import" in the expanded section
const snippetContainer = po.page
const snippetContainer = fileTree
.locator("div.ml-12")
.filter({ hasText: /import/i })
.first();
......
......@@ -20,3 +20,61 @@ test("mention file", async ({ po }) => {
await po.snapshotServerDump("all-messages");
});
test("reference file from editor file tree", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
await po.navigation.goToChatTab();
await po.previewPanel.selectPreviewMode("code");
// Wait for the file tree to finish loading
await expect(
po.page.getByText("Loading files...", { exact: false }),
).toBeHidden({
timeout: Timeout.LONG,
});
// Type [dump] into chat input first, before clicking the mention button.
// This avoids Lexical's ExternalValueSyncPlugin overwriting typed text when the atom updates.
const chatInput = po.chatActions.getChatInput();
await chatInput.click();
await chatInput.pressSequentially("[dump]");
// Wait for the atom to sync with the typed text
await expect(async () => {
const text = await chatInput.textContent();
expect(text).toContain("[dump]");
}).toPass({ timeout: Timeout.SHORT });
// Find the file row containing "App.tsx" and its mention button.
// Use xpath=.. to go from the text span to its immediate parent div.
const appTsxText = po.page
.locator(".file-tree")
.getByText("App.tsx", { exact: true })
.first();
await expect(appTsxText).toBeVisible({ timeout: Timeout.MEDIUM });
// Navigate to the parent div (the .group row) and hover to reveal the mention button
const fileRow = appTsxText.locator("xpath=..");
await fileRow.hover();
// Click the "Mention file in chat" button within this specific row
const mentionButton = fileRow.getByRole("button", {
name: "Mention file in chat",
});
await expect(mentionButton).toBeVisible({ timeout: Timeout.SHORT });
await mentionButton.click();
// Verify the file reference was appended to the chat input
await expect(async () => {
const text = await chatInput.textContent();
expect(text).toContain("[dump]");
expect(text).toContain("App.tsx");
}).toPass({ timeout: Timeout.SHORT });
// Send the message and verify the server receives the file reference
await po.page.getByRole("button", { name: "Send message" }).click();
await po.chatActions.waitForChatCompletion();
await po.snapshotServerDump("all-messages");
});
===
role: system
message: [[SYSTEM_MESSAGE]]
===
role: user
message: This is my codebase. <dyad-file path=".gitignore">
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
</dyad-file>
<dyad-file path="file1.txt">
// File contents excluded from context
</dyad-file>
<dyad-file path="index.html">
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dyad-generated-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</dyad-file>
<dyad-file path="src/App.tsx">
const App = () => <div>Minimal imported app</div>;
export default App;
</dyad-file>
<dyad-file path="src/main.tsx">
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(<App />);
</dyad-file>
<dyad-file path="src/vite-env.d.ts">
/// <reference types="vite/client" />
</dyad-file>
<dyad-file path="tsconfig.app.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="tsconfig.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="tsconfig.node.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="vite.config.ts">
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig(() => ({
server: {
host: "::",
port: 8080,
},
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));
</dyad-file>
===
role: assistant
message: OK, got it. I'm ready to help
===
role: user
message: Generate an AI_RULES.md file for this app. Describe the tech stack in 5-10 bullet points and describe clear rules about what libraries to use for what.
===
role: assistant
message: <dyad-write path="file1.txt">
A file (2)
</dyad-write>
More
EOM
===
role: user
message: [dump] @file:src/App.tsx
\ No newline at end of file
import { useEffect, useMemo, useRef, useState } from "react";
import {
MessageCircle,
ChevronDown,
ChevronRight,
Folder,
......@@ -11,9 +12,15 @@ import {
import { selectedFileAtom } from "@/atoms/viewAtoms";
import { useSetAtom } from "jotai";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { AppFileSearchResult } from "@/ipc/types";
import { useSearchAppFiles } from "@/hooks/useSearchAppFiles";
import { useTranslation } from "react-i18next";
import { chatInputValueAtom } from "@/atoms/chatAtoms";
interface FileTreeProps {
appId: number | null;
......@@ -38,6 +45,42 @@ const useDebouncedValue = <T,>(value: T, delay = 200) => {
return debouncedValue;
};
const MentionFileButton = ({ filePath }: { filePath: string }) => {
const handleMentionFile = useMentionFile(filePath);
const { t } = useTranslation("home");
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="ml-1 flex-shrink-0 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
onClick={handleMentionFile}
aria-label={t("mentionFileInChat")}
>
<MessageCircle size={14} />
</button>
}
/>
<TooltipContent>{t("mentionFileInChat")}</TooltipContent>
</Tooltip>
);
};
const useMentionFile = (filePath: string) => {
const setChatInputValue = useSetAtom(chatInputValueAtom);
return (e: React.MouseEvent) => {
e.stopPropagation();
const mention = `@file:${filePath}`;
setChatInputValue((prev) => {
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`(^|\\s)${escaped}(\\s|$)`).test(prev)) return prev;
const separator = prev.trim() ? " " : "";
return prev.trimEnd() + separator + mention + " ";
});
};
};
const highlightMatch = (text: string, query: string) => {
const trimmedQuery = query.trim();
if (!trimmedQuery) return text;
......@@ -317,7 +360,7 @@ const SearchResultItem = ({
return (
<div className="py-1">
<div
className="flex items-center rounded px-1.5 py-1 text-sm hover:bg-(--sidebar) cursor-pointer"
className="group flex items-center rounded px-1.5 py-1 text-sm hover:bg-(--sidebar) cursor-pointer"
onClick={handleFileClick}
>
{/* Chevron */}
......@@ -328,6 +371,9 @@ const SearchResultItem = ({
{/* Path */}
<span className="truncate flex-1">{path}</span>
{/* Mention button */}
<MentionFileButton filePath={path} />
{/* Count badge (right-aligned, circular) */}
<span
className="
......@@ -400,7 +446,7 @@ const TreeNode = ({
return (
<li className="py-0.5">
<div
className="flex items-center rounded px-1.5 py-0.5 text-sm hover:bg-(--sidebar)"
className="group flex items-center rounded px-1.5 py-0.5 text-sm hover:bg-(--sidebar)"
onClick={handleClick}
>
{node.isDirectory && (
......@@ -411,6 +457,7 @@ const TreeNode = ({
<span className="truncate flex-1">
{isSearchMode ? highlightMatch(node.name, searchQuery) : node.name}
</span>
{!node.isDirectory && <MentionFileButton filePath={node.path} />}
</div>
{match?.matchesContent &&
......
{
"mentionFileInChat": "Mention file in chat",
"buildingApp": "Building your app",
"settingUp": "We're setting up your app with AI magic.",
"mightTakeMoment": "This might take a moment...",
......
{
"mentionFileInChat": "Mencionar arquivo no chat",
"buildingApp": "Construindo seu app",
"settingUp": "Estamos configurando seu app com a magia da IA.",
"mightTakeMoment": "Isso pode levar um momento...",
......
{
"mentionFileInChat": "在聊天中提及文件",
"buildingApp": "正在构建您的应用",
"settingUp": "我们正在用 AI 魔法为您设置应用。",
"mightTakeMoment": "这可能需要一些时间...",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论