Skip to content
Merged
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
41 changes: 41 additions & 0 deletions src/main/presenter/acpWorkspacePresenter/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<void> {
// 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<void> {
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
*/
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ declare global {
getWindowId(): number | null
getWebContentsId(): number
openExternal?(url: string): Promise<void>
toRelativePath?(filePath: string, baseDir?: string): string
formatPathForInput?(filePath: string): string
}
floatingButtonAPI: typeof floatingButtonAPI
}
Expand Down
40 changes: 40 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'path'
import {
clipboard,
contextBridge,
Expand Down Expand Up @@ -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
},
Comment thread
zerob13 marked this conversation as resolved.
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, `'\\''`)}'`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
exposeElectronAPI()
Expand Down
21 changes: 20 additions & 1 deletion src/renderer/src/components/ChatView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@

<!-- ACP Workspace 面板 -->
<Transition name="workspace-slide">
<AcpWorkspaceView v-if="showAcpWorkspace" class="h-full flex-shrink-0" />
<AcpWorkspaceView
v-if="showAcpWorkspace"
class="h-full flex-shrink-0"
@append-file-path="handleAppendFilePath"
/>
</Transition>
</div>

Expand Down Expand Up @@ -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
// 当用户没有主动向上滚动时才自动滚动到底部
Expand Down
108 changes: 88 additions & 20 deletions src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,52 @@
<template>
<div>
<button
class="flex w-full items-center gap-1.5 px-4 py-1 text-left text-xs transition hover:bg-muted/40"
:style="{ paddingLeft: `${16 + depth * 12}px` }"
type="button"
@click="handleClick"
>
<!-- Expand/collapse icon for directories -->
<Icon
v-if="node.isDirectory"
:icon="node.expanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
class="h-3 w-3 flex-shrink-0 text-muted-foreground"
/>
<span v-else class="w-3" />
<ContextMenu>
<ContextMenuTrigger as-child>
<button
class="flex w-full items-center gap-1.5 px-4 py-1 text-left text-xs transition hover:bg-muted/40"
:style="{ paddingLeft: `${16 + depth * 12}px` }"
type="button"
@click="handleClick"
>
<!-- Expand/collapse icon for directories -->
<Icon
v-if="node.isDirectory"
:icon="node.expanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
class="h-3 w-3 flex-shrink-0 text-muted-foreground"
/>
<span v-else class="w-3" />

<!-- File/folder icon -->
<Icon :icon="iconName" class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />

<!-- File/folder icon -->
<Icon :icon="iconName" class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<!-- Name -->
<span class="truncate text-foreground/90 dark:text-white/80">
{{ node.name }}
</span>
</button>
</ContextMenuTrigger>

<!-- Name -->
<span class="truncate text-foreground/90 dark:text-white/80">
{{ node.name }}
</span>
</button>
<ContextMenuContent
class="w-48"
align="start"
:side="depth === 0 ? 'bottom' : 'right'"
:side-offset="4"
>
<ContextMenuItem v-if="!node.isDirectory" @select="handleOpenFile">
<Icon icon="lucide:external-link" class="h-4 w-4" />
{{ t('chat.acp.workspace.files.contextMenu.openFile') }}
</ContextMenuItem>
<ContextMenuItem @select="handleRevealInFolder">
<Icon icon="lucide:folder-open-dot" class="h-4 w-4" />
{{ t('chat.acp.workspace.files.contextMenu.revealInFolder') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @select="handleAppendFromMenu">
<Icon icon="lucide:arrow-down-left" class="h-4 w-4" />
{{ t('chat.acp.workspace.files.contextMenu.insertPath') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>

<!-- Recursive children -->
<template v-if="node.isDirectory && node.expanded && node.children">
Expand All @@ -31,6 +56,7 @@
:node="child"
:depth="depth + 1"
@toggle="$emit('toggle', $event)"
@append-path="$emit('append-path', $event)"
/>
</template>
</div>
Expand All @@ -39,6 +65,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Icon } from '@iconify/vue'
import { useI18n } from 'vue-i18n'
import { usePresenter } from '@/composables/usePresenter'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@shadcn/components/ui/context-menu'
import type { AcpFileNode } from '@shared/presenter'

const props = defineProps<{
Expand All @@ -48,8 +83,12 @@ const props = defineProps<{

const emit = defineEmits<{
toggle: [node: AcpFileNode]
'append-path': [filePath: string]
}>()

const { t } = useI18n()
const acpWorkspacePresenter = usePresenter('acpWorkspacePresenter')

const extensionIconMap: Record<string, string> = {
pdf: 'lucide:file-text',
md: 'lucide:file-text',
Expand Down Expand Up @@ -88,9 +127,38 @@ const iconName = computed(() => {
return 'lucide:file'
})

const emitAppendPath = () => emit('append-path', props.node.path)

const handleClick = () => {
if (props.node.isDirectory) {
emit('toggle', props.node)
return
}

emitAppendPath()
}

const handleOpenFile = async () => {
if (props.node.isDirectory) {
return
}

try {
await acpWorkspacePresenter.openFile(props.node.path)
} catch (error) {
console.error(`[AcpWorkspace] Failed to open file: ${props.node.path}`, error)
}
}

const handleRevealInFolder = async () => {
try {
await acpWorkspacePresenter.revealFileInFolder(props.node.path)
} catch (error) {
console.error(`[AcpWorkspace] Failed to reveal path: ${props.node.path}`, error)
}
}

const handleAppendFromMenu = () => {
emitAppendPath()
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
:node="node"
:depth="0"
@toggle="handleToggle"
@append-path="handleAppendPath"
/>
</div>
<div v-else class="px-4 py-3 text-[11px] text-muted-foreground">
Expand All @@ -53,6 +54,9 @@ import type { AcpFileNode } from '@shared/presenter'
const { t } = useI18n()
const store = useAcpWorkspaceStore()
const showFiles = ref(true)
const emit = defineEmits<{
'append-path': [filePath: string]
}>()

const countFiles = (nodes: AcpFileNode[]): number => {
let count = 0
Expand All @@ -70,6 +74,10 @@ const fileCount = computed(() => countFiles(store.fileTree))
const handleToggle = async (node: AcpFileNode) => {
await store.toggleFileNode(node)
}

const handleAppendPath = (filePath: string) => {
emit('append-path', filePath)
}
</script>

<style scoped>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<!-- Content -->
<div class="flex-1 overflow-y-auto">
<!-- Files Section -->
<AcpWorkspaceFiles />
<AcpWorkspaceFiles @append-path="emit('append-file-path', $event)" />

<!-- Plan Section (hidden when empty) -->
<AcpWorkspacePlan />
Expand All @@ -44,4 +44,7 @@ import AcpWorkspaceTerminal from './AcpWorkspaceTerminal.vue'

const { t } = useI18n()
const store = useAcpWorkspaceStore()
const emit = defineEmits<{
'append-file-path': [filePath: string]
}>()
</script>
7 changes: 6 additions & 1 deletion src/renderer/src/i18n/da-DK/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@
"files": {
"section": "Filer",
"empty": "Ingen filer",
"loading": "Indlæser filer..."
"loading": "Indlæser filer...",
"contextMenu": {
"insertPath": "Indsæt i inputboksen",
"openFile": "åbne fil",
"revealInFolder": "Åbn i filhåndtering"
}
},
"terminal": {
"section": "Terminal",
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/i18n/en-US/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@
"files": {
"section": "Files",
"empty": "No files",
"loading": "Loading files..."
"loading": "Loading files...",
"contextMenu": {
"openFile": "Open file",
"revealInFolder": "Show in file manager",
"insertPath": "Insert into input"
}
},
"terminal": {
"section": "Terminal",
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/i18n/fa-IR/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@
"files": {
"section": "فایل‌ها",
"empty": "فایلی وجود ندارد",
"loading": "در حال بارگذاری فایل‌ها..."
"loading": "در حال بارگذاری فایل‌ها...",
"contextMenu": {
"insertPath": "در جعبه ورودی وارد کنید",
"openFile": "باز کردن فایل",
"revealInFolder": "در فایل منیجر باز کنید"
}
},
"terminal": {
"section": "ترمینال",
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/i18n/fr-FR/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@
"files": {
"section": "Fichiers",
"empty": "Aucun fichier",
"loading": "Chargement des fichiers..."
"loading": "Chargement des fichiers...",
"contextMenu": {
"insertPath": "Insérer dans la zone de saisie",
"openFile": "ouvrir le fichier",
"revealInFolder": "Ouvrir dans le gestionnaire de fichiers"
}
},
"terminal": {
"section": "Terminal",
Expand Down
Loading