diff --git a/src/main/presenter/acpWorkspacePresenter/index.ts b/src/main/presenter/acpWorkspacePresenter/index.ts index 5256d7d95..20ce6ed99 100644 --- a/src/main/presenter/acpWorkspacePresenter/index.ts +++ b/src/main/presenter/acpWorkspacePresenter/index.ts @@ -1,4 +1,5 @@ import path from 'path' +import { shell } from 'electron' import { eventBus, SendTarget } from '@/eventbus' import { ACP_WORKSPACE_EVENTS } from '@/events' import { readDirectoryShallow } from './directoryReader' @@ -73,6 +74,46 @@ export class AcpWorkspacePresenter implements IAcpWorkspacePresenter { return readDirectoryShallow(dirPath) } + /** + * Reveal a file or directory in the system file manager + */ + async revealFileInFolder(filePath: string): Promise { + // Security check: only allow revealing within registered workdirs + if (!this.isPathAllowed(filePath)) { + console.warn(`[AcpWorkspace] Blocked reveal attempt for unauthorized path: ${filePath}`) + return + } + + const normalizedPath = path.resolve(filePath) + + try { + shell.showItemInFolder(normalizedPath) + } catch (error) { + console.error(`[AcpWorkspace] Failed to reveal path: ${normalizedPath}`, error) + } + } + + /** + * Open a file or directory with the system default application + */ + async openFile(filePath: string): Promise { + if (!this.isPathAllowed(filePath)) { + console.warn(`[AcpWorkspace] Blocked open attempt for unauthorized path: ${filePath}`) + return + } + + const normalizedPath = path.resolve(filePath) + + try { + const errorMessage = await shell.openPath(normalizedPath) + if (errorMessage) { + console.error(`[AcpWorkspace] Failed to open path: ${normalizedPath}`, errorMessage) + } + } catch (error) { + console.error(`[AcpWorkspace] Failed to open path: ${normalizedPath}`, error) + } + } + /** * Get plan entries */ diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 195757e54..c44294752 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -11,6 +11,8 @@ declare global { getWindowId(): number | null getWebContentsId(): number openExternal?(url: string): Promise + toRelativePath?(filePath: string, baseDir?: string): string + formatPathForInput?(filePath: string): string } floatingButtonAPI: typeof floatingButtonAPI } diff --git a/src/preload/index.ts b/src/preload/index.ts index f3d34a69b..b4672fa4b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import path from 'path' import { clipboard, contextBridge, @@ -44,6 +45,45 @@ const api = { }, openExternal: (url: string) => { return shell.openExternal(url) + }, + toRelativePath: (filePath: string, baseDir?: string) => { + if (!baseDir) return filePath + + try { + const relative = path.relative(baseDir, filePath) + if ( + relative === '' || + (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) + ) { + return relative + } + } catch (error) { + console.warn('Preload: Failed to compute relative path', filePath, baseDir, error) + } + return filePath + }, + formatPathForInput: (filePath: string) => { + const containsSpace = /\s/.test(filePath) + const hasDoubleQuote = filePath.includes('"') + const hasSingleQuote = filePath.includes("'") + + if (!containsSpace && !hasDoubleQuote && !hasSingleQuote) { + return filePath + } + + // Prefer double quotes; escape any existing ones + if (hasDoubleQuote) { + const escaped = filePath.replace(/"/g, '\\"') + return `"${escaped}"` + } + + // Use double quotes when only spaces + if (containsSpace) { + return `"${filePath}"` + } + + // Fallback: no spaces but contains single quotes + return `'${filePath.replace(/'/g, `'\\''`)}'` } } exposeElectronAPI() diff --git a/src/renderer/src/components/ChatView.vue b/src/renderer/src/components/ChatView.vue index f7fccb6b2..a508cb3b1 100644 --- a/src/renderer/src/components/ChatView.vue +++ b/src/renderer/src/components/ChatView.vue @@ -12,7 +12,11 @@ - + @@ -102,6 +106,21 @@ const handleFileUpload = () => { scrollToBottom() } +const formatFilePathForEditor = (filePath: string) => + window.api?.formatPathForInput?.(filePath) ?? (/\s/.test(filePath) ? `"${filePath}"` : filePath) + +const toRelativePath = (filePath: string) => { + const workdir = acpWorkspaceStore.currentWorkdir ?? undefined + return window.api?.toRelativePath?.(filePath, workdir) ?? filePath +} + +const handleAppendFilePath = (filePath: string) => { + const relativePath = toRelativePath(filePath) + const formattedPath = formatFilePathForEditor(relativePath) + chatInput.value?.appendText(`${formattedPath} `) + chatInput.value?.restoreFocus() +} + const onStreamEnd = (_, _msg) => { // 状态处理已移至 store // 当用户没有主动向上滚动时才自动滚动到底部 diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue b/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue index a83bf1a8f..26cf09245 100644 --- a/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue +++ b/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue @@ -1,27 +1,52 @@