Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/locales/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/locales/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/locales/zh-tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/translate/de-de.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/translate/el-gr.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/translate/es-es.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/translate/fr-fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/translate/ja-jp.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}に翻訳",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/translate/pt-pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/translate/ro-ro.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/i18n/translate/ru-ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,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}}",
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/src/pages/home/Markdown/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -65,6 +66,15 @@ const CodeBlock: React.FC<Props> = ({ 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 (
<code className={className} style={{ textWrap: 'wrap', fontSize: '95%', padding: '2px 4px' }}>
<ClickableFilePath path={children} />
</code>
)
}

return (
<code className={className} style={{ textWrap: 'wrap', fontSize: '95%', padding: '2px 4px' }}>
{children}
Expand Down
33 changes: 33 additions & 0 deletions src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <span data-testid="clickable-file-path">{path}</span>
}))

describe('CodeBlock', () => {
const defaultProps = {
blockId: 'test-msg-block-id',
Expand Down Expand Up @@ -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(<CodeBlock {...pathProps} />)

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(<CodeBlock {...defaultProps} className={undefined} children={path} />)
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(<CodeBlock {...defaultProps} className={undefined} children={text} />)
expect(screen.queryByTestId('clickable-file-path')).not.toBeInTheDocument()
}
)
})

describe('save', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { EllipsisOutlined } from '@ant-design/icons'
import { CursorIcon, VSCodeIcon, ZedIcon } from '@renderer/components/Icons/SVGIcon'
import { useExternalApps } from '@renderer/hooks/useExternalApps'
import type { ExternalAppInfo } from '@shared/externalApp/types'
import { Dropdown, type MenuProps } from 'antd'
import { FolderOpen } from 'lucide-react'
import { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'

interface ClickableFilePathProps {
path: string
displayName?: string
}

const getEditorIcon = (app: ExternalAppInfo) => {
switch (app.id) {
case 'vscode':
return <VSCodeIcon className="size-4" />
case 'cursor':
return <CursorIcon className="size-4" />
case 'zed':
return <ZedIcon className="size-4" />
}
}

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) => {
const encodedPath = path.split(/[/\\]/).map(encodeURIComponent).join('/')
window.open(`${app.protocol}file/${encodedPath}?windowId=_blank`)
},
[path]
)

const handleOpen = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
window.api.file.openPath(path).catch(() => {
window.toast.error(t('chat.input.tools.open_file_error', { path }))
})
},
[path, t]
)

const menuItems: MenuProps['items'] = useMemo(() => {
const items: MenuProps['items'] = [
{
key: 'reveal',
label: t('chat.input.tools.reveal_in_finder'),
icon: <FolderOpen size={16} />,
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 (
<span className="inline-flex items-center gap-0.5">
<span
onClick={handleOpen}
className="cursor-pointer hover:underline"
style={{ color: 'var(--color-link)', wordBreak: 'break-all' }}>
{displayName ?? path}
</span>
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
<EllipsisOutlined
onClick={(e) => e.stopPropagation()}
className="cursor-pointer opacity-50 hover:opacity-100"
style={{ color: 'var(--color-link)', fontSize: '12px' }}
/>
</Dropdown>
</span>
)
})
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,7 +40,7 @@ export function EditTool({
label: (
<ToolHeader
toolName={AgentToolsType.Edit}
params={input?.file_path}
params={input?.file_path ? <ClickableFilePath path={input.file_path} /> : undefined}
variant="collapse-label"
showStatus={false}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,7 +35,17 @@ export function GlobTool({
),
children: (
<div>
<div>{truncatedOutput}</div>
<div>
{truncatedOutput?.split('\n').map((line, i) =>
line.startsWith('/') ? (
<div key={i}>
<ClickableFilePath path={line} />
</div>
) : (
<div key={i}>{line}</div>
)
)}
</div>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -35,7 +36,20 @@ export function GrepTool({
),
children: (
<div>
<div>{truncatedOutput}</div>
<div>
{truncatedOutput?.split('\n').map((line, i) => {
const match = line.match(/^(\/[^:]+)(:.*)?$/)
if (match) {
return (
<div key={i}>
<ClickableFilePath path={match[1]} />
{match[2] ?? ''}
</div>
)
}
return <div key={i}>{line}</div>
})}
</div>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
Expand Down
Loading
Loading