From 8d1d09b1eca6ade429da4cf5a28dca74acade83c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:44:57 +0800 Subject: [PATCH 01/90] fix: eliminate UI freeze on multi-file selection via batch processing (#11377) * Initial plan * fix: improve file upload performance with batch processing and progress feedback - Add batch processing (5 files concurrently) to uploadNotes function - Use Promise.allSettled for parallel file processing - Add setTimeout(0) between batches to yield to event loop - Show loading toast when uploading more than 5 files - Add translation keys for uploading progress (en, zh-cn, zh-tw) Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * feat: add batch upload and file watcher control functionalities * feat: add hint node type and implement TreeNode component for notes - Updated NotesTreeNode type to include 'hint' as a node type. - Implemented TreeNode component to handle rendering of notes and folders, including hint nodes. - Added drag-and-drop functionality for organizing notes. - Created context hooks for managing notes actions, selection, editing, drag-and-drop, search, and UI state. - Developed file upload handling for drag-and-drop and file selection. - Enhanced menu options for notes with actions like create, rename, delete, and export. - Integrated auto-renaming feature for notes based on content. * clean comment * feat: add pause and resume functionality to file watcher; enhance error handling in useInPlaceEdit hook * fix: adjust padding in item container style for improved layout --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> Co-authored-by: suyao --- packages/shared/IpcChannel.ts | 3 + packages/shared/config/types.ts | 2 +- src/main/ipc.ts | 3 + src/main/services/FileStorage.ts | 166 ++ src/preload/index.ts | 4 + .../__tests__/DynamicVirtualList.test.tsx | 6 +- .../DynamicVirtualList.test.tsx.snap | 6 +- .../src/components/VirtualList/dynamic.tsx | 97 +- src/renderer/src/hooks/useInPlaceEdit.ts | 118 +- src/renderer/src/i18n/locales/en-us.json | 5 +- src/renderer/src/i18n/locales/zh-cn.json | 5 +- src/renderer/src/i18n/locales/zh-tw.json | 5 +- src/renderer/src/i18n/translate/de-de.json | 5 +- src/renderer/src/i18n/translate/el-gr.json | 5 +- src/renderer/src/i18n/translate/es-es.json | 5 +- src/renderer/src/i18n/translate/fr-fr.json | 5 +- src/renderer/src/i18n/translate/ja-jp.json | 5 +- src/renderer/src/i18n/translate/pt-pt.json | 5 +- src/renderer/src/i18n/translate/ru-ru.json | 5 +- .../home/Tabs/components/SessionItem.tsx | 11 +- .../src/pages/home/Tabs/components/Topics.tsx | 18 +- src/renderer/src/pages/notes/NotesPage.tsx | 32 +- src/renderer/src/pages/notes/NotesSidebar.tsx | 1617 +++-------------- .../src/pages/notes/components/TreeNode.tsx | 498 +++++ .../src/pages/notes/context/NotesContexts.tsx | 109 ++ .../pages/notes/hooks/useNotesDragAndDrop.ts | 101 + .../src/pages/notes/hooks/useNotesEditing.ts | 94 + .../pages/notes/hooks/useNotesFileUpload.ts | 112 ++ .../src/pages/notes/hooks/useNotesMenu.tsx | 263 +++ src/renderer/src/services/NotesService.ts | 101 +- src/renderer/src/types/note.ts | 2 +- 31 files changed, 1963 insertions(+), 1450 deletions(-) create mode 100644 src/renderer/src/pages/notes/components/TreeNode.tsx create mode 100644 src/renderer/src/pages/notes/context/NotesContexts.tsx create mode 100644 src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts create mode 100644 src/renderer/src/pages/notes/hooks/useNotesEditing.ts create mode 100644 src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts create mode 100644 src/renderer/src/pages/notes/hooks/useNotesMenu.tsx diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 67bd137b8e..167721a7f0 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -196,6 +196,9 @@ export enum IpcChannel { File_ValidateNotesDirectory = 'file:validateNotesDirectory', File_StartWatcher = 'file:startWatcher', File_StopWatcher = 'file:stopWatcher', + File_PauseWatcher = 'file:pauseWatcher', + File_ResumeWatcher = 'file:resumeWatcher', + File_BatchUploadMarkdown = 'file:batchUploadMarkdown', File_ShowInFolder = 'file:showInFolder', // file service diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 5c42f1d2b2..8fba6399f8 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -10,7 +10,7 @@ export type LoaderReturn = { messageSource?: 'preprocess' | 'embedding' | 'validation' } -export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' +export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' | 'refresh' export type FileChangeEvent = { eventType: FileChangeEventType diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e537b85261..f91e61eaa4 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -595,6 +595,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager)) ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager)) ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager)) + ipcMain.handle(IpcChannel.File_PauseWatcher, fileManager.pauseFileWatcher.bind(fileManager)) + ipcMain.handle(IpcChannel.File_ResumeWatcher, fileManager.resumeFileWatcher.bind(fileManager)) + ipcMain.handle(IpcChannel.File_BatchUploadMarkdown, fileManager.batchUploadMarkdownFiles.bind(fileManager)) ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager)) // file service diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index c8eb6abb03..81f5c15bd9 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -151,6 +151,7 @@ class FileStorage { private currentWatchPath?: string private debounceTimer?: NodeJS.Timeout private watcherConfig: Required = DEFAULT_WATCHER_CONFIG + private isPaused = false constructor() { this.initStorageDir() @@ -1479,6 +1480,12 @@ class FileStorage { private createChangeHandler() { return (eventType: string, filePath: string) => { + // Skip processing if watcher is paused + if (this.isPaused) { + logger.debug('File change ignored (watcher paused)', { eventType, filePath }) + return + } + if (!this.shouldWatchFile(filePath, eventType)) { return } @@ -1636,6 +1643,165 @@ class FileStorage { logger.error('Failed to show item in folder:', error as Error) } } + + /** + * Batch upload markdown files from native File objects + * This handles all I/O operations in the Main process to avoid blocking Renderer + */ + public batchUploadMarkdownFiles = async ( + _: Electron.IpcMainInvokeEvent, + filePaths: string[], + targetPath: string + ): Promise<{ + fileCount: number + folderCount: number + skippedFiles: number + }> => { + try { + logger.info('Starting batch upload', { fileCount: filePaths.length, targetPath }) + + const basePath = path.resolve(targetPath) + const MARKDOWN_EXTS = ['.md', '.markdown'] + + // Filter markdown files + const markdownFiles = filePaths.filter((filePath) => { + const ext = path.extname(filePath).toLowerCase() + return MARKDOWN_EXTS.includes(ext) + }) + + const skippedFiles = filePaths.length - markdownFiles.length + + if (markdownFiles.length === 0) { + return { fileCount: 0, folderCount: 0, skippedFiles } + } + + // Collect unique folders needed + const foldersSet = new Set() + const fileOperations: Array<{ sourcePath: string; targetPath: string }> = [] + + for (const filePath of markdownFiles) { + try { + // Get relative path if file is from a directory upload + const fileName = path.basename(filePath) + const relativePath = path.dirname(filePath) + + // Determine target directory structure + let targetDir = basePath + const folderParts: string[] = [] + + // Extract folder structure from file path for nested uploads + // This is a simplified version - in real scenario we'd need the original directory structure + if (relativePath && relativePath !== '.') { + const parts = relativePath.split(path.sep) + // Get the last few parts that represent the folder structure within upload + const relevantParts = parts.slice(Math.max(0, parts.length - 3)) + folderParts.push(...relevantParts) + } + + // Build target directory path + for (const part of folderParts) { + targetDir = path.join(targetDir, part) + foldersSet.add(targetDir) + } + + // Determine final file name + const nameWithoutExt = fileName.endsWith('.md') + ? fileName.slice(0, -3) + : fileName.endsWith('.markdown') + ? fileName.slice(0, -9) + : fileName + + const { safeName } = await this.fileNameGuard(_, targetDir, nameWithoutExt, true) + const finalPath = path.join(targetDir, safeName + '.md') + + fileOperations.push({ sourcePath: filePath, targetPath: finalPath }) + } catch (error) { + logger.error('Failed to prepare file operation:', error as Error, { filePath }) + } + } + + // Create folders in order (shallow to deep) + const sortedFolders = Array.from(foldersSet).sort((a, b) => a.length - b.length) + for (const folder of sortedFolders) { + try { + if (!fs.existsSync(folder)) { + await fs.promises.mkdir(folder, { recursive: true }) + } + } catch (error) { + logger.debug('Folder already exists or creation failed', { folder, error: (error as Error).message }) + } + } + + // Process files in batches + const BATCH_SIZE = 10 // Higher batch size since we're in Main process + let successCount = 0 + + for (let i = 0; i < fileOperations.length; i += BATCH_SIZE) { + const batch = fileOperations.slice(i, i + BATCH_SIZE) + + const results = await Promise.allSettled( + batch.map(async (op) => { + // Read from source and write to target in Main process + const content = await fs.promises.readFile(op.sourcePath, 'utf-8') + await fs.promises.writeFile(op.targetPath, content, 'utf-8') + return true + }) + ) + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successCount++ + } else { + logger.error('Failed to upload file:', result.reason, { + file: batch[index].sourcePath + }) + } + }) + } + + logger.info('Batch upload completed', { + successCount, + folderCount: foldersSet.size, + skippedFiles + }) + + return { + fileCount: successCount, + folderCount: foldersSet.size, + skippedFiles + } + } catch (error) { + logger.error('Batch upload failed:', error as Error) + throw error + } + } + + /** + * Pause file watcher to prevent events during batch operations + */ + public pauseFileWatcher = async (): Promise => { + if (this.watcher) { + logger.debug('Pausing file watcher') + this.isPaused = true + // Clear any pending debounced notifications + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = undefined + } + } + } + + /** + * Resume file watcher and trigger a refresh + */ + public resumeFileWatcher = async (): Promise => { + if (this.watcher && this.currentWatchPath) { + logger.debug('Resuming file watcher') + this.isPaused = false + // Send a synthetic refresh event to trigger tree reload + this.notifyChange('refresh', this.currentWatchPath) + } + } } export const fileStorage = new FileStorage() diff --git a/src/preload/index.ts b/src/preload/index.ts index 92f44075aa..25b1064d49 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -221,6 +221,10 @@ const api = { startFileWatcher: (dirPath: string, config?: any) => ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config), stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher), + pauseFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_PauseWatcher), + resumeFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_ResumeWatcher), + batchUploadMarkdown: (filePaths: string[], targetPath: string) => + ipcRenderer.invoke(IpcChannel.File_BatchUploadMarkdown, filePaths, targetPath), onFileChange: (callback: (data: FileChangeEvent) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: any) => { if (data && typeof data === 'object') { diff --git a/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx b/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx index c9cbbf7ddc..cf1ff29544 100644 --- a/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx +++ b/src/renderer/src/components/VirtualList/__tests__/DynamicVirtualList.test.tsx @@ -140,11 +140,11 @@ describe('DynamicVirtualList', () => { // Should call isSticky function during rendering expect(isSticky).toHaveBeenCalled() - // Should apply sticky styles to sticky items + // Sticky items within visible range should have proper z-index but may be absolute until scrolled const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement expect(stickyItem).toBeInTheDocument() - expect(stickyItem).toHaveStyle('position: sticky') - expect(stickyItem).toHaveStyle('z-index: 1') + // When sticky item is in visible range, it gets z-index but may not be sticky yet + expect(stickyItem).toHaveStyle('z-index: 999') }) it('should apply absolute positioning to non-sticky items', () => { diff --git a/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap b/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap index c5567f9b08..7bf4582822 100644 --- a/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap +++ b/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap @@ -24,7 +24,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = ` >
basic rendering > snapshot test 1`] = `
basic rendering > snapshot test 1`] = `
extends InheritedVirtualizerOptions */ isSticky?: (index: number) => boolean + /** + * Get the depth/level of an item for hierarchical sticky positioning + * Used with isSticky to determine ancestor relationships + */ + getItemDepth?: (index: number) => number + /** * Range extractor function, cannot be used with isSticky */ @@ -101,6 +107,7 @@ function DynamicVirtualList(props: DynamicVirtualListProps) { size, estimateSize, isSticky, + getItemDepth, rangeExtractor: customRangeExtractor, itemContainerStyle, scrollerStyle, @@ -115,7 +122,7 @@ function DynamicVirtualList(props: DynamicVirtualListProps) { const internalScrollerRef = useRef(null) const scrollerRef = internalScrollerRef - const activeStickyIndexRef = useRef(0) + const activeStickyIndexesRef = useRef([]) const stickyIndexes = useMemo(() => { if (!isSticky) return [] @@ -124,21 +131,54 @@ function DynamicVirtualList(props: DynamicVirtualListProps) { const internalStickyRangeExtractor = useCallback( (range: Range) => { - // The active sticky index is the last one that is before or at the start of the visible range - const newActiveStickyIndex = - [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? stickyIndexes[0] ?? 0 + const activeStickies: number[] = [] - if (newActiveStickyIndex !== activeStickyIndexRef.current) { - activeStickyIndexRef.current = newActiveStickyIndex + if (getItemDepth) { + // With depth information, we can build a proper ancestor chain + // Find all sticky items before the visible range + const stickiesBeforeRange = stickyIndexes.filter((index) => index < range.startIndex) + + if (stickiesBeforeRange.length > 0) { + // Find the depth of the first visible item (or last sticky before it) + const firstVisibleIndex = range.startIndex + const referenceDepth = getItemDepth(firstVisibleIndex) + + // Build ancestor chain: include all sticky parents + const ancestorChain: number[] = [] + let minDepth = referenceDepth + + // Walk backwards from the last sticky before visible range + for (let i = stickiesBeforeRange.length - 1; i >= 0; i--) { + const stickyIndex = stickiesBeforeRange[i] + const stickyDepth = getItemDepth(stickyIndex) + + // Include this sticky if it's a parent (smaller depth) of our reference + if (stickyDepth < minDepth) { + ancestorChain.unshift(stickyIndex) + minDepth = stickyDepth + } + } + + activeStickies.push(...ancestorChain) + } + } else { + // Fallback: without depth info, just use the last sticky before range + const lastStickyBeforeRange = [...stickyIndexes].reverse().find((index) => index < range.startIndex) + if (lastStickyBeforeRange !== undefined) { + activeStickies.push(lastStickyBeforeRange) + } } - // Merge the active sticky index and the default range extractor - const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)]) + // Update the ref with current active stickies + activeStickyIndexesRef.current = activeStickies + + // Merge the active sticky indexes and the default range extractor + const next = new Set([...activeStickyIndexesRef.current, ...defaultRangeExtractor(range)]) // Sort the set to maintain proper order return [...next].sort((a, b) => a - b) }, - [stickyIndexes] + [stickyIndexes, getItemDepth] ) const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined) @@ -221,14 +261,47 @@ function DynamicVirtualList(props: DynamicVirtualListProps) { }}> {virtualItems.map((virtualItem) => { const isItemSticky = stickyIndexes.includes(virtualItem.index) - const isItemActiveSticky = isItemSticky && activeStickyIndexRef.current === virtualItem.index + const isItemActiveSticky = isItemSticky && activeStickyIndexesRef.current.includes(virtualItem.index) + + // Calculate the sticky offset for multi-level sticky headers + const activeStickyIndex = isItemActiveSticky ? activeStickyIndexesRef.current.indexOf(virtualItem.index) : -1 + + // Calculate cumulative offset based on actual sizes of previous sticky items + let stickyOffset = 0 + if (activeStickyIndex >= 0) { + for (let i = 0; i < activeStickyIndex; i++) { + const prevStickyIndex = activeStickyIndexesRef.current[i] + stickyOffset += estimateSize(prevStickyIndex) + } + } + + // Check if this item is visually covered by sticky items + // If covered, disable pointer events to prevent hover/click bleeding through + const isCoveredBySticky = (() => { + if (!activeStickyIndexesRef.current.length) return false + if (isItemActiveSticky) return false // Sticky items themselves are not covered + + // Calculate if this item's visual position is under any sticky header + const itemVisualTop = virtualItem.start + let totalStickyHeight = 0 + for (const stickyIdx of activeStickyIndexesRef.current) { + totalStickyHeight += estimateSize(stickyIdx) + } + + // If item starts within the sticky area, it's covered + return itemVisualTop < totalStickyHeight + })() const style: React.CSSProperties = { ...itemContainerStyle, position: isItemActiveSticky ? 'sticky' : 'absolute', - top: 0, + top: isItemActiveSticky ? stickyOffset : 0, left: 0, - zIndex: isItemSticky ? 1 : undefined, + zIndex: isItemActiveSticky ? 1000 + (100 - activeStickyIndex) : isItemSticky ? 999 : 0, + pointerEvents: isCoveredBySticky ? 'none' : 'auto', + ...(isItemActiveSticky && { + backgroundColor: 'var(--color-background)' + }), ...(horizontal ? { transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`, diff --git a/src/renderer/src/hooks/useInPlaceEdit.ts b/src/renderer/src/hooks/useInPlaceEdit.ts index 675de75c7c..ab614f9528 100644 --- a/src/renderer/src/hooks/useInPlaceEdit.ts +++ b/src/renderer/src/hooks/useInPlaceEdit.ts @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react' - -import { useTimer } from './useTimer' +import { loggerService } from '@logger' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +const logger = loggerService.withContext('useInPlaceEdit') export interface UseInPlaceEditOptions { onSave: ((value: string) => void) | ((value: string) => Promise) onCancel?: () => void + onError?: (error: unknown) => void autoSelectOnStart?: boolean trimOnSave?: boolean } @@ -12,14 +14,10 @@ export interface UseInPlaceEditOptions { export interface UseInPlaceEditReturn { isEditing: boolean isSaving: boolean - editValue: string - inputRef: React.RefObject startEdit: (initialValue: string) => void saveEdit: () => void cancelEdit: () => void - handleKeyDown: (e: React.KeyboardEvent) => void - handleInputChange: (e: React.ChangeEvent) => void - handleValueChange: (value: string) => void + inputProps: React.InputHTMLAttributes & { ref: React.RefObject } } /** @@ -32,63 +30,69 @@ export interface UseInPlaceEditReturn { * @returns An object containing the editing state and handler functions */ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditReturn { - const { onSave, onCancel, autoSelectOnStart = true, trimOnSave = true } = options + const { onSave, onCancel, onError, autoSelectOnStart = true, trimOnSave = true } = options + const { t } = useTranslation() const [isSaving, setIsSaving] = useState(false) const [isEditing, setIsEditing] = useState(false) const [editValue, setEditValue] = useState('') - const [originalValue, setOriginalValue] = useState('') + const originalValueRef = useRef('') const inputRef = useRef(null) - const { setTimeoutTimer } = useTimer() - const startEdit = useCallback( - (initialValue: string) => { - setIsEditing(true) - setEditValue(initialValue) - setOriginalValue(initialValue) + const startEdit = useCallback((initialValue: string) => { + setIsEditing(true) + setEditValue(initialValue) + originalValueRef.current = initialValue + }, []) - setTimeoutTimer( - 'startEdit', - () => { - inputRef.current?.focus() - if (autoSelectOnStart) { - inputRef.current?.select() - } - }, - 0 - ) - }, - [autoSelectOnStart, setTimeoutTimer] - ) + useLayoutEffect(() => { + if (isEditing) { + inputRef.current?.focus() + if (autoSelectOnStart) { + inputRef.current?.select() + } + } + }, [autoSelectOnStart, isEditing]) const saveEdit = useCallback(async () => { if (isSaving) return + const finalValue = trimOnSave ? editValue.trim() : editValue + if (finalValue === originalValueRef.current) { + setIsEditing(false) + return + } + setIsSaving(true) try { - const finalValue = trimOnSave ? editValue.trim() : editValue - if (finalValue !== originalValue) { - await onSave(finalValue) - } + await onSave(finalValue) setIsEditing(false) setEditValue('') - setOriginalValue('') + } catch (error) { + logger.error('Error saving in-place edit', { error }) + + // Call custom error handler if provided, otherwise show default toast + if (onError) { + onError(error) + } else { + window.toast.error(t('common.save_failed') || 'Failed to save') + } } finally { setIsSaving(false) } - }, [isSaving, trimOnSave, editValue, originalValue, onSave]) + }, [isSaving, trimOnSave, editValue, onSave, onError, t]) const cancelEdit = useCallback(() => { setIsEditing(false) setEditValue('') - setOriginalValue('') onCancel?.() }, [onCancel]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + if (e.nativeEvent.isComposing) return + if (e.key === 'Enter') { e.preventDefault() saveEdit() } else if (e.key === 'Escape') { @@ -104,37 +108,29 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe setEditValue(e.target.value) }, []) - const handleValueChange = useCallback((value: string) => { - setEditValue(value) - }, []) - - // Handle clicks outside the input to save - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (isEditing && inputRef.current && !inputRef.current.contains(event.target as Node)) { - saveEdit() - } + const handleBlur = useCallback(() => { + // 这里的逻辑需要注意: + // 如果点击了“取消”按钮,可能会先触发 Blur 保存。 + // 通常 InPlaceEdit 的逻辑是 Blur 即 Save。 + // 如果不想 Blur 保存,可以去掉这一行,或者判断 relatedTarget。 + if (!isSaving) { + saveEdit() } - - if (isEditing) { - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - } - return - }, [isEditing, saveEdit]) + }, [saveEdit, isSaving]) return { isEditing, isSaving, - editValue, - inputRef, startEdit, saveEdit, cancelEdit, - handleKeyDown, - handleInputChange, - handleValueChange + inputProps: { + ref: inputRef, + value: editValue, + onChange: handleInputChange, + onKeyDown: handleKeyDown, + onBlur: handleBlur, + disabled: isSaving // 保存时禁用输入 + } } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 2ebddb6889..e8fdad0afb 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2220,7 +2220,10 @@ "untitled_folder": "New Folder", "untitled_note": "Untitled Note", "upload_failed": "Note upload failed", - "upload_success": "Note uploaded success" + "upload_files": "Upload Files", + "upload_folder": "Upload Folder", + "upload_success": "Note uploaded success", + "uploading_files": "Uploading {{count}} files..." }, "notification": { "assistant": "Assistant Response", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 4218c68f55..0ce7627392 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2220,7 +2220,10 @@ "untitled_folder": "新文件夹", "untitled_note": "无标题笔记", "upload_failed": "笔记上传失败", - "upload_success": "笔记上传成功" + "upload_files": "上传文件", + "upload_folder": "上传文件夹", + "upload_success": "笔记上传成功", + "uploading_files": "正在上传 {{count}} 个文件..." }, "notification": { "assistant": "助手响应", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index bcf12aa63e..20a3d84df2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2220,7 +2220,10 @@ "untitled_folder": "新資料夾", "untitled_note": "無標題筆記", "upload_failed": "筆記上傳失敗", - "upload_success": "筆記上傳成功" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "筆記上傳成功", + "uploading_files": "正在上傳 {{count}} 個檔案..." }, "notification": { "assistant": "助手回應", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 963f065515..aaed5b498e 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -2220,7 +2220,10 @@ "untitled_folder": "Neuer Ordner", "untitled_note": "Unbenannte Notiz", "upload_failed": "Notizen-Upload fehlgeschlagen", - "upload_success": "Notizen erfolgreich hochgeladen" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Notizen erfolgreich hochgeladen", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Assistenten-Antwort", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 6bc8b318db..f8125631a9 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -2220,7 +2220,10 @@ "untitled_folder": "Νέος φάκελος", "untitled_note": "σημείωση χωρίς τίτλο", "upload_failed": "Η σημείωση δεν ανέβηκε", - "upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Απάντηση Βοηθού", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 925977529b..2a5874f6b6 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -2220,7 +2220,10 @@ "untitled_folder": "Nueva carpeta", "untitled_note": "Nota sin título", "upload_failed": "Error al cargar la nota", - "upload_success": "Nota cargada con éxito" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Nota cargada con éxito", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Respuesta del asistente", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 5bd57f7773..bc884c8c69 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -2220,7 +2220,10 @@ "untitled_folder": "nouveau dossier", "untitled_note": "Note sans titre", "upload_failed": "Échec du téléchargement de la note", - "upload_success": "Note téléchargée avec succès" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Note téléchargée avec succès", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Réponse de l'assistant", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 9d0926c48b..f1c0fe575d 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -2220,7 +2220,10 @@ "untitled_folder": "新ファイル夹", "untitled_note": "無題のメモ", "upload_failed": "ノートのアップロードに失敗しました", - "upload_success": "ノートのアップロードが成功しました" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "ノートのアップロードが成功しました", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "助手回應", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index b84971d725..33976b2e1f 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -2220,7 +2220,10 @@ "untitled_folder": "Nova pasta", "untitled_note": "Nota sem título", "upload_failed": "Falha ao carregar a nota", - "upload_success": "Nota carregada com sucesso" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Nota carregada com sucesso", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Resposta do assistente", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 50db747396..fbdffdb379 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -2220,7 +2220,10 @@ "untitled_folder": "Новая папка", "untitled_note": "Незаглавленная заметка", "upload_failed": "Не удалось загрузить заметку", - "upload_success": "Заметка успешно загружена" + "upload_files": "[to be translated]:Upload Files", + "upload_folder": "[to be translated]:Upload Folder", + "upload_success": "Заметка успешно загружена", + "uploading_files": "[to be translated]:Uploading {{count}} files..." }, "notification": { "assistant": "Ответ ассистента", diff --git a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx index 2c719fa132..2fb9652d46 100644 --- a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx @@ -42,7 +42,7 @@ const SessionItem: FC = ({ session, agentId, onDelete, onPress const targetSession = useDeferredValue(_targetSession) const dispatch = useAppDispatch() - const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({ + const { isEditing, isSaving, startEdit, inputProps } = useInPlaceEdit({ onSave: async (value) => { if (value !== session.name) { await updateSession({ id: session.id, name: value }) @@ -179,14 +179,7 @@ const SessionItem: FC = ({ session, agentId, onDelete, onPress {isFulfilled && !isActive && } {isEditing ? ( - ) => handleValueChange(e.target.value)} - onKeyDown={handleKeyDown} - onClick={(e: React.MouseEvent) => e.stopPropagation()} - style={{ opacity: isSaving ? 0.5 : 1 }} - /> + ) : ( <> diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index 7219f7d383..c9b4b5ea41 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -81,7 +81,7 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se const deleteTimerRef = useRef(null) const [editingTopicId, setEditingTopicId] = useState(null) - const topicEdit = useInPlaceEdit({ + const { startEdit, isEditing, inputProps } = useInPlaceEdit({ onSave: (name: string) => { const topic = assistant.topics.find((t) => t.id === editingTopicId) if (topic && name !== topic.name) { @@ -526,29 +526,23 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se setTargetTopic(topic)} className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')} - onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)} + onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)} style={{ borderRadius, - cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer' + cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer' }}> {isPending(topic.id) && !isActive && } {isFulfilled(topic.id) && !isActive && } - {editingTopicId === topic.id && topicEdit.isEditing ? ( - e.stopPropagation()} - /> + {editingTopicId === topic.id && isEditing ? ( + e.stopPropagation()} /> ) : ( { setEditingTopicId(topic.id) - topicEdit.startEdit(topic.name) + startEdit(topic.name) }}> {topicName} diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index 105ceee36a..7692aa9975 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -295,6 +295,16 @@ const NotesPage: FC = () => { break } + case 'refresh': { + // 批量操作完成后的单次刷新 + logger.debug('Received refresh event, triggering tree refresh') + const refresh = refreshTreeRef.current + if (refresh) { + await refresh() + } + break + } + case 'add': case 'addDir': case 'unlink': @@ -621,7 +631,27 @@ const NotesPage: FC = () => { throw new Error('No folder path selected') } - const result = await uploadNotes(files, targetFolderPath) + // Validate uploadNotes function is available + if (typeof uploadNotes !== 'function') { + logger.error('uploadNotes function is not available', { uploadNotes }) + window.toast.error(t('notes.upload_failed')) + return + } + + let result: Awaited> + try { + result = await uploadNotes(files, targetFolderPath) + } catch (uploadError) { + logger.error('Upload operation failed:', uploadError as Error) + throw uploadError + } + + // Validate result object + if (!result || typeof result !== 'object') { + logger.error('Invalid upload result:', { result }) + window.toast.error(t('notes.upload_failed')) + return + } // 检查上传结果 if (result.fileCount === 0) { diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 8663d9625d..6ed144dd7e 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -1,47 +1,31 @@ -import { loggerService } from '@logger' -import HighlightText from '@renderer/components/HighlightText' -import { DeleteIcon } from '@renderer/components/Icons' -import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' -import Scrollbar from '@renderer/components/Scrollbar' -import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit' -import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { DynamicVirtualList } from '@renderer/components/VirtualList' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' -import { fetchNoteSummary } from '@renderer/services/ApiService' -import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import type { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService' -import type { RootState } from '@renderer/store' import { useAppSelector } from '@renderer/store' import { selectSortType } from '@renderer/store/note' import type { NotesSortType, NotesTreeNode } from '@renderer/types/note' -import { exportNote } from '@renderer/utils/export' -import { useVirtualizer } from '@tanstack/react-virtual' -import type { InputRef, MenuProps } from 'antd' -import { Dropdown, Input } from 'antd' -import type { ItemType, MenuItemType } from 'antd/es/menu/interface' -import { - ChevronDown, - ChevronRight, - Edit3, - File, - FilePlus, - FileSearch, - Folder, - FolderOpen, - Loader2, - Sparkles, - Star, - StarOff, - UploadIcon, - X -} from 'lucide-react' -import type { FC, Ref } from 'react' +import type { MenuProps } from 'antd' +import { Dropdown } from 'antd' +import { FilePlus, Folder, FolderUp, Loader2, Upload, X } from 'lucide-react' +import type { FC } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector } from 'react-redux' import styled from 'styled-components' +import TreeNode from './components/TreeNode' +import { + NotesActionsContext, + NotesDragContext, + NotesEditingContext, + NotesSearchContext, + NotesSelectionContext, + NotesUIContext +} from './context/NotesContexts' import { useFullTextSearch } from './hooks/useFullTextSearch' +import { useNotesDragAndDrop } from './hooks/useNotesDragAndDrop' +import { useNotesEditing } from './hooks/useNotesEditing' +import { useNotesFileUpload } from './hooks/useNotesFileUpload' +import { useNotesMenu } from './hooks/useNotesMenu' interface NotesSidebarProps { onCreateFolder: (name: string, targetFolderId?: string) => void @@ -58,278 +42,6 @@ interface NotesSidebarProps { selectedFolderId?: string | null } -const logger = loggerService.withContext('NotesSidebar') - -interface TreeNodeProps { - node: NotesTreeNode | SearchResult - depth: number - selectedFolderId?: string | null - activeNodeId?: string - editingNodeId: string | null - renamingNodeIds: Set - newlyRenamedNodeIds: Set - draggedNodeId: string | null - dragOverNodeId: string | null - dragPosition: 'before' | 'inside' | 'after' - inPlaceEdit: any - getMenuItems: (node: NotesTreeNode) => any[] - onSelectNode: (node: NotesTreeNode) => void - onToggleExpanded: (nodeId: string) => void - onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void - onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void - onDragLeave: () => void - onDrop: (e: React.DragEvent, node: NotesTreeNode) => void - onDragEnd: () => void - renderChildren?: boolean // 控制是否渲染子节点 - searchKeyword?: string // 搜索关键词,用于高亮 - showMatches?: boolean // 是否显示匹配预览 - openDropdownKey: string | null - onDropdownOpenChange: (key: string | null) => void -} - -const TreeNode = memo( - ({ - node, - depth, - selectedFolderId, - activeNodeId, - editingNodeId, - renamingNodeIds, - newlyRenamedNodeIds, - draggedNodeId, - dragOverNodeId, - dragPosition, - inPlaceEdit, - getMenuItems, - onSelectNode, - onToggleExpanded, - onDragStart, - onDragOver, - onDragLeave, - onDrop, - onDragEnd, - renderChildren = true, - searchKeyword = '', - showMatches = false, - openDropdownKey, - onDropdownOpenChange - }) => { - const { t } = useTranslation() - const [showAllMatches, setShowAllMatches] = useState(false) - - // 检查是否是搜索结果 - const searchResult = 'matchType' in node ? (node as SearchResult) : null - const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0 - - // 处理匹配项点击 - const handleMatchClick = useCallback( - (match: SearchMatch) => { - // 发送定位事件 - EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, { - noteId: node.id, - lineNumber: match.lineNumber, - lineContent: match.lineContent - }) - }, - [node] - ) - - const isActive = selectedFolderId - ? node.type === 'folder' && node.id === selectedFolderId - : node.id === activeNodeId - const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing - const isRenaming = renamingNodeIds.has(node.id) - const isNewlyRenamed = newlyRenamedNodeIds.has(node.id) - const hasChildren = node.children && node.children.length > 0 - const isDragging = draggedNodeId === node.id - const isDragOver = dragOverNodeId === node.id - const isDragBefore = isDragOver && dragPosition === 'before' - const isDragInside = isDragOver && dragPosition === 'inside' - const isDragAfter = isDragOver && dragPosition === 'after' - - const getNodeNameClassName = () => { - if (isRenaming) return 'shimmer' - if (isNewlyRenamed) return 'typing' - return '' - } - - const displayName = useMemo(() => { - if (!searchKeyword) { - return node.name - } - - const name = node.name ?? '' - if (!name) { - return name - } - - const keyword = searchKeyword - const nameLower = name.toLowerCase() - const keywordLower = keyword.toLowerCase() - const matchStart = nameLower.indexOf(keywordLower) - - if (matchStart === -1) { - return name - } - - const matchEnd = matchStart + keyword.length - const beforeMatch = Math.min(2, matchStart) - const contextStart = matchStart - beforeMatch - const contextLength = 50 - const contextEnd = Math.min(name.length, matchEnd + contextLength) - - const prefix = contextStart > 0 ? '...' : '' - const suffix = contextEnd < name.length ? '...' : '' - - return prefix + name.substring(contextStart, contextEnd) + suffix - }, [node.name, searchKeyword]) - - return ( -
- onDropdownOpenChange(open ? node.id : null)}> -
e.stopPropagation()}> - onDragStart(e, node)} - onDragOver={(e) => onDragOver(e, node)} - onDragLeave={onDragLeave} - onDrop={(e) => onDrop(e, node)} - onDragEnd={onDragEnd}> - onSelectNode(node)}> - - - {node.type === 'folder' && ( - { - e.stopPropagation() - onToggleExpanded(node.id) - }} - title={node.expanded ? t('notes.collapse') : t('notes.expand')}> - {node.expanded ? : } - - )} - - - {node.type === 'folder' ? ( - node.expanded ? ( - - ) : ( - - ) - ) : ( - - )} - - - {isEditing ? ( - } - value={inPlaceEdit.editValue} - onChange={inPlaceEdit.handleInputChange} - onBlur={inPlaceEdit.saveEdit} - onKeyDown={inPlaceEdit.handleKeyDown} - onClick={(e) => e.stopPropagation()} - autoFocus - size="small" - /> - ) : ( - - - {searchKeyword ? : node.name} - - {searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && ( - - {searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')} - - )} - - )} - - -
-
- - {showMatches && hasMatches && ( - - {(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => ( - handleMatchClick(match)}> - {match.lineNumber} - - - - - ))} - {searchResult!.matches!.length > 3 && ( - { - e.stopPropagation() - setShowAllMatches(!showAllMatches) - }}> - {showAllMatches ? ( - <> - - {t('notes.search.show_less')} - - ) : ( - <> - +{searchResult!.matches!.length - 3}{' '} - {t('notes.search.more_matches')} - - )} - - )} - - )} - - {renderChildren && node.type === 'folder' && node.expanded && hasChildren && ( -
- {node.children!.map((child) => ( - - ))} -
- )} -
- ) - } -) - const NotesSidebar: FC = ({ onCreateFolder, onCreateNote, @@ -345,28 +57,52 @@ const NotesSidebar: FC = ({ selectedFolderId }) => { const { t } = useTranslation() - const { bases } = useKnowledgeBases() const { activeNode } = useActiveNode(notesTree) const sortType = useAppSelector(selectSortType) - const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions) - const [editingNodeId, setEditingNodeId] = useState(null) - const [renamingNodeIds, setRenamingNodeIds] = useState>(new Set()) - const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState>(new Set()) - const [draggedNodeId, setDraggedNodeId] = useState(null) - const [dragOverNodeId, setDragOverNodeId] = useState(null) - const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside') + const [isShowStarred, setIsShowStarred] = useState(false) const [isShowSearch, setIsShowSearch] = useState(false) const [searchKeyword, setSearchKeyword] = useState('') const [isDragOverSidebar, setIsDragOverSidebar] = useState(false) const [openDropdownKey, setOpenDropdownKey] = useState(null) - const dragNodeRef = useRef(null) - const scrollbarRef = useRef(null) + const notesTreeRef = useRef(notesTree) + const virtualListRef = useRef(null) const trimmedSearchKeyword = useMemo(() => searchKeyword.trim(), [searchKeyword]) const hasSearchKeyword = trimmedSearchKeyword.length > 0 - // 全文搜索配置 + const { editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit, handleStartEdit, handleAutoRename } = + useNotesEditing({ onRenameNode }) + + const { + draggedNodeId, + dragOverNodeId, + dragPosition, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd + } = useNotesDragAndDrop({ onMoveNode }) + + const { handleDropFiles, handleSelectFiles, handleSelectFolder } = useNotesFileUpload({ + onUploadFiles, + setIsDragOverSidebar + }) + + const { getMenuItems } = useNotesMenu({ + renamingNodeIds, + onCreateNote, + onCreateFolder, + onRenameNode, + onToggleStar, + onDeleteNode, + onSelectNode, + handleStartEdit, + handleAutoRename, + activeNode + }) + const searchOptions = useMemo( () => ({ debounceMs: 300, @@ -388,268 +124,10 @@ const NotesSidebar: FC = ({ stats: searchStats } = useFullTextSearch(searchOptions) - const inPlaceEdit = useInPlaceEdit({ - onSave: (newName: string) => { - if (editingNodeId && newName) { - onRenameNode(editingNodeId, newName) - logger.debug(`Renamed node ${editingNodeId} to "${newName}"`) - } - setEditingNodeId(null) - }, - onCancel: () => { - setEditingNodeId(null) - } - }) - - // 滚动到活动节点 - useEffect(() => { - if (activeNode?.id && !isShowStarred && !isShowSearch && scrollbarRef.current) { - // 延迟一下确保DOM已更新 - setTimeout(() => { - const scrollContainer = scrollbarRef.current as HTMLElement - if (scrollContainer) { - const activeElement = scrollContainer.querySelector(`[data-node-id="${activeNode.id}"]`) as HTMLElement - if (activeElement) { - // 获取元素相对于滚动容器的位置 - const containerHeight = scrollContainer.clientHeight - const elementOffsetTop = activeElement.offsetTop - const elementHeight = activeElement.offsetHeight - const currentScrollTop = scrollContainer.scrollTop - - // 检查元素是否在可视区域内 - const elementTop = elementOffsetTop - const elementBottom = elementOffsetTop + elementHeight - const viewTop = currentScrollTop - const viewBottom = currentScrollTop + containerHeight - - // 如果元素不在可视区域内,滚动到中心位置 - if (elementTop < viewTop || elementBottom > viewBottom) { - const targetScrollTop = elementOffsetTop - (containerHeight - elementHeight) / 2 - scrollContainer.scrollTo({ - top: Math.max(0, targetScrollTop), - behavior: 'instant' - }) - } - } - } - }, 200) - } - }, [activeNode?.id, isShowStarred, isShowSearch]) - - const handleCreateFolder = useCallback(() => { - onCreateFolder(t('notes.untitled_folder')) - }, [onCreateFolder, t]) - - const handleCreateNote = useCallback(() => { - onCreateNote(t('notes.untitled_note')) - }, [onCreateNote, t]) - - const handleSelectSortType = useCallback( - (selectedSortType: NotesSortType) => { - onSortNodes(selectedSortType) - }, - [onSortNodes] - ) - - const handleStartEdit = useCallback( - (node: NotesTreeNode) => { - setEditingNodeId(node.id) - inPlaceEdit.startEdit(node.name) - }, - [inPlaceEdit] - ) - - const handleDeleteNode = useCallback( - (node: NotesTreeNode) => { - const confirmText = - node.type === 'folder' - ? t('notes.delete_folder_confirm', { name: node.name }) - : t('notes.delete_note_confirm', { name: node.name }) - - window.modal.confirm({ - title: t('notes.delete'), - content: confirmText, - centered: true, - okButtonProps: { danger: true }, - onOk: () => { - onDeleteNode(node.id) - } - }) - }, - [onDeleteNode, t] - ) - - const handleExportKnowledge = useCallback( - async (note: NotesTreeNode) => { - try { - if (bases.length === 0) { - window.toast.warning(t('chat.save.knowledge.empty.no_knowledge_base')) - return - } - - const result = await SaveToKnowledgePopup.showForNote(note) - - if (result?.success) { - window.toast.success(t('notes.export_success', { count: result.savedCount })) - } - } catch (error) { - window.toast.error(t('notes.export_failed')) - logger.error(`Failed to export note to knowledge base: ${error}`) - } - }, - [bases.length, t] - ) - - const handleImageAction = useCallback( - async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => { - try { - if (activeNode?.id !== node.id) { - onSelectNode(node) - await new Promise((resolve) => setTimeout(resolve, 500)) - } - - await exportNote({ node, platform }) - } catch (error) { - logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error) - window.toast.error(t('common.copy_failed')) - } - }, - [activeNode, onSelectNode, t] - ) - - const handleAutoRename = useCallback( - async (note: NotesTreeNode) => { - if (note.type !== 'file') return - - setRenamingNodeIds((prev) => new Set(prev).add(note.id)) - try { - const content = await window.api.file.readExternal(note.externalPath) - if (!content || content.trim().length === 0) { - window.toast.warning(t('notes.auto_rename.empty_note')) - return - } - - const summaryText = await fetchNoteSummary({ content }) - if (summaryText) { - onRenameNode(note.id, summaryText) - window.toast.success(t('notes.auto_rename.success')) - } else { - window.toast.error(t('notes.auto_rename.failed')) - } - } catch (error) { - window.toast.error(t('notes.auto_rename.failed')) - logger.error(`Failed to auto-rename note: ${error}`) - } finally { - setRenamingNodeIds((prev) => { - const next = new Set(prev) - next.delete(note.id) - return next - }) - - setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id)) - - setTimeout(() => { - setNewlyRenamedNodeIds((prev) => { - const next = new Set(prev) - next.delete(note.id) - return next - }) - }, 700) - } - }, - [onRenameNode, t] - ) - - const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => { - setDraggedNodeId(node.id) - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', node.id) - - dragNodeRef.current = e.currentTarget as HTMLDivElement - - if (e.currentTarget.parentElement) { - const rect = e.currentTarget.getBoundingClientRect() - const ghostElement = e.currentTarget.cloneNode(true) as HTMLElement - ghostElement.style.width = `${rect.width}px` - ghostElement.style.opacity = '0.7' - ghostElement.style.position = 'absolute' - ghostElement.style.top = '-1000px' - document.body.appendChild(ghostElement) - e.dataTransfer.setDragImage(ghostElement, 10, 10) - setTimeout(() => { - document.body.removeChild(ghostElement) - }, 0) - } - }, []) - - const handleDragOver = useCallback( - (e: React.DragEvent, node: NotesTreeNode) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - - if (draggedNodeId === node.id) { - return - } - - setDragOverNodeId(node.id) - - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() - const mouseY = e.clientY - const thresholdTop = rect.top + rect.height * 0.3 - const thresholdBottom = rect.bottom - rect.height * 0.3 - - if (mouseY < thresholdTop) { - setDragPosition('before') - } else if (mouseY > thresholdBottom) { - setDragPosition('after') - } else { - setDragPosition(node.type === 'folder' ? 'inside' : 'after') - } - }, - [draggedNodeId] - ) - - const handleDragLeave = useCallback(() => { - setDragOverNodeId(null) - setDragPosition('inside') - }, []) - - const handleDrop = useCallback( - (e: React.DragEvent, targetNode: NotesTreeNode) => { - e.preventDefault() - const draggedId = e.dataTransfer.getData('text/plain') - - if (draggedId && draggedId !== targetNode.id) { - onMoveNode(draggedId, targetNode.id, dragPosition) - } - - setDraggedNodeId(null) - setDragOverNodeId(null) - setDragPosition('inside') - }, - [onMoveNode, dragPosition] - ) - - const handleDragEnd = useCallback(() => { - setDraggedNodeId(null) - setDragOverNodeId(null) - setDragPosition('inside') - }, []) - - const handleToggleStarredView = useCallback(() => { - setIsShowStarred(!isShowStarred) - }, [isShowStarred]) - - const handleToggleSearchView = useCallback(() => { - setIsShowSearch(!isShowSearch) - }, [isShowSearch]) - - // 同步 notesTree 到 ref useEffect(() => { notesTreeRef.current = notesTree }, [notesTree]) - // 触发全文搜索 useEffect(() => { if (!isShowSearch) { reset() @@ -663,6 +141,61 @@ const NotesSidebar: FC = ({ } }, [isShowSearch, hasSearchKeyword, trimmedSearchKeyword, search, reset]) + // --- Logic --- + + const handleCreateFolder = useCallback(() => { + onCreateFolder(t('notes.untitled_folder')) + }, [onCreateFolder, t]) + + const handleCreateNote = useCallback(() => { + onCreateNote(t('notes.untitled_note')) + }, [onCreateNote, t]) + + const handleToggleStarredView = useCallback(() => { + setIsShowStarred(!isShowStarred) + }, [isShowStarred]) + + const handleToggleSearchView = useCallback(() => { + setIsShowSearch(!isShowSearch) + }, [isShowSearch]) + + const handleSelectSortType = useCallback( + (selectedSortType: NotesSortType) => { + onSortNodes(selectedSortType) + }, + [onSortNodes] + ) + + const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => { + return [ + { + label: t('notes.new_note'), + key: 'new_note', + icon: , + onClick: handleCreateNote + }, + { + label: t('notes.new_folder'), + key: 'new_folder', + icon: , + onClick: handleCreateFolder + }, + { type: 'divider' }, + { + label: t('notes.upload_files'), + key: 'upload_files', + icon: , + onClick: handleSelectFiles + }, + { + label: t('notes.upload_folder'), + key: 'upload_folder', + icon: , + onClick: handleSelectFolder + } + ] + }, [t, handleCreateNote, handleCreateFolder, handleSelectFiles, handleSelectFolder]) + // Flatten tree nodes for virtualization and filtering const flattenedNodes = useMemo(() => { const flattenForVirtualization = ( @@ -706,493 +239,210 @@ const NotesSidebar: FC = ({ } if (isShowStarred) { - // For filtered views, return flat list without virtualization for simplicity const filteredNodes = flattenForFiltering(notesTree) return filteredNodes.map((node) => ({ node, depth: 0 })) } - // For normal tree view, use hierarchical flattening for virtualization return flattenForVirtualization(notesTree) }, [notesTree, isShowStarred, isShowSearch, hasSearchKeyword, searchResults]) - // Use virtualization only for normal tree view with many items - const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100 - - const parentRef = useRef(null) - - const virtualizer = useVirtualizer({ - count: flattenedNodes.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 28, // Estimated height of each tree item - overscan: 10 - }) - - const filteredTree = useMemo(() => { - if (isShowStarred || isShowSearch) { - return flattenedNodes.map(({ node }) => node) + // Scroll to active node + useEffect(() => { + if (activeNode?.id && !isShowStarred && !isShowSearch && virtualListRef.current) { + setTimeout(() => { + const activeIndex = flattenedNodes.findIndex(({ node }) => node.id === activeNode.id) + if (activeIndex !== -1) { + virtualListRef.current?.scrollToIndex(activeIndex, { + align: 'center', + behavior: 'auto' + }) + } + }, 200) } - return notesTree - }, [flattenedNodes, isShowStarred, isShowSearch, notesTree]) + }, [activeNode?.id, isShowStarred, isShowSearch, flattenedNodes]) - const getMenuItems = useCallback( - (node: NotesTreeNode) => { - const baseMenuItems: MenuProps['items'] = [] + // Determine which items should be sticky (only folders in normal view) + const isSticky = useCallback( + (index: number) => { + const item = flattenedNodes[index] + if (!item) return false - // only show auto rename for file for now - if (node.type !== 'folder') { - baseMenuItems.push({ - label: t('notes.auto_rename.label'), - key: 'auto-rename', - icon: , - disabled: renamingNodeIds.has(node.id), - onClick: () => { - handleAutoRename(node) - } - }) - } - - if (node.type === 'folder') { - baseMenuItems.push( - { - label: t('notes.new_note'), - key: 'new_note', - icon: , - onClick: () => { - onCreateNote(t('notes.untitled_note'), node.id) - } - }, - { - label: t('notes.new_folder'), - key: 'new_folder', - icon: , - onClick: () => { - onCreateFolder(t('notes.untitled_folder'), node.id) - } - }, - { type: 'divider' } - ) - } - - baseMenuItems.push( - { - label: t('notes.rename'), - key: 'rename', - icon: , - onClick: () => { - handleStartEdit(node) - } - }, - { - label: t('notes.open_outside'), - key: 'open_outside', - icon: , - onClick: () => { - window.api.openPath(node.externalPath) - } - } - ) - if (node.type !== 'folder') { - baseMenuItems.push( - { - label: node.isStarred ? t('notes.unstar') : t('notes.star'), - key: 'star', - icon: node.isStarred ? : , - onClick: () => { - onToggleStar(node.id) - } - }, - { - label: t('notes.export_knowledge'), - key: 'export_knowledge', - icon: , - onClick: () => { - handleExportKnowledge(node) - } - }, - { - label: t('chat.topics.export.title'), - key: 'export', - icon: , - children: [ - exportMenuOptions.image && { - label: t('chat.topics.copy.image'), - key: 'copy-image', - onClick: () => handleImageAction(node, 'copyImage') - }, - exportMenuOptions.image && { - label: t('chat.topics.export.image'), - key: 'export-image', - onClick: () => handleImageAction(node, 'exportImage') - }, - exportMenuOptions.markdown && { - label: t('chat.topics.export.md.label'), - key: 'markdown', - onClick: () => exportNote({ node, platform: 'markdown' }) - }, - exportMenuOptions.docx && { - label: t('chat.topics.export.word'), - key: 'word', - onClick: () => exportNote({ node, platform: 'docx' }) - }, - exportMenuOptions.notion && { - label: t('chat.topics.export.notion'), - key: 'notion', - onClick: () => exportNote({ node, platform: 'notion' }) - }, - exportMenuOptions.yuque && { - label: t('chat.topics.export.yuque'), - key: 'yuque', - onClick: () => exportNote({ node, platform: 'yuque' }) - }, - exportMenuOptions.obsidian && { - label: t('chat.topics.export.obsidian'), - key: 'obsidian', - onClick: () => exportNote({ node, platform: 'obsidian' }) - }, - exportMenuOptions.joplin && { - label: t('chat.topics.export.joplin'), - key: 'joplin', - onClick: () => exportNote({ node, platform: 'joplin' }) - }, - exportMenuOptions.siyuan && { - label: t('chat.topics.export.siyuan'), - key: 'siyuan', - onClick: () => exportNote({ node, platform: 'siyuan' }) - } - ].filter(Boolean) as ItemType[] - } - ) - } - baseMenuItems.push( - { type: 'divider' }, - { - label: t('notes.delete'), - danger: true, - key: 'delete', - icon: , - onClick: () => { - handleDeleteNode(node) - } - } - ) - - return baseMenuItems + // Only folders should be sticky, and only in normal view (not search or starred) + return item.node.type === 'folder' && !isShowSearch && !isShowStarred }, - [ - t, - handleStartEdit, - onToggleStar, - handleExportKnowledge, - handleImageAction, - handleDeleteNode, + [flattenedNodes, isShowSearch, isShowStarred] + ) + + // Get the depth of an item for hierarchical sticky positioning + const getItemDepth = useCallback( + (index: number) => { + const item = flattenedNodes[index] + return item?.depth ?? 0 + }, + [flattenedNodes] + ) + + const actionsValue = useMemo( + () => ({ + getMenuItems, + onSelectNode, + onToggleExpanded, + onDropdownOpenChange: setOpenDropdownKey + }), + [getMenuItems, onSelectNode, onToggleExpanded] + ) + + const selectionValue = useMemo( + () => ({ + selectedFolderId, + activeNodeId: activeNode?.id + }), + [selectedFolderId, activeNode?.id] + ) + + const editingValue = useMemo( + () => ({ + editingNodeId, renamingNodeIds, - handleAutoRename, - exportMenuOptions, - onCreateNote, - onCreateFolder + newlyRenamedNodeIds, + inPlaceEdit + }), + [editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit] + ) + + const dragValue = useMemo( + () => ({ + draggedNodeId, + dragOverNodeId, + dragPosition, + onDragStart: handleDragStart, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + onDragEnd: handleDragEnd + }), + [ + draggedNodeId, + dragOverNodeId, + dragPosition, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd ] ) - const handleDropFiles = useCallback( - async (e: React.DragEvent) => { - e.preventDefault() - setIsDragOverSidebar(false) - - // 处理文件夹拖拽:从 dataTransfer.items 获取完整的文件路径信息 - const items = Array.from(e.dataTransfer.items) - const files: File[] = [] - - const processEntry = async (entry: FileSystemEntry, path: string = '') => { - if (entry.isFile) { - const fileEntry = entry as FileSystemFileEntry - return new Promise((resolve) => { - fileEntry.file((file) => { - // 手动设置 webkitRelativePath 以保持文件夹结构 - Object.defineProperty(file, 'webkitRelativePath', { - value: path + file.name, - writable: false - }) - files.push(file) - resolve() - }) - }) - } else if (entry.isDirectory) { - const dirEntry = entry as FileSystemDirectoryEntry - const reader = dirEntry.createReader() - return new Promise((resolve) => { - reader.readEntries(async (entries) => { - const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/')) - await Promise.all(promises) - resolve() - }) - }) - } - } - - // 如果支持 DataTransferItem API(文件夹拖拽) - if (items.length > 0 && items[0].webkitGetAsEntry()) { - const promises = items.map((item) => { - const entry = item.webkitGetAsEntry() - return entry ? processEntry(entry) : Promise.resolve() - }) - - await Promise.all(promises) - - if (files.length > 0) { - onUploadFiles(files) - } - } else { - const regularFiles = Array.from(e.dataTransfer.files) - if (regularFiles.length > 0) { - onUploadFiles(regularFiles) - } - } - }, - [onUploadFiles] + const searchValue = useMemo( + () => ({ + searchKeyword: isShowSearch ? trimmedSearchKeyword : '', + showMatches: isShowSearch + }), + [isShowSearch, trimmedSearchKeyword] ) - const handleClickToSelectFiles = useCallback(() => { - const fileInput = document.createElement('input') - fileInput.type = 'file' - fileInput.multiple = true - fileInput.accept = '.md,.markdown' - fileInput.webkitdirectory = false - - fileInput.onchange = (e) => { - const target = e.target as HTMLInputElement - if (target.files && target.files.length > 0) { - const selectedFiles = Array.from(target.files) - onUploadFiles(selectedFiles) - } - fileInput.remove() - } - - fileInput.click() - }, [onUploadFiles]) - - const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => { - return [ - { - label: t('notes.new_note'), - key: 'new_note', - icon: , - onClick: handleCreateNote - }, - { - label: t('notes.new_folder'), - key: 'new_folder', - icon: , - onClick: handleCreateFolder - } - ] - }, [t, handleCreateNote, handleCreateFolder]) - return ( - { - e.preventDefault() - if (!draggedNodeId) { - setIsDragOverSidebar(true) - } - }} - onDragLeave={() => setIsDragOverSidebar(false)} - onDrop={(e) => { - if (!draggedNodeId) { - handleDropFiles(e) - } - }}> - + + + + + + + { + e.preventDefault() + if (!draggedNodeId) { + setIsDragOverSidebar(true) + } + }} + onDragLeave={() => setIsDragOverSidebar(false)} + onDrop={(e) => { + if (!draggedNodeId) { + handleDropFiles(e) + } + }}> + - - {isShowSearch && isSearching && ( - - - {t('notes.search.searching')} - - - - - )} - {isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && ( - - - {t('notes.search.found_results', { - count: searchStats.total, - nameCount: searchStats.fileNameMatches, - contentCount: searchStats.contentMatches + searchStats.bothMatches - })} - - - )} - {shouldUseVirtualization ? ( - setOpenDropdownKey(open ? 'empty-area' : null)}> - -
- {virtualizer.getVirtualItems().map((virtualItem) => { - const { node, depth } = flattenedNodes[virtualItem.index] - return ( -
-
+ + {isShowSearch && isSearching && ( + + + {t('notes.search.searching')} + + + + + )} + {isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && ( + + + {t('notes.search.found_results', { + count: searchStats.total, + nameCount: searchStats.fileNameMatches, + contentCount: searchStats.contentMatches + searchStats.bothMatches + })} + + + )} + setOpenDropdownKey(open ? 'empty-area' : null)}> + 28} + itemContainerStyle={{ padding: '8px 8px 0 8px' }} + overscan={10} + isSticky={isSticky} + getItemDepth={getItemDepth}> + {({ node, depth }) => } + + + {!isShowStarred && !isShowSearch && ( +
-
- ) - })} -
- {!isShowStarred && !isShowSearch && ( - - - - - - - {t('notes.drop_markdown_hint')} - - - - )} - - - ) : ( - setOpenDropdownKey(open ? 'empty-area' : null)}> - - - {isShowStarred || isShowSearch - ? filteredTree.map((node) => ( - - )) - : notesTree.map((node) => ( - - ))} - {!isShowStarred && !isShowSearch && ( - - - - - - - {t('notes.drop_markdown_hint')} - - - - )} - - - - )} - + )} + - {isDragOverSidebar && } - + {isDragOverSidebar && } + + + + + + + ) } -const SidebarContainer = styled.div` +export const SidebarContainer = styled.div` width: 250px; min-width: 250px; height: calc(100vh - var(--navbar-height)); @@ -1204,7 +454,7 @@ const SidebarContainer = styled.div` position: relative; ` -const NotesTreeContainer = styled.div` +export const NotesTreeContainer = styled.div` flex: 1; overflow: hidden; display: flex; @@ -1212,183 +462,7 @@ const NotesTreeContainer = styled.div` height: calc(100vh - var(--navbar-height) - 45px); ` -const VirtualizedTreeContainer = styled.div` - flex: 1; - height: 100%; - overflow: auto; - position: relative; - padding-top: 10px; -` - -const StyledScrollbar = styled(Scrollbar)` - flex: 1; - height: 100%; - min-height: 0; -` - -const TreeContent = styled.div` - padding: 8px; -` - -const TreeNodeContainer = styled.div<{ - active: boolean - depth: number - isDragging?: boolean - isDragOver?: boolean - isDragBefore?: boolean - isDragInside?: boolean - isDragAfter?: boolean -}>` - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 6px; - border-radius: 4px; - cursor: pointer; - margin-bottom: 2px; - background-color: ${(props) => { - if (props.isDragInside) return 'var(--color-primary-background)' - if (props.active) return 'var(--color-background-soft)' - return 'transparent' - }}; - border: 0.5px solid - ${(props) => { - if (props.isDragInside) return 'var(--color-primary)' - if (props.active) return 'var(--color-border)' - return 'transparent' - }}; - opacity: ${(props) => (props.isDragging ? 0.5 : 1)}; - transition: all 0.2s ease; - position: relative; - - &:hover { - background-color: var(--color-background-soft); - - .node-actions { - opacity: 1; - } - } - - /* 添加拖拽指示线 */ - ${(props) => - props.isDragBefore && - ` - &::before { - content: ''; - position: absolute; - top: -2px; - left: 0; - right: 0; - height: 2px; - background-color: var(--color-primary); - border-radius: 1px; - } - `} - - ${(props) => - props.isDragAfter && - ` - &::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - right: 0; - height: 2px; - background-color: var(--color-primary); - border-radius: 1px; - } - `} -` - -const TreeNodeContent = styled.div` - display: flex; - align-items: center; - flex: 1; - min-width: 0; -` - -const NodeIndent = styled.div<{ depth: number }>` - width: ${(props) => props.depth * 16}px; - flex-shrink: 0; -` - -const ExpandIcon = styled.div` - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-2); - margin-right: 4px; - - &:hover { - color: var(--color-text); - } -` - -const NodeIcon = styled.div` - display: flex; - align-items: center; - justify-content: center; - margin-right: 8px; - color: var(--color-text-2); - flex-shrink: 0; -` - -const NodeName = styled.div` - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 13px; - color: var(--color-text); - position: relative; - will-change: background-position, width; - - --color-shimmer-mid: var(--color-text-1); - --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); - - &.shimmer { - background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end)); - background-size: 200% 100%; - background-clip: text; - color: transparent; - animation: shimmer 3s linear infinite; - } - - &.typing { - display: block; - white-space: nowrap; - overflow: hidden; - animation: typewriter 0.5s steps(40, end); - } - - @keyframes shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } - } - - @keyframes typewriter { - from { - width: 0; - } - to { - width: 100%; - } - } -` - -const EditInput = styled(Input)` - flex: 1; - font-size: 13px; -` - -const DragOverIndicator = styled.div` +export const DragOverIndicator = styled.div` position: absolute; top: 0; right: 0; @@ -1400,31 +474,14 @@ const DragOverIndicator = styled.div` pointer-events: none; ` -const DropHintNode = styled.div` - margin: 6px 0; - margin-bottom: 20px; - - ${TreeNodeContainer} { - background-color: transparent; - border: 1px dashed var(--color-border); - cursor: default; - opacity: 0.6; - - &:hover { - background-color: var(--color-background-soft); - opacity: 0.8; - } - } -` - -const DropHintText = styled.div` +export const DropHintText = styled.div` color: var(--color-text-3); font-size: 12px; font-style: italic; ` // 搜索相关样式 -const SearchStatusBar = styled.div` +export const SearchStatusBar = styled.div` display: flex; align-items: center; gap: 8px; @@ -1448,7 +505,7 @@ const SearchStatusBar = styled.div` } ` -const CancelButton = styled.button` +export const CancelButton = styled.button` margin-left: auto; display: flex; align-items: center; @@ -1473,98 +530,4 @@ const CancelButton = styled.button` } ` -const NodeNameContainer = styled.div` - display: flex; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; -` - -const MatchBadge = styled.span<{ matchType: string }>` - display: inline-flex; - align-items: center; - padding: 0 4px; - height: 16px; - font-size: 10px; - line-height: 1; - border-radius: 2px; - background-color: ${(props) => - props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'}; - color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')}; - font-weight: 500; - flex-shrink: 0; -` - -const SearchMatchesContainer = styled.div<{ depth: number }>` - margin-left: ${(props) => props.depth * 16 + 40}px; - margin-top: 4px; - margin-bottom: 8px; - padding: 6px 8px; - background-color: var(--color-background-mute); - border-radius: 4px; - border-left: 2px solid var(--color-primary-soft); -` - -const MatchItem = styled.div` - display: flex; - gap: 8px; - margin-bottom: 4px; - font-size: 12px; - padding: 4px 6px; - margin-left: -6px; - margin-right: -6px; - border-radius: 3px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background-color: var(--color-background-soft); - transform: translateX(2px); - } - - &:active { - background-color: var(--color-active); - } - - &:last-child { - margin-bottom: 0; - } -` - -const MatchLineNumber = styled.span` - color: var(--color-text-3); - font-family: monospace; - flex-shrink: 0; - width: 30px; -` - -const MatchContext = styled.div` - color: var(--color-text-2); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: monospace; -` - -const MoreMatches = styled.div<{ depth: number }>` - margin-top: 4px; - padding: 4px 6px; - margin-left: -6px; - margin-right: -6px; - font-size: 11px; - color: var(--color-text-3); - border-radius: 3px; - cursor: pointer; - display: flex; - align-items: center; - transition: all 0.15s ease; - - &:hover { - color: var(--color-text-2); - background-color: var(--color-background-soft); - } -` - export default memo(NotesSidebar) diff --git a/src/renderer/src/pages/notes/components/TreeNode.tsx b/src/renderer/src/pages/notes/components/TreeNode.tsx new file mode 100644 index 0000000000..0801d31050 --- /dev/null +++ b/src/renderer/src/pages/notes/components/TreeNode.tsx @@ -0,0 +1,498 @@ +import HighlightText from '@renderer/components/HighlightText' +import { + useNotesActions, + useNotesDrag, + useNotesEditing, + useNotesSearch, + useNotesSelection, + useNotesUI +} from '@renderer/pages/notes/context/NotesContexts' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import type { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService' +import type { NotesTreeNode } from '@renderer/types/note' +import { Dropdown } from 'antd' +import { ChevronDown, ChevronRight, File, FilePlus, Folder, FolderOpen } from 'lucide-react' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface TreeNodeProps { + node: NotesTreeNode | SearchResult + depth: number + renderChildren?: boolean + onHintClick?: () => void +} + +const TreeNode = memo(({ node, depth, renderChildren = true, onHintClick }) => { + const { t } = useTranslation() + + // Use split contexts - only subscribe to what this node needs + const { selectedFolderId, activeNodeId } = useNotesSelection() + const { editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit } = useNotesEditing() + const { draggedNodeId, dragOverNodeId, dragPosition, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd } = + useNotesDrag() + const { searchKeyword, showMatches } = useNotesSearch() + const { openDropdownKey } = useNotesUI() + const { getMenuItems, onSelectNode, onToggleExpanded, onDropdownOpenChange } = useNotesActions() + + const [showAllMatches, setShowAllMatches] = useState(false) + const { isEditing: isInputEditing, inputProps } = inPlaceEdit + + // 检查是否是 hint 节点 + const isHintNode = node.type === 'hint' + + // 检查是否是搜索结果 + const searchResult = 'matchType' in node ? (node as SearchResult) : null + const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0 + + // 处理匹配项点击 + const handleMatchClick = useCallback( + (match: SearchMatch) => { + // 发送定位事件 + EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, { + noteId: node.id, + lineNumber: match.lineNumber, + lineContent: match.lineContent + }) + }, + [node] + ) + + const isActive = selectedFolderId ? node.type === 'folder' && node.id === selectedFolderId : node.id === activeNodeId + const isEditing = editingNodeId === node.id && isInputEditing + const isRenaming = renamingNodeIds.has(node.id) + const isNewlyRenamed = newlyRenamedNodeIds.has(node.id) + const hasChildren = node.children && node.children.length > 0 + const isDragging = draggedNodeId === node.id + const isDragOver = dragOverNodeId === node.id + const isDragBefore = isDragOver && dragPosition === 'before' + const isDragInside = isDragOver && dragPosition === 'inside' + const isDragAfter = isDragOver && dragPosition === 'after' + + const getNodeNameClassName = () => { + if (isRenaming) return 'shimmer' + if (isNewlyRenamed) return 'typing' + return '' + } + + const displayName = useMemo(() => { + if (!searchKeyword) { + return node.name + } + + const name = node.name ?? '' + if (!name) { + return name + } + + const keyword = searchKeyword + const nameLower = name.toLowerCase() + const keywordLower = keyword.toLowerCase() + const matchStart = nameLower.indexOf(keywordLower) + + if (matchStart === -1) { + return name + } + + const matchEnd = matchStart + keyword.length + const beforeMatch = Math.min(2, matchStart) + const contextStart = matchStart - beforeMatch + const contextLength = 50 + const contextEnd = Math.min(name.length, matchEnd + contextLength) + + const prefix = contextStart > 0 ? '...' : '' + const suffix = contextEnd < name.length ? '...' : '' + + return prefix + name.substring(contextStart, contextEnd) + suffix + }, [node.name, searchKeyword]) + + // Special render for hint nodes + if (isHintNode) { + return ( +
+ + + + + + {t('notes.drop_markdown_hint')} + + +
+ ) + } + + return ( +
+ onDropdownOpenChange(open ? node.id : null)}> +
e.stopPropagation()}> + onDragStart(e, node as NotesTreeNode)} + onDragOver={(e) => onDragOver(e, node as NotesTreeNode)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, node as NotesTreeNode)} + onDragEnd={onDragEnd}> + onSelectNode(node as NotesTreeNode)}> + + + {node.type === 'folder' && ( + { + e.stopPropagation() + onToggleExpanded(node.id) + }} + title={node.expanded ? t('notes.collapse') : t('notes.expand')}> + {node.expanded ? : } + + )} + + + {node.type === 'folder' ? ( + node.expanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + {isEditing ? ( + e.stopPropagation()} autoFocus /> + ) : ( + + + {searchKeyword ? : node.name} + + {searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && ( + + {searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')} + + )} + + )} + + +
+
+ + {showMatches && hasMatches && ( + + {(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => ( + handleMatchClick(match)}> + {match.lineNumber} + + + + + ))} + {searchResult!.matches!.length > 3 && ( + { + e.stopPropagation() + setShowAllMatches(!showAllMatches) + }}> + {showAllMatches ? ( + <> + + {t('notes.search.show_less')} + + ) : ( + <> + +{searchResult!.matches!.length - 3}{' '} + {t('notes.search.more_matches')} + + )} + + )} + + )} + + {renderChildren && node.type === 'folder' && node.expanded && hasChildren && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ) +}) + +export const TreeNodeContainer = styled.div<{ + active: boolean + depth: number + isDragging?: boolean + isDragOver?: boolean + isDragBefore?: boolean + isDragInside?: boolean + isDragAfter?: boolean +}>` + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + margin-bottom: 2px; + /* CRITICAL: Must have fully opaque background for sticky to work properly */ + /* Transparent/semi-transparent backgrounds will show content bleeding through when sticky */ + background-color: ${(props) => { + if (props.isDragInside) return 'var(--color-primary-background)' + // Use hover color for active state - it's guaranteed to be opaque + if (props.active) return 'var(--color-hover, var(--color-background-mute))' + return 'var(--color-background)' + }}; + border: 0.5px solid + ${(props) => { + if (props.isDragInside) return 'var(--color-primary)' + if (props.active) return 'var(--color-border)' + return 'transparent' + }}; + opacity: ${(props) => (props.isDragging ? 0.5 : 1)}; + transition: all 0.2s ease; + position: relative; + + &:hover { + background-color: var(--color-background-soft); + + .node-actions { + opacity: 1; + } + } + + /* 添加拖拽指示线 */ + ${(props) => + props.isDragBefore && + ` + &::before { + content: ''; + position: absolute; + top: -2px; + left: 0; + right: 0; + height: 2px; + background-color: var(--color-primary); + border-radius: 1px; + } + `} + + ${(props) => + props.isDragAfter && + ` + &::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 2px; + background-color: var(--color-primary); + border-radius: 1px; + } + `} +` + +export const TreeNodeContent = styled.div` + display: flex; + align-items: center; + flex: 1; + min-width: 0; +` + +export const NodeIndent = styled.div<{ depth: number }>` + width: ${(props) => props.depth * 16}px; + flex-shrink: 0; +` + +export const ExpandIcon = styled.div` + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-2); + margin-right: 4px; + + &:hover { + color: var(--color-text); + } +` + +export const NodeIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + color: var(--color-text-2); + flex-shrink: 0; +` + +export const NodeName = styled.div` + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + color: var(--color-text); + position: relative; + will-change: background-position, width; + + --color-shimmer-mid: var(--color-text-1); + --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); + + &.shimmer { + background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end)); + background-size: 200% 100%; + background-clip: text; + color: transparent; + animation: shimmer 3s linear infinite; + } + + &.typing { + display: block; + white-space: nowrap; + overflow: hidden; + animation: typewriter 0.5s steps(40, end); + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + + @keyframes typewriter { + from { + width: 0; + } + to { + width: 100%; + } + } +` + +export const SearchMatchesContainer = styled.div<{ depth: number }>` + margin-left: ${(props) => props.depth * 16 + 40}px; + margin-top: 4px; + margin-bottom: 8px; + padding: 6px 8px; + background-color: var(--color-background-mute); + border-radius: 4px; + border-left: 2px solid var(--color-primary-soft); +` + +export const NodeNameContainer = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +` + +export const MatchBadge = styled.span<{ matchType: string }>` + display: inline-flex; + align-items: center; + padding: 0 4px; + height: 16px; + font-size: 10px; + line-height: 1; + border-radius: 2px; + background-color: ${(props) => + props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'}; + color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')}; + font-weight: 500; + flex-shrink: 0; +` + +export const MatchItem = styled.div` + display: flex; + gap: 8px; + margin-bottom: 4px; + font-size: 12px; + padding: 4px 6px; + margin-left: -6px; + margin-right: -6px; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background-color: var(--color-background-soft); + transform: translateX(2px); + } + + &:active { + background-color: var(--color-active); + } + + &:last-child { + margin-bottom: 0; + } +` + +export const MatchLineNumber = styled.span` + color: var(--color-text-3); + font-family: monospace; + flex-shrink: 0; + width: 30px; +` + +export const MatchContext = styled.div` + color: var(--color-text-2); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; +` + +export const MoreMatches = styled.div<{ depth: number }>` + margin-top: 4px; + padding: 4px 6px; + margin-left: -6px; + margin-right: -6px; + font-size: 11px; + color: var(--color-text-3); + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + transition: all 0.15s ease; + + &:hover { + color: var(--color-text-2); + background-color: var(--color-background-soft); + } +` + +const EditInput = styled.input` + flex: 1; + font-size: 13px; +` + +const DropHintText = styled.div` + color: var(--color-text-3); + font-size: 12px; + font-style: italic; +` + +export default TreeNode diff --git a/src/renderer/src/pages/notes/context/NotesContexts.tsx b/src/renderer/src/pages/notes/context/NotesContexts.tsx new file mode 100644 index 0000000000..6bbb86c8d1 --- /dev/null +++ b/src/renderer/src/pages/notes/context/NotesContexts.tsx @@ -0,0 +1,109 @@ +import type { UseInPlaceEditReturn } from '@renderer/hooks/useInPlaceEdit' +import type { NotesTreeNode } from '@renderer/types/note' +import type { MenuProps } from 'antd' +import { createContext, use } from 'react' + +// ==================== 1. Actions Context (Static, rarely changes) ==================== +export interface NotesActionsContextType { + getMenuItems: (node: NotesTreeNode) => MenuProps['items'] + onSelectNode: (node: NotesTreeNode) => void + onToggleExpanded: (nodeId: string) => void + onDropdownOpenChange: (key: string | null) => void +} + +export const NotesActionsContext = createContext(null) + +export const useNotesActions = () => { + const context = use(NotesActionsContext) + if (!context) { + throw new Error('useNotesActions must be used within NotesActionsContext.Provider') + } + return context +} + +// ==================== 2. Selection Context (Low frequency updates) ==================== +export interface NotesSelectionContextType { + selectedFolderId?: string | null + activeNodeId?: string +} + +export const NotesSelectionContext = createContext(null) + +export const useNotesSelection = () => { + const context = use(NotesSelectionContext) + if (!context) { + throw new Error('useNotesSelection must be used within NotesSelectionContext.Provider') + } + return context +} + +// ==================== 3. Editing Context (Medium frequency updates) ==================== +export interface NotesEditingContextType { + editingNodeId: string | null + renamingNodeIds: Set + newlyRenamedNodeIds: Set + inPlaceEdit: UseInPlaceEditReturn +} + +export const NotesEditingContext = createContext(null) + +export const useNotesEditing = () => { + const context = use(NotesEditingContext) + if (!context) { + throw new Error('useNotesEditing must be used within NotesEditingContext.Provider') + } + return context +} + +// ==================== 4. Drag Context (High frequency updates) ==================== +export interface NotesDragContextType { + draggedNodeId: string | null + dragOverNodeId: string | null + dragPosition: 'before' | 'inside' | 'after' + onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void + onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void + onDragLeave: () => void + onDrop: (e: React.DragEvent, node: NotesTreeNode) => void + onDragEnd: () => void +} + +export const NotesDragContext = createContext(null) + +export const useNotesDrag = () => { + const context = use(NotesDragContext) + if (!context) { + throw new Error('useNotesDrag must be used within NotesDragContext.Provider') + } + return context +} + +// ==================== 5. Search Context (Medium frequency updates) ==================== +export interface NotesSearchContextType { + searchKeyword: string + showMatches: boolean +} + +export const NotesSearchContext = createContext(null) + +export const useNotesSearch = () => { + const context = use(NotesSearchContext) + if (!context) { + throw new Error('useNotesSearch must be used within NotesSearchContext.Provider') + } + return context +} + +// ==================== 6. UI Context (Medium frequency updates) ==================== +export interface NotesUIContextType { + openDropdownKey: string | null +} + +export const NotesUIContext = createContext(null) + +export const useNotesUI = () => { + const context = use(NotesUIContext) + if (!context) { + throw new Error('useNotesUI must be used within NotesUIContext.Provider') + } + return context +} diff --git a/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts b/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts new file mode 100644 index 0000000000..1822c00e9d --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts @@ -0,0 +1,101 @@ +import type { NotesTreeNode } from '@renderer/types/note' +import { useCallback, useRef, useState } from 'react' + +interface UseNotesDragAndDropProps { + onMoveNode: (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => void +} + +export const useNotesDragAndDrop = ({ onMoveNode }: UseNotesDragAndDropProps) => { + const [draggedNodeId, setDraggedNodeId] = useState(null) + const [dragOverNodeId, setDragOverNodeId] = useState(null) + const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside') + const dragNodeRef = useRef(null) + + const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => { + setDraggedNodeId(node.id) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', node.id) + + dragNodeRef.current = e.currentTarget as HTMLDivElement + + // Create ghost element + if (e.currentTarget.parentElement) { + const rect = e.currentTarget.getBoundingClientRect() + const ghostElement = e.currentTarget.cloneNode(true) as HTMLElement + ghostElement.style.width = `${rect.width}px` + ghostElement.style.opacity = '0.7' + ghostElement.style.position = 'absolute' + ghostElement.style.top = '-1000px' + document.body.appendChild(ghostElement) + e.dataTransfer.setDragImage(ghostElement, 10, 10) + setTimeout(() => { + document.body.removeChild(ghostElement) + }, 0) + } + }, []) + + const handleDragOver = useCallback( + (e: React.DragEvent, node: NotesTreeNode) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + + if (draggedNodeId === node.id) { + return + } + + setDragOverNodeId(node.id) + + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const mouseY = e.clientY + const thresholdTop = rect.top + rect.height * 0.3 + const thresholdBottom = rect.bottom - rect.height * 0.3 + + if (mouseY < thresholdTop) { + setDragPosition('before') + } else if (mouseY > thresholdBottom) { + setDragPosition('after') + } else { + setDragPosition(node.type === 'folder' ? 'inside' : 'after') + } + }, + [draggedNodeId] + ) + + const handleDragLeave = useCallback(() => { + setDragOverNodeId(null) + setDragPosition('inside') + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent, targetNode: NotesTreeNode) => { + e.preventDefault() + const draggedId = e.dataTransfer.getData('text/plain') + + if (draggedId && draggedId !== targetNode.id) { + onMoveNode(draggedId, targetNode.id, dragPosition) + } + + setDraggedNodeId(null) + setDragOverNodeId(null) + setDragPosition('inside') + }, + [onMoveNode, dragPosition] + ) + + const handleDragEnd = useCallback(() => { + setDraggedNodeId(null) + setDragOverNodeId(null) + setDragPosition('inside') + }, []) + + return { + draggedNodeId, + dragOverNodeId, + dragPosition, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd + } +} diff --git a/src/renderer/src/pages/notes/hooks/useNotesEditing.ts b/src/renderer/src/pages/notes/hooks/useNotesEditing.ts new file mode 100644 index 0000000000..58cbdee9e3 --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useNotesEditing.ts @@ -0,0 +1,94 @@ +import { loggerService } from '@logger' +import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit' +import { fetchNoteSummary } from '@renderer/services/ApiService' +import type { NotesTreeNode } from '@renderer/types/note' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const logger = loggerService.withContext('UseNotesEditing') + +interface UseNotesEditingProps { + onRenameNode: (nodeId: string, newName: string) => void +} + +export const useNotesEditing = ({ onRenameNode }: UseNotesEditingProps) => { + const { t } = useTranslation() + const [editingNodeId, setEditingNodeId] = useState(null) + const [renamingNodeIds, setRenamingNodeIds] = useState>(new Set()) + const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState>(new Set()) + + const inPlaceEdit = useInPlaceEdit({ + onSave: (newName: string) => { + if (editingNodeId && newName) { + onRenameNode(editingNodeId, newName) + window.toast.success(t('common.saved')) + logger.debug(`Renamed node ${editingNodeId} to "${newName}"`) + } + setEditingNodeId(null) + }, + onCancel: () => { + setEditingNodeId(null) + } + }) + + const handleStartEdit = useCallback( + (node: NotesTreeNode) => { + setEditingNodeId(node.id) + inPlaceEdit.startEdit(node.name) + }, + [inPlaceEdit] + ) + + const handleAutoRename = useCallback( + async (note: NotesTreeNode) => { + if (note.type !== 'file') return + + setRenamingNodeIds((prev) => new Set(prev).add(note.id)) + try { + const content = await window.api.file.readExternal(note.externalPath) + if (!content || content.trim().length === 0) { + window.toast.warning(t('notes.auto_rename.empty_note')) + return + } + + const summaryText = await fetchNoteSummary({ content }) + if (summaryText) { + onRenameNode(note.id, summaryText) + window.toast.success(t('notes.auto_rename.success')) + } else { + window.toast.error(t('notes.auto_rename.failed')) + } + } catch (error) { + window.toast.error(t('notes.auto_rename.failed')) + logger.error(`Failed to auto-rename note: ${error}`) + } finally { + setRenamingNodeIds((prev) => { + const next = new Set(prev) + next.delete(note.id) + return next + }) + + setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id)) + + setTimeout(() => { + setNewlyRenamedNodeIds((prev) => { + const next = new Set(prev) + next.delete(note.id) + return next + }) + }, 700) + } + }, + [onRenameNode, t] + ) + + return { + editingNodeId, + renamingNodeIds, + newlyRenamedNodeIds, + inPlaceEdit, + handleStartEdit, + handleAutoRename, + setEditingNodeId + } +} diff --git a/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts b/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts new file mode 100644 index 0000000000..aba1a90992 --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts @@ -0,0 +1,112 @@ +import { useCallback } from 'react' + +interface UseNotesFileUploadProps { + onUploadFiles: (files: File[]) => void + setIsDragOverSidebar: (isDragOver: boolean) => void +} + +export const useNotesFileUpload = ({ onUploadFiles, setIsDragOverSidebar }: UseNotesFileUploadProps) => { + const handleDropFiles = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + setIsDragOverSidebar(false) + + // 处理文件夹拖拽:从 dataTransfer.items 获取完整文件路径信息 + const items = Array.from(e.dataTransfer.items) + const files: File[] = [] + + const processEntry = async (entry: FileSystemEntry, path: string = '') => { + if (entry.isFile) { + const fileEntry = entry as FileSystemFileEntry + return new Promise((resolve) => { + fileEntry.file((file) => { + // 手动设置 webkitRelativePath 以保持文件夹结构 + Object.defineProperty(file, 'webkitRelativePath', { + value: path + file.name, + writable: false + }) + files.push(file) + resolve() + }) + }) + } else if (entry.isDirectory) { + const dirEntry = entry as FileSystemDirectoryEntry + const reader = dirEntry.createReader() + return new Promise((resolve) => { + reader.readEntries(async (entries) => { + const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/')) + await Promise.all(promises) + resolve() + }) + }) + } + } + + // 如果支持 DataTransferItem API(文件夹拖拽) + if (items.length > 0 && items[0].webkitGetAsEntry()) { + const promises = items.map((item) => { + const entry = item.webkitGetAsEntry() + return entry ? processEntry(entry) : Promise.resolve() + }) + + await Promise.all(promises) + + if (files.length > 0) { + onUploadFiles(files) + } + } else { + const regularFiles = Array.from(e.dataTransfer.files) + if (regularFiles.length > 0) { + onUploadFiles(regularFiles) + } + } + }, + [onUploadFiles, setIsDragOverSidebar] + ) + + const handleSelectFiles = useCallback(() => { + const fileInput = document.createElement('input') + fileInput.type = 'file' + fileInput.multiple = true + fileInput.accept = '.md,.markdown' + fileInput.webkitdirectory = false + + fileInput.onchange = (e) => { + const target = e.target as HTMLInputElement + if (target.files && target.files.length > 0) { + const selectedFiles = Array.from(target.files) + onUploadFiles(selectedFiles) + } + fileInput.remove() + } + + fileInput.click() + }, [onUploadFiles]) + + const handleSelectFolder = useCallback(() => { + const folderInput = document.createElement('input') + folderInput.type = 'file' + // @ts-ignore - webkitdirectory is a non-standard attribute + folderInput.webkitdirectory = true + // @ts-ignore - directory is a non-standard attribute + folderInput.directory = true + folderInput.multiple = true + + folderInput.onchange = (e) => { + const target = e.target as HTMLInputElement + if (target.files && target.files.length > 0) { + const selectedFiles = Array.from(target.files) + onUploadFiles(selectedFiles) + } + folderInput.remove() + } + + folderInput.click() + }, [onUploadFiles]) + + return { + handleDropFiles, + handleSelectFiles, + handleSelectFolder + } +} diff --git a/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx b/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx new file mode 100644 index 0000000000..f08f9b1505 --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useNotesMenu.tsx @@ -0,0 +1,263 @@ +import { loggerService } from '@logger' +import { DeleteIcon } from '@renderer/components/Icons' +import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' +import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import type { RootState } from '@renderer/store' +import type { NotesTreeNode } from '@renderer/types/note' +import { exportNote } from '@renderer/utils/export' +import type { MenuProps } from 'antd' +import type { ItemType, MenuItemType } from 'antd/es/menu/interface' +import { Edit3, FilePlus, FileSearch, Folder, FolderOpen, Sparkles, Star, StarOff, UploadIcon } from 'lucide-react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +const logger = loggerService.withContext('UseNotesMenu') + +interface UseNotesMenuProps { + renamingNodeIds: Set + onCreateNote: (name: string, targetFolderId?: string) => void + onCreateFolder: (name: string, targetFolderId?: string) => void + onRenameNode: (nodeId: string, newName: string) => void + onToggleStar: (nodeId: string) => void + onDeleteNode: (nodeId: string) => void + onSelectNode: (node: NotesTreeNode) => void + handleStartEdit: (node: NotesTreeNode) => void + handleAutoRename: (node: NotesTreeNode) => void + activeNode?: NotesTreeNode | null +} + +export const useNotesMenu = ({ + renamingNodeIds, + onCreateNote, + onCreateFolder, + onToggleStar, + onDeleteNode, + onSelectNode, + handleStartEdit, + handleAutoRename, + activeNode +}: UseNotesMenuProps) => { + const { t } = useTranslation() + const { bases } = useKnowledgeBases() + const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions) + + const handleExportKnowledge = useCallback( + async (note: NotesTreeNode) => { + try { + if (bases.length === 0) { + window.toast.warning(t('chat.save.knowledge.empty.no_knowledge_base')) + return + } + + const result = await SaveToKnowledgePopup.showForNote(note) + + if (result?.success) { + window.toast.success(t('notes.export_success', { count: result.savedCount })) + } + } catch (error) { + window.toast.error(t('notes.export_failed')) + logger.error(`Failed to export note to knowledge base: ${error}`) + } + }, + [bases.length, t] + ) + + const handleImageAction = useCallback( + async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => { + try { + if (activeNode?.id !== node.id) { + onSelectNode(node) + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + await exportNote({ node, platform }) + } catch (error) { + logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error) + window.toast.error(t('common.copy_failed')) + } + }, + [activeNode, onSelectNode, t] + ) + + const handleDeleteNodeWrapper = useCallback( + (node: NotesTreeNode) => { + const confirmText = + node.type === 'folder' + ? t('notes.delete_folder_confirm', { name: node.name }) + : t('notes.delete_note_confirm', { name: node.name }) + + window.modal.confirm({ + title: t('notes.delete'), + content: confirmText, + centered: true, + okButtonProps: { danger: true }, + onOk: () => { + onDeleteNode(node.id) + } + }) + }, + [onDeleteNode, t] + ) + + const getMenuItems = useCallback( + (node: NotesTreeNode) => { + const baseMenuItems: MenuProps['items'] = [] + + // only show auto rename for file for now + if (node.type !== 'folder') { + baseMenuItems.push({ + label: t('notes.auto_rename.label'), + key: 'auto-rename', + icon: , + disabled: renamingNodeIds.has(node.id), + onClick: () => { + handleAutoRename(node) + } + }) + } + + if (node.type === 'folder') { + baseMenuItems.push( + { + label: t('notes.new_note'), + key: 'new_note', + icon: , + onClick: () => { + onCreateNote(t('notes.untitled_note'), node.id) + } + }, + { + label: t('notes.new_folder'), + key: 'new_folder', + icon: , + onClick: () => { + onCreateFolder(t('notes.untitled_folder'), node.id) + } + }, + { type: 'divider' } + ) + } + + baseMenuItems.push( + { + label: t('notes.rename'), + key: 'rename', + icon: , + onClick: () => { + handleStartEdit(node) + } + }, + { + label: t('notes.open_outside'), + key: 'open_outside', + icon: , + onClick: () => { + window.api.openPath(node.externalPath) + } + } + ) + if (node.type !== 'folder') { + baseMenuItems.push( + { + label: node.isStarred ? t('notes.unstar') : t('notes.star'), + key: 'star', + icon: node.isStarred ? : , + onClick: () => { + onToggleStar(node.id) + } + }, + { + label: t('notes.export_knowledge'), + key: 'export_knowledge', + icon: , + onClick: () => { + handleExportKnowledge(node) + } + }, + { + label: t('chat.topics.export.title'), + key: 'export', + icon: , + children: [ + exportMenuOptions.image && { + label: t('chat.topics.copy.image'), + key: 'copy-image', + onClick: () => handleImageAction(node, 'copyImage') + }, + exportMenuOptions.image && { + label: t('chat.topics.export.image'), + key: 'export-image', + onClick: () => handleImageAction(node, 'exportImage') + }, + exportMenuOptions.markdown && { + label: t('chat.topics.export.md.label'), + key: 'markdown', + onClick: () => exportNote({ node, platform: 'markdown' }) + }, + exportMenuOptions.docx && { + label: t('chat.topics.export.word'), + key: 'word', + onClick: () => exportNote({ node, platform: 'docx' }) + }, + exportMenuOptions.notion && { + label: t('chat.topics.export.notion'), + key: 'notion', + onClick: () => exportNote({ node, platform: 'notion' }) + }, + exportMenuOptions.yuque && { + label: t('chat.topics.export.yuque'), + key: 'yuque', + onClick: () => exportNote({ node, platform: 'yuque' }) + }, + exportMenuOptions.obsidian && { + label: t('chat.topics.export.obsidian'), + key: 'obsidian', + onClick: () => exportNote({ node, platform: 'obsidian' }) + }, + exportMenuOptions.joplin && { + label: t('chat.topics.export.joplin'), + key: 'joplin', + onClick: () => exportNote({ node, platform: 'joplin' }) + }, + exportMenuOptions.siyuan && { + label: t('chat.topics.export.siyuan'), + key: 'siyuan', + onClick: () => exportNote({ node, platform: 'siyuan' }) + } + ].filter(Boolean) as ItemType[] + } + ) + } + baseMenuItems.push( + { type: 'divider' }, + { + label: t('notes.delete'), + danger: true, + key: 'delete', + icon: , + onClick: () => { + handleDeleteNodeWrapper(node) + } + } + ) + + return baseMenuItems + }, + [ + t, + handleStartEdit, + onToggleStar, + handleExportKnowledge, + handleImageAction, + handleDeleteNodeWrapper, + renamingNodeIds, + handleAutoRename, + exportMenuOptions, + onCreateNote, + onCreateFolder + ] + ) + + return { getMenuItems } +} diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts index 940c8db106..4b71941fe8 100644 --- a/src/renderer/src/services/NotesService.ts +++ b/src/renderer/src/services/NotesService.ts @@ -83,6 +83,68 @@ export async function renameNode(node: NotesTreeNode, newName: string): Promise< } export async function uploadNotes(files: File[], targetPath: string): Promise { + const basePath = normalizePath(targetPath) + const totalFiles = files.length + + if (files.length === 0) { + return { + uploadedNodes: [], + totalFiles: 0, + skippedFiles: 0, + fileCount: 0, + folderCount: 0 + } + } + + try { + // Get file paths from File objects + // For browser File objects from drag-and-drop, we need to use FileReader to save temporarily + // However, for directory uploads, the files already have paths + const filePaths: string[] = [] + + for (const file of files) { + // @ts-ignore - webkitRelativePath exists on File objects from directory uploads + if (file.path) { + // @ts-ignore - Electron File objects have .path property + filePaths.push(file.path) + } else { + // For browser File API, we'd need to use FileReader and create temp files + // For now, fall back to the old method for these cases + logger.warn('File without path detected, using fallback method') + return uploadNotesLegacy(files, targetPath) + } + } + + // Pause file watcher to prevent N refresh events + await window.api.file.pauseFileWatcher() + + try { + // Use the new optimized batch upload API that runs in Main process + const result = await window.api.file.batchUploadMarkdown(filePaths, basePath) + + return { + uploadedNodes: [], + totalFiles, + skippedFiles: result.skippedFiles, + fileCount: result.fileCount, + folderCount: result.folderCount + } + } finally { + // Resume watcher and trigger single refresh + await window.api.file.resumeFileWatcher() + } + } catch (error) { + logger.error('Batch upload failed, falling back to legacy method:', error as Error) + // Fall back to old method if new method fails + return uploadNotesLegacy(files, targetPath) + } +} + +/** + * Legacy upload method using Renderer process + * Kept as fallback for browser File API files without paths + */ +async function uploadNotesLegacy(files: File[], targetPath: string): Promise { const basePath = normalizePath(targetPath) const markdownFiles = filterMarkdown(files) const skippedFiles = files.length - markdownFiles.length @@ -101,18 +163,37 @@ export async function uploadNotes(files: File[], targetPath: string): Promise { + const { dir, name } = resolveFileTarget(file, basePath) + const { safeName } = await window.api.file.checkFileName(dir, name, true) + const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}` + + const content = await file.text() + await window.api.file.write(finalPath, content) + return true + }) + ) + + // Count successful uploads + results.forEach((result) => { + if (result.status === 'fulfilled') { + fileCount += 1 + } else { + logger.error('Failed to write uploaded file:', result.reason) + } + }) + + // Yield to the event loop between batches to keep UI responsive + if (i + BATCH_SIZE < markdownFiles.length) { + await new Promise((resolve) => setTimeout(resolve, 0)) } } diff --git a/src/renderer/src/types/note.ts b/src/renderer/src/types/note.ts index fda85e63d8..83bbd74e5d 100644 --- a/src/renderer/src/types/note.ts +++ b/src/renderer/src/types/note.ts @@ -13,7 +13,7 @@ export type NotesSortType = export interface NotesTreeNode { id: string name: string // 不包含扩展名 - type: 'folder' | 'file' + type: 'folder' | 'file' | 'hint' treePath: string // 相对路径 externalPath: string // 绝对路径 children?: NotesTreeNode[] From 8f39ecf762146626f7a628e47881d0fb38b5097e Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 7 Dec 2025 14:01:11 +0800 Subject: [PATCH 02/90] fix(models): update assistant default model when editing model capabilities (#11732) * fix(ProviderSettings): update assistant default model when model changes Ensure assistant's default model is updated when the underlying model is modified to maintain consistency * refactor(EditModelPopup): simplify assistant model update logic Replace manual model updates with a single map operation to update both model and defaultModel fields. This makes the code more concise and easier to maintain. * refactor(EditModelPopup): remove unused dispatch import and variable * feat(EditModelPopup): add support for translate and quick model updates Update the EditModelPopup component to handle updates for translate and quick models in addition to the default model. This ensures consistency across all model types when changes are made. --- .../EditModelPopup/EditModelPopup.tsx | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup.tsx index ee82e16ef0..78d906d1e5 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup.tsx @@ -2,8 +2,6 @@ import { TopView } from '@renderer/components/TopView' import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant' import { useProvider } from '@renderer/hooks/useProvider' import ModelEditContent from '@renderer/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent' -import { useAppDispatch } from '@renderer/store' -import { setModel } from '@renderer/store/assistants' import type { Model, Provider } from '@renderer/types' import React, { useCallback, useState } from 'react' @@ -19,9 +17,9 @@ interface Props extends ShowParams { const PopupContainer: React.FC = ({ provider: _provider, model, resolve }) => { const [open, setOpen] = useState(true) const { provider, updateProvider, models } = useProvider(_provider.id) - const { assistants } = useAssistants() - const { defaultModel, setDefaultModel } = useDefaultModel() - const dispatch = useAppDispatch() + const { assistants, updateAssistants } = useAssistants() + const { defaultModel, setDefaultModel, translateModel, setTranslateModel, quickModel, setQuickModel } = + useDefaultModel() const onOk = () => { setOpen(false) @@ -42,22 +40,46 @@ const PopupContainer: React.FC = ({ provider: _provider, model, resolve } updateProvider({ models: updatedModels }) - assistants.forEach((assistant) => { - if (assistant?.model?.id === updatedModel.id && assistant.model.provider === provider.id) { - dispatch( - setModel({ - assistantId: assistant.id, - model: updatedModel - }) - ) - } - }) + updateAssistants( + assistants.map((a) => { + let model = a.model + let defaultModel = a.defaultModel + if (a.model?.id === updatedModel.id && a.model.provider === provider.id) { + model = updatedModel + } + if (a.defaultModel?.id === updatedModel.id && a.defaultModel?.provider === provider.id) { + defaultModel = updatedModel + } + return { ...a, model, defaultModel } + }) + ) if (defaultModel?.id === updatedModel.id && defaultModel?.provider === provider.id) { setDefaultModel(updatedModel) } + if (translateModel?.id === updatedModel.id && translateModel?.provider === provider.id) { + setTranslateModel(updatedModel) + } + if (quickModel?.id === updatedModel.id && quickModel?.provider === provider.id) { + setQuickModel(updatedModel) + } }, - [models, updateProvider, provider.id, assistants, defaultModel, dispatch, setDefaultModel] + [ + models, + updateProvider, + updateAssistants, + assistants, + defaultModel?.id, + defaultModel?.provider, + provider.id, + translateModel?.id, + translateModel?.provider, + quickModel?.id, + quickModel?.provider, + setDefaultModel, + setTranslateModel, + setQuickModel + ] ) return ( From ebfc60b039b9251437637b8e782e531b742cf288 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sun, 7 Dec 2025 16:42:00 +0800 Subject: [PATCH 03/90] fix(windows): improve Git Bash detection for portable installations (#11671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): improve Git Bash detection for portable installations Enhance Git Bash detection on Windows to support portable Git installations and custom installation paths. The previous implementation only checked fixed paths and failed to detect Git when installed to custom locations or added to PATH manually. Key improvements: - Use where.exe to find git.exe in PATH and derive bash.exe location - Support CHERRY_STUDIO_GIT_BASH_PATH environment variable override - Add security check to skip executables in current directory - Implement three-tier fallback strategy (env var -> git derivation -> common paths) - Add detailed logging for troubleshooting This fixes the issue where users with portable Git installations could run git.exe from command line but the app failed to detect Git Bash. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(windows): improve Git Bash detection for portable installations Enhance Git Bash detection on Windows to support portable Git installations and custom installation paths. The previous implementation only checked fixed paths and failed to detect Git when installed to custom locations or added to PATH manually. Key improvements: - Move findExecutable and findGitBash to utils/process.ts for better code organization - Use where.exe to find git.exe in PATH and derive bash.exe location - Add security check to skip executables in current directory - Implement two-tier fallback strategy (git derivation -> common paths) - Add detailed logging for troubleshooting - Remove environment variable override to simplify implementation This fixes the issue where users with portable Git installations could run git.exe from command line but the app failed to detect Git Bash. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(windows): improve Git Bash detection for portable installations Enhance Git Bash detection on Windows to support portable Git installations and custom installation paths. The previous implementation only checked fixed paths and failed to detect Git when installed to custom locations or added to PATH manually. Key improvements: - Move findExecutable and findGitBash to utils/process.ts for better code organization - Use where.exe to find git.exe in PATH and derive bash.exe location - Add security check to skip executables in current directory - Implement two-tier fallback strategy (git derivation -> common paths) - Add detailed logging for troubleshooting - Remove environment variable override to simplify implementation This fixes the issue where users with portable Git installations could run git.exe from command line but the app failed to detect Git Bash. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * update iswin * test: add comprehensive test coverage for findExecutable and findGitBash Add 33 test cases covering: - Git found in common paths (Program Files, Program Files (x86)) - Git found via where.exe in PATH - Windows/Unix line ending handling (CRLF/LF) - Whitespace trimming from where.exe output - Security checks to skip executables in current directory - Multiple Git installation structures (Standard, Portable, MSYS2) - Bash.exe path derivation from git.exe location - Common paths fallback when git.exe not found - LOCALAPPDATA environment variable handling - Priority order (derivation over common paths) - Error scenarios (Git not installed, bash.exe missing) - Real-world scenarios (official installer, portable, Scoop) All tests pass with proper mocking of fs, path, and child_process modules. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor: clarify path navigation comments in findGitBash Replace confusing arrow notation showing intermediate directories with clearer descriptions of the navigation intent: - "navigate up 2 levels" instead of showing "-> Git/cmd -> Git ->" - "bash.exe in same directory" for portable installations - Emphasizes the intent rather than the intermediate steps Makes the code more maintainable by clearly stating what each path pattern is checking for. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * test: skip process utility tests on non-Windows platforms Use describe.skipIf to skip all tests when not on Windows since findExecutable and findGitBash have platform guards that return null on non-Windows systems. Remove redundant platform mocking in nested describe blocks since the entire suite is already Windows-only. This fixes test failures on macOS and Linux where all 33 tests were failing because the functions correctly return null on those platforms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * format * fix: improve Git Bash detection error handling and logging - Add try-catch wrapper in IPC handler to handle unexpected errors - Fix inaccurate comment: usr/bin/bash.exe is for MSYS2, not Git 2.x - Change log level from INFO to DEBUG for internal "not found" message - Keep WARN level only in IPC handler for user-facing message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/main/ipc.ts | 30 +- src/main/utils/__tests__/process.test.ts | 572 +++++++++++++++++++++++ src/main/utils/process.ts | 125 ++++- 3 files changed, 701 insertions(+), 26 deletions(-) create mode 100644 src/main/utils/__tests__/process.test.ts diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f91e61eaa4..444ca5fb8e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6,7 +6,7 @@ import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { generateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' -import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' +import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { UpgradeChannel } from '@shared/config/constant' @@ -499,35 +499,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } try { - // Check common Git Bash installation paths - const commonPaths = [ - path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), - path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), - path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe') - ] + const bashPath = findGitBash() - // Check if any of the common paths exist - for (const bashPath of commonPaths) { - if (fs.existsSync(bashPath)) { - logger.debug('Git Bash found', { path: bashPath }) - return true - } - } - - // Check if git is in PATH - const { execSync } = require('child_process') - try { - execSync('git --version', { stdio: 'ignore' }) - logger.debug('Git found in PATH') + if (bashPath) { + logger.info('Git Bash is available', { path: bashPath }) return true - } catch { - // Git not in PATH } - logger.debug('Git Bash not found on Windows system') + logger.warn('Git Bash not found. Please install Git for Windows from https://git-scm.com/downloads/win') return false } catch (error) { - logger.error('Error checking Git Bash', error as Error) + logger.error('Unexpected error checking Git Bash', error as Error) return false } }) diff --git a/src/main/utils/__tests__/process.test.ts b/src/main/utils/__tests__/process.test.ts new file mode 100644 index 0000000000..45c0f8b42b --- /dev/null +++ b/src/main/utils/__tests__/process.test.ts @@ -0,0 +1,572 @@ +import { execFileSync } from 'child_process' +import fs from 'fs' +import path from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { findExecutable, findGitBash } from '../process' + +// Mock dependencies +vi.mock('child_process') +vi.mock('fs') +vi.mock('path') + +// These tests only run on Windows since the functions have platform guards +describe.skipIf(process.platform !== 'win32')('process utilities', () => { + beforeEach(() => { + vi.clearAllMocks() + + // Mock path.join to concatenate paths with backslashes (Windows-style) + vi.mocked(path.join).mockImplementation((...args) => args.join('\\')) + + // Mock path.resolve to handle path resolution with .. support + vi.mocked(path.resolve).mockImplementation((...args) => { + let result = args.join('\\') + + // Handle .. navigation + while (result.includes('\\..')) { + result = result.replace(/\\[^\\]+\\\.\./g, '') + } + + // Ensure absolute path + if (!result.match(/^[A-Z]:/)) { + result = `C:\\cwd\\${result}` + } + + return result + }) + + // Mock path.dirname + vi.mocked(path.dirname).mockImplementation((p) => { + const parts = p.split('\\') + parts.pop() + return parts.join('\\') + }) + + // Mock path.sep + Object.defineProperty(path, 'sep', { value: '\\', writable: true }) + + // Mock process.cwd() + vi.spyOn(process, 'cwd').mockReturnValue('C:\\cwd') + }) + + describe('findExecutable', () => { + describe('git common paths', () => { + it('should find git at Program Files path', () => { + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + process.env.ProgramFiles = 'C:\\Program Files' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + expect(fs.existsSync).toHaveBeenCalledWith(gitPath) + }) + + it('should find git at Program Files (x86) path', () => { + const gitPath = 'C:\\Program Files (x86)\\Git\\cmd\\git.exe' + process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + expect(fs.existsSync).toHaveBeenCalledWith(gitPath) + }) + + it('should use fallback paths when environment variables are not set', () => { + delete process.env.ProgramFiles + delete process.env['ProgramFiles(x86)'] + + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + }) + }) + + describe('where.exe PATH lookup', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + // Common paths don't exist + vi.mocked(fs.existsSync).mockReturnValue(false) + }) + + it('should find executable via where.exe', () => { + const gitPath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + expect(execFileSync).toHaveBeenCalledWith('where.exe', ['git.exe'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }) + }) + + it('should add .exe extension when calling where.exe', () => { + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + + findExecutable('node') + + expect(execFileSync).toHaveBeenCalledWith('where.exe', ['node.exe'], expect.any(Object)) + }) + + it('should handle Windows line endings (CRLF)', () => { + const gitPath1 = 'C:\\Git\\bin\\git.exe' + const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\r\n${gitPath2}\r\n`) + + const result = findExecutable('git') + + // Should return the first valid path + expect(result).toBe(gitPath1) + }) + + it('should handle Unix line endings (LF)', () => { + const gitPath1 = 'C:\\Git\\bin\\git.exe' + const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\n${gitPath2}\n`) + + const result = findExecutable('git') + + expect(result).toBe(gitPath1) + }) + + it('should handle mixed line endings', () => { + const gitPath1 = 'C:\\Git\\bin\\git.exe' + const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\r\n${gitPath2}\n`) + + const result = findExecutable('git') + + expect(result).toBe(gitPath1) + }) + + it('should trim whitespace from paths', () => { + const gitPath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(` ${gitPath} \n`) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + }) + + it('should filter empty lines', () => { + const gitPath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`\n\n${gitPath}\n\n`) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + }) + }) + + describe('security checks', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + vi.mocked(fs.existsSync).mockReturnValue(false) + }) + + it('should skip executables in current directory', () => { + const maliciousPath = 'C:\\cwd\\git.exe' + const safePath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${maliciousPath}\n${safePath}`) + + vi.mocked(path.resolve).mockImplementation((p) => { + if (p.includes('cwd\\git.exe')) return 'c:\\cwd\\git.exe' + return 'c:\\git\\bin\\git.exe' + }) + + vi.mocked(path.dirname).mockImplementation((p) => { + if (p.includes('cwd\\git.exe')) return 'c:\\cwd' + return 'c:\\git\\bin' + }) + + const result = findExecutable('git') + + // Should skip malicious path and return safe path + expect(result).toBe(safePath) + }) + + it('should skip executables in current directory subdirectories', () => { + const maliciousPath = 'C:\\cwd\\subdir\\git.exe' + const safePath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${maliciousPath}\n${safePath}`) + + vi.mocked(path.resolve).mockImplementation((p) => { + if (p.includes('cwd\\subdir')) return 'c:\\cwd\\subdir\\git.exe' + return 'c:\\git\\bin\\git.exe' + }) + + vi.mocked(path.dirname).mockImplementation((p) => { + if (p.includes('cwd\\subdir')) return 'c:\\cwd\\subdir' + return 'c:\\git\\bin' + }) + + const result = findExecutable('git') + + expect(result).toBe(safePath) + }) + + it('should return null when only malicious executables are found', () => { + const maliciousPath = 'C:\\cwd\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(maliciousPath) + + vi.mocked(path.resolve).mockReturnValue('c:\\cwd\\git.exe') + vi.mocked(path.dirname).mockReturnValue('c:\\cwd') + + const result = findExecutable('git') + + expect(result).toBeNull() + }) + }) + + describe('error handling', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + vi.mocked(fs.existsSync).mockReturnValue(false) + }) + + it('should return null when where.exe fails', () => { + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Command failed') + }) + + const result = findExecutable('nonexistent') + + expect(result).toBeNull() + }) + + it('should return null when where.exe returns empty output', () => { + vi.mocked(execFileSync).mockReturnValue('') + + const result = findExecutable('git') + + expect(result).toBeNull() + }) + + it('should return null when where.exe returns only whitespace', () => { + vi.mocked(execFileSync).mockReturnValue(' \n\n ') + + const result = findExecutable('git') + + expect(result).toBeNull() + }) + }) + + describe('non-git executables', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + }) + + it('should skip common paths check for non-git executables', () => { + const nodePath = 'C:\\Program Files\\nodejs\\node.exe' + + vi.mocked(execFileSync).mockReturnValue(nodePath) + + const result = findExecutable('node') + + expect(result).toBe(nodePath) + // Should not check common Git paths + expect(fs.existsSync).not.toHaveBeenCalledWith(expect.stringContaining('Git\\cmd\\node.exe')) + }) + }) + }) + + describe('findGitBash', () => { + describe('git.exe path derivation', () => { + it('should derive bash.exe from standard Git installation (Git/cmd/git.exe)', () => { + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + // findExecutable will find git at common path + process.env.ProgramFiles = 'C:\\Program Files' + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === gitPath || p === bashPath + }) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should derive bash.exe from portable Git installation (Git/bin/git.exe)', () => { + const gitPath = 'C:\\PortableGit\\bin\\git.exe' + const bashPath = 'C:\\PortableGit\\bin\\bash.exe' + + // Mock: common git paths don't exist, but where.exe finds portable git + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Portable bash.exe exists at Git/bin/bash.exe (second path in possibleBashPaths) + if (pathStr === bashPath) return true + return false + }) + + // where.exe returns portable git path + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should derive bash.exe from MSYS2 Git installation (Git/usr/bin/bash.exe)', () => { + const gitPath = 'C:\\msys64\\usr\\bin\\git.exe' + const bashPath = 'C:\\msys64\\usr\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // MSYS2 bash.exe exists at usr/bin/bash.exe (third path in possibleBashPaths) + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should try multiple bash.exe locations in order', () => { + const gitPath = 'C:\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Git\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Standard path exists (first in possibleBashPaths) + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should handle when git.exe is found but bash.exe is not at any derived location', () => { + const gitPath = 'C:\\Git\\cmd\\git.exe' + + // git.exe exists via where.exe, but bash.exe doesn't exist at any derived location + vi.mocked(fs.existsSync).mockImplementation(() => { + // Only return false for all bash.exe checks + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + // Should fall back to common paths check + expect(result).toBeNull() + }) + }) + + describe('common paths fallback', () => { + beforeEach(() => { + // git.exe not found + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + }) + + it('should check Program Files path', () => { + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + process.env.ProgramFiles = 'C:\\Program Files' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should check Program Files (x86) path', () => { + const bashPath = 'C:\\Program Files (x86)\\Git\\bin\\bash.exe' + process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should check LOCALAPPDATA path', () => { + const bashPath = 'C:\\Users\\User\\AppData\\Local\\Programs\\Git\\bin\\bash.exe' + process.env.LOCALAPPDATA = 'C:\\Users\\User\\AppData\\Local' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should skip LOCALAPPDATA check when environment variable is not set', () => { + delete process.env.LOCALAPPDATA + + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = findGitBash() + + expect(result).toBeNull() + // Should not check invalid path with empty LOCALAPPDATA + expect(fs.existsSync).not.toHaveBeenCalledWith(expect.stringContaining('undefined')) + }) + + it('should use fallback values when environment variables are not set', () => { + delete process.env.ProgramFiles + delete process.env['ProgramFiles(x86)'] + + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + }) + + describe('priority order', () => { + it('should prioritize git.exe derivation over common paths', () => { + const gitPath = 'C:\\CustomPath\\Git\\cmd\\git.exe' + const derivedBashPath = 'C:\\CustomPath\\Git\\bin\\bash.exe' + const commonBashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + // Both exist + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist (so findExecutable uses where.exe) + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Both bash paths exist, but derived should be checked first + if (pathStr === derivedBashPath) return true + if (pathStr === commonBashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + // Should return derived path, not common path + expect(result).toBe(derivedBashPath) + }) + }) + + describe('error scenarios', () => { + it('should return null when Git is not installed anywhere', () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + + const result = findGitBash() + + expect(result).toBeNull() + }) + + it('should return null when git.exe exists but bash.exe does not', () => { + const gitPath = 'C:\\Git\\cmd\\git.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + // git.exe exists, but no bash.exe anywhere + return p === gitPath + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBeNull() + }) + }) + + describe('real-world scenarios', () => { + it('should handle official Git for Windows installer', () => { + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + process.env.ProgramFiles = 'C:\\Program Files' + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === gitPath || p === bashPath + }) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should handle portable Git installation in custom directory', () => { + const gitPath = 'D:\\DevTools\\PortableGit\\bin\\git.exe' + const bashPath = 'D:\\DevTools\\PortableGit\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Portable Git paths exist (portable uses second path: Git/bin/bash.exe) + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should handle Git installed via Scoop', () => { + // Scoop typically installs to %USERPROFILE%\scoop\apps\git\current + const gitPath = 'C:\\Users\\User\\scoop\\apps\\git\\current\\cmd\\git.exe' + const bashPath = 'C:\\Users\\User\\scoop\\apps\\git\\current\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Scoop bash path exists (standard structure: cmd -> bin) + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + }) + }) +}) diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index f36e86861d..b59a37a048 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -1,10 +1,11 @@ import { loggerService } from '@logger' import { HOME_CHERRY_DIR } from '@shared/config/constant' -import { spawn } from 'child_process' +import { execFileSync, spawn } from 'child_process' import fs from 'fs' import os from 'os' import path from 'path' +import { isWin } from '../constant' import { getResourcePath } from '.' const logger = loggerService.withContext('Utils:Process') @@ -39,7 +40,7 @@ export function runInstallScript(scriptPath: string): Promise { } export async function getBinaryName(name: string): Promise { - if (process.platform === 'win32') { + if (isWin) { return `${name}.exe` } return name @@ -60,3 +61,123 @@ export async function isBinaryExists(name: string): Promise { const cmd = await getBinaryPath(name) return await fs.existsSync(cmd) } + +/** + * Find executable in common paths or PATH environment variable + * Based on Claude Code's implementation with security checks + * @param name - Name of the executable to find (without .exe extension) + * @returns Full path to the executable or null if not found + */ +export function findExecutable(name: string): string | null { + // This implementation uses where.exe which is Windows-only + if (!isWin) { + return null + } + + // Special handling for git - check common installation paths first + if (name === 'git') { + const commonGitPaths = [ + path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'cmd', 'git.exe'), + path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe') + ] + + for (const gitPath of commonGitPaths) { + if (fs.existsSync(gitPath)) { + logger.debug(`Found ${name} at common path`, { path: gitPath }) + return gitPath + } + } + } + + // Use where.exe to find executable in PATH + // Use execFileSync to prevent command injection + try { + // Add .exe extension for more precise matching on Windows + const executableName = `${name}.exe` + const result = execFileSync('where.exe', [executableName], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }) + + // Handle both Windows (\r\n) and Unix (\n) line endings + const paths = result.trim().split(/\r?\n/).filter(Boolean) + const currentDir = process.cwd().toLowerCase() + + // Security check: skip executables in current directory + for (const exePath of paths) { + // Trim whitespace from where.exe output + const cleanPath = exePath.trim() + const resolvedPath = path.resolve(cleanPath).toLowerCase() + const execDir = path.dirname(resolvedPath).toLowerCase() + + // Skip if in current directory or subdirectory (potential malware) + if (execDir === currentDir || execDir.startsWith(currentDir + path.sep)) { + logger.warn('Skipping potentially malicious executable in current directory', { + path: cleanPath + }) + continue + } + + logger.debug(`Found ${name} via where.exe`, { path: cleanPath }) + return cleanPath + } + + return null + } catch (error) { + logger.debug(`where.exe ${name} failed`, { error }) + return null + } +} + +/** + * Find Git Bash executable on Windows + * @returns Full path to bash.exe or null if not found + */ +export function findGitBash(): string | null { + // Git Bash is Windows-only + if (!isWin) { + return null + } + + // 1. Find git.exe and derive bash.exe path + const gitPath = findExecutable('git') + if (gitPath) { + // Try multiple possible locations for bash.exe relative to git.exe + // Different Git installations have different directory structures + const possibleBashPaths = [ + path.join(gitPath, '..', '..', 'bin', 'bash.exe'), // Standard Git: git.exe at Git/cmd/ -> navigate up 2 levels -> then bin/bash.exe + path.join(gitPath, '..', 'bash.exe'), // Portable Git: git.exe at Git/bin/ -> bash.exe in same directory + path.join(gitPath, '..', '..', 'usr', 'bin', 'bash.exe') // MSYS2 Git: git.exe at msys64/usr/bin/ -> navigate up 2 levels -> then usr/bin/bash.exe + ] + + for (const bashPath of possibleBashPaths) { + const resolvedBashPath = path.resolve(bashPath) + if (fs.existsSync(resolvedBashPath)) { + logger.debug('Found bash.exe via git.exe path derivation', { path: resolvedBashPath }) + return resolvedBashPath + } + } + + logger.debug('bash.exe not found at expected locations relative to git.exe', { + gitPath, + checkedPaths: possibleBashPaths.map((p) => path.resolve(p)) + }) + } + + // 2. Fallback: check common Git Bash paths directly + const commonBashPaths = [ + path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), + path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), + ...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe')] : []) + ] + + for (const bashPath of commonBashPaths) { + if (fs.existsSync(bashPath)) { + logger.debug('Found bash.exe at common path', { path: bashPath }) + return bashPath + } + } + + logger.debug('Git Bash not found - checked git derivation and common paths') + return null +} From b58a2fce038f9b5d34b30fc0f9c22cd728e05b85 Mon Sep 17 00:00:00 2001 From: chenxue Date: Sun, 7 Dec 2025 21:03:19 +0800 Subject: [PATCH 04/90] feat(aihubmix): fix website domain (#11734) fix domain --- src/main/services/WindowService.ts | 6 +++--- src/renderer/src/pages/code/index.ts | 2 +- src/renderer/src/utils/oauth.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 63eaaba995..3f96497e63 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -271,9 +271,9 @@ export class WindowService { 'https://account.siliconflow.cn/oauth', 'https://cloud.siliconflow.cn/bills', 'https://cloud.siliconflow.cn/expensebill', - 'https://aihubmix.com/token', - 'https://aihubmix.com/topup', - 'https://aihubmix.com/statistics', + 'https://console.aihubmix.com/token', + 'https://console.aihubmix.com/topup', + 'https://console.aihubmix.com/statistics', 'https://dash.302.ai/sso/login', 'https://dash.302.ai/charge', 'https://www.aiionly.com/login' diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index 17c74903b2..dcc9f43534 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -62,7 +62,7 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => { const CODE_TOOLS_API_ENDPOINTS = { aihubmix: { gemini: { - api_base_url: 'https://api.aihubmix.com/gemini' + api_base_url: 'https://aihubmix.com/gemini' } }, deepseek: { diff --git a/src/renderer/src/utils/oauth.ts b/src/renderer/src/utils/oauth.ts index 00c5493343..9df68c0c21 100644 --- a/src/renderer/src/utils/oauth.ts +++ b/src/renderer/src/utils/oauth.ts @@ -201,7 +201,7 @@ export const providerCharge = async (provider: string) => { height: 700 }, aihubmix: { - url: `https://aihubmix.com/topup?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, + url: `https://console.aihubmix.com/topup?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, width: 720, height: 900 }, @@ -244,7 +244,7 @@ export const providerBills = async (provider: string) => { height: 700 }, aihubmix: { - url: `https://aihubmix.com/statistics?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, + url: `https://console.aihubmix.com/statistics?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, width: 900, height: 700 }, From 516b8479d6b82a841fa5ffe32d16c09ef819f6ea Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 7 Dec 2025 21:04:40 +0800 Subject: [PATCH 05/90] style: update gemini logo images (#11731) * style: update gemini logo images and fix model logo condition Update the Gemini logo images in both apps and models directories Remove or fix the always-true isLight condition in getModelLogoById * style: downsample gemini icon * style(minapp): Add bordered property for gemini minapp Add FIXME comment to indicate 'bodered' should be 'bordered' and update config to use correct property --- .../src/assets/images/apps/gemini.png | Bin 3259 -> 19756 bytes .../src/assets/images/models/gemini.png | Bin 3259 -> 19756 bytes src/renderer/src/config/minapps.ts | 3 ++- src/renderer/src/config/models/logo.ts | 1 + src/renderer/src/types/index.ts | 1 + 5 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/assets/images/apps/gemini.png b/src/renderer/src/assets/images/apps/gemini.png index 63c42078969844b82aeeb15a07ea88058ef90183..df8b95ced9ca4544bddb77e60ddaed3b9bbde06a 100644 GIT binary patch literal 19756 zcmZ6y1y~$GvnaZ|xVr^g2(F8}li(KI-642zSlnH*I01qLmjrkB;3T-ayXNwrbKbe{ zy_v74YHCWlx_eq?s-skuWiU|Rp#lH^3^`fpPj7JOKM5J}?OLf*mHP&OZl7c%0aX)Z z2X8l_mO64)N=g8xHyjy&0K^5r{fFi41OVRw5dI4X0OWzB|BHVDGW-t@2mpw%1;GCg zkKP;n&msGk-lYEr!sP@1uf}}P|8fuI!~I|UKVmkfs_Ab4#aUL@4FEvH`%eM_GP8-_ zwCUPv=(y`BDGHi9IkK5rIGI_pc{w`&M+zY9CHRIqTDqHpy&N4ry9s)UQ2!T);2ZuQ zGCMW+zgXPuMW}U@RKZeCu9je4HV7MpS`-xw1`E4dSP6cT{`f!QZ+9ZpHtz1ug6!;` zo}O%;Tx?FR*6bVt0s`z1PIgXC);A7TH}B8xre3U{-QNGVlK)4Kw56N5tF5!Ut&utC`WSNEH!@PDX+Dz3Jc zZEza|F6vax2OL_eREY5Rha#Mi%k@j#ApKv01yYrNlR#W z0Z;rwZnsXu|_1cq0>Ob|> zjs3MXJ8wj9+Ri-FI3Cg!z+c7Lf($^-@UAG^xI?sw|9`@7mY_xA1Yb9YfG=R$b;_Zb zy#+0lzO?90(}zpy31rL!(}A7KVOq!;EjAW9z7=kdgDqFu@RksBQ1*ln^Q6d+IKX*$ zI5pnjDtq7_2GOnbR!MCzN9W!|p~~}fiDJJ!jrVQouCNftCi@)Yv+&W zK(!!O{G=c4t#%*!nU42uV0CAH9ZYOyN2D+ClFIw3Imb7jVoCPH;!|iXl2|RIH0M*f zlBT_>PI=gko=#S#o=$1bb+GZOZ_aY=xNrVv4^d-2q#>Z!3sI|v&&dQw$HOhChjb#(LXCZZ5dK%j?Uu9$qy~D1eb<_bdh7;(B}kwvlR1 z@?e-I@UA{IY&vTk7LzIEzGwUuZ`*tsDUc+NnJ9pBbS?kgo5=kl(7HOnhyzmv^JuzF z-(dlm%o=xXR=Eo4h*GxFRaMRAyR)L@ZhR|CH|8~m6Ek^M)W)hqNn~>c$=b#`0lgl0 zET37W6|^1%2@{`p=5V~DELOS-v=0ZOa$uw((I}@oR5xz6@Sh8cw6~*Ev(P9TBZ({P zKkpzN)YSlIe8p{|XFa$?yIDkj?p%+Z_kPwmMo1}E{<7q46VFw47Sf3CL~mF4a#oM# z^2mAfPw2RAnckCk0lJ_PA50e8Kcl|t^v1OM{S#OVJjtA)ZLQ=|=f;`{`cF*;sn%

)=dvn|N26A9o~H&# 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 diff --git a/src/renderer/src/assets/images/models/gemini.png b/src/renderer/src/assets/images/models/gemini.png index 63c42078969844b82aeeb15a07ea88058ef90183..df8b95ced9ca4544bddb77e60ddaed3b9bbde06a 100644 GIT binary patch literal 19756 zcmZ6y1y~$GvnaZ|xVr^g2(F8}li(KI-642zSlnH*I01qLmjrkB;3T-ayXNwrbKbe{ zy_v74YHCWlx_eq?s-skuWiU|Rp#lH^3^`fpPj7JOKM5J}?OLf*mHP&OZl7c%0aX)Z z2X8l_mO64)N=g8xHyjy&0K^5r{fFi41OVRw5dI4X0OWzB|BHVDGW-t@2mpw%1;GCg zkKP;n&msGk-lYEr!sP@1uf}}P|8fuI!~I|UKVmkfs_Ab4#aUL@4FEvH`%eM_GP8-_ zwCUPv=(y`BDGHi9IkK5rIGI_pc{w`&M+zY9CHRIqTDqHpy&N4ry9s)UQ2!T);2ZuQ zGCMW+zgXPuMW}U@RKZeCu9je4HV7MpS`-xw1`E4dSP6cT{`f!QZ+9ZpHtz1ug6!;` zo}O%;Tx?FR*6bVt0s`z1PIgXC);A7TH}B8xre3U{-QNGVlK)4Kw56N5tF5!Ut&utC`WSNEH!@PDX+Dz3Jc zZEza|F6vax2OL_eREY5Rha#Mi%k@j#ApKv01yYrNlR#W z0Z;rwZnsXu|_1cq0>Ob|> zjs3MXJ8wj9+Ri-FI3Cg!z+c7Lf($^-@UAG^xI?sw|9`@7mY_xA1Yb9YfG=R$b;_Zb zy#+0lzO?90(}zpy31rL!(}A7KVOq!;EjAW9z7=kdgDqFu@RksBQ1*ln^Q6d+IKX*$ zI5pnjDtq7_2GOnbR!MCzN9W!|p~~}fiDJJ!jrVQouCNftCi@)Yv+&W zK(!!O{G=c4t#%*!nU42uV0CAH9ZYOyN2D+ClFIw3Imb7jVoCPH;!|iXl2|RIH0M*f zlBT_>PI=gko=#S#o=$1bb+GZOZ_aY=xNrVv4^d-2q#>Z!3sI|v&&dQw$HOhChjb#(LXCZZ5dK%j?Uu9$qy~D1eb<_bdh7;(B}kwvlR1 z@?e-I@UA{IY&vTk7LzIEzGwUuZ`*tsDUc+NnJ9pBbS?kgo5=kl(7HOnhyzmv^JuzF z-(dlm%o=xXR=Eo4h*GxFRaMRAyR)L@ZhR|CH|8~m6Ek^M)W)hqNn~>c$=b#`0lgl0 zET37W6|^1%2@{`p=5V~DELOS-v=0ZOa$uw((I}@oR5xz6@Sh8cw6~*Ev(P9TBZ({P zKkpzN)YSlIe8p{|XFa$?yIDkj?p%+Z_kPwmMo1}E{<7q46VFw47Sf3CL~mF4a#oM# z^2mAfPw2RAnckCk0lJ_PA50e8Kcl|t^v1OM{S#OVJjtA)ZLQ=|=f;`{`cF*;sn%

)=dvn|N26A9o~H&# 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 diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 815b3f4760..81a4a98723 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -101,7 +101,8 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ id: 'gemini', name: 'Gemini', url: 'https://gemini.google.com/', - logo: GeminiAppLogo + logo: GeminiAppLogo, + bodered: true }, { id: 'silicon', diff --git a/src/renderer/src/config/models/logo.ts b/src/renderer/src/config/models/logo.ts index 77f4f5fb9d..64ba94b470 100644 --- a/src/renderer/src/config/models/logo.ts +++ b/src/renderer/src/config/models/logo.ts @@ -163,6 +163,7 @@ import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' import type { Model } from '@renderer/types' export function getModelLogoById(modelId: string): string | undefined { + // FIXME: This is always true. Either remove it or fetch it. const isLight = true if (!modelId) { diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 5b72a4181c..128d2be707 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -439,6 +439,7 @@ export type MinAppType = { name: string logo?: string url: string + // FIXME: It should be `bordered` bodered?: boolean background?: string style?: CSSProperties From bba7ecae6ea4fbae1bf2e4d27be81b25dba4a487 Mon Sep 17 00:00:00 2001 From: Phantom Date: Mon, 8 Dec 2025 10:58:56 +0800 Subject: [PATCH 06/90] feat(agent): add tooltip for model selection and improve i18n (#11738) * refactor(settings): rename actions prop to contentAfter for clarity The prop name 'actions' was misleading as it could imply functionality rather than layout. 'contentAfter' better describes its purpose of displaying content after the title. * feat(agent): add tooltip for model selection in agent settings Add tooltip to explain that only Anthropic endpoint models are supported for agents * feat(i18n): add model tooltip and translate upload strings Add tooltip message about Anthropic endpoint model requirement for Agent feature Translate previously untranslated upload-related strings in multiple languages --- .../src/components/Popups/agent/AgentModal.tsx | 10 +++++++--- src/renderer/src/i18n/locales/en-us.json | 3 +++ src/renderer/src/i18n/locales/zh-cn.json | 3 +++ src/renderer/src/i18n/locales/zh-tw.json | 7 +++++-- src/renderer/src/i18n/translate/de-de.json | 9 ++++++--- src/renderer/src/i18n/translate/el-gr.json | 9 ++++++--- src/renderer/src/i18n/translate/es-es.json | 9 ++++++--- src/renderer/src/i18n/translate/fr-fr.json | 9 ++++++--- src/renderer/src/i18n/translate/ja-jp.json | 9 ++++++--- src/renderer/src/i18n/translate/pt-pt.json | 9 ++++++--- src/renderer/src/i18n/translate/ru-ru.json | 9 ++++++--- .../settings/AgentSettings/AccessibleDirsSetting.tsx | 2 +- .../pages/settings/AgentSettings/AdvancedSettings.tsx | 2 +- .../src/pages/settings/AgentSettings/ModelSetting.tsx | 5 ++++- .../src/pages/settings/AgentSettings/shared.tsx | 8 ++++---- 15 files changed, 70 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index e72433e88a..0d3ce94731 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -1,5 +1,6 @@ import { loggerService } from '@logger' import { ErrorBoundary } from '@renderer/components/ErrorBoundary' +import { HelpTooltip } from '@renderer/components/TooltipIcons' import { TopView } from '@renderer/components/TopView' import { permissionModeCards } from '@renderer/config/agent' import { useAgents } from '@renderer/hooks/agents/useAgents' @@ -340,9 +341,12 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { - +

+ + +
, - )} - + {isCherryIN && isChineseUser ? ( + + ) : ( + + setApiHost(e.target.value)} + onBlur={onUpdateApiHost} + /> + {isApiHostResettable && ( + + )} + + )} {isVertexProvider(provider) && ( {t('settings.provider.vertex_ai.api_host_help')} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 8d9176be15..30b6b72129 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 182, + version: 183, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index a80336e697..8559a39e27 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2976,6 +2976,22 @@ const migrateConfig = { logger.error('migrate 182 error', error as Error) return state } + }, + '183': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === SystemProviderIds.cherryin) { + provider.apiHost = 'https://open.cherryin.cc' + provider.anthropicApiHost = 'https://open.cherryin.cc' + } + }) + state.llm.providers = moveProvider(state.llm.providers, SystemProviderIds.poe, 10) + logger.info('migrate 183 success') + return state + } catch (error) { + logger.error('migrate 183 error', error as Error) + return state + } } } From fda22874753fc11fd86707ab920baecb8623fe6b Mon Sep 17 00:00:00 2001 From: Ying-xi <62348590+Ying-xi@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:21:10 +0800 Subject: [PATCH 31/90] fix(knowledge): prioritize query & refine intent prompt (#11828) Fixes logic issues in knowledge base search: 1. Inverted search priority in KnowledgeService to use specific sub-queries over generic rewrites. 2. Updated SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY to explicitly allow decomposed questions, improving intent recognition for complex queries. --- src/renderer/src/config/prompts.ts | 2 +- src/renderer/src/services/KnowledgeService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/config/prompts.ts b/src/renderer/src/config/prompts.ts index 926a138f14..815eb7d113 100644 --- a/src/renderer/src/config/prompts.ts +++ b/src/renderer/src/config/prompts.ts @@ -306,7 +306,7 @@ export const SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY = ` **Use user's language to rephrase the question.** Follow these guidelines: 1. If the question is a simple writing task, greeting (e.g., Hi, Hello, How are you), or does not require searching for information (unless the greeting contains a follow-up question), return 'not_needed' in the 'question' XML block. This indicates that no search is required. - 2. For knowledge, You need rewrite user query into 'rewrite' XML block with one alternative version while preserving the original intent and meaning. Also include the original question in the 'question' block. + 2. For knowledge, You need rewrite user query into 'rewrite' XML block with one alternative version while preserving the original intent and meaning. Also include the rephrased or decomposed question(s) in the 'question' block. 3. Always return the rephrased question inside the 'question' XML block. 4. Always wrap the rephrased question in the appropriate XML blocks: use for queries that can be answered from a pre-existing knowledge base. Ensure that the rephrased question is always contained within a block inside the wrapper. 5. *use knowledge to rephrase the question* diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index e2f2e6fc15..e78cfa62e5 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -162,7 +162,7 @@ export const searchKnowledgeBase = async ( const searchResults: KnowledgeSearchResult[] = await window.api.knowledgeBase.search( { - search: rewrite || query, + search: query || rewrite || '', base: baseParams }, currentSpan?.spanContext() From 03db02d5f751e06577e017573165fa7efeb97183 Mon Sep 17 00:00:00 2001 From: Phantom Date: Thu, 11 Dec 2025 11:29:18 +0800 Subject: [PATCH 32/90] fix(ThinkingButton): show correct icon when isFixedReasoning (#11825) --- .../tools/components/ThinkingButton.tsx | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/ThinkingButton.tsx index 5909189516..96e7adca93 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/ThinkingButton.tsx @@ -93,7 +93,7 @@ const ThinkingButton: FC = ({ quickPanel, model, assistantId }): ReactEle level: option, label: getReasoningEffortOptionsLabel(option), description: '', - icon: ThinkingIcon(option), + icon: ThinkingIcon({ option }), isSelected: currentReasoningEffort === option, action: () => onThinkingChange(option) })) @@ -135,7 +135,7 @@ const ThinkingButton: FC = ({ quickPanel, model, assistantId }): ReactEle { label: t('assistants.settings.reasoning_effort.label'), description: '', - icon: ThinkingIcon(currentReasoningEffort), + icon: ThinkingIcon({ option: currentReasoningEffort }), isMenu: true, action: () => openQuickPanel() } @@ -163,37 +163,40 @@ const ThinkingButton: FC = ({ quickPanel, model, assistantId }): ReactEle aria-label={ariaLabel} aria-pressed={currentReasoningEffort !== 'none'} style={isFixedReasoning ? { cursor: 'default' } : undefined}> - {ThinkingIcon(currentReasoningEffort)} + {ThinkingIcon({ option: currentReasoningEffort, isFixedReasoning })} ) } -const ThinkingIcon = (option?: ThinkingOption) => { +const ThinkingIcon = (props: { option?: ThinkingOption; isFixedReasoning?: boolean }) => { let IconComponent: React.FC> | null = null - - switch (option) { - case 'minimal': - IconComponent = MdiLightbulbOn30 - break - case 'low': - IconComponent = MdiLightbulbOn50 - break - case 'medium': - IconComponent = MdiLightbulbOn80 - break - case 'high': - IconComponent = MdiLightbulbOn - break - case 'auto': - IconComponent = MdiLightbulbAutoOutline - break - case 'none': - IconComponent = MdiLightbulbOffOutline - break - default: - IconComponent = MdiLightbulbOffOutline - break + if (props.isFixedReasoning) { + IconComponent = MdiLightbulbAutoOutline + } else { + switch (props.option) { + case 'minimal': + IconComponent = MdiLightbulbOn30 + break + case 'low': + IconComponent = MdiLightbulbOn50 + break + case 'medium': + IconComponent = MdiLightbulbOn80 + break + case 'high': + IconComponent = MdiLightbulbOn + break + case 'auto': + IconComponent = MdiLightbulbAutoOutline + break + case 'none': + IconComponent = MdiLightbulbOffOutline + break + default: + IconComponent = MdiLightbulbOffOutline + break + } } return From 880673c4eba641a60b4ae6d78512c240b766c52b Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 11 Dec 2025 11:57:16 +0800 Subject: [PATCH 33/90] fix(AssistantPresetCard): update group handling to use isArray for better type safety --- .../assistants/presets/components/AssistantPresetCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/store/assistants/presets/components/AssistantPresetCard.tsx b/src/renderer/src/pages/store/assistants/presets/components/AssistantPresetCard.tsx index 25b81cf38a..4c9ce082d7 100644 --- a/src/renderer/src/pages/store/assistants/presets/components/AssistantPresetCard.tsx +++ b/src/renderer/src/pages/store/assistants/presets/components/AssistantPresetCard.tsx @@ -7,6 +7,7 @@ import type { AssistantPreset } from '@renderer/types' import { getLeadingEmoji } from '@renderer/utils' import { Button, Dropdown } from 'antd' import { t } from 'i18next' +import { isArray } from 'lodash' import { ArrowDownAZ, Ellipsis, PlusIcon, SquareArrowOutUpRight } from 'lucide-react' import { type FC, memo, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' @@ -142,7 +143,7 @@ const AssistantPresetCard: FC = ({ preset, onClick, activegroup, getLocal {getLocalizedGroupName('我的')} )} - {!!preset.group?.length && + {isArray(preset.group) && preset.group.map((group) => ( {getLocalizedGroupName(group)} From 600a045ff710f36d7951d1e94866935f0c95158e Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:05:41 +0800 Subject: [PATCH 34/90] chore: add gitcode release sync workflow (#11807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add gitcode release sync workflow * fix(ci): address review feedback for gitcode sync workflow - Use Authorization header instead of token in URL query parameter - Add file existence check before copying signed Windows artifacts - Remove inappropriate `|| true` from artifact listing - Use heredoc for safe GITHUB_OUTPUT writing - Add error context logging in upload_file function - Add curl timeout for API requests (connect: 30s, max: 60s) - Add cleanup step for temp files with `if: always()` - Add env var validation for GitCode credentials 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/sync-to-gitcode.yml | 293 ++++++++++++++++++++++++++ scripts/win-sign.js | 10 +- 2 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sync-to-gitcode.yml diff --git a/.github/workflows/sync-to-gitcode.yml b/.github/workflows/sync-to-gitcode.yml new file mode 100644 index 0000000000..4462ff6375 --- /dev/null +++ b/.github/workflows/sync-to-gitcode.yml @@ -0,0 +1,293 @@ +name: Sync Release to GitCode + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v1.0.0)' + required: true + clean: + description: 'Clean node_modules before build' + type: boolean + default: false + +permissions: + contents: read + +jobs: + build-and-sync-to-gitcode: + runs-on: [self-hosted, windows-signing] + steps: + - name: Get tag name + id: get-tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + else + echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + fi + + - name: Check out Git repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ steps.get-tag.outputs.tag }} + + - name: Set package.json version + shell: bash + run: | + TAG="${{ steps.get-tag.outputs.tag }}" + VERSION="${TAG#v}" + npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Install corepack + shell: bash + run: corepack enable && corepack prepare yarn@4.9.1 --activate + + - name: Clean node_modules + if: ${{ github.event.inputs.clean == 'true' }} + shell: bash + run: rm -rf node_modules + + - name: Install Dependencies + shell: bash + run: yarn install + + - name: Build Windows with code signing + shell: bash + run: yarn build:win + env: + WIN_SIGN: true + CHERRY_CERT_PATH: ${{ secrets.CHERRY_CERT_PATH }} + CHERRY_CERT_KEY: ${{ secrets.CHERRY_CERT_KEY }} + CHERRY_CERT_CSP: ${{ secrets.CHERRY_CERT_CSP }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} + + - name: List built Windows artifacts + shell: bash + run: | + echo "Built Windows artifacts:" + ls -la dist/*.exe dist/*.blockmap dist/latest*.yml + + - name: Download GitHub release assets + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ steps.get-tag.outputs.tag }} + run: | + echo "Downloading release assets for $TAG_NAME..." + mkdir -p release-assets + cd release-assets + + # Download all assets from the release + gh release download "$TAG_NAME" \ + --repo "${{ github.repository }}" \ + --pattern "*" \ + --skip-existing + + echo "Downloaded GitHub release assets:" + ls -la + + - name: Replace Windows files with signed versions + shell: bash + run: | + echo "Replacing Windows files with signed versions..." + + # Verify signed files exist first + if ! ls dist/*.exe 1>/dev/null 2>&1; then + echo "ERROR: No signed .exe files found in dist/" + exit 1 + fi + + # Remove unsigned Windows files from downloaded assets + # *.exe, *.exe.blockmap, latest.yml (Windows only) + rm -f release-assets/*.exe release-assets/*.exe.blockmap release-assets/latest.yml 2>/dev/null || true + + # Copy signed Windows files with error checking + cp dist/*.exe release-assets/ || { echo "ERROR: Failed to copy .exe files"; exit 1; } + cp dist/*.exe.blockmap release-assets/ || { echo "ERROR: Failed to copy .blockmap files"; exit 1; } + cp dist/latest.yml release-assets/ || { echo "ERROR: Failed to copy latest.yml"; exit 1; } + + echo "Final release assets:" + ls -la release-assets/ + + - name: Get release info + id: release-info + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ steps.get-tag.outputs.tag }} + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + run: | + # Always use gh cli to avoid special character issues + RELEASE_NAME=$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json name -q '.name') + # Use delimiter to safely handle special characters in release name + { + echo 'name<> $GITHUB_OUTPUT + # Extract releaseNotes from electron-builder.yml (from releaseNotes: | to end of file, remove 4-space indent) + sed -n '/releaseNotes: |/,$ { /releaseNotes: |/d; s/^ //; p }' electron-builder.yml > release_body.txt + + - name: Create GitCode release and upload files + shell: bash + env: + GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} + GITCODE_OWNER: ${{ vars.GITCODE_OWNER }} + GITCODE_REPO: ${{ vars.GITCODE_REPO }} + GITCODE_API_URL: ${{ vars.GITCODE_API_URL }} + TAG_NAME: ${{ steps.get-tag.outputs.tag }} + RELEASE_NAME: ${{ steps.release-info.outputs.name }} + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + run: | + # Validate required environment variables + if [ -z "$GITCODE_TOKEN" ]; then + echo "ERROR: GITCODE_TOKEN is not set" + exit 1 + fi + if [ -z "$GITCODE_OWNER" ]; then + echo "ERROR: GITCODE_OWNER is not set" + exit 1 + fi + if [ -z "$GITCODE_REPO" ]; then + echo "ERROR: GITCODE_REPO is not set" + exit 1 + fi + + API_URL="${GITCODE_API_URL:-https://api.gitcode.com/api/v5}" + + echo "Creating GitCode release..." + echo "Tag: $TAG_NAME" + echo "Repo: $GITCODE_OWNER/$GITCODE_REPO" + + # Step 1: Create release + # Use --rawfile to read body directly from file, avoiding shell variable encoding issues + jq -n \ + --arg tag "$TAG_NAME" \ + --arg name "$RELEASE_NAME" \ + --rawfile body release_body.txt \ + '{ + tag_name: $tag, + name: $name, + body: $body, + target_commitish: "main" + }' > /tmp/release_payload.json + + RELEASE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + --connect-timeout 30 --max-time 60 \ + "${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases" \ + -H "Content-Type: application/json; charset=utf-8" \ + -H "Authorization: Bearer ${GITCODE_TOKEN}" \ + --data-binary "@/tmp/release_payload.json") + + HTTP_CODE=$(echo "$RELEASE_RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$RELEASE_RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "Release created successfully" + else + echo "Warning: Release creation returned HTTP $HTTP_CODE" + echo "$RESPONSE_BODY" + exit 1 + fi + + # Step 2: Upload files to release + echo "Uploading files to GitCode release..." + + # Function to upload a single file with retry + upload_file() { + local file="$1" + local filename=$(basename "$file") + local max_retries=3 + local retry=0 + + echo "Uploading: $filename" + + # URL encode the filename + encoded_filename=$(printf '%s' "$filename" | jq -sRr @uri) + + while [ $retry -lt $max_retries ]; do + # Get upload URL + UPLOAD_INFO=$(curl -s --connect-timeout 30 --max-time 60 \ + -H "Authorization: Bearer ${GITCODE_TOKEN}" \ + "${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}") + + UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty') + + if [ -n "$UPLOAD_URL" ]; then + # Write headers to temp file to avoid shell escaping issues + echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt + + # Upload file using PUT with headers from file + UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \ + -K /tmp/upload_headers.txt \ + --data-binary "@${file}" \ + "$UPLOAD_URL") + + HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo " Uploaded: $filename" + return 0 + else + echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries" + echo " Response: $RESPONSE_BODY" + fi + else + echo " Failed to get upload URL, retry $((retry + 1))/$max_retries" + echo " Response: $UPLOAD_INFO" + fi + + retry=$((retry + 1)) + [ $retry -lt $max_retries ] && sleep 3 + done + + echo " Failed: $filename after $max_retries retries" + exit 1 + } + + # Upload non-yml/json files first + for file in release-assets/*; do + if [ -f "$file" ]; then + filename=$(basename "$file") + if [[ ! "$filename" =~ \.(yml|yaml|json)$ ]]; then + upload_file "$file" + fi + fi + done + + # Upload yml/json files last + for file in release-assets/*; do + if [ -f "$file" ]; then + filename=$(basename "$file") + if [[ "$filename" =~ \.(yml|yaml|json)$ ]]; then + upload_file "$file" + fi + fi + done + + echo "GitCode release sync completed!" + + - name: Cleanup temp files + if: always() + shell: bash + run: | + rm -f /tmp/release_payload.json /tmp/upload_headers.txt release_body.txt + rm -rf release-assets/ diff --git a/scripts/win-sign.js b/scripts/win-sign.js index f9b37c3aed..cdbfe11e17 100644 --- a/scripts/win-sign.js +++ b/scripts/win-sign.js @@ -5,9 +5,17 @@ exports.default = async function (configuration) { const { path } = configuration if (configuration.path) { try { + const certPath = process.env.CHERRY_CERT_PATH + const keyContainer = process.env.CHERRY_CERT_KEY + const csp = process.env.CHERRY_CERT_CSP + + if (!certPath || !keyContainer || !csp) { + throw new Error('CHERRY_CERT_PATH, CHERRY_CERT_KEY or CHERRY_CERT_CSP is not set') + } + console.log('Start code signing...') console.log('Signing file:', path) - const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"` + const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /v /f "${certPath}" /csp "${csp}" /k "${keyContainer}" "${path}"` execSync(signCommand, { stdio: 'inherit' }) console.log('Code signing completed') } catch (error) { From c4fd48376dcffc6f43a88dd21491164be1c81ae3 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:51:32 +0800 Subject: [PATCH 35/90] feat(SelectionAssistant): open URL for search action (#11770) * feat(SelectionAssistant): open URL for search action When selected text is a valid URI or file path, directly open it instead of searching. This enhances the search action to be smarter about handling URLs and file paths. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: format * feat: increase maximum custom and enabled items in settings actions list Updated the maximum number of custom items from 8 to 10 and enabled items from 6 to 8 in the settings actions list to enhance user customization options. --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../hooks/useSettingsActionsList.ts | 4 +- .../selection/toolbar/SelectionToolbar.tsx | 90 +++++++++++++------ 2 files changed, 64 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts b/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts index 341ac8f9c6..843fee150b 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts @@ -9,8 +9,8 @@ import { DEFAULT_SEARCH_ENGINES } from '../components/SelectionActionSearchModal const logger = loggerService.withContext('useSettingsActionsList') -const MAX_CUSTOM_ITEMS = 8 -const MAX_ENABLED_ITEMS = 6 +const MAX_CUSTOM_ITEMS = 10 +const MAX_ENABLED_ITEMS = 8 export const useActionItems = ( initialItems: ActionItem[] | undefined, diff --git a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx index 505a3b8fda..37a56acba5 100644 --- a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx +++ b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx @@ -202,6 +202,30 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } }, [customCss, demo]) + /** + * Check if text is a valid URI or file path + */ + const isUriOrFilePath = (text: string): boolean => { + const trimmed = text.trim() + // Must not contain newlines or whitespace + if (/\s/.test(trimmed)) { + return false + } + // URI patterns: http://, https://, ftp://, file://, etc. + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) { + return true + } + // Windows absolute path: C:\, D:\, etc. + if (/^[a-zA-Z]:[/\\]/.test(trimmed)) { + return true + } + // Unix absolute path: /path/to/file + if (/^\/[^/]/.test(trimmed)) { + return true + } + return false + } + // copy selected text to clipboard const handleCopy = useCallback(async () => { if (selectedText.current) { @@ -219,6 +243,43 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } }, [setTimeoutTimer]) + const handleSearch = useCallback((action: ActionItem) => { + if (!action.selectedText) return + + const selectedText = action.selectedText.trim() + + let actionString = '' + if (isUriOrFilePath(selectedText)) { + actionString = selectedText + } else { + if (!action.searchEngine) return + + const customUrl = action.searchEngine.split('|')[1] + if (!customUrl) return + + actionString = customUrl.replace('{{queryString}}', encodeURIComponent(selectedText)) + } + + window.api?.openWebsite(actionString) + window.api?.selection.hideToolbar() + }, []) + + /** + * Quote the selected text to the inputbar of the main window + */ + const handleQuote = (action: ActionItem) => { + if (action.selectedText) { + window.api?.quoteToMainWindow(action.selectedText) + window.api?.selection.hideToolbar() + } + } + + const handleDefaultAction = (action: ActionItem) => { + // [macOS] only macOS has the available isFullscreen mode + window.api?.selection.processAction(action, isFullScreen.current) + window.api?.selection.hideToolbar() + } + const handleAction = useCallback( (action: ActionItem) => { if (demo) return @@ -241,36 +302,9 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { break } }, - [demo, handleCopy] + [demo, handleCopy, handleSearch] ) - const handleSearch = (action: ActionItem) => { - if (!action.searchEngine) return - - const customUrl = action.searchEngine.split('|')[1] - if (!customUrl) return - - const searchUrl = customUrl.replace('{{queryString}}', encodeURIComponent(action.selectedText || '')) - window.api?.openWebsite(searchUrl) - window.api?.selection.hideToolbar() - } - - /** - * Quote the selected text to the inputbar of the main window - */ - const handleQuote = (action: ActionItem) => { - if (action.selectedText) { - window.api?.quoteToMainWindow(action.selectedText) - window.api?.selection.hideToolbar() - } - } - - const handleDefaultAction = (action: ActionItem) => { - // [macOS] only macOS has the available isFullscreen mode - window.api?.selection.processAction(action, isFullScreen.current) - window.api?.selection.hideToolbar() - } - return ( From 9ac7e2c78d96b3d4933d6c14095ae77b83ce8aef Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 11 Dec 2025 15:01:01 +0800 Subject: [PATCH 36/90] feat: enhance web search tool switching logic to support provider-specific context (#11769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance web search tool switching logic to support provider-specific context * Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: consolidate control flow in switchWebSearchTool (#11771) * Initial plan * refactor: make control flow consistent in switchWebSearchTool Replace early returns with break statements in all switch cases to ensure consistent control flow. Move fallback logic into default case for clarity. Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: format * fix: ensure switchWebSearchTool is always called for cherryin providers - Add missing else branch to prevent silent failure when provider extraction fails - Add empty string check for extracted providerId from split operation - Ensures web search functionality is preserved in all edge cases Addresses PR review feedback from #11769 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * refactor: simplify repetitive switchWebSearchTool calls - Extract providerId determination logic before calling switchWebSearchTool - Call switchWebSearchTool only once at the end with updated providerId - Reduce code duplication while maintaining all edge case handling Addresses review feedback from @kangfenmao 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * refactor: eliminate code duplication in switchWebSearchTool - Extract helper functions: ensureToolsObject, applyToolBasedSearch, applyProviderOptionsSearch - Replace switch statement and fallback if-else chain with providerHandlers map - Use array-based priority order for fallback logic - Reduce code from 73 lines to 80 lines but with much better maintainability - Eliminates 12 instances of "if (!params.tools) params.tools = {}" - Single source of truth for each provider's configuration logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- .../built-in/webSearchPlugin/helper.ts | 101 ++++++++++++++---- .../plugins/built-in/webSearchPlugin/index.ts | 18 +++- 2 files changed, 95 insertions(+), 24 deletions(-) diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts index 61e6f49b81..6e313bdd27 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -6,6 +6,7 @@ import { type Tool } from 'ai' import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' import type { ProviderOptionsMap } from '../../../options/types' +import type { AiRequestContext } from '../../' import type { OpenRouterSearchConfig } from './openrouter' /** @@ -95,28 +96,84 @@ export type WebSearchToolInputSchema = { 'openai-chat': InferToolInput } -export const switchWebSearchTool = (config: WebSearchPluginConfig, params: any) => { - if (config.openai) { - if (!params.tools) params.tools = {} - params.tools.web_search = openai.tools.webSearch(config.openai) - } else if (config['openai-chat']) { - if (!params.tools) params.tools = {} - params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat']) - } else if (config.anthropic) { - if (!params.tools) params.tools = {} - params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic) - } else if (config.google) { - // case 'google-vertex': - if (!params.tools) params.tools = {} - params.tools.web_search = google.tools.googleSearch(config.google || {}) - } else if (config.xai) { - const searchOptions = createXaiOptions({ - searchParameters: { ...config.xai, mode: 'on' } - }) - params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) - } else if (config.openrouter) { - const searchOptions = createOpenRouterOptions(config.openrouter) - params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) +/** + * Helper function to ensure params.tools object exists + */ +const ensureToolsObject = (params: any) => { + if (!params.tools) params.tools = {} +} + +/** + * Helper function to apply tool-based web search configuration + */ +const applyToolBasedSearch = (params: any, toolName: string, toolInstance: any) => { + ensureToolsObject(params) + params.tools[toolName] = toolInstance +} + +/** + * Helper function to apply provider options-based web search configuration + */ +const applyProviderOptionsSearch = (params: any, searchOptions: any) => { + params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) +} + +export const switchWebSearchTool = (config: WebSearchPluginConfig, params: any, context?: AiRequestContext) => { + const providerId = context?.providerId + + // Provider-specific configuration map + const providerHandlers: Record void> = { + openai: () => { + const cfg = config.openai ?? DEFAULT_WEB_SEARCH_CONFIG.openai + applyToolBasedSearch(params, 'web_search', openai.tools.webSearch(cfg)) + }, + 'openai-chat': () => { + const cfg = (config['openai-chat'] ?? DEFAULT_WEB_SEARCH_CONFIG['openai-chat']) as OpenAISearchPreviewConfig + applyToolBasedSearch(params, 'web_search_preview', openai.tools.webSearchPreview(cfg)) + }, + anthropic: () => { + const cfg = config.anthropic ?? DEFAULT_WEB_SEARCH_CONFIG.anthropic + applyToolBasedSearch(params, 'web_search', anthropic.tools.webSearch_20250305(cfg)) + }, + google: () => { + const cfg = (config.google ?? DEFAULT_WEB_SEARCH_CONFIG.google) as GoogleSearchConfig + applyToolBasedSearch(params, 'web_search', google.tools.googleSearch(cfg)) + }, + xai: () => { + const cfg = config.xai ?? DEFAULT_WEB_SEARCH_CONFIG.xai + const searchOptions = createXaiOptions({ searchParameters: { ...cfg, mode: 'on' } }) + applyProviderOptionsSearch(params, searchOptions) + }, + openrouter: () => { + const cfg = (config.openrouter ?? DEFAULT_WEB_SEARCH_CONFIG.openrouter) as OpenRouterSearchConfig + const searchOptions = createOpenRouterOptions(cfg) + applyProviderOptionsSearch(params, searchOptions) + } } + + // Try provider-specific handler first + const handler = providerId && providerHandlers[providerId] + if (handler) { + handler() + return params + } + + // Fallback: apply based on available config keys (prioritized order) + const fallbackOrder: Array = [ + 'openai', + 'openai-chat', + 'anthropic', + 'google', + 'xai', + 'openrouter' + ] + + for (const key of fallbackOrder) { + if (config[key]) { + providerHandlers[key]() + break + } + } + return params } diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts index a46df7dd4c..e02fd179fe 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts @@ -17,8 +17,22 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR name: 'webSearch', enforce: 'pre', - transformParams: async (params: any) => { - switchWebSearchTool(config, params) + transformParams: async (params: any, context) => { + let { providerId } = context + + // For cherryin providers, extract the actual provider from the model's provider string + // Expected format: "cherryin.{actualProvider}" (e.g., "cherryin.gemini") + if (providerId === 'cherryin' || providerId === 'cherryin-chat') { + const provider = params.model?.provider + if (provider && typeof provider === 'string' && provider.includes('.')) { + const extractedProviderId = provider.split('.')[1] + if (extractedProviderId) { + providerId = extractedProviderId + } + } + } + + switchWebSearchTool(config, params, { ...context, providerId }) return params } }) From 8cd4b1b747c3895621991108ada665208472f8b4 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:02:26 +0800 Subject: [PATCH 37/90] =?UTF-8?q?=F0=9F=90=9B=20fix:=20stabilize=20MCP=20l?= =?UTF-8?q?og=20IPC=20registration=20(#11830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc.ts | 1 - src/main/services/MCPService.ts | 12 +++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 714292c67e..478564eb47 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -766,7 +766,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool) ipcMain.handle(IpcChannel.Mcp_GetServerVersion, mcpService.getServerVersion) ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs) - ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs) // DXT upload handler ipcMain.handle(IpcChannel.Mcp_UploadDxt, async (event, fileBuffer: ArrayBuffer, fileName: string) => { diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index f9b43f039d..cc6bbaa366 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -162,6 +162,7 @@ class McpService { this.cleanup = this.cleanup.bind(this) this.checkMcpConnectivity = this.checkMcpConnectivity.bind(this) this.getServerVersion = this.getServerVersion.bind(this) + this.getServerLogs = this.getServerLogs.bind(this) } private getServerKey(server: MCPServer): string { @@ -392,15 +393,8 @@ class McpService { source: 'stdio' }) }) - ;(stdioTransport as any).stdout?.on('data', (data: any) => { - const msg = data.toString() - this.emitServerLog(server, { - timestamp: Date.now(), - level: 'stdout', - message: msg.trim(), - source: 'stdio' - }) - }) + // StdioClientTransport does not expose stdout as a readable stream for raw logging + // (stdout is reserved for JSON-RPC). Avoid attaching a listener that would never fire. return stdioTransport } else { throw new Error('Either baseUrl or command must be provided') From ed695a8620919333b6363e9c1921ab5f364e7b05 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 11 Dec 2025 15:04:04 +0800 Subject: [PATCH 38/90] feat: Support custom git bash path (#11813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: allow custom Git Bash path for Claude Code Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * format code * format code * update i18n * fix: correct Git Bash invalid path translation key Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * test: cover null inputs for validateGitBashPath Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * refactor: rely on findGitBash for env override check Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: validate env override for Git Bash path Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * chore: align Git Bash path getter with platform guard Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * test: cover env override behavior in findGitBash Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * refactor: unify Git Bash path detection logic - Add customPath parameter to findGitBash() for config-based paths - Simplify checkGitBash IPC handler by delegating to findGitBash - Change validateGitBashPath success log level from info to debug - Only show success Alert when custom path is configured - Add tests for customPath parameter priority handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Claude --- packages/shared/IpcChannel.ts | 2 + src/main/ipc.ts | 36 ++++- src/main/services/ConfigManager.ts | 3 +- .../agents/services/claudecode/index.ts | 7 +- src/main/utils/__tests__/process.test.ts | 128 +++++++++++++++++- src/main/utils/process.ts | 50 ++++++- src/preload/index.ts | 5 +- .../components/Popups/agent/AgentModal.tsx | 77 ++++++++++- src/renderer/src/i18n/locales/en-us.json | 14 ++ src/renderer/src/i18n/locales/zh-cn.json | 14 ++ src/renderer/src/i18n/locales/zh-tw.json | 14 ++ src/renderer/src/i18n/translate/de-de.json | 14 ++ src/renderer/src/i18n/translate/el-gr.json | 14 ++ src/renderer/src/i18n/translate/es-es.json | 14 ++ src/renderer/src/i18n/translate/fr-fr.json | 16 ++- src/renderer/src/i18n/translate/ja-jp.json | 14 ++ src/renderer/src/i18n/translate/pt-pt.json | 14 ++ src/renderer/src/i18n/translate/ru-ru.json | 14 ++ 18 files changed, 438 insertions(+), 12 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 88e7ae85d5..f3cf112fe0 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -241,6 +241,8 @@ export enum IpcChannel { System_GetHostname = 'system:getHostname', System_GetCpuName = 'system:getCpuName', System_CheckGitBash = 'system:checkGitBash', + System_GetGitBashPath = 'system:getGitBashPath', + System_SetGitBashPath = 'system:setGitBashPath', // DevTools System_ToggleDevTools = 'system:toggleDevTools', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 478564eb47..a960eb7dc0 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6,7 +6,7 @@ import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { generateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' -import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' +import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript, validateGitBashPath } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { UpgradeChannel } from '@shared/config/constant' @@ -35,7 +35,7 @@ import appService from './services/AppService' import AppUpdater from './services/AppUpdater' import BackupManager from './services/BackupManager' import { codeToolsService } from './services/CodeToolsService' -import { configManager } from './services/ConfigManager' +import { ConfigKeys, configManager } from './services/ConfigManager' import CopilotService from './services/CopilotService' import DxtService from './services/DxtService' import { ExportService } from './services/ExportService' @@ -499,7 +499,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } try { - const bashPath = findGitBash() + const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined + const bashPath = findGitBash(customPath) if (bashPath) { logger.info('Git Bash is available', { path: bashPath }) @@ -513,6 +514,35 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return false } }) + + ipcMain.handle(IpcChannel.System_GetGitBashPath, () => { + if (!isWin) { + return null + } + + const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined + return customPath ?? null + }) + + ipcMain.handle(IpcChannel.System_SetGitBashPath, (_, newPath: string | null) => { + if (!isWin) { + return false + } + + if (!newPath) { + configManager.set(ConfigKeys.GitBashPath, null) + return true + } + + const validated = validateGitBashPath(newPath) + if (!validated) { + return false + } + + configManager.set(ConfigKeys.GitBashPath, validated) + return true + }) + ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => { const win = BrowserWindow.fromWebContents(e.sender) win && win.webContents.toggleDevTools() diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 61e285ac1b..c693d4b05a 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -31,7 +31,8 @@ export enum ConfigKeys { DisableHardwareAcceleration = 'disableHardwareAcceleration', Proxy = 'proxy', EnableDeveloperMode = 'enableDeveloperMode', - ClientId = 'clientId' + ClientId = 'clientId', + GitBashPath = 'gitBashPath' } export class ConfigManager { diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index e5cefadd68..ba863f7c50 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -15,6 +15,8 @@ import { query } from '@anthropic-ai/claude-agent-sdk' import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' +import { ConfigKeys, configManager } from '@main/services/ConfigManager' +import { validateGitBashPath } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' @@ -107,6 +109,8 @@ class ClaudeCodeService implements AgentServiceInterface { Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy')) ) as Record + const customGitBashPath = validateGitBashPath(configManager.get(ConfigKeys.GitBashPath) as string | undefined) + const env = { ...loginShellEnvWithoutProxies, // TODO: fix the proxy api server @@ -126,7 +130,8 @@ class ClaudeCodeService implements AgentServiceInterface { // Set CLAUDE_CONFIG_DIR to app's userData directory to avoid path encoding issues // on Windows when the username contains non-ASCII characters (e.g., Chinese characters) // This prevents the SDK from using the user's home directory which may have encoding problems - CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude') + CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude'), + ...(customGitBashPath ? { CLAUDE_CODE_GIT_BASH_PATH: customGitBashPath } : {}) } const errorChunks: string[] = [] diff --git a/src/main/utils/__tests__/process.test.ts b/src/main/utils/__tests__/process.test.ts index 45c0f8b42b..0485ec5fad 100644 --- a/src/main/utils/__tests__/process.test.ts +++ b/src/main/utils/__tests__/process.test.ts @@ -3,7 +3,7 @@ import fs from 'fs' import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { findExecutable, findGitBash } from '../process' +import { findExecutable, findGitBash, validateGitBashPath } from '../process' // Mock dependencies vi.mock('child_process') @@ -289,7 +289,133 @@ describe.skipIf(process.platform !== 'win32')('process utilities', () => { }) }) + describe('validateGitBashPath', () => { + it('returns null when path is null', () => { + const result = validateGitBashPath(null) + + expect(result).toBeNull() + }) + + it('returns null when path is undefined', () => { + const result = validateGitBashPath(undefined) + + expect(result).toBeNull() + }) + + it('returns normalized path when valid bash.exe exists', () => { + const customPath = 'C:\\PortableGit\\bin\\bash.exe' + vi.mocked(fs.existsSync).mockImplementation((p) => p === 'C:\\PortableGit\\bin\\bash.exe') + + const result = validateGitBashPath(customPath) + + expect(result).toBe('C:\\PortableGit\\bin\\bash.exe') + }) + + it('returns null when file does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = validateGitBashPath('C:\\missing\\bash.exe') + + expect(result).toBeNull() + }) + + it('returns null when path is not bash.exe', () => { + const customPath = 'C:\\PortableGit\\bin\\git.exe' + vi.mocked(fs.existsSync).mockReturnValue(true) + + const result = validateGitBashPath(customPath) + + expect(result).toBeNull() + }) + }) + describe('findGitBash', () => { + describe('customPath parameter', () => { + beforeEach(() => { + delete process.env.CLAUDE_CODE_GIT_BASH_PATH + }) + + it('uses customPath when valid', () => { + const customPath = 'C:\\CustomGit\\bin\\bash.exe' + vi.mocked(fs.existsSync).mockImplementation((p) => p === customPath) + + const result = findGitBash(customPath) + + expect(result).toBe(customPath) + expect(execFileSync).not.toHaveBeenCalled() + }) + + it('falls back when customPath is invalid', () => { + const customPath = 'C:\\Invalid\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === customPath) return false + if (p === gitPath) return true + if (p === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash(customPath) + + expect(result).toBe(bashPath) + }) + + it('prioritizes customPath over env override', () => { + const customPath = 'C:\\CustomGit\\bin\\bash.exe' + const envPath = 'C:\\EnvGit\\bin\\bash.exe' + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + + vi.mocked(fs.existsSync).mockImplementation((p) => p === customPath || p === envPath) + + const result = findGitBash(customPath) + + expect(result).toBe(customPath) + }) + }) + + describe('env override', () => { + beforeEach(() => { + delete process.env.CLAUDE_CODE_GIT_BASH_PATH + }) + + it('uses CLAUDE_CODE_GIT_BASH_PATH when valid', () => { + const envPath = 'C:\\OverrideGit\\bin\\bash.exe' + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + + vi.mocked(fs.existsSync).mockImplementation((p) => p === envPath) + + const result = findGitBash() + + expect(result).toBe(envPath) + expect(execFileSync).not.toHaveBeenCalled() + }) + + it('falls back when CLAUDE_CODE_GIT_BASH_PATH is invalid', () => { + const envPath = 'C:\\Invalid\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === envPath) return false + if (p === gitPath) return true + if (p === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + }) + describe('git.exe path derivation', () => { it('should derive bash.exe from standard Git installation (Git/cmd/git.exe)', () => { const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index b59a37a048..7175af7e75 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -131,15 +131,37 @@ export function findExecutable(name: string): string | null { /** * Find Git Bash executable on Windows + * @param customPath - Optional custom path from config * @returns Full path to bash.exe or null if not found */ -export function findGitBash(): string | null { +export function findGitBash(customPath?: string | null): string | null { // Git Bash is Windows-only if (!isWin) { return null } - // 1. Find git.exe and derive bash.exe path + // 1. Check custom path from config first + if (customPath) { + const validated = validateGitBashPath(customPath) + if (validated) { + logger.debug('Using custom Git Bash path from config', { path: validated }) + return validated + } + logger.warn('Custom Git Bash path provided but invalid', { path: customPath }) + } + + // 2. Check environment variable override + const envOverride = process.env.CLAUDE_CODE_GIT_BASH_PATH + if (envOverride) { + const validated = validateGitBashPath(envOverride) + if (validated) { + logger.debug('Using CLAUDE_CODE_GIT_BASH_PATH override for bash.exe', { path: validated }) + return validated + } + logger.warn('CLAUDE_CODE_GIT_BASH_PATH provided but path is invalid', { path: envOverride }) + } + + // 3. Find git.exe and derive bash.exe path const gitPath = findExecutable('git') if (gitPath) { // Try multiple possible locations for bash.exe relative to git.exe @@ -164,7 +186,7 @@ export function findGitBash(): string | null { }) } - // 2. Fallback: check common Git Bash paths directly + // 4. Fallback: check common Git Bash paths directly const commonBashPaths = [ path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), @@ -181,3 +203,25 @@ export function findGitBash(): string | null { logger.debug('Git Bash not found - checked git derivation and common paths') return null } + +export function validateGitBashPath(customPath?: string | null): string | null { + if (!customPath) { + return null + } + + const resolved = path.resolve(customPath) + + if (!fs.existsSync(resolved)) { + logger.warn('Custom Git Bash path does not exist', { path: resolved }) + return null + } + + const isExe = resolved.toLowerCase().endsWith('bash.exe') + if (!isExe) { + logger.warn('Custom Git Bash path is not bash.exe', { path: resolved }) + return null + } + + logger.debug('Validated custom Git Bash path', { path: resolved }) + return resolved +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 654e727cc6..fda288f68e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -124,7 +124,10 @@ const api = { getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType), getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname), getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName), - checkGitBash: (): Promise => ipcRenderer.invoke(IpcChannel.System_CheckGitBash) + checkGitBash: (): Promise => ipcRenderer.invoke(IpcChannel.System_CheckGitBash), + getGitBashPath: (): Promise => ipcRenderer.invoke(IpcChannel.System_GetGitBashPath), + setGitBashPath: (newPath: string | null): Promise => + ipcRenderer.invoke(IpcChannel.System_SetGitBashPath, newPath) }, devTools: { toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools) diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 0d3ce94731..8a8b4fe61b 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -60,6 +60,7 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const [form, setForm] = useState(() => buildAgentForm(agent)) const [hasGitBash, setHasGitBash] = useState(true) + const [customGitBashPath, setCustomGitBashPath] = useState('') useEffect(() => { if (open) { @@ -70,7 +71,11 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const checkGitBash = useCallback( async (showToast = false) => { try { - const gitBashInstalled = await window.api.system.checkGitBash() + const [gitBashInstalled, savedPath] = await Promise.all([ + window.api.system.checkGitBash(), + window.api.system.getGitBashPath().catch(() => null) + ]) + setCustomGitBashPath(savedPath ?? '') setHasGitBash(gitBashInstalled) if (showToast) { if (gitBashInstalled) { @@ -93,6 +98,46 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const selectedPermissionMode = form.configuration?.permission_mode ?? 'default' + const handlePickGitBash = useCallback(async () => { + try { + const selected = await window.api.file.select({ + title: t('agent.gitBash.pick.title', 'Select Git Bash executable'), + filters: [{ name: 'Executable', extensions: ['exe'] }], + properties: ['openFile'] + }) + + if (!selected || selected.length === 0) { + return + } + + const pickedPath = selected[0].path + const ok = await window.api.system.setGitBashPath(pickedPath) + if (!ok) { + window.toast.error( + t('agent.gitBash.pick.invalidPath', 'Selected file is not a valid Git Bash executable (bash.exe).') + ) + return + } + + setCustomGitBashPath(pickedPath) + await checkGitBash(true) + } catch (error) { + logger.error('Failed to pick Git Bash path', error as Error) + window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path')) + } + }, [checkGitBash, t]) + + const handleClearGitBash = useCallback(async () => { + try { + await window.api.system.setGitBashPath(null) + setCustomGitBashPath('') + await checkGitBash(true) + } catch (error) { + logger.error('Failed to clear Git Bash path', error as Error) + window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path')) + } + }, [checkGitBash, t]) + const onPermissionModeChange = useCallback((value: PermissionMode) => { setForm((prev) => { const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {}) @@ -324,6 +369,9 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { +
} type="error" @@ -331,6 +379,33 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { style={{ marginBottom: 16 }} /> )} + + {hasGitBash && customGitBashPath && ( + +
+ {t('agent.gitBash.customPath', { + defaultValue: 'Using custom path: {{path}}', + path: customGitBashPath + })} +
+
+ + +
+
+ } + type="success" + showIcon + style={{ marginBottom: 16 }} + /> + )}