diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 7bbe4d8173e..419ea45e5ac 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -888,7 +888,11 @@ "collapse": "Collapse", "collapse_in": "Collapse", "collapse_out": "Remove from collapse", - "expand": "Expand" + "expand": "Expand", + "file_not_found": "File not found: {{path}}", + "open_file": "Open File", + "open_file_error": "Failed to open file: {{path}}", + "reveal_in_finder": "Reveal in Finder" }, "topics": " Topics ", "translate": "Translate to {{target_language}}", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index de9f4bf6b16..e660c6c9b00 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -888,7 +888,11 @@ "collapse": "折叠", "collapse_in": "加入折叠", "collapse_out": "移出折叠", - "expand": "展开" + "expand": "展开", + "file_not_found": "文件不存在: {{path}}", + "open_file": "打开文件", + "open_file_error": "无法打开文件: {{path}}", + "reveal_in_finder": "在文件管理器中显示" }, "topics": "话题", "translate": "翻译成 {{target_language}}", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 16d653ad2f6..5400a309762 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -888,7 +888,11 @@ "collapse": "折疊", "collapse_in": "加入折疊", "collapse_out": "移出折疊", - "expand": "展開" + "expand": "展開", + "file_not_found": "檔案不存在: {{path}}", + "open_file": "開啟檔案", + "open_file_error": "無法開啟檔案: {{path}}", + "reveal_in_finder": "在檔案管理器中顯示" }, "topics": "話題", "translate": "翻譯成 {{target_language}}", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index fff068a8ab5..16b0d14b444 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -888,7 +888,11 @@ "collapse": "Falten", "collapse_in": "Zum Falten hinzufügen", "collapse_out": "Aus Falten entfernen", - "expand": "Ausklappen" + "expand": "Ausklappen", + "file_not_found": "[to be translated]:File not found: {{path}}", + "open_file": "[to be translated]:Open File", + "open_file_error": "[to be translated]:Failed to open file: {{path}}", + "reveal_in_finder": "[to be translated]:Reveal in Finder" }, "topics": "Themen", "translate": "Übersetzen nach {{target_language}}", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index e97c4184716..f2409e290f6 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -888,7 +888,11 @@ "collapse": "Σύμπτυξη", "collapse_in": "Εισαγωγή σε σύμπτυξη", "collapse_out": "Αφαίρεση από σύμπτυξη", - "expand": "Επέκταση" + "expand": "Επέκταση", + "file_not_found": "[to be translated]:File not found: {{path}}", + "open_file": "[to be translated]:Open File", + "open_file_error": "[to be translated]:Failed to open file: {{path}}", + "reveal_in_finder": "[to be translated]:Reveal in Finder" }, "topics": "Θέματα", "translate": "Μετάφραση στο {{target_language}}", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 1d953b16cdc..d40fec898da 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -888,7 +888,11 @@ "collapse": "Contraer", "collapse_in": "Agregar a la contracción", "collapse_out": "Eliminar de la contracción", - "expand": "Expandir" + "expand": "Expandir", + "file_not_found": "[to be translated]:File not found: {{path}}", + "open_file": "[to be translated]:Open File", + "open_file_error": "[to be translated]:Failed to open file: {{path}}", + "reveal_in_finder": "[to be translated]:Reveal in Finder" }, "topics": "Temas", "translate": "Traducir a {{target_language}}", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 04657bdfb1d..1d2175e4e7c 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -888,7 +888,11 @@ "collapse": "Réduire", "collapse_in": "Ajouter à la réduction", "collapse_out": "Retirer de la réduction", - "expand": "Développer" + "expand": "Développer", + "file_not_found": "[to be translated]:File not found: {{path}}", + "open_file": "[to be translated]:Open File", + "open_file_error": "[to be translated]:Failed to open file: {{path}}", + "reveal_in_finder": "[to be translated]:Reveal in Finder" }, "topics": "Sujets", "translate": "Traduire en {{target_language}}", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index edbf0006974..0602c62a785 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -888,7 +888,11 @@ "collapse": "折りたたむ", "collapse_in": "折りたたむ", "collapse_out": "展開", - "expand": "展開" + "expand": "展開", + "file_not_found": "[to be translated]:File not found: {{path}}", + "open_file": "[to be translated]:Open File", + "open_file_error": "[to be translated]:Failed to open file: {{path}}", + "reveal_in_finder": "[to be translated]:Reveal in Finder" }, "topics": " トピック ", "translate": "{{target_language}}に翻訳", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 39abb9b43e8..e9d130fc028 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -888,7 +888,11 @@ "collapse": "Recolher", "collapse_in": "Incluir no recolhimento", "collapse_out": "Remover do recolhimento", - "expand": "Expandir" + "expand": "Expandir", + "file_not_found": "[to be translated]:File not found: {{path}}", + "open_file": "[to be translated]:Open File", + "open_file_error": "[to be translated]:Failed to open file: {{path}}", + "reveal_in_finder": "[to be translated]:Reveal in Finder" }, "topics": "Tópicos", "translate": "Traduzir para {{target_language}}", diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index b50e20624e0..8179f74ff49 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -888,7 +888,11 @@ "collapse": "Restrânge", "collapse_in": "Restrânge", "collapse_out": "Elimină din restrângere", - "expand": "Extinde" + "expand": "Extinde", + "file_not_found": "[to be translated]:File not found: {{path}}", + "open_file": "[to be translated]:Open File", + "open_file_error": "[to be translated]:Failed to open file: {{path}}", + "reveal_in_finder": "[to be translated]:Reveal in Finder" }, "topics": " Subiecte ", "translate": "Tradu în {{target_language}}", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 96d276c3ac4..2bd275bd004 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -888,7 +888,11 @@ "collapse": "Свернуть", "collapse_in": "Свернуть", "collapse_out": "Развернуть", - "expand": "Развернуть" + "expand": "Развернуть", + "file_not_found": "[to be translated]:File not found: {{path}}", + "open_file": "[to be translated]:Open File", + "open_file_error": "[to be translated]:Failed to open file: {{path}}", + "reveal_in_finder": "[to be translated]:Reveal in Finder" }, "topics": " Топики ", "translate": "Перевести на {{target_language}}", diff --git a/src/renderer/src/pages/agents/components/AgentChatNavbar/OpenExternalAppButton.tsx b/src/renderer/src/pages/agents/components/AgentChatNavbar/OpenExternalAppButton.tsx index f6c56d7ff57..5e8f5eea7df 100644 --- a/src/renderer/src/pages/agents/components/AgentChatNavbar/OpenExternalAppButton.tsx +++ b/src/renderer/src/pages/agents/components/AgentChatNavbar/OpenExternalAppButton.tsx @@ -1,7 +1,7 @@ import { DownOutlined } from '@ant-design/icons' import { loggerService } from '@logger' -import { CursorIcon, VSCodeIcon, ZedIcon } from '@renderer/components/Icons/SVGIcon' import { useExternalApps } from '@renderer/hooks/useExternalApps' +import { buildEditorUrl, getEditorIcon } from '@renderer/utils/editorUtils' import type { ExternalAppInfo } from '@shared/externalApp/types' import { Button, Dropdown, type MenuProps, Space, Tooltip } from 'antd' import { useCallback, useMemo, useState } from 'react' @@ -9,17 +9,6 @@ import { useTranslation } from 'react-i18next' const logger = loggerService.withContext('OpenExternalAppButton') -const getEditorIcon = (app: ExternalAppInfo) => { - switch (app.id) { - case 'vscode': - return - case 'cursor': - return - case 'zed': - return - } -} - type OpenExternalAppButtonProps = { workdir: string className?: string @@ -37,24 +26,12 @@ const OpenExternalAppButton = ({ workdir, className }: OpenExternalAppButtonProp const openInEditor = useCallback( (app: ExternalAppInfo) => { - const encodedPath = workdir.split(/[/\\]/).map(encodeURIComponent).join('/') switch (app.id) { case 'vscode': - case 'cursor': { - // https://code.visualstudio.com/docs/configure/command-line#_opening-vs-code-with-urls - // https://github.com/microsoft/vscode/issues/141548#issuecomment-1102200617 - const appUrl = `${app.protocol}file/${encodedPath}?windowId=_blank` - window.open(appUrl) - break - } - case 'zed': { - // https://github.com/zed-industries/zed/issues/8482 - // Zed parses URLs by stripping "zed://file" prefix, so the format is - // zed://file/absolute/path (no extra "/" between "file" and path, no query params) - const appUrl = `${app.protocol}file${encodedPath}` - window.open(appUrl) + case 'cursor': + case 'zed': + window.open(buildEditorUrl(app, workdir)) break - } default: logger.error(`Unexpected Error: External app not found: ${app.id}`) window.toast.error(`Unexpected Error: External app not found: ${app.id}`) diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index 4e01e93e2cd..582033a6c68 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -1,5 +1,6 @@ import { CodeBlockView, HtmlArtifactsCard } from '@renderer/components/CodeBlockView' import { useSettings } from '@renderer/hooks/useSettings' +import { ClickableFilePath } from '@renderer/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import store from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' @@ -65,6 +66,15 @@ const CodeBlock: React.FC = ({ children, className, node, blockId }) => { ) } + // Detect inline code that looks like an absolute file path (e.g. /Users/foo/bar.tsx) + if (typeof children === 'string' && /^\/[\w.-]+(?:\/[\w.-]+)+$/.test(children)) { + return ( + + + + ) + } + return ( {children} diff --git a/src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx index 9ada24b8298..3dfdfc3cb72 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx @@ -63,6 +63,11 @@ vi.mock('@renderer/components/CodeBlockView', () => ({ HtmlArtifactsCard: mocks.HtmlArtifactsCard })) +// Mock ClickableFilePath +vi.mock('@renderer/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath', () => ({ + ClickableFilePath: ({ path }: { path: string }) => {path} +})) + describe('CodeBlock', () => { const defaultProps = { blockId: 'test-msg-block-id', @@ -106,6 +111,34 @@ describe('CodeBlock', () => { expect(codeElement.tagName).toBe('CODE') expect(mocks.CodeBlockView).not.toHaveBeenCalled() }) + + it('should render ClickableFilePath for absolute file paths', () => { + const pathProps = { + ...defaultProps, + className: undefined, + children: '/Users/foo/bar.tsx' + } + render() + + expect(screen.getByTestId('clickable-file-path')).toBeInTheDocument() + expect(screen.getByText('/Users/foo/bar.tsx')).toBeInTheDocument() + }) + + it.each(['/home/user/project/src/index.ts', '/tmp/test.log', '/var/log/app.log', '/etc/nginx/nginx.conf'])( + 'should detect %s as a file path', + (path) => { + render() + expect(screen.getByTestId('clickable-file-path')).toBeInTheDocument() + } + ) + + it.each(['inline code', '/single-segment', '//comment style', 'not/absolute/path', '/path with spaces/file.ts'])( + 'should NOT detect %s as a file path', + (text) => { + render() + expect(screen.queryByTestId('clickable-file-path')).not.toBeInTheDocument() + } + ) }) describe('save', () => { diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath.tsx new file mode 100644 index 00000000000..53a675fcdaf --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath.tsx @@ -0,0 +1,108 @@ +import { MoreOutlined } from '@ant-design/icons' +import { useExternalApps } from '@renderer/hooks/useExternalApps' +import { buildEditorUrl, getEditorIcon } from '@renderer/utils/editorUtils' +import type { ExternalAppInfo } from '@shared/externalApp/types' +import { Dropdown, type MenuProps, Tooltip } from 'antd' +import { FolderOpen } from 'lucide-react' +import { memo, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +interface ClickableFilePathProps { + path: string + displayName?: string +} + +export const ClickableFilePath = memo(function ClickableFilePath({ path, displayName }: ClickableFilePathProps) { + const { t } = useTranslation() + const { data: externalApps } = useExternalApps() + + const availableEditors = useMemo( + () => externalApps?.filter((app) => app.tags.includes('code-editor')) ?? [], + [externalApps] + ) + + const openInEditor = useCallback( + (app: ExternalAppInfo) => { + window.open(buildEditorUrl(app, path)) + }, + [path] + ) + + const handleOpen = useCallback( + (e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation() + window.api.file.openPath(path).catch(() => { + window.toast.error(t('chat.input.tools.open_file_error', { path })) + }) + }, + [path, t] + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleOpen(e) + } + }, + [handleOpen] + ) + + const menuItems: MenuProps['items'] = useMemo(() => { + const items: MenuProps['items'] = [ + { + key: 'reveal', + label: t('chat.input.tools.reveal_in_finder'), + icon: , + onClick: ({ domEvent }) => { + domEvent.stopPropagation() + window.api.file.showInFolder(path).catch(() => { + window.toast.error(t('chat.input.tools.file_not_found', { path })) + }) + } + } + ] + + if (availableEditors.length > 0) { + items.push({ type: 'divider' }) + for (const app of availableEditors) { + items.push({ + key: app.id, + label: app.name, + icon: getEditorIcon(app), + onClick: ({ domEvent }) => { + domEvent.stopPropagation() + openInEditor(app) + } + }) + } + } + + return items + }, [path, t, availableEditors, openInEditor]) + + return ( + + + + {displayName ?? path} + + + + + e.stopPropagation()} + className="cursor-pointer rounded px-0.5 opacity-60 hover:bg-black/10 hover:opacity-100" + style={{ color: 'var(--color-link)', fontSize: '14px' }} + /> + + + + ) +}) diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx index c01aae2cd6f..d9167a4444e 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx @@ -1,5 +1,6 @@ import type { CollapseProps } from 'antd' +import { ClickableFilePath } from './ClickableFilePath' import { ToolHeader } from './GenericTools' import type { EditToolInput, EditToolOutput } from './types' import { AgentToolsType } from './types' @@ -39,7 +40,7 @@ export function EditTool({ label: ( : undefined} variant="collapse-label" showStatus={false} /> diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx index 6e4def8afd5..831b508be28 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx @@ -2,6 +2,7 @@ import type { CollapseProps } from 'antd' import { useTranslation } from 'react-i18next' import { countLines, truncateOutput } from '../shared/truncateOutput' +import { ClickableFilePath } from './ClickableFilePath' import { ToolHeader, TruncatedIndicator } from './GenericTools' import { AgentToolsType, @@ -34,7 +35,17 @@ export function GlobTool({ ), children: (
-
{truncatedOutput}
+
+ {truncatedOutput?.split('\n').map((line, i) => + line.startsWith('/') ? ( +
+ +
+ ) : ( +
{line}
+ ) + )} +
{isTruncated && }
) diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx index cd4f907b5f9..c98c5c834cf 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx @@ -2,6 +2,7 @@ import type { CollapseProps } from 'antd' import { useTranslation } from 'react-i18next' import { countLines, truncateOutput } from '../shared/truncateOutput' +import { ClickableFilePath } from './ClickableFilePath' import { ToolHeader, TruncatedIndicator } from './GenericTools' import { AgentToolsType, type GrepToolInput, type GrepToolOutput } from './types' @@ -35,7 +36,20 @@ export function GrepTool({ ), children: (
-
{truncatedOutput}
+
+ {truncatedOutput?.split('\n').map((line, i) => { + const match = line.match(/^(\/[\w./@+-][^:]*[^:])(:.*)?$/) + if (match) { + return ( +
+ + {match[2] ?? ''} +
+ ) + } + return
{line}
+ })} +
{isTruncated && }
) diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx index dd9c0f18ab8..bf53bcab584 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx @@ -1,5 +1,6 @@ import type { CollapseProps } from 'antd' +import { ClickableFilePath } from './ClickableFilePath' import { renderCodeBlock } from './EditTool' import { ToolHeader } from './GenericTools' import type { MultiEditToolInput, MultiEditToolOutput } from './types' @@ -17,7 +18,7 @@ export function MultiEditTool({ label: ( : undefined} variant="collapse-label" showStatus={false} /> diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx index c3db3bded7a..87c6859ba9d 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx @@ -3,6 +3,7 @@ import { Tag } from 'antd' import ReactMarkdown from 'react-markdown' import { truncateOutput } from '../shared/truncateOutput' +import { ClickableFilePath } from './ClickableFilePath' import { ToolHeader, TruncatedIndicator } from './GenericTools' import type { NotebookEditToolInput, NotebookEditToolOutput } from './types' import { AgentToolsType } from './types' @@ -21,7 +22,7 @@ export function NotebookEditTool({ label: (
- {input?.notebook_path} + {input?.notebook_path ? : undefined}
), children: ( diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx index b0d6d58455b..97328f584d8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' import { truncateOutput } from '../shared/truncateOutput' +import { ClickableFilePath } from './ClickableFilePath' import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools' import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types' import { AgentToolsType } from './types' @@ -54,7 +55,12 @@ export function ReadTool({ label: ( } + params={ + : undefined} + width="120px" + /> + } stats={ stats ? `${t('message.tools.units.line', { count: stats.lineCount })}, ${formatFileSize(stats.fileSize)}` diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx index 406fc896f1b..a2fa5c86055 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx @@ -3,6 +3,7 @@ import { getLanguageByFilePath } from '@renderer/utils/code-language' import type { CollapseProps } from 'antd' import { useMemo } from 'react' +import { ClickableFilePath } from './ClickableFilePath' import { SkeletonValue, ToolHeader } from './GenericTools' import { AgentToolsType, type WriteToolInput, type WriteToolOutput } from './types' @@ -19,7 +20,12 @@ export function WriteTool({ label: ( } + params={ + : undefined} + width="200px" + /> + } variant="collapse-label" showStatus={false} /> diff --git a/src/renderer/src/pages/home/Messages/Tools/__tests__/ClickableFilePath.test.tsx b/src/renderer/src/pages/home/Messages/Tools/__tests__/ClickableFilePath.test.tsx new file mode 100644 index 00000000000..7ac9ecbcdc0 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/__tests__/ClickableFilePath.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ClickableFilePath } from '../MessageAgentTools/ClickableFilePath' + +const mockOpenPath = vi.fn().mockResolvedValue(undefined) +const mockShowInFolder = vi.fn().mockResolvedValue(undefined) + +vi.stubGlobal('api', { + file: { + openPath: mockOpenPath, + showInFolder: mockShowInFolder, + read: vi.fn(), + writeWithId: vi.fn() + } +}) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + 'chat.input.tools.open_file': 'Open File', + 'chat.input.tools.reveal_in_finder': 'Reveal in Finder' + } + return map[key] ?? key + } + }) +})) + +vi.mock('@renderer/hooks/useExternalApps', () => ({ + useExternalApps: () => ({ + data: [ + { id: 'vscode', name: 'Visual Studio Code', protocol: 'vscode://', tags: ['code-editor'], path: '/app/vscode' }, + { id: 'cursor', name: 'Cursor', protocol: 'cursor://', tags: ['code-editor'], path: '/app/cursor' } + ] + }) +})) + +vi.mock('@renderer/utils/editorUtils', () => ({ + getEditorIcon: ({ id }: { id: string }) => , + buildEditorUrl: (app: { protocol: string }, path: string) => + `${app.protocol}file/${path.split('/').map(encodeURIComponent).join('/')}?windowId=_blank` +})) + +describe('ClickableFilePath', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the path as text', () => { + render() + expect(screen.getByText('/Users/foo/bar.tsx')).toBeInTheDocument() + }) + + it('should render displayName when provided', () => { + render() + expect(screen.getByText('bar.tsx')).toBeInTheDocument() + expect(screen.queryByText('/Users/foo/bar.tsx')).not.toBeInTheDocument() + }) + + it('should call openPath on click', () => { + render() + fireEvent.click(screen.getByText('/Users/foo/bar.tsx')) + expect(mockOpenPath).toHaveBeenCalledWith('/Users/foo/bar.tsx') + }) + + it('should have clickable styling', () => { + render() + const span = screen.getByText('/tmp/test.ts') + expect(span).toHaveClass('cursor-pointer') + expect(span).toHaveStyle({ color: 'var(--color-link)' }) + }) + + it('should render ellipsis dropdown trigger', () => { + render() + expect(document.querySelector('.anticon-more')).toBeInTheDocument() + }) + + it('should have role="link" and tabIndex for keyboard accessibility', () => { + render() + const span = screen.getByText('/tmp/test.ts') + expect(span).toHaveAttribute('role', 'link') + expect(span).toHaveAttribute('tabindex', '0') + }) + + it('should call openPath on Enter key', () => { + render() + fireEvent.keyDown(screen.getByText('/Users/foo/bar.tsx'), { key: 'Enter' }) + expect(mockOpenPath).toHaveBeenCalledWith('/Users/foo/bar.tsx') + }) + + it('should call openPath on Space key', () => { + render() + fireEvent.keyDown(screen.getByText('/Users/foo/bar.tsx'), { key: ' ' }) + expect(mockOpenPath).toHaveBeenCalledWith('/Users/foo/bar.tsx') + }) +}) diff --git a/src/renderer/src/utils/editorUtils.tsx b/src/renderer/src/utils/editorUtils.tsx new file mode 100644 index 00000000000..896e38d1409 --- /dev/null +++ b/src/renderer/src/utils/editorUtils.tsx @@ -0,0 +1,29 @@ +import { CursorIcon, VSCodeIcon, ZedIcon } from '@renderer/components/Icons/SVGIcon' +import type { ExternalAppInfo } from '@shared/externalApp/types' + +export const getEditorIcon = (app: ExternalAppInfo, className = 'size-4') => { + switch (app.id) { + case 'vscode': + return + case 'cursor': + return + case 'zed': + return + } +} + +/** + * Build the protocol URL to open a file/folder in an external editor. + * @see https://code.visualstudio.com/docs/configure/command-line#_opening-vs-code-with-urls + * @see https://github.com/microsoft/vscode/issues/141548#issuecomment-1102200617 + * @see https://github.com/zed-industries/zed/issues/8482 + */ +export function buildEditorUrl(app: ExternalAppInfo, filePath: string): string { + const encodedPath = filePath.split(/[/\\]/).map(encodeURIComponent).join('/') + if (app.id === 'zed') { + // Zed parses URLs by stripping "zed://file" prefix, so the format is + // zed://file/absolute/path (no extra "/" between "file" and path, no query params) + return `${app.protocol}file${encodedPath}` + } + return `${app.protocol}file/${encodedPath}?windowId=_blank` +}