diff --git a/src/renderer/components/FileTree/FileTree.tsx b/src/renderer/components/FileTree/FileTree.tsx index c14d5a4f709..dc7bf9bcdbe 100644 --- a/src/renderer/components/FileTree/FileTree.tsx +++ b/src/renderer/components/FileTree/FileTree.tsx @@ -49,6 +49,8 @@ export function FileTree(props: FileTreeProps) { searchKeyword = '', onSearchKeywordChange, searchPlaceholder, + searchToolbar, + searchClearLabel, emptyState } = props @@ -114,28 +116,31 @@ export function FileTree(props: FileTreeProps) { return (
-
- - onSearchKeywordChange?.(e.target.value)} - placeholder={searchPlaceholder} - className="h-8 pr-7 pl-7 text-sm" - data-testid="file-tree-search-input" - /> - {searchKeyword && ( - - )} +
+
+ + onSearchKeywordChange?.(e.target.value)} + placeholder={searchPlaceholder} + className="h-8 min-w-0 flex-1 pr-7 pl-7 text-sm" + data-testid="file-tree-search-input" + /> + {searchKeyword && ( + + )} +
+ {searchToolbar}
{tree}
diff --git a/src/renderer/components/FileTree/FileTreeRow.tsx b/src/renderer/components/FileTree/FileTreeRow.tsx index 318f1d7c3a8..e821aae1112 100644 --- a/src/renderer/components/FileTree/FileTreeRow.tsx +++ b/src/renderer/components/FileTree/FileTreeRow.tsx @@ -79,7 +79,6 @@ export function FileTreeRow(props: FileTreeRowProps) { data-node-id={node.id} data-kind={node.kind} onClick={handleRowClick} - onContextMenu={(e) => e.stopPropagation()} title={node.name} style={indent} className={cn( diff --git a/src/renderer/components/FileTree/__tests__/FileTree.test.tsx b/src/renderer/components/FileTree/__tests__/FileTree.test.tsx index e1b33b55727..3d43de52a54 100644 --- a/src/renderer/components/FileTree/__tests__/FileTree.test.tsx +++ b/src/renderer/components/FileTree/__tests__/FileTree.test.tsx @@ -166,6 +166,27 @@ describe('FileTree - editable form (all callbacks)', () => { expect(screen.getByText('Menu for Root')).toBeInTheDocument() }) + + it('stops row context-menu events after opening the row menu', () => { + const onWrapperContextMenu = vi.fn() + + render( +
+ [{ type: 'item', id: `menu-${n.id}`, label: `Menu for ${n.name}`, onSelect: () => {} }]} + renderList={passthroughRenderList} + /> +
+ ) + + const rootRow = screen.getByText('Root').closest('[data-node-id="root"]')! + fireEvent.contextMenu(rootRow) + + expect(screen.getByText('Menu for Root')).toBeInTheDocument() + expect(onWrapperContextMenu).not.toHaveBeenCalled() + }) }) describe('FileTree - icon behaviour', () => { @@ -243,8 +264,15 @@ describe('FileTree - icon behaviour', () => { describe('FileTree - search box', () => { it('does not render the search input when showSearch is omitted', () => { - render() + render( + Tools} + renderList={passthroughRenderList} + /> + ) expect(screen.queryByTestId('file-tree-search-input')).toBeNull() + expect(screen.queryByTestId('search-toolbar')).toBeNull() }) it('renders the search input when showSearch is true', () => { @@ -290,6 +318,30 @@ describe('FileTree - search box', () => { expect(onSearchKeywordChange).toHaveBeenCalledWith('a') }) + it('renders the search toolbar and keeps the clear button usable', async () => { + const onSearchKeywordChange = vi.fn() + const user = userEvent.setup() + render( + + Refresh + + } + onSearchKeywordChange={onSearchKeywordChange} + renderList={passthroughRenderList} + /> + ) + + expect(screen.getByTestId('search-toolbar')).toBeInTheDocument() + await user.click(screen.getByLabelText('Clear files search')) + expect(onSearchKeywordChange).toHaveBeenCalledWith('') + }) + it('clears the keyword via the clear button', async () => { const onSearchKeywordChange = vi.fn() const user = userEvent.setup() diff --git a/src/renderer/components/FileTree/types.ts b/src/renderer/components/FileTree/types.ts index 49bee0ef044..5d1a00602d0 100644 --- a/src/renderer/components/FileTree/types.ts +++ b/src/renderer/components/FileTree/types.ts @@ -71,6 +71,10 @@ export interface FileTreeProps { onSearchKeywordChange?: (keyword: string) => void /** Placeholder for the search input. */ searchPlaceholder?: string + /** Optional trailing toolbar rendered on the search row. */ + searchToolbar?: React.ReactNode + /** Accessible label for the search clear button. */ + searchClearLabel?: string emptyState?: React.ReactNode } diff --git a/src/renderer/components/chat/panes/ArtifactPane.tsx b/src/renderer/components/chat/panes/ArtifactPane.tsx index be5b098d0b8..6140bda12b2 100644 --- a/src/renderer/components/chat/panes/ArtifactPane.tsx +++ b/src/renderer/components/chat/panes/ArtifactPane.tsx @@ -1,35 +1,39 @@ import { Button, Markdown, Tooltip } from '@cherrystudio/ui' import { cn } from '@cherrystudio/ui/lib/utils' -import { usePersistCache } from '@data/hooks/useCache' import { loggerService } from '@logger' import ImagePreviewPanel from '@renderer/components/ArtifactPreview/image/ImagePreviewPanel' import type { OfficePreviewPanelProps } from '@renderer/components/ArtifactPreview/office/OfficePreviewPanel' import { EmptyState, LoadingState } from '@renderer/components/chat/primitives' import HtmlPreviewFrame from '@renderer/components/CodeBlockView/HtmlPreviewFrame' import CodeViewer from '@renderer/components/CodeViewer' -import { FileTree } from '@renderer/components/FileTree' +import type { CommandContextMenuExtraItem } from '@renderer/components/command' +import { FileTree, type FileTreeNode } from '@renderer/components/FileTree' +import { getEditorIcon } from '@renderer/components/icons/EditorIcon' +import { FinderIcon } from '@renderer/components/icons/SvgIcon' +import { useExternalApps } from '@renderer/hooks/useExternalApps' import { type FileSizeState, useFileSize } from '@renderer/hooks/useFileSize' import { type IsTextState, useIsTextFile } from '@renderer/hooks/useIsTextFile' -import { useResizeDrag } from '@renderer/hooks/useResizeDrag' import { getLanguageByFilePath } from '@renderer/utils/codeLanguage' +import { buildEditorUrl } from '@renderer/utils/editor' +import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import { joinPath } from '@renderer/utils/path' +import { isMac, isWin } from '@renderer/utils/platform' import type { FilePath } from '@shared/types/file' import { toFileUrl } from '@shared/utils/file' -import { AlertCircle, FileText, Folder, FolderOpen, Maximize2, Minimize2, RotateCw, Sparkles } from 'lucide-react' -import { AnimatePresence, motion } from 'motion/react' +import { AlertCircle, FileText, FolderOpen, RotateCw, Sparkles, X } from 'lucide-react' import { type ComponentType, - type MouseEvent as ReactMouseEvent, + type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, + useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { CHAT_SHELL_TRANSITION } from '../shell/paneLayout' -import { getVerticalSplitterProps } from '../shell/splitterA11y' +import { type ArtifactPaneFileSelection, WORKSPACE_ROOT_ID } from './artifactPanePath' import OpenExternalAppButton from './OpenExternalAppButton' import { type ArtifactFileTreeModel, isSelectableFileNode, useArtifactFileTreeModel } from './useArtifactFileTreeModel' @@ -40,27 +44,20 @@ export { normalizeArtifactPaneFilePath, resolveArtifactPaneFileSelection } from const logger = loggerService.withContext('ArtifactPane') -export const ARTIFACT_PANE_WIDTH = 460 -export const ARTIFACT_FILE_TREE_DEFAULT_WIDTH = 160 -export const ARTIFACT_FILE_TREE_CACHE_KEY = 'ui.chat.artifact_pane.file_tree.width' -const ARTIFACT_FILE_TREE_MIN_WIDTH = 80 -const ARTIFACT_FILE_TREE_MAX_WIDTH_OFFSET = 140 - export interface ArtifactPaneProps { workspacePath?: string maximized?: boolean + previewFileSelection?: ArtifactPaneFileSelection | null + onPreviewClose?: () => void pdfLayoutPending?: boolean pdfLayoutRefreshKey?: number selectedFile?: string | null onSelectedFileChange?: (file: string | null) => void - fileTreeOpen?: boolean - onFileTreeOpenChange?: (open: boolean) => void /** Caller-owned expanded folder ids. The synthetic workspace root is managed internally. */ fileTreeExpandedIds?: ReadonlySet onFileTreeExpandedIdsChange?: (next: ReadonlySet) => void fileTreeSearchKeyword?: string onFileTreeSearchKeywordChange?: (keyword: string) => void - onToggleMaximized?: () => void /** Show a search input inside the file tree that filters nodes by name. */ enableFileSearch?: boolean } @@ -100,6 +97,23 @@ const isPdfFile = (name: string) => PDF_EXT.has(extOf(name)) export const isOfficeDocumentFile = (name: string) => OFFICE_DOCUMENT_EXT.has(extOf(name)) export const isImageFile = (name: string) => IMAGE_EXT.has(extOf(name)) +function getPreviewFileTitle(filePath: string): string { + const segments = filePath + .trim() + .split(/[/\\]+/) + .filter(Boolean) + return segments.at(-1) ?? filePath +} + +function getFileTreeNodeTargetPath(workspacePath: string | undefined, node: { id: string }): string | null { + if (!workspacePath) return null + return node.id === WORKSPACE_ROOT_ID ? workspacePath : joinPath(workspacePath, node.id) +} + +function renderFileManagerIcon(): ReactNode { + return isMac ? : +} + type PdfPreviewPanelComponent = ComponentType<{ filePath: string fileName: string @@ -130,87 +144,6 @@ const loadOfficePreviewPanel = () => { return officePreviewPanelPromise } -function getArtifactFileTreeWidthBounds(artifactPaneWidth: number) { - const minWidth = ARTIFACT_FILE_TREE_MIN_WIDTH - const maxWidth = Math.max(minWidth, Math.round(artifactPaneWidth - ARTIFACT_FILE_TREE_MAX_WIDTH_OFFSET)) - return { minWidth, maxWidth } -} - -function clampArtifactFileTreeWidth(width: number, artifactPaneWidth: number): number { - const { minWidth, maxWidth } = getArtifactFileTreeWidthBounds(artifactPaneWidth) - return Math.min(maxWidth, Math.max(minWidth, Math.round(width))) -} - -function useArtifactFileTreeResize() { - const [storedWidth, setStoredWidth] = usePersistCache(ARTIFACT_FILE_TREE_CACHE_KEY) - const artifactPaneRef = useRef(null) - const paneRef = useRef(null) - const currentArtifactPaneWidthRef = useRef(ARTIFACT_PANE_WIDTH) - const paneLeftRef = useRef(0) - const [artifactPaneWidth, setArtifactPaneWidth] = useState(ARTIFACT_PANE_WIDTH) - const paneWidth = clampArtifactFileTreeWidth(storedWidth ?? ARTIFACT_FILE_TREE_DEFAULT_WIDTH, artifactPaneWidth) - - const measureArtifactPaneWidth = useCallback(() => { - const width = artifactPaneRef.current?.getBoundingClientRect().width - return width && Number.isFinite(width) ? width : ARTIFACT_PANE_WIDTH - }, []) - - useEffect(() => { - const updateArtifactPaneWidth = () => setArtifactPaneWidth(measureArtifactPaneWidth()) - updateArtifactPaneWidth() - - const element = artifactPaneRef.current - if (!element || typeof ResizeObserver === 'undefined') return - - const observer = new ResizeObserver(updateArtifactPaneWidth) - observer.observe(element) - return () => observer.disconnect() - }, [measureArtifactPaneWidth]) - - const handleMouseMove = useCallback( - (moveEvent: MouseEvent) => { - setStoredWidth( - clampArtifactFileTreeWidth(moveEvent.clientX - paneLeftRef.current, currentArtifactPaneWidthRef.current) - ) - }, - [setStoredWidth] - ) - - const { isResizing, startResizing: startResizeDrag } = useResizeDrag({ onMove: handleMouseMove }) - - const startResizing = useCallback( - (event: ReactMouseEvent) => { - const currentArtifactPaneWidth = measureArtifactPaneWidth() - currentArtifactPaneWidthRef.current = currentArtifactPaneWidth - setArtifactPaneWidth(currentArtifactPaneWidth) - paneLeftRef.current = paneRef.current?.getBoundingClientRect().left ?? event.clientX - paneWidth - startResizeDrag(event) - }, - [measureArtifactPaneWidth, paneWidth, startResizeDrag] - ) - - const setPaneWidth = useCallback( - // Clamp against the live measured width (same value that feeds the splitter's aria-valuemax), - // not currentArtifactPaneWidthRef — that ref is only written at mouse-drag start, so a keyboard - // resize without a prior drag would otherwise clamp to a stale bound and undershoot the max. - (nextWidth: number) => setStoredWidth(clampArtifactFileTreeWidth(nextWidth, artifactPaneWidth)), - [artifactPaneWidth, setStoredWidth] - ) - - const { minWidth, maxWidth } = getArtifactFileTreeWidthBounds(artifactPaneWidth) - - return { - artifactPaneRef, - isResizing, - paneRef, - paneWidth, - minWidth, - maxWidth, - startResizing, - setPaneWidth - } -} - export function ArtifactFilePreview({ workspacePath, filePath, @@ -491,71 +424,69 @@ export function ArtifactFilePreview({ interface ArtifactPaneViewProps { workspacePath?: string maximized?: boolean + previewFileSelection?: ArtifactPaneFileSelection | null + onPreviewClose?: () => void pdfLayoutPending?: boolean pdfLayoutRefreshKey?: number enableFileSearch?: boolean - onToggleMaximized?: () => void /** The lifted directory-tree model. Owning it above the component lets the * agent right-pane survive the Host↔Overlay maximize remount without * rebuilding the tree. */ model: ArtifactFileTreeModel selectedFile: string | null onSelectedFileChange: (file: string | null) => void - treeOpen: boolean - onTreeOpenChange: (open: boolean) => void searchKeyword: string onSearchKeywordChange: (keyword: string) => void } /** - * Presentational artifact pane: renders the file tree (from a supplied - * `model`) and the selected-file preview. Owns only genuinely-local concerns - * (panel resize, the selected-file content sniff, the refresh token); all - * tree state is provided. + * Presentational artifact pane: renders file tree and selected-file overlay + * preview from the supplied model. */ export function ArtifactPaneView({ workspacePath, maximized = false, + previewFileSelection = null, + onPreviewClose, pdfLayoutPending = false, pdfLayoutRefreshKey = 0, enableFileSearch = false, - onToggleMaximized, model, selectedFile, onSelectedFileChange, - treeOpen, - onTreeOpenChange, searchKeyword, onSearchKeywordChange }: ArtifactPaneViewProps) { const { t } = useTranslation() - const { - artifactPaneRef, - isResizing: isFileTreeResizing, - paneRef: fileTreePaneRef, - paneWidth: fileTreeWidth, - minWidth: fileTreeMinWidth, - maxWidth: fileTreeMaxWidth, - startResizing: startFileTreeResizing, - setPaneWidth: setFileTreeWidth - } = useArtifactFileTreeResize() - + const { data: externalApps } = useExternalApps({ enabled: true }) + const artifactPaneRef = useRef(null) + const overlayRef = useRef(null) const [contentRefreshToken, setContentRefreshToken] = useState(0) - const previousWorkspacePathRef = useRef(workspacePath) // Destructure the stable callbacks so effect/callback deps don't have to // list the whole `model` (a fresh object every render). const { refresh, reloadExpandedDirectories } = model - // Drop the content-refresh token when the workspace changes so the preview - // doesn't carry a stale forced-reload key into a different tree. - useEffect(() => { - if (previousWorkspacePathRef.current !== workspacePath) { - previousWorkspacePathRef.current = workspacePath - setContentRefreshToken(0) - } - }, [workspacePath]) - const trimmedFileSearch = enableFileSearch ? searchKeyword.trim() : '' + const overlaySelection = previewFileSelection + ? previewFileSelection + : workspacePath && selectedFile + ? { workspacePath, filePath: selectedFile } + : null + const overlayWorkspacePath = overlaySelection?.workspacePath + const overlayFilePath = overlaySelection?.filePath + const previewWorkspacePath = overlayWorkspacePath ?? workspacePath + const previewFilePath = overlayFilePath ?? selectedFile + const previewKey = `${previewWorkspacePath ?? ''}\0${previewFilePath ?? ''}` + const previousPreviewKeyRef = useRef(previewKey) + const availableEditors = useMemo( + () => externalApps?.filter((app) => app.tags.includes('code-editor')) ?? [], + [externalApps] + ) + const fileManagerName = useMemo(() => { + if (isMac) return t('agent.session.file_manager.finder') + if (isWin) return t('agent.session.file_manager.file_explorer') + return t('agent.session.file_manager.files') + }, [t]) const handleSelectedChange = useCallback( (id: string | null) => { @@ -568,74 +499,265 @@ export function ArtifactPaneView({ [model.nodeById, onSelectedFileChange] ) - const isPdfSelection = selectedFile ? isPdfFile(selectedFile) : false - const isOfficeDocumentSelection = selectedFile ? isOfficeDocumentFile(selectedFile) : false - const isImageSelection = selectedFile ? isImageFile(selectedFile) : false + const isPdfSelection = previewFilePath ? isPdfFile(previewFilePath) : false + const isOfficeDocumentSelection = previewFilePath ? isOfficeDocumentFile(previewFilePath) : false + const isImageSelection = previewFilePath ? isImageFile(previewFilePath) : false const shouldSniffSelectedFile = !isPdfSelection && !isOfficeDocumentSelection && !isImageSelection - const sniffedIsText = useIsTextFile(workspacePath, selectedFile, { enabled: shouldSniffSelectedFile }) + const sniffedIsText = useIsTextFile(previewWorkspacePath, previewFilePath, { enabled: shouldSniffSelectedFile }) const isText = shouldSniffSelectedFile ? sniffedIsText : 'binary' - const fileSize = useFileSize(workspacePath, selectedFile) + const fileSize = useFileSize(previewWorkspacePath, previewFilePath) + + useEffect(() => { + if (previousPreviewKeyRef.current === previewKey) return + previousPreviewKeyRef.current = previewKey + setContentRefreshToken(0) + }, [previewKey]) + + useEffect(() => { + if (!overlayWorkspacePath || !overlayFilePath) return + overlayRef.current?.focus() + }, [overlayFilePath, overlayWorkspacePath]) const handleRefresh = useCallback(() => { refresh() - if (treeOpen) { - reloadExpandedDirectories() - } - if (workspacePath && selectedFile && (isText === 'text' || isOfficeDocumentSelection || isImageSelection)) { - setContentRefreshToken((v) => v + 1) + reloadExpandedDirectories() + if ( + overlayWorkspacePath && + overlayFilePath && + (isText === 'text' || isOfficeDocumentSelection || isImageSelection) + ) { + setContentRefreshToken((value) => value + 1) } }, [ - refresh, - reloadExpandedDirectories, - selectedFile, - treeOpen, - workspacePath, - isText, + isImageSelection, isOfficeDocumentSelection, - isImageSelection + isText, + overlayFilePath, + overlayWorkspacePath, + refresh, + reloadExpandedDirectories ]) - const isSelectedHtmlPreview = selectedFile ? isHtmlFile(selectedFile) : false + const handleClosePreview = useCallback(() => { + onPreviewClose?.() + onSelectedFileChange(null) + }, [onPreviewClose, onSelectedFileChange]) + + const handleOverlayKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key !== 'Escape') return + event.stopPropagation() + handleClosePreview() + }, + [handleClosePreview] + ) + + const openPath = useCallback( + async (path: string) => { + try { + await window.api.file.openPath(path) + } catch (error) { + window.toast.error(formatErrorMessageWithPrefix(error, t('files.error.open_path', { path }))) + } + }, + [t] + ) + + const showInFolder = useCallback( + async (path: string) => { + try { + await window.api.file.showInFolder(path) + } catch (error) { + window.toast.error(formatErrorMessageWithPrefix(error, t('files.error.open_path', { path }))) + } + }, + [t] + ) + + const getFileTreeMenuItems = useCallback( + (node: FileTreeNode): readonly CommandContextMenuExtraItem[] => { + const targetPath = getFileTreeNodeTargetPath(workspacePath, node) + if (!targetPath) return [] + + if (node.kind === 'file') { + return [ + { + type: 'item', + id: 'open-default-app', + label: t('agent.preview_pane.default_app'), + icon: , + onSelect: () => void openPath(targetPath) + }, + { + type: 'item', + id: 'show-in-folder', + label: fileManagerName, + icon: renderFileManagerIcon(), + onSelect: () => void showInFolder(targetPath) + }, + ...availableEditors.map((app) => ({ + type: 'item', + id: `open-editor-${app.id}`, + label: app.name, + icon: getEditorIcon(app), + onSelect: () => window.open(buildEditorUrl(app, targetPath)) + })) + ] + } + + return [ + { + type: 'item', + id: 'open-file-manager', + label: fileManagerName, + icon: renderFileManagerIcon(), + onSelect: () => void openPath(targetPath) + }, + ...availableEditors.map((app) => ({ + type: 'item', + id: `open-editor-${app.id}`, + label: app.name, + icon: getEditorIcon(app), + onSelect: () => window.open(buildEditorUrl(app, targetPath)) + })) + ] + }, + [availableEditors, fileManagerName, openPath, showInFolder, t, workspacePath] + ) + + const searchToolbar = ( +
+ + + + {workspacePath && } +
+ ) + + const isSelectedHtmlPreview = previewFilePath ? isHtmlFile(previewFilePath) : false const isSelectedPdfPreview = isPdfSelection const isSelectedOfficePreview = isOfficeDocumentSelection const isSelectedImagePreview = isImageSelection - const openableFilePath = isOfficeDocumentSelection ? selectedFile : null - const maximizeLabel = t(maximized ? 'agent.preview_pane.minimize' : 'agent.preview_pane.maximize') - const FileTreeIcon = treeOpen ? FolderOpen : Folder - const MaximizeIcon = maximized ? Minimize2 : Maximize2 + const renderOverlay = () => { + if (!overlaySelection) return null - const renderRight = () => { - if (!workspacePath) { - return ( + return ( +
+
+
+ {getPreviewFileTitle(overlaySelection.filePath)} +
+ + + +
+
+ + ) : undefined + } + /> +
+
+ ) + } + + const renderFileTree = () => + model.isLoading ? ( + + ) : ( + + {model.error + ? t('common.error') + : trimmedFileSearch + ? t('agent.preview_pane.no_search_results') + : workspacePath + ? t('agent.preview_pane.empty.title') + : t('agent.preview_pane.empty.description')} +
+ } + /> + ) + + if (!workspacePath && !overlaySelection) { + return ( +
- ) - } - if (model.error) { - return - } - return ( - +
) } - const headerToggleClass = (active: boolean) => - cn( - 'text-muted-foreground hover:bg-accent hover:text-foreground', - active && 'bg-accent text-foreground hover:text-foreground' + if (model.error && !overlaySelection) { + return ( +
+ +
) + } return (
-
- - {treeOpen && ( - -
- -
-
-
- - )} - -
-
-
- - - -
- -
- - - - {workspacePath && } - {onToggleMaximized && ( - - - - )} -
-
-
- {renderRight()} +
+
+ + {renderOverlay()}
) @@ -777,29 +787,32 @@ export function ArtifactPaneView({ const ArtifactPane = ({ workspacePath, maximized = false, + previewFileSelection, + onPreviewClose, pdfLayoutPending = false, pdfLayoutRefreshKey = 0, selectedFile: selectedFileProp, onSelectedFileChange, - fileTreeOpen: fileTreeOpenProp, - onFileTreeOpenChange, fileTreeExpandedIds: fileTreeExpandedIdsProp, onFileTreeExpandedIdsChange, fileTreeSearchKeyword: fileTreeSearchKeywordProp, onFileTreeSearchKeywordChange, - onToggleMaximized, enableFileSearch = false }: ArtifactPaneProps) => { - const [internalFileTreeOpen, setInternalFileTreeOpen] = useState(false) const [internalSelectedFile, setInternalSelectedFile] = useState(null) + const [internalPreviewFileSelection, setInternalPreviewFileSelection] = useState( + null + ) const [internalFileTreeExpandedIds, setInternalFileTreeExpandedIds] = useState>(() => new Set()) const [internalFileTreeSearchKeyword, setInternalFileTreeSearchKeyword] = useState('') const previousWorkspacePathRef = useRef(workspacePath) const hasMountedRef = useRef(false) const selectedFileControlled = selectedFileProp !== undefined const selectedFile = selectedFileControlled ? selectedFileProp : internalSelectedFile - const fileTreeOpenControlled = fileTreeOpenProp !== undefined - const treeOpen = fileTreeOpenProp ?? internalFileTreeOpen + const previewFileSelectionControlled = previewFileSelection !== undefined + const effectivePreviewFileSelection = previewFileSelectionControlled + ? previewFileSelection + : internalPreviewFileSelection const fileTreeExpandedIdsControlled = fileTreeExpandedIdsProp !== undefined const expandedIds = fileTreeExpandedIdsProp ?? internalFileTreeExpandedIds const fileTreeSearchKeywordControlled = fileTreeSearchKeywordProp !== undefined @@ -808,16 +821,12 @@ const ArtifactPane = ({ const setSelectedFile = useCallback( (file: string | null) => { if (!selectedFileControlled) setInternalSelectedFile(file) + if (!previewFileSelectionControlled) { + setInternalPreviewFileSelection(file && workspacePath ? { workspacePath, filePath: file } : null) + } onSelectedFileChange?.(file) }, - [onSelectedFileChange, selectedFileControlled] - ) - const setTreeOpen = useCallback( - (open: boolean) => { - if (!fileTreeOpenControlled) setInternalFileTreeOpen(open) - onFileTreeOpenChange?.(open) - }, - [fileTreeOpenControlled, onFileTreeOpenChange] + [onSelectedFileChange, previewFileSelectionControlled, selectedFileControlled, workspacePath] ) const setExpandedIdsState = useCallback( (ids: ReadonlySet) => { @@ -836,7 +845,7 @@ const ArtifactPane = ({ const model = useArtifactFileTreeModel({ workspacePath, - treeOpen, + treeOpen: true, expandedIds, searchKeyword: fileSearchKeyword, enableFileSearch, @@ -850,6 +859,7 @@ const ArtifactPane = ({ const workspaceChanged = previousWorkspacePathRef.current !== workspacePath if (workspaceChanged) { if (!selectedFileControlled) setSelectedFile(null) + if (!previewFileSelectionControlled) setInternalPreviewFileSelection(null) resetLazyChildren() } previousWorkspacePathRef.current = workspacePath @@ -862,6 +872,7 @@ const ArtifactPane = ({ }, [ fileTreeExpandedIdsControlled, fileTreeSearchKeywordControlled, + previewFileSelectionControlled, selectedFileControlled, setExpandedIdsState, setFileSearchKeyword, @@ -880,15 +891,14 @@ const ArtifactPane = ({ diff --git a/src/renderer/components/chat/panes/OpenExternalAppButton.tsx b/src/renderer/components/chat/panes/OpenExternalAppButton.tsx index 90772e1374c..39e9a575ed2 100644 --- a/src/renderer/components/chat/panes/OpenExternalAppButton.tsx +++ b/src/renderer/components/chat/panes/OpenExternalAppButton.tsx @@ -16,6 +16,7 @@ import { buildEditorUrl } from '@renderer/utils/editor' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import { joinPath } from '@renderer/utils/path' import { isMac, isWin } from '@renderer/utils/platform' +import { cn } from '@renderer/utils/style' import type { ExternalAppId, ExternalAppInfo } from '@shared/types/externalApp' import { ChevronDown, FileText, FolderOpen } from 'lucide-react' import { useCallback, useMemo } from 'react' @@ -23,6 +24,8 @@ import { useTranslation } from 'react-i18next' const FILE_MANAGER_TARGET = 'file_manager' as const const TOOLBAR_BUTTON_CLASS = 'text-muted-foreground hover:bg-accent hover:text-foreground' +const SPLIT_BUTTON_GROUP_CLASS = 'h-8 overflow-hidden rounded-md border border-border-subtle' +const SPLIT_BUTTON_CLASS = 'h-full rounded-none p-0' type OpenExternalAppButtonProps = { workdir: string @@ -122,11 +125,11 @@ const OpenExternalAppButton = ({ workdir, filePath, className }: OpenExternalApp const primaryLabel = t('common.open_in', { name: defaultAppName }) return ( - + ), - ButtonGroup: ({ children, ...props }: PropsWithChildren>) => ( -
{children}
- ), + ButtonGroup: ({ + children, + ...props + }: PropsWithChildren & { attached?: boolean }>) => { + const domProps = { ...props } + delete domProps.attached + return
{children}
+ }, MenuItem: ({ label, icon, @@ -331,6 +338,7 @@ vi.mock('@renderer/components/FileTree', () => ({ onExpandedChange, selectedId, onSelectedChange, + getMenuItems, ...props }: { nodes: MockFileTreeNode[] @@ -338,8 +346,18 @@ vi.mock('@renderer/components/FileTree', () => ({ onExpandedChange?: (ids: ReadonlySet) => void selectedId?: string | null onSelectedChange?: (id: string | null) => void + getMenuItems?: ( + node: MockFileTreeNode + ) => ReadonlyArray< + { type: 'item'; id: string; label: string; icon?: React.ReactNode; onSelect: () => void } | { type: 'separator' } + > + searchToolbar?: React.ReactNode + searchClearLabel?: string + searchKeyword?: string + onSearchKeywordChange?: (keyword: string) => void truncateLabels?: boolean }) => { + const [menuNode, setMenuNode] = useState(null) const renderNode = (node: MockFileTreeNode) => (
+ ) : null} {nodes.map(renderNode)} + {menuNode ? ( +
+ {getMenuItems?.(menuNode).map((item, index) => + item.type === 'item' ? ( + + ) : ( +
+ ) + )} +
+ ) : null}
) } @@ -406,15 +451,8 @@ vi.mock('@renderer/components/ArtifactPreview/pdf/PdfPreviewPanel', () => { } }) -vi.mock('@data/hooks/useCache', () => ({ - usePersistCache: (key: string) => - key === 'ui.chat.artifact_pane.file_tree.width' - ? [mocks.artifactFileTreeWidth, mocks.setArtifactFileTreeWidth] - : [null, vi.fn()] -})) - vi.mock('@renderer/components/icons/SvgIcon', () => ({ - FinderIcon: (props: React.SVGProps) => + FinderIcon: (props: React.SVGProps) => })) vi.mock('@renderer/utils/platform', () => ({ @@ -423,7 +461,16 @@ vi.mock('@renderer/utils/platform', () => ({ })) vi.mock('@renderer/hooks/useExternalApps', () => ({ - useExternalApps: () => ({ data: [] }) + useExternalApps: () => ({ data: mocks.externalApps }) +})) + +vi.mock('@renderer/utils/editor', () => ({ + buildEditorUrl: (app: { id: string }, path: string) => `editor://${app.id}${path}`, + getEditorIcon: (app: { id: string }) => +})) + +vi.mock('@renderer/components/icons/EditorIcon', () => ({ + getEditorIcon: (app: { id: string }) => })) vi.mock('react-i18next', () => ({ @@ -441,7 +488,6 @@ vi.mock('react-i18next', () => ({ describe('ArtifactPane', () => { beforeEach(() => { vi.clearAllMocks() - mocks.artifactFileTreeWidth = null mocks.pdfPreviewPanelProps.length = 0 mocks.officePreviewPanelProps.length = 0 mocks.nextTreeId = 0 @@ -459,6 +505,9 @@ describe('ArtifactPane', () => { mocks.treeOnMutation.mockImplementation(() => () => {}) mocks.listDirectory.mockResolvedValue([]) mocks.listDirectoryEntries.mockResolvedValue([]) + mocks.openPath.mockResolvedValue(undefined) + mocks.showInFolder.mockResolvedValue(undefined) + mocks.externalApps = [] mocks.isDirectory.mockResolvedValue(false) // Default: tests select text files; override per-test for binary cases. mocks.isTextFile.mockResolvedValue(true) @@ -469,7 +518,8 @@ describe('ArtifactPane', () => { configurable: true, value: { file: { - openPath: vi.fn(), + openPath: mocks.openPath, + showInFolder: mocks.showInFolder, isTextFile: mocks.isTextFile, isDirectory: mocks.isDirectory, listDirectory: mocks.listDirectory, @@ -487,6 +537,14 @@ describe('ArtifactPane', () => { } } }) + Object.defineProperty(window, 'open', { + configurable: true, + value: mocks.windowOpen + }) + Object.defineProperty(window, 'toast', { + configurable: true, + value: { error: mocks.toastError } + }) Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: mocks.createObjectURL @@ -537,8 +595,6 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue('# Hello') render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-README.md')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-README.md')) @@ -560,10 +616,6 @@ describe('ArtifactPane', () => { render() - expect(mocks.treeCreate).not.toHaveBeenCalled() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(mocks.treeCreate).toHaveBeenCalledWith('/tmp/workspace', expect.objectContaining({ maxDepth: 3 })) ) @@ -605,8 +657,6 @@ describe('ArtifactPane', () => { .mockResolvedValueOnce([{ path: '/tmp/workspace/src/deep/file.ts', isDirectory: false }]) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-src')) @@ -645,8 +695,6 @@ describe('ArtifactPane', () => { ) const { rerender } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-src')) @@ -674,8 +722,6 @@ describe('ArtifactPane', () => { .mockResolvedValueOnce([{ path: '/tmp/workspace/src/new.md', isDirectory: false }]) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-src')) @@ -687,6 +733,180 @@ describe('ArtifactPane', () => { expect(screen.queryByTestId('tree-node-src/old.md')).not.toBeInTheDocument() }) + it('opens file previews in an overlay and clears selection when closed', async () => { + mockWorkspaceTree('/tmp/workspace', ['README.md']) + mocks.fsReadText.mockResolvedValue('# Overlay') + + render() + + await waitFor(() => expect(screen.getByTestId('tree-node-README.md')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('tree-node-README.md')) + + await waitFor(() => expect(screen.getByTestId('artifact-file-preview-overlay')).toBeInTheDocument()) + expect(screen.getByTestId('artifact-file-preview-overlay')).toHaveTextContent('README.md') + await waitFor(() => expect(screen.getByTestId('markdown')).toHaveTextContent('# Overlay')) + expect(screen.getByTestId('tree-node-README.md')).toHaveAttribute('data-selected', 'true') + + fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.close' })) + + expect(screen.queryByTestId('artifact-file-preview-overlay')).not.toBeInTheDocument() + expect(screen.getByTestId('tree-node-README.md')).toHaveAttribute('data-selected', 'false') + }) + + it('renders an external preview selection even when no workspace tree is available', async () => { + mocks.fsReadText.mockResolvedValue('# External') + + render( + + ) + + await waitFor(() => expect(screen.getByTestId('artifact-file-preview-overlay')).toBeInTheDocument()) + expect(screen.getByTestId('artifact-file-preview-overlay')).toHaveTextContent('记忆商人.md') + await waitFor(() => expect(mocks.fsReadText).toHaveBeenCalledWith('/Users/suyao/Desktop/记忆商人.md')) + expect(screen.getByTestId('markdown')).toHaveTextContent('# External') + expect(mocks.treeCreate).not.toHaveBeenCalled() + }) + + it('clears the standalone preview overlay when the watcher reports the selected file was removed', async () => { + mockWorkspaceTree('/tmp/workspace', ['README.md']) + mocks.fsReadText.mockResolvedValue('# Overlay') + let pushMutation: ((payload: { treeId: string; event: { type: 'removed'; path: string } }) => void) | undefined + mocks.treeOnMutation.mockImplementation((cb) => { + pushMutation = cb as typeof pushMutation + return () => { + pushMutation = undefined + } + }) + + render() + + await waitFor(() => expect(screen.getByTestId('tree-node-README.md')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('tree-node-README.md')) + await waitFor(() => expect(screen.getByTestId('artifact-file-preview-overlay')).toHaveTextContent('README.md')) + + await waitFor(() => expect(pushMutation).toBeDefined()) + act(() => { + pushMutation?.({ treeId: 'tree-1', event: { type: 'removed', path: '/tmp/workspace/README.md' } }) + }) + + await waitFor(() => expect(screen.queryByTestId('artifact-file-preview-overlay')).not.toBeInTheDocument()) + expect(screen.queryByTestId('markdown')).not.toBeInTheDocument() + }) + + it('focuses the preview overlay after file-tree selection and closes it with Escape', async () => { + const user = userEvent.setup() + mockWorkspaceTree('/tmp/workspace', ['README.md']) + mocks.fsReadText.mockResolvedValue('# Overlay') + + render() + + await waitFor(() => expect(screen.getByTestId('tree-node-README.md')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('tree-node-README.md')) + const overlay = await screen.findByTestId('artifact-file-preview-overlay') + + await waitFor(() => expect(overlay).toHaveFocus()) + + await user.keyboard('{Escape}') + expect(screen.queryByTestId('artifact-file-preview-overlay')).not.toBeInTheDocument() + expect(screen.getByTestId('tree-node-README.md')).toHaveAttribute('data-selected', 'false') + }) + + it('shows refresh and root external-open controls in the overlay file-tree search row', async () => { + mockWorkspaceTree('/tmp/workspace', ['README.md']) + + render() + + await waitFor(() => expect(screen.getByTestId('file-tree-search-toolbar')).toBeInTheDocument()) + + const toolbar = screen.getByTestId('file-tree-search-toolbar') + expect(within(toolbar).getByRole('button', { name: 'agent.preview_pane.refresh' })).toBeInTheDocument() + expect(within(toolbar).getByRole('button', { name: 'Open in Finder' })).toBeInTheDocument() + }) + + it('refreshes the overlay file tree and re-reads preview content', async () => { + mockWorkspaceTree('/tmp/workspace', ['src/index.ts']) + mocks.listDirectoryEntries + .mockResolvedValueOnce([{ path: '/tmp/workspace/src/old.md', isDirectory: false }]) + .mockResolvedValueOnce([ + { path: '/tmp/workspace/src/old.md', isDirectory: false }, + { path: '/tmp/workspace/src/new.md', isDirectory: false } + ]) + mocks.fsReadText.mockResolvedValueOnce('# Old').mockResolvedValueOnce('# New') + + render() + + await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('tree-node-src')) + await waitFor(() => expect(screen.getByTestId('tree-node-src/old.md')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('tree-node-src/old.md')) + await waitFor(() => expect(screen.getByTestId('markdown')).toHaveTextContent('# Old')) + expect(mocks.fsReadText).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.refresh' })) + + await waitFor(() => expect(screen.getByTestId('tree-node-src/new.md')).toBeInTheDocument()) + expect(screen.getByTestId('tree-node-src/old.md')).toBeInTheDocument() + await waitFor(() => expect(mocks.fsReadText).toHaveBeenCalledTimes(2)) + expect(mocks.fsReadText).toHaveBeenLastCalledWith('/tmp/workspace/src/old.md') + await waitFor(() => expect(screen.getByTestId('markdown')).toHaveTextContent('# New')) + }) + + it('opens file-tree node context menu targets from workspace-relative paths', async () => { + mocks.externalApps = [ + { + id: 'vscode', + name: 'VS Code', + protocol: 'vscode://', + tags: ['code-editor'], + path: '/Applications/Visual Studio Code.app' + } + ] + mockWorkspaceTree('/tmp/workspace', ['src/index.ts']) + + render() + + await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) + + fireEvent.contextMenu(screen.getByTestId('tree-node-__workspace_root__')) + fireEvent.click(screen.getByRole('menuitem', { name: 'Finder' })) + await waitFor(() => expect(mocks.openPath).toHaveBeenCalledWith('/tmp/workspace')) + + fireEvent.contextMenu(screen.getByTestId('tree-node-src')) + fireEvent.click(screen.getByRole('menuitem', { name: 'VS Code' })) + expect(mocks.windowOpen).toHaveBeenCalledWith('editor://vscode/tmp/workspace/src') + + fireEvent.contextMenu(screen.getByTestId('tree-node-src/index.ts')) + fireEvent.click(screen.getByRole('menuitem', { name: 'agent.preview_pane.default_app' })) + await waitFor(() => expect(mocks.openPath).toHaveBeenCalledWith('/tmp/workspace/src/index.ts')) + + fireEvent.contextMenu(screen.getByTestId('tree-node-src/index.ts')) + fireEvent.click(screen.getByRole('menuitem', { name: 'Finder' })) + await waitFor(() => expect(mocks.showInFolder).toHaveBeenCalledWith('/tmp/workspace/src/index.ts')) + }) + + it('uses the Finder icon for file-manager context menu actions on macOS', async () => { + mockWorkspaceTree('/tmp/workspace', ['src/index.ts']) + + render() + + await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) + + fireEvent.contextMenu(screen.getByTestId('tree-node-__workspace_root__')) + expect(within(screen.getByRole('menuitem', { name: 'Finder' })).getByTestId('finder-icon')).toBeInTheDocument() + + fireEvent.contextMenu(screen.getByTestId('tree-node-src/index.ts')) + expect(within(screen.getByRole('menuitem', { name: 'Finder' })).getByTestId('finder-icon')).toBeInTheDocument() + }) + it('keeps the selected lazy file while expanded directories are refreshing', async () => { let resolveReload: (entries: Array<{ path: string; isDirectory: boolean }>) => void = () => undefined mockWorkspaceTree('/tmp/workspace', ['src/index.ts']) @@ -700,8 +920,6 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue('# Old') render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-src')) @@ -742,8 +960,6 @@ describe('ArtifactPane', () => { }) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-src')) @@ -791,8 +1007,6 @@ describe('ArtifactPane', () => { }) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-src')) @@ -827,7 +1041,7 @@ describe('ArtifactPane', () => { ]) mocks.fsReadText.mockResolvedValue('export const value = 1') - render() + render() await waitFor(() => expect(mocks.listDirectoryEntries).toHaveBeenCalledWith( @@ -851,7 +1065,7 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue('export const value = 1') const { rerender } = render( - + ) await waitFor(() => expect(screen.getByTestId('tree-node-src/feature/deep-result.ts')).toBeInTheDocument()) @@ -860,7 +1074,7 @@ describe('ArtifactPane', () => { await waitFor(() => expect(screen.getByTestId('code-viewer')).toHaveTextContent('export const value = 1')) - rerender() + rerender() expect(screen.queryByTestId('tree-node-src/feature/deep-result.ts')).not.toBeInTheDocument() expect(screen.getByTestId('code-viewer')).toHaveTextContent('export const value = 1') @@ -871,7 +1085,7 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['README.md']) mocks.listDirectoryEntries.mockResolvedValue([]) - render() + render() await new Promise((resolve) => setTimeout(resolve, 100)) expect(mocks.listDirectoryEntries).not.toHaveBeenCalled() @@ -884,47 +1098,6 @@ describe('ArtifactPane', () => { ) }) - it('drops pending lazy directory results when the file tree closes', async () => { - let resolveListDirectory: (entries: Array<{ path: string; isDirectory: boolean }>) => void = () => undefined - mockWorkspaceTree('/tmp/workspace', ['src/index.ts']) - mockWorkspaceTree('/tmp/workspace/src', []) - mockWorkspaceTree('/tmp/workspace', ['src/index.ts']) - mocks.listDirectoryEntries - .mockReturnValueOnce( - new Promise>((resolve) => { - resolveListDirectory = resolve - }) - ) - .mockResolvedValueOnce([{ path: '/tmp/workspace/src/fresh.ts', isDirectory: false }]) - - render() - - const fileTreeButton = screen.getByRole('button', { name: 'agent.preview_pane.file_tree' }) - fireEvent.click(fileTreeButton) - await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) - - fireEvent.click(screen.getByTestId('tree-node-src')) - await waitFor(() => - expect(mocks.listDirectoryEntries).toHaveBeenCalledWith( - '/tmp/workspace/src', - expect.objectContaining({ recursive: false, includeFiles: true, includeDirectories: true }) - ) - ) - - fireEvent.click(fileTreeButton) - await waitFor(() => expect(screen.queryByTestId('file-tree')).not.toBeInTheDocument()) - - await act(async () => { - resolveListDirectory([{ path: '/tmp/workspace/src/stale.ts', isDirectory: false }]) - }) - - fireEvent.click(fileTreeButton) - await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) - - await waitFor(() => expect(screen.getByTestId('tree-node-src/fresh.ts')).toBeInTheDocument()) - expect(screen.queryByTestId('tree-node-src/stale.ts')).not.toBeInTheDocument() - }) - it('logs and displays directory listing errors', async () => { const error = new Error('Permission denied') const errorSpy = vi.spyOn(loggerService, 'error').mockImplementation(() => undefined) @@ -932,366 +1105,23 @@ describe('ArtifactPane', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByText('Permission denied')).toBeInTheDocument()) expect(screen.getByTestId('empty-state')).toHaveTextContent('common.error') expect(screen.getByTestId('empty-state')).not.toHaveTextContent('agent.preview_pane.empty.title') expect(errorSpy).toHaveBeenCalledWith('Failed to create directory tree for /tmp/workspace', error) }) - it('renders header tool buttons without a close button or view-mode toggle', () => { - render() - - for (const label of ['agent.preview_pane.file_tree', 'agent.preview_pane.refresh', 'agent.preview_pane.maximize']) { - expect(screen.getByRole('button', { name: label })).toBeInTheDocument() - } - expect(screen.queryByRole('button', { name: 'agent.preview_pane.preview' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'agent.preview_pane.code' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'agent.preview_pane.close' })).not.toBeInTheDocument() - expect(screen.queryByRole('tab')).not.toBeInTheDocument() - }) - - it('uses the minimize action while maximized', () => { - const onToggleMaximized = vi.fn() - - const { container } = render() - - const minimizeButton = screen.getByRole('button', { name: 'agent.preview_pane.minimize' }) - expect(container.firstElementChild).toHaveClass('rounded-lg', 'border', 'border-border-subtle', 'shadow-sm') - expect(minimizeButton).toHaveAttribute('aria-pressed', 'true') - expect(screen.queryByRole('button', { name: 'agent.preview_pane.maximize' })).not.toBeInTheDocument() - - fireEvent.click(minimizeButton) - - expect(onToggleMaximized).toHaveBeenCalledTimes(1) - }) - - it('renders the workspace opener between refresh and maximize when a workspace path exists', () => { - render() - - const refreshButton = screen.getByRole('button', { name: 'agent.preview_pane.refresh' }) - const openButton = screen.getByRole('button', { name: 'Open in Finder' }) - const maximizeButton = screen.getByRole('button', { name: 'agent.preview_pane.maximize' }) - const toolbarButtons = screen.getAllByRole('button') - - expect(toolbarButtons.indexOf(openButton)).toBe(toolbarButtons.indexOf(refreshButton) + 1) - expect(toolbarButtons.indexOf(maximizeButton)).toBe(toolbarButtons.indexOf(openButton) + 1) - }) - it('does not render the workspace opener without a workspace path', () => { render() expect(screen.queryByRole('button', { name: 'Open in Finder' })).not.toBeInTheDocument() }) - it('starts with the file tree collapsed', () => { - render() - - const folderButton = screen.getByRole('button', { name: 'agent.preview_pane.file_tree' }) - - expect(mocks.treeCreate).not.toHaveBeenCalled() - expect(folderButton).toHaveAttribute('aria-pressed', 'false') - expect(folderButton.querySelector('.lucide-folder')).toBeInTheDocument() - expect(folderButton.querySelector('.lucide-folder-open')).not.toBeInTheDocument() - expect(screen.queryByTestId('file-tree')).not.toBeInTheDocument() - }) - - it('keeps the toolbar inside the right content column', () => { - const { container } = render() - - const root = container.firstElementChild - const body = root?.firstElementChild - const content = body?.querySelector('section') - const toolbar = content?.firstElementChild - const preview = content?.children.item(1) - - expect(root).toHaveClass('h-full', 'min-h-0', 'overflow-hidden') - expect(root).not.toHaveClass('rounded-2xl', 'border-frame-border', 'shadow-sm') - expect(body).toHaveClass('min-h-0', 'overflow-hidden') - expect(toolbar).toHaveClass('h-(--navbar-height)') - expect(toolbar).toContainElement(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - expect(content).toHaveClass('min-h-0', 'min-w-0') - expect(preview).toHaveClass('min-h-0', 'min-w-0', 'overflow-auto') - }) - - it('toggles the file tree when the folder button is clicked', async () => { - mockWorkspaceTree('/tmp/workspace', ['README.md']) - - render() - - const folderButton = screen.getByRole('button', { name: 'agent.preview_pane.file_tree' }) - - expect(folderButton).toHaveAttribute('aria-pressed', 'false') - expect(folderButton.querySelector('.lucide-folder')).toBeInTheDocument() - expect(folderButton.querySelector('.lucide-folder-open')).not.toBeInTheDocument() - - fireEvent.click(folderButton) - await waitFor(() => expect(screen.getByTestId('file-tree')).toBeInTheDocument()) - expect(screen.getByTestId('artifact-file-tree-motion-pane')).toHaveAttribute('data-initial-width', '0') - expect(screen.getByTestId('artifact-file-tree-motion-pane')).toHaveAttribute('data-initial-opacity', '0') - expect(screen.getByTestId('artifact-file-tree-motion-pane')).toHaveAttribute( - 'data-animate-width', - String(ARTIFACT_FILE_TREE_DEFAULT_WIDTH) - ) - expect(screen.getByTestId('artifact-file-tree-motion-pane')).toHaveAttribute('data-animate-opacity', '1') - expect(screen.getByTestId('artifact-file-tree-motion-pane')).toHaveAttribute('data-exit-width', '0') - expect(screen.getByTestId('artifact-file-tree-motion-pane')).toHaveAttribute('data-exit-opacity', '0') - expect(screen.getByTestId('artifact-file-tree-motion-pane')).toHaveAttribute('data-has-transition', 'true') - expect(screen.getByTestId('file-tree')).toHaveAttribute('data-truncate-labels', 'undefined') - expect(screen.getByTestId('tree-node-__workspace_root__')).toHaveTextContent('workspace') - expect(screen.getByTestId('tree-node-README.md')).toHaveTextContent('README.md') - expect(folderButton).toHaveAttribute('aria-pressed', 'true') - expect(folderButton.querySelector('.lucide-folder-open')).toBeInTheDocument() - expect(folderButton.querySelector('.lucide-folder')).not.toBeInTheDocument() - - fireEvent.click(folderButton) - await waitFor(() => expect(screen.queryByTestId('file-tree')).not.toBeInTheDocument()) - expect(folderButton).toHaveAttribute('aria-pressed', 'false') - expect(folderButton.querySelector('.lucide-folder')).toBeInTheDocument() - expect(folderButton.querySelector('.lucide-folder-open')).not.toBeInTheDocument() - }) - - it('renders a right-edge resize handle for the file tree', async () => { - mockWorkspaceTree('/tmp/workspace', ['README.md']) - - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('file-tree')).toBeInTheDocument()) - - const handle = container.querySelector('[data-artifact-file-tree-resize-handle]') - const scrollRegion = container.querySelector('[data-artifact-file-tree-scroll-region]') - - expect(handle).toBeInTheDocument() - expect(handle).toHaveClass('right-0', 'cursor-col-resize') - expect(scrollRegion).toHaveClass('overflow-y-auto', 'overflow-x-hidden') - }) - - it('clamps dragged file tree width from the default artifact pane width and cleans document resize styles', async () => { - mockWorkspaceTree('/tmp/workspace', ['README.md']) - - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('file-tree')).toBeInTheDocument()) - - const pane = container.querySelector('[data-artifact-file-tree-pane]') - const handle = container.querySelector('[data-artifact-file-tree-resize-handle]') - - if (!pane || !handle) { - throw new Error('Expected artifact file tree pane and resize handle') - } - - vi.spyOn(pane, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, ARTIFACT_FILE_TREE_DEFAULT_WIDTH, 500)) - - fireEvent.mouseDown(handle, { clientX: 260 }) - expect(document.body.style.cursor).toBe('col-resize') - expect(document.body.style.userSelect).toBe('none') - expect(pane).toHaveAttribute('data-resizing', 'true') - expect(screen.getByTestId('artifact-file-tree-motion-pane')).toHaveAttribute('data-has-transition', 'true') - - fireEvent.mouseMove(document, { clientX: 250 }) - fireEvent.mouseMove(document, { clientX: 180 }) - fireEvent.mouseMove(document, { clientX: 500 }) - - expect(mocks.setArtifactFileTreeWidth).toHaveBeenNthCalledWith(1, 150) - expect(mocks.setArtifactFileTreeWidth).toHaveBeenNthCalledWith(2, 80) - expect(mocks.setArtifactFileTreeWidth).toHaveBeenNthCalledWith(3, 320) - - fireEvent.mouseUp(document) - - expect(document.body.style.cursor).toBe('') - expect(document.body.style.userSelect).toBe('') - expect(pane).not.toHaveAttribute('data-resizing') - }) - - it('cleans the file tree resize state on window blur', async () => { - mockWorkspaceTree('/tmp/workspace', ['README.md']) - - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('file-tree')).toBeInTheDocument()) - - const pane = container.querySelector('[data-artifact-file-tree-pane]') - const handle = container.querySelector('[data-artifact-file-tree-resize-handle]') - - if (!pane || !handle) { - throw new Error('Expected artifact file tree pane and resize handle') - } - - vi.spyOn(pane, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, ARTIFACT_FILE_TREE_DEFAULT_WIDTH, 500)) - - fireEvent.mouseDown(handle, { clientX: 260 }) - fireEvent.mouseMove(document, { clientX: 250 }) - expect(pane).toHaveAttribute('data-resizing', 'true') - expect(document.body.style.cursor).toBe('col-resize') - expect(mocks.setArtifactFileTreeWidth).toHaveBeenCalledTimes(1) - - fireEvent.blur(window) - - expect(document.body.style.cursor).toBe('') - expect(document.body.style.userSelect).toBe('') - expect(pane).not.toHaveAttribute('data-resizing') - - fireEvent.mouseMove(document, { clientX: 500 }) - - expect(mocks.setArtifactFileTreeWidth).toHaveBeenCalledTimes(1) - }) - - it('clamps dragged file tree width from the measured artifact pane width', async () => { - mockWorkspaceTree('/tmp/workspace', ['README.md']) - - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('file-tree')).toBeInTheDocument()) - - const root = container.firstElementChild - const pane = container.querySelector('[data-artifact-file-tree-pane]') - const handle = container.querySelector('[data-artifact-file-tree-resize-handle]') - - if (!root || !pane || !handle) { - throw new Error('Expected artifact root, file tree pane, and resize handle') - } - - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 500, 500)) - vi.spyOn(pane, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, ARTIFACT_FILE_TREE_DEFAULT_WIDTH, 500)) - - fireEvent.mouseDown(handle, { clientX: 260 }) - fireEvent.mouseMove(document, { clientX: 50 }) - fireEvent.mouseMove(document, { clientX: 600 }) - fireEvent.mouseUp(document) - - expect(mocks.setArtifactFileTreeWidth).toHaveBeenNthCalledWith(1, 80) - expect(mocks.setArtifactFileTreeWidth).toHaveBeenNthCalledWith(2, 360) - }) - - it('keeps a non-zero minimum file tree width for narrow artifact panes', async () => { - mockWorkspaceTree('/tmp/workspace', ['README.md']) - - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('file-tree')).toBeInTheDocument()) - - const root = container.firstElementChild - const pane = container.querySelector('[data-artifact-file-tree-pane]') - const handle = container.querySelector('[data-artifact-file-tree-resize-handle]') - - if (!root || !pane || !handle) { - throw new Error('Expected artifact root, file tree pane, and resize handle') - } - - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 360, 500)) - vi.spyOn(pane, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, ARTIFACT_FILE_TREE_DEFAULT_WIDTH, 500)) - - fireEvent.mouseDown(handle, { clientX: 260 }) - fireEvent.mouseMove(document, { clientX: 50 }) - fireEvent.mouseUp(document) - - expect(mocks.setArtifactFileTreeWidth).toHaveBeenNthCalledWith(1, 80) - }) - - it('caps the minimum file tree width for large artifact panes', async () => { - mockWorkspaceTree('/tmp/workspace', ['README.md']) - - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('file-tree')).toBeInTheDocument()) - - const root = container.firstElementChild - const pane = container.querySelector('[data-artifact-file-tree-pane]') - const handle = container.querySelector('[data-artifact-file-tree-resize-handle]') - - if (!root || !pane || !handle) { - throw new Error('Expected artifact root, file tree pane, and resize handle') - } - - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 720, 500)) - vi.spyOn(pane, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, ARTIFACT_FILE_TREE_DEFAULT_WIDTH, 500)) - - fireEvent.mouseDown(handle, { clientX: 260 }) - fireEvent.mouseMove(document, { clientX: 50 }) - fireEvent.mouseMove(document, { clientX: 800 }) - fireEvent.mouseUp(document) - - expect(mocks.setArtifactFileTreeWidth).toHaveBeenNthCalledWith(1, 80) - expect(mocks.setArtifactFileTreeWidth).toHaveBeenNthCalledWith(2, 580) - }) - - it('disables HTML iframe pointer events while resizing the file tree', async () => { - mockWorkspaceTree('/tmp/workspace', ['index.html']) - mocks.fsReadText.mockResolvedValue('

Hello

') - - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('tree-node-index.html')).toBeInTheDocument()) - - fireEvent.click(screen.getByTestId('tree-node-index.html')) - await waitFor(() => expect(container.querySelector('iframe[title="index.html"]')).not.toBeNull()) - - const pane = container.querySelector('[data-artifact-file-tree-pane]') - const handle = container.querySelector('[data-artifact-file-tree-resize-handle]') - const rightPane = container.querySelector('[data-artifact-right-pane]') - - if (!pane || !handle || !rightPane) { - throw new Error('Expected artifact panes and resize handle') - } - - fireEvent.mouseDown(handle, { clientX: 260 }) - - expect(document.body.style.cursor).toBe('col-resize') - expect(document.body.style.userSelect).toBe('none') - expect(pane).toHaveAttribute('data-resizing', 'true') - expect(rightPane).toHaveClass('pointer-events-none') - - fireEvent.mouseUp(document) - - expect(document.body.style.cursor).toBe('') - expect(document.body.style.userSelect).toBe('') - expect(rightPane).not.toHaveClass('pointer-events-none') - }) - - it('disables PDF preview panel pointer events while resizing the file tree', async () => { - mockWorkspaceTree('/tmp/workspace', ['paper.pdf']) - - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('tree-node-paper.pdf')).toBeInTheDocument()) - - fireEvent.click(screen.getByTestId('tree-node-paper.pdf')) - await waitFor(() => expect(screen.getByTestId('pdf-preview-panel')).toBeInTheDocument()) - - const pane = container.querySelector('[data-artifact-file-tree-pane]') - const handle = container.querySelector('[data-artifact-file-tree-resize-handle]') - const rightPane = container.querySelector('[data-artifact-right-pane]') - - if (!pane || !handle || !rightPane) { - throw new Error('Expected artifact panes and resize handle') - } - - fireEvent.mouseDown(handle, { clientX: 260 }) - - expect(pane).toHaveAttribute('data-resizing', 'true') - expect(rightPane).toHaveClass('pointer-events-none') - - fireEvent.mouseUp(document) - - expect(rightPane).not.toHaveClass('pointer-events-none') - }) - it('renders markdown files through the shared Markdown component', async () => { mockWorkspaceTree('/tmp/workspace', ['README.md']) mocks.fsReadText.mockResolvedValue('# Hello') render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-README.md')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-README.md')) @@ -1316,8 +1146,6 @@ describe('ArtifactPane', () => { await waitFor(() => expect(mocks.fsReadText).toHaveBeenCalledWith('/tmp/workspace/README.md')) expect(screen.getByTestId('markdown')).toHaveTextContent('# Controlled') - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-README.md')).toBeInTheDocument()) expect(screen.getByTestId('tree-node-README.md')).toHaveAttribute('data-selected', 'true') @@ -1330,9 +1158,7 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['src/index.ts']) mocks.fsReadText.mockResolvedValue('const value = "a very long line";') - const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) + render() await waitFor(() => expect(screen.getByTestId('tree-node-src/index.ts')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-src/index.ts')) @@ -1341,7 +1167,7 @@ describe('ArtifactPane', () => { expect(screen.getByTestId('code-viewer')).toHaveTextContent('const value = "a very long line";') expect(screen.getByTestId('code-viewer')).toHaveAttribute('data-language', 'TypeScript') expect(screen.getByTestId('code-viewer')).toHaveAttribute('data-wrapped', 'false') - expect(container.querySelector('section')?.children.item(1)).toHaveClass('overflow-auto') + expect(screen.getByTestId('artifact-file-preview-overlay')).toHaveClass('overflow-auto') }) it('renders HTML previews in an iframe with Popup-aligned sandbox, file base, and hidden outer overflow', async () => { @@ -1351,8 +1177,6 @@ describe('ArtifactPane', () => { ) const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-index.html')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-index.html')) @@ -1366,7 +1190,7 @@ describe('ArtifactPane', () => { expect(iframe).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms') expect(iframe).toHaveAttribute('title', 'index.html') expect(iframe).toHaveClass('h-full', 'w-full', 'border-0', 'bg-background') - expect(container.querySelector('section')?.children.item(1)).toHaveClass('overflow-hidden') + expect(screen.getByTestId('artifact-file-preview-overlay')).toHaveClass('overflow-hidden') }) it('keeps empty HTML previews blank without showing the Popup empty text', async () => { @@ -1374,8 +1198,6 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue(' ') const { container } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-empty.html')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-empty.html')) @@ -1383,15 +1205,13 @@ describe('ArtifactPane', () => { await waitFor(() => expect(mocks.fsReadText).toHaveBeenCalledWith('/tmp/workspace/empty.html')) expect(container.querySelector('iframe')).toBeNull() expect(screen.queryByText('html_artifacts.empty_preview')).not.toBeInTheDocument() - expect(container.querySelector('section')?.children.item(1)).toHaveClass('overflow-hidden') + expect(screen.getByTestId('artifact-file-preview-overlay')).toHaveClass('overflow-hidden') }) it('does not read content when a folder node is selected', async () => { mockWorkspaceTree('/tmp/workspace', ['src/index.ts']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-__workspace_root__')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-__workspace_root__')) @@ -1406,8 +1226,6 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue('export {}') render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) expect(screen.getByTestId('tree-node-src')).toHaveAttribute('data-kind', 'folder') @@ -1426,8 +1244,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/Users/me/dev', ['/Users/me/dev/test.md']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-test.md')).toBeInTheDocument()) expect(screen.getByTestId('tree-node-__workspace_root__')).toHaveTextContent('dev') @@ -1439,8 +1255,6 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue('export {}') render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-src')).toBeInTheDocument()) expect(screen.getByTestId('tree-node-src')).toHaveAttribute('data-kind', 'folder') @@ -1456,8 +1270,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['paper.pdf']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-paper.pdf')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-paper.pdf')) @@ -1480,8 +1292,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['paper.pdf']) const { rerender } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-paper.pdf')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-paper.pdf')) @@ -1503,8 +1313,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['paper.pdf']) const { rerender } = render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-paper.pdf')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-paper.pdf')) @@ -1524,8 +1332,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['data.bin']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-data.bin')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-data.bin')) @@ -1539,7 +1345,6 @@ describe('ArtifactPane', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-photo.png')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-photo.png')) @@ -1556,7 +1361,6 @@ describe('ArtifactPane', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-photo.png')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-photo.png')) @@ -1583,7 +1387,6 @@ describe('ArtifactPane', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-icon.svg')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-icon.svg')) @@ -1599,7 +1402,6 @@ describe('ArtifactPane', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-huge.png')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-huge.png')) @@ -1613,8 +1415,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['archive.custom']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-archive.custom')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-archive.custom')) @@ -1628,8 +1428,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['gone.ts']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-gone.ts')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-gone.ts')) @@ -1643,8 +1441,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['locked.ts']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-locked.ts')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-locked.ts')) @@ -1661,8 +1457,6 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue('boot at 12:00') render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-notes.log')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-notes.log')) @@ -1676,8 +1470,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['huge.json']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-huge.json')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-huge.json')) @@ -1691,8 +1483,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['paper.pdf']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-paper.pdf')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-paper.pdf')) @@ -1706,8 +1496,6 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue('boot at 12:00') render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-notes.log')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-notes.log')) @@ -1722,8 +1510,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', [fileName]) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId(`tree-node-${fileName}`)).toBeInTheDocument()) fireEvent.click(screen.getByTestId(`tree-node-${fileName}`)) @@ -1748,8 +1534,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['proposal.docx']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-proposal.docx')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-proposal.docx')) @@ -1771,8 +1555,6 @@ describe('ArtifactPane', () => { mocks.fsReadText.mockResolvedValue('{"enabled":true}') render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-config.json')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-config.json')) @@ -1782,29 +1564,7 @@ describe('ArtifactPane', () => { expect(screen.getByTestId('code-viewer')).toHaveTextContent('{"enabled":true}') }) - it('re-reads the selected text file when refresh is clicked', async () => { - mockWorkspaceTree('/tmp/workspace', ['README.md']) - mocks.fsReadText.mockResolvedValueOnce('# Before').mockResolvedValueOnce('# After') - - render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) - await waitFor(() => expect(screen.getByTestId('tree-node-README.md')).toBeInTheDocument()) - - fireEvent.click(screen.getByTestId('tree-node-README.md')) - await waitFor(() => expect(screen.getByTestId('markdown')).toHaveTextContent('# Before')) - - // The watcher keeps the tree current; the refresh button now only - // re-pulls the active file's content (the FS scan stays a one-shot). - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.refresh' })) - - await waitFor(() => expect(mocks.fsReadText).toHaveBeenCalledTimes(2)) - expect(mocks.fsReadText).toHaveBeenLastCalledWith('/tmp/workspace/README.md') - expect(screen.getByTestId('markdown')).toHaveTextContent('# After') - expect(mocks.treeCreate).toHaveBeenCalledTimes(1) - }) - - it('clears the selected file when the watcher reports the file was removed', async () => { + it('clears the preview overlay when the watcher reports the file was removed', async () => { mockWorkspaceTree('/tmp/workspace', ['README.md']) mocks.fsReadText.mockResolvedValueOnce('# Before') @@ -1819,8 +1579,6 @@ describe('ArtifactPane', () => { }) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-README.md')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-README.md')) @@ -1831,7 +1589,7 @@ describe('ArtifactPane', () => { pushMutation?.({ treeId: 'tree-1', event: { type: 'removed', path: '/tmp/workspace/README.md' } }) }) - await waitFor(() => expect(screen.getByTestId('empty-state')).toHaveTextContent('agent.preview_pane.select_file')) + await waitFor(() => expect(screen.queryByTestId('artifact-file-preview-overlay')).not.toBeInTheDocument()) expect(screen.queryByTestId('markdown')).not.toBeInTheDocument() }) @@ -1839,8 +1597,6 @@ describe('ArtifactPane', () => { mockWorkspaceTree('/tmp/workspace', ['paper.pdf']) render() - - fireEvent.click(screen.getByRole('button', { name: 'agent.preview_pane.file_tree' })) await waitFor(() => expect(screen.getByTestId('tree-node-paper.pdf')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('tree-node-paper.pdf')) diff --git a/src/renderer/components/chat/panes/__tests__/OpenExternalAppButton.test.tsx b/src/renderer/components/chat/panes/__tests__/OpenExternalAppButton.test.tsx index ff622f6c822..6ee38306551 100644 --- a/src/renderer/components/chat/panes/__tests__/OpenExternalAppButton.test.tsx +++ b/src/renderer/components/chat/panes/__tests__/OpenExternalAppButton.test.tsx @@ -40,9 +40,14 @@ vi.mock('@cherrystudio/ui', async () => { {children} ), - ButtonGroup: ({ children, ...props }: PropsWithChildren>) => ( -
{children}
- ), + ButtonGroup: ({ + children, + ...props + }: PropsWithChildren & { attached?: boolean }>) => { + const domProps = { ...props } + delete domProps.attached + return
{children}
+ }, MenuItem: ({ label, icon, @@ -69,7 +74,7 @@ vi.mock('@cherrystudio/ui', async () => { const { open } = ReactActual.use(PopoverContext) return open ?
{children}
: null }, - PopoverTrigger: ({ children }: PropsWithChildren) => { + PopoverTrigger: ({ children }: PropsWithChildren<{ asChild?: boolean }>) => { const { setOpen } = ReactActual.use(PopoverContext) return ReactActual.isValidElement(children) ? ( // eslint-disable-next-line @eslint-react/no-clone-element -- mock reproduces Radix asChild slot behavior @@ -194,6 +199,14 @@ describe('OpenExternalAppButton', () => { render() + const primaryButton = screen.getByRole('button', { name: 'Open in VS Code' }) + const moreButton = screen.getByRole('button', { name: 'More' }) + expect(primaryButton.parentElement).toHaveClass('h-8', 'border', 'border-border-subtle') + expect(primaryButton).toHaveClass('h-full') + expect(primaryButton).not.toHaveClass('border') + expect(moreButton).toHaveClass('h-full') + expect(moreButton).not.toHaveClass('border') + // Menu targets live behind the split button's "More" popover trigger. fireEvent.click(screen.getByRole('button', { name: 'More' })) fireEvent.click(screen.getByRole('button', { name: 'Finder' })) diff --git a/src/renderer/components/chat/shell/RightPaneHost.tsx b/src/renderer/components/chat/shell/RightPaneHost.tsx index 82473abb1e1..327f1b238c0 100644 --- a/src/renderer/components/chat/shell/RightPaneHost.tsx +++ b/src/renderer/components/chat/shell/RightPaneHost.tsx @@ -138,7 +138,7 @@ export function RightPaneHost({ }) observer.observe(container) return () => observer.disconnect() - }, [hasVisiblePane, minWidth, onReservedSpaceUnavailable, reservedCenterWidth]) + }, [hasVisiblePane, minWidth, onReservedSpaceUnavailable, paneRef, reservedCenterWidth]) return ( @@ -174,7 +174,7 @@ export function RightPaneHost({ onResize: setPaneWidth, invert: true })} - className="group/right-pane-resize-handle absolute top-0 bottom-0 left-0 z-10 w-2 cursor-col-resize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"> + className="group/right-pane-resize-handle absolute top-0 bottom-0 left-0 z-30 w-2 cursor-col-resize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40">
)} diff --git a/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx b/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx index 141e8ec488d..be7b3d0a206 100644 --- a/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx +++ b/src/renderer/components/chat/shell/__tests__/RightPaneHost.test.tsx @@ -164,6 +164,19 @@ describe('RightPaneHost', () => { expect(handle).toHaveClass('left-0', 'cursor-col-resize') }) + it('keeps the resize handle above pane content overlays', () => { + const { container } = render( + +
preview overlay
+
+ ) + + const handle = container.querySelector('[data-right-pane-resize-handle]') + + expect(handle).toBeInTheDocument() + expect(handle).toHaveClass('z-30') + }) + it('notifies when the open animation completes', () => { const onOpenAnimationComplete = vi.fn() diff --git a/src/renderer/components/command/CommandMenus.tsx b/src/renderer/components/command/CommandMenus.tsx index 371617f9755..e1491fc1303 100644 --- a/src/renderer/components/command/CommandMenus.tsx +++ b/src/renderer/components/command/CommandMenus.tsx @@ -385,6 +385,8 @@ export function CommandContextMenu({ const handleCherryContextMenu = useCallback( (event: React.MouseEvent) => { + event.stopPropagation() + if (!getExtraItems) { return } diff --git a/src/renderer/components/command/__tests__/CommandMenus.test.tsx b/src/renderer/components/command/__tests__/CommandMenus.test.tsx index 41a53360649..501983e577e 100644 --- a/src/renderer/components/command/__tests__/CommandMenus.test.tsx +++ b/src/renderer/components/command/__tests__/CommandMenus.test.tsx @@ -378,6 +378,37 @@ describe('CommandContextMenu', () => { await waitFor(() => expect(onSelect).toHaveBeenCalledOnce()) }) + it('stops cherry context-menu events after an inner menu handles them', () => { + const outerOpenChange = vi.fn() + const innerOpenChange = vi.fn() + preferenceValues['menu.presentation_mode'] = 'cherry' + + render( + + + +
+ + + +
+
+
+
+ ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'inner trigger' })) + + expect(innerOpenChange).toHaveBeenCalledWith(true) + expect(outerOpenChange).not.toHaveBeenCalled() + }) + it('stops pointer and mouse down events from bubbling out of cherry context menu content', () => { const onPointerDown = vi.fn() const onMouseDown = vi.fn() diff --git a/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx b/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx index 81437e963fe..99b2e3935cb 100644 --- a/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx +++ b/src/renderer/pages/agents/__tests__/AgentChatArtifactPane.test.tsx @@ -11,8 +11,8 @@ import AgentChat from '../AgentChat' vi.mock('@cherrystudio/ui', async (importOriginal) => ({ ...(await importOriginal()), Badge: ({ children }: PropsWithChildren) => {children}, - Button: ({ children, ...props }: PropsWithChildren>) => ( - ), @@ -166,32 +166,32 @@ vi.mock('@renderer/components/chat/panes/useArtifactFileTreeModel', () => { vi.mock('@renderer/components/chat/panes/ArtifactPane', () => { const MockArtifactPane = ({ workspacePath, + previewFileSelection, + onPreviewClose, selectedFile, onSelectedFileChange, - fileTreeOpen, - onFileTreeOpenChange, fileTreeExpandedIds, onFileTreeExpandedIdsChange, fileTreeSearchKeyword, onFileTreeSearchKeywordChange }: { workspacePath?: string + previewFileSelection?: { workspacePath: string; filePath: string } | null + onPreviewClose?: () => void selectedFile?: string | null onSelectedFileChange?: (file: string | null) => void - fileTreeOpen?: boolean - onFileTreeOpenChange?: (open: boolean) => void fileTreeExpandedIds?: ReadonlySet onFileTreeExpandedIdsChange?: (ids: ReadonlySet) => void fileTreeSearchKeyword?: string onFileTreeSearchKeywordChange?: (keyword: string) => void }) => { const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview') - const [internalFileTreeOpen, setInternalFileTreeOpen] = useState(false) const [internalExpandedIds, setInternalExpandedIds] = useState>(() => new Set()) const [internalFileSearchKeyword, setInternalFileSearchKeyword] = useState('') - const resolvedFileTreeOpen = fileTreeOpen ?? internalFileTreeOpen const resolvedExpandedIds = fileTreeExpandedIds ?? internalExpandedIds const resolvedFileSearchKeyword = fileTreeSearchKeyword ?? internalFileSearchKeyword + const overlaySelection = + previewFileSelection ?? (selectedFile && workspacePath ? { workspacePath, filePath: selectedFile } : null) useEffect(() => { setViewMode('preview') @@ -203,21 +203,11 @@ vi.mock('@renderer/components/chat/panes/ArtifactPane', () => { data-workspace-path={workspacePath ?? ''} data-selected-file={selectedFile ?? ''} data-view-mode={viewMode} - data-file-tree-open={String(resolvedFileTreeOpen)} data-expanded-ids={Array.from(resolvedExpandedIds).sort().join(',')} data-file-search-keyword={resolvedFileSearchKeyword}> - + + + )} ) } + const MockArtifactFilePreview = ({ + workspacePath, + filePath, + officeActions + }: { + workspacePath?: string + filePath?: string | null + officeActions?: ReactNode + }) => ( +
+ {officeActions} +
+ ) + const MockArtifactPaneView = ({ model, - treeOpen, - onTreeOpenChange, searchKeyword, onSearchKeywordChange, selectedFile, onSelectedFileChange, - workspacePath + workspacePath, + previewFileSelection, + onPreviewClose }: { model: { effectiveExpandedIds: ReadonlySet setExpandedIds: (ids: ReadonlySet) => void } - treeOpen: boolean - onTreeOpenChange: (open: boolean) => void searchKeyword: string onSearchKeywordChange: (keyword: string) => void selectedFile: string | null onSelectedFileChange: (file: string | null) => void workspacePath?: string + previewFileSelection?: { workspacePath: string; filePath: string } | null + onPreviewClose?: () => void }) => ( id !== '__workspace_root__'))} onFileTreeExpandedIdsChange={model.setExpandedIds} fileTreeSearchKeyword={searchKeyword} @@ -283,22 +298,7 @@ vi.mock('@renderer/components/chat/panes/ArtifactPane', () => { return { ARTIFACT_PANE_WIDTH: 460, - ArtifactFilePreview: ({ - workspacePath, - filePath, - officeActions - }: { - workspacePath?: string - filePath?: string | null - officeActions?: ReactNode - }) => ( -
- {officeActions} -
- ), + ArtifactFilePreview: MockArtifactFilePreview, isOfficeDocumentFile: (filePath: string) => /\.(?:docx?|xlsx?|xlsm|pptx?)$/i.test(filePath), isImageFile: (filePath: string) => /\.(?:png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i.test(filePath), normalizeArtifactPaneFilePath: (workspacePath: string, rawPath: string) => @@ -383,8 +383,8 @@ vi.mock('motion/react', async (importOriginal) => ({ })) vi.mock('@renderer/components/NavbarIcon', () => ({ - default: ({ children, ...props }: PropsWithChildren>) => ( - ) @@ -706,24 +706,20 @@ describe('AgentChat artifact pane', () => { renderAgentChat({ pane: