Merge remote-tracking branch 'origin/main' into feat/aisdk-package
This commit is contained in:
+8
-2
@@ -56,8 +56,14 @@ if (isLinux && process.env.XDG_SESSION_TYPE === 'wayland') {
|
||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal')
|
||||
}
|
||||
|
||||
// Enable features for unresponsive renderer js call stacks
|
||||
app.commandLine.appendSwitch('enable-features', 'DocumentPolicyIncludeJSCallStacksInCrashReports')
|
||||
// DocumentPolicyIncludeJSCallStacksInCrashReports: Enable features for unresponsive renderer js call stacks
|
||||
// EarlyEstablishGpuChannel,EstablishGpuChannelAsync: Enable features for early establish gpu channel
|
||||
// speed up the startup time
|
||||
// https://github.com/microsoft/vscode/pull/241640/files
|
||||
app.commandLine.appendSwitch(
|
||||
'enable-features',
|
||||
'DocumentPolicyIncludeJSCallStacksInCrashReports,EarlyEstablishGpuChannel,EstablishGpuChannelAsync'
|
||||
)
|
||||
app.on('web-contents-created', (_, webContents) => {
|
||||
webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
|
||||
+20
-11
@@ -7,7 +7,7 @@ import { isLinux, isMac, isPortable, isWin } from '@main/constant'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
@@ -16,11 +16,12 @@ import { Notification } from 'src/renderer/src/types/notification'
|
||||
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 CopilotService from './services/CopilotService'
|
||||
import DxtService from './services/DxtService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import { fileStorage as fileManager } from './services/FileStorage'
|
||||
import FileService from './services/FileSystemService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
@@ -61,16 +62,15 @@ import { compress, decompress } from './utils/zip'
|
||||
|
||||
const logger = loggerService.withContext('IPC')
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
const exportService = new ExportService()
|
||||
const obsidianVaultService = new ObsidianVaultService()
|
||||
const vertexAIService = VertexAIService.getInstance()
|
||||
const memoryService = MemoryService.getInstance()
|
||||
const dxtService = new DxtService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
const appUpdater = new AppUpdater()
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
|
||||
// Initialize Python service with main window
|
||||
@@ -90,13 +90,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
installPath: path.dirname(app.getPath('exe'))
|
||||
}))
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
|
||||
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string, bypassRules?: string) => {
|
||||
let proxyConfig: ProxyConfig
|
||||
|
||||
if (proxy === 'system') {
|
||||
// system proxy will use the system filter by themselves
|
||||
proxyConfig = { mode: 'system' }
|
||||
} else if (proxy) {
|
||||
proxyConfig = { mode: 'fixed_servers', proxyRules: proxy }
|
||||
proxyConfig = { mode: 'fixed_servers', proxyRules: proxy, proxyBypassRules: bypassRules }
|
||||
} else {
|
||||
proxyConfig = { mode: 'direct' }
|
||||
}
|
||||
@@ -530,13 +531,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
|
||||
mainWindow?.setMinimumSize(1080, 600)
|
||||
const [width, height] = mainWindow?.getSize() ?? [1080, 600]
|
||||
if (width < 1080) {
|
||||
mainWindow?.setSize(1080, height)
|
||||
mainWindow?.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
|
||||
const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
|
||||
if (width < MIN_WINDOW_WIDTH) {
|
||||
mainWindow?.setSize(MIN_WINDOW_WIDTH, height)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Windows_GetSize, () => {
|
||||
const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
|
||||
return [width, height]
|
||||
})
|
||||
|
||||
// VertexAI
|
||||
ipcMain.handle(IpcChannel.VertexAI_GetAuthHeaders, async (_, params) => {
|
||||
return vertexAIService.getAuthHeaders(params)
|
||||
@@ -695,4 +701,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
(_, spanId: string, modelName: string, context: string, msg: any) =>
|
||||
addStreamMessage(spanId, modelName, context, msg)
|
||||
)
|
||||
|
||||
// CodeTools
|
||||
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
|
||||
}
|
||||
|
||||
@@ -73,17 +73,19 @@ export async function addFileLoader(
|
||||
// 获取文件类型,如果没有匹配则默认为文本类型
|
||||
const loaderType = FILE_LOADER_MAP[file.ext.toLowerCase()] || 'text'
|
||||
let loaderReturn: AddLoaderReturn
|
||||
// 使用文件的实际路径
|
||||
const filePath = file.path
|
||||
|
||||
// JSON类型处理
|
||||
let jsonObject = {}
|
||||
let jsonParsed = true
|
||||
logger.info(`[KnowledgeBase] processing file ${file.path} as ${loaderType} type`)
|
||||
logger.info(`[KnowledgeBase] processing file ${filePath} as ${loaderType} type`)
|
||||
switch (loaderType) {
|
||||
case 'common':
|
||||
// 内置类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new LocalPathLoader({
|
||||
path: file.path,
|
||||
path: filePath,
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
@@ -99,7 +101,7 @@ export async function addFileLoader(
|
||||
// epub类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new EpubLoader({
|
||||
filePath: file.path,
|
||||
filePath: filePath,
|
||||
chunkSize: base.chunkSize ?? 1000,
|
||||
chunkOverlap: base.chunkOverlap ?? 200
|
||||
}) as any,
|
||||
@@ -109,14 +111,14 @@ export async function addFileLoader(
|
||||
|
||||
case 'drafts':
|
||||
// Drafts类型处理
|
||||
loaderReturn = await ragApplication.addLoader(new DraftsExportLoader(file.path) as any, forceReload)
|
||||
loaderReturn = await ragApplication.addLoader(new DraftsExportLoader(filePath), forceReload)
|
||||
break
|
||||
|
||||
case 'html':
|
||||
// HTML类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: await readTextFileWithAutoEncoding(file.path),
|
||||
urlOrContent: await readTextFileWithAutoEncoding(filePath),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
@@ -126,11 +128,11 @@ export async function addFileLoader(
|
||||
|
||||
case 'json':
|
||||
try {
|
||||
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
|
||||
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(filePath))
|
||||
} catch (error) {
|
||||
jsonParsed = false
|
||||
logger.warn(
|
||||
`[KnowledgeBase] failed parsing json file, falling back to text processing: ${file.path}`,
|
||||
`[KnowledgeBase] failed parsing json file, falling back to text processing: ${filePath}`,
|
||||
error as Error
|
||||
)
|
||||
}
|
||||
@@ -145,7 +147,7 @@ export async function addFileLoader(
|
||||
// 如果是其他文本类型且尚未读取文件,则读取文件
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new TextLoader({
|
||||
text: await readTextFileWithAutoEncoding(file.path),
|
||||
text: await readTextFileWithAutoEncoding(filePath),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
@@ -54,20 +55,21 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
|
||||
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
|
||||
try {
|
||||
logger.info(`Preprocess processing started: ${file.path}`)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`Preprocess processing started: ${filePath}`)
|
||||
|
||||
// 步骤1: 准备上传
|
||||
const { uid, url } = await this.preupload()
|
||||
logger.info(`Preprocess preupload completed: uid=${uid}`)
|
||||
|
||||
await this.validateFile(file.path)
|
||||
await this.validateFile(filePath)
|
||||
|
||||
// 步骤2: 上传文件
|
||||
await this.putFile(file.path, url)
|
||||
await this.putFile(filePath, url)
|
||||
|
||||
// 步骤3: 等待处理完成
|
||||
await this.waitForProcessing(sourceId, uid)
|
||||
logger.info(`Preprocess parsing completed successfully for: ${file.path}`)
|
||||
logger.info(`Preprocess parsing completed successfully for: ${filePath}`)
|
||||
|
||||
// 步骤4: 导出文件
|
||||
const { path: outputPath } = await this.exportFile(file, uid)
|
||||
@@ -77,9 +79,7 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
processedFile: this.createProcessedFileInfo(file, outputPath)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Preprocess processing failed for ${file.path}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
logger.error(`Preprocess processing failed for:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -102,11 +102,12 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
* @returns 导出文件的路径
|
||||
*/
|
||||
public async exportFile(file: FileMetadata, uid: string): Promise<{ path: string }> {
|
||||
logger.info(`Exporting file: ${file.path}`)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`Exporting file: ${filePath}`)
|
||||
|
||||
// 步骤1: 转换文件
|
||||
await this.convertFile(uid, file.path)
|
||||
logger.info(`File conversion completed for: ${file.path}`)
|
||||
await this.convertFile(uid, filePath)
|
||||
logger.info(`File conversion completed for: ${filePath}`)
|
||||
|
||||
// 步骤2: 等待导出并获取URL
|
||||
const exportUrl = await this.waitForExport(uid)
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import axios from 'axios'
|
||||
@@ -63,8 +64,9 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
file: FileMetadata
|
||||
): Promise<{ processedFile: FileMetadata; quota: number }> {
|
||||
try {
|
||||
logger.info(`MinerU preprocess processing started: ${file.path}`)
|
||||
await this.validateFile(file.path)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`MinerU preprocess processing started: ${filePath}`)
|
||||
await this.validateFile(filePath)
|
||||
|
||||
// 1. 获取上传URL并上传文件
|
||||
const batchId = await this.uploadFile(file)
|
||||
@@ -86,7 +88,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
quota
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`MinerU preprocess processing failed for ${file.path}: ${error.message}`)
|
||||
logger.error(`MinerU preprocess processing failed for:`, error as Error)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
@@ -205,16 +207,14 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
try {
|
||||
// 步骤1: 获取上传URL
|
||||
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
|
||||
logger.debug(`Got upload URLs for batch: ${batchId}`)
|
||||
|
||||
logger.debug(`batchId: ${batchId}, fileurls: ${fileUrls}`)
|
||||
// 步骤2: 上传文件到获取的URL
|
||||
await this.putFileToUrl(file.path, fileUrls[0])
|
||||
logger.info(`File uploaded successfully: ${file.path}`)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
await this.putFileToUrl(filePath, fileUrls[0])
|
||||
logger.info(`File uploaded successfully: ${filePath}`, { batchId, fileUrls })
|
||||
|
||||
return batchId
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to upload file ${file.path}: ${error.message}`)
|
||||
logger.error(`Failed to upload file:`, error as Error)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { MistralClientManager } from '@main/services/MistralClientManager'
|
||||
import { MistralService } from '@main/services/remotefile/MistralService'
|
||||
import { Mistral } from '@mistralai/mistralai'
|
||||
@@ -38,7 +39,8 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
|
||||
|
||||
private async preupload(file: FileMetadata): Promise<PreuploadResponse> {
|
||||
let document: PreuploadResponse
|
||||
logger.info(`preprocess preupload started for local file: ${file.path}`)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`preprocess preupload started for local file: ${filePath}`)
|
||||
|
||||
if (file.ext.toLowerCase() === '.pdf') {
|
||||
const uploadResponse = await this.fileService.uploadFile(file)
|
||||
@@ -58,7 +60,7 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
|
||||
documentUrl: fileUrl.url
|
||||
}
|
||||
} else {
|
||||
const base64Image = Buffer.from(fs.readFileSync(file.path)).toString('base64')
|
||||
const base64Image = Buffer.from(fs.readFileSync(filePath)).toString('base64')
|
||||
document = {
|
||||
type: 'image_url',
|
||||
imageUrl: `data:image/png;base64,${base64Image}`
|
||||
@@ -97,8 +99,8 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
|
||||
// 使用统一的存储路径:Data/Files/{file.id}/
|
||||
const conversionId = file.id
|
||||
const outputPath = path.join(this.storageDir, file.id)
|
||||
// const outputPath = this.storageDir
|
||||
const outputFileName = path.basename(file.path, path.extname(file.path))
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
const outputFileName = path.basename(filePath, path.extname(filePath))
|
||||
fs.mkdirSync(outputPath, { recursive: true })
|
||||
|
||||
const markdownParts: string[] = []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
@@ -11,6 +12,7 @@ import path from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('AppUpdater')
|
||||
|
||||
@@ -20,7 +22,7 @@ export default class AppUpdater {
|
||||
private cancellationToken: CancellationToken = new CancellationToken()
|
||||
private updateCheckResult: UpdateCheckResult | null = null
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
constructor() {
|
||||
autoUpdater.logger = logger as Logger
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
@@ -32,12 +34,12 @@ export default class AppUpdater {
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
logger.error('update error', error as Error)
|
||||
mainWindow.webContents.send(IpcChannel.UpdateError, error)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateError, error)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||
logger.info('update available', releaseInfo)
|
||||
mainWindow.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
||||
})
|
||||
|
||||
// 检测到不需要更新时
|
||||
@@ -48,17 +50,17 @@ export default class AppUpdater {
|
||||
return
|
||||
}
|
||||
|
||||
mainWindow.webContents.send(IpcChannel.UpdateNotAvailable)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateNotAvailable)
|
||||
})
|
||||
|
||||
// 更新下载进度
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
mainWindow.webContents.send(IpcChannel.DownloadProgress, progress)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.DownloadProgress, progress)
|
||||
})
|
||||
|
||||
// 当需要更新的内容下载完成后
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
mainWindow.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
||||
this.releaseInfo = releaseInfo
|
||||
logger.info('update downloaded', releaseInfo)
|
||||
})
|
||||
@@ -98,30 +100,6 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
private async _getIpCountry() {
|
||||
try {
|
||||
// add timeout using AbortController
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const ipinfo = await fetch('https://ipinfo.io/json', {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const data = await ipinfo.json()
|
||||
return data.country || 'CN'
|
||||
} catch (error) {
|
||||
logger.error('Failed to get ipinfo:', error as Error)
|
||||
return 'CN'
|
||||
}
|
||||
}
|
||||
|
||||
public setAutoUpdate(isActive: boolean) {
|
||||
autoUpdater.autoDownload = isActive
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
@@ -186,7 +164,7 @@ export default class AppUpdater {
|
||||
}
|
||||
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION)
|
||||
const ipCountry = await this._getIpCountry()
|
||||
const ipCountry = await getIpCountry()
|
||||
logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`)
|
||||
if (ipCountry.toLowerCase() !== 'cn') {
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { removeEnvProxy } from '@main/utils'
|
||||
import { isUserInChina } from '@main/utils/ipService'
|
||||
import { getBinaryName } from '@main/utils/process'
|
||||
import { spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(require('child_process').exec)
|
||||
const logger = loggerService.withContext('CodeToolsService')
|
||||
|
||||
interface VersionInfo {
|
||||
installed: string | null
|
||||
latest: string | null
|
||||
needsUpdate: boolean
|
||||
}
|
||||
|
||||
class CodeToolsService {
|
||||
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
|
||||
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
||||
|
||||
constructor() {
|
||||
this.getBunPath = this.getBunPath.bind(this)
|
||||
this.getPackageName = this.getPackageName.bind(this)
|
||||
this.getCliExecutableName = this.getCliExecutableName.bind(this)
|
||||
this.isPackageInstalled = this.isPackageInstalled.bind(this)
|
||||
this.getVersionInfo = this.getVersionInfo.bind(this)
|
||||
this.updatePackage = this.updatePackage.bind(this)
|
||||
this.run = this.run.bind(this)
|
||||
}
|
||||
|
||||
public async getBunPath() {
|
||||
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const bunName = await getBinaryName('bun')
|
||||
const bunPath = path.join(dir, bunName)
|
||||
return bunPath
|
||||
}
|
||||
|
||||
public async getPackageName(cliTool: string) {
|
||||
if (cliTool === 'claude-code') {
|
||||
return '@anthropic-ai/claude-code'
|
||||
}
|
||||
if (cliTool === 'gemini-cli') {
|
||||
return '@google/gemini-cli'
|
||||
}
|
||||
return '@qwen-code/qwen-code'
|
||||
}
|
||||
|
||||
public async getCliExecutableName(cliTool: string) {
|
||||
if (cliTool === 'claude-code') {
|
||||
return 'claude'
|
||||
}
|
||||
if (cliTool === 'gemini-cli') {
|
||||
return 'gemini'
|
||||
}
|
||||
return 'qwen'
|
||||
}
|
||||
|
||||
private async isPackageInstalled(cliTool: string): Promise<boolean> {
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
|
||||
// Ensure bin directory exists
|
||||
if (!fs.existsSync(binDir)) {
|
||||
fs.mkdirSync(binDir, { recursive: true })
|
||||
}
|
||||
|
||||
return fs.existsSync(executablePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version information for a CLI tool
|
||||
*/
|
||||
public async getVersionInfo(cliTool: string): Promise<VersionInfo> {
|
||||
logger.info(`Starting version check for ${cliTool}`)
|
||||
const packageName = await this.getPackageName(cliTool)
|
||||
const isInstalled = await this.isPackageInstalled(cliTool)
|
||||
|
||||
let installedVersion: string | null = null
|
||||
let latestVersion: string | null = null
|
||||
|
||||
// Get installed version if package is installed
|
||||
if (isInstalled) {
|
||||
logger.info(`${cliTool} is installed, getting current version`)
|
||||
try {
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
|
||||
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
|
||||
// Extract version number from output (format may vary by tool)
|
||||
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
||||
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
||||
logger.info(`${cliTool} current installed version: ${installedVersion}`)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get installed version for ${cliTool}:`, error as Error)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${cliTool} is not installed`)
|
||||
}
|
||||
|
||||
// Get latest version from npm (with cache)
|
||||
const cacheKey = `${packageName}-latest`
|
||||
const cached = this.versionCache.get(cacheKey)
|
||||
const now = Date.now()
|
||||
|
||||
if (cached && now - cached.timestamp < this.CACHE_DURATION) {
|
||||
logger.info(`Using cached latest version for ${packageName}: ${cached.version}`)
|
||||
latestVersion = cached.version
|
||||
} else {
|
||||
logger.info(`Fetching latest version for ${packageName} from npm`)
|
||||
try {
|
||||
const bunPath = await this.getBunPath()
|
||||
const { stdout } = await execAsync(`"${bunPath}" info ${packageName} version`, { timeout: 15000 })
|
||||
latestVersion = stdout.trim().replace(/["']/g, '')
|
||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||
|
||||
// Cache the result
|
||||
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
|
||||
logger.debug(`Cached latest version for ${packageName}`)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
|
||||
// If we have a cached version, use it even if expired
|
||||
if (cached) {
|
||||
logger.info(`Using expired cached version for ${packageName}: ${cached.version}`)
|
||||
latestVersion = cached.version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needsUpdate = !!(installedVersion && latestVersion && installedVersion !== latestVersion)
|
||||
logger.info(
|
||||
`Version check result for ${cliTool}: installed=${installedVersion}, latest=${latestVersion}, needsUpdate=${needsUpdate}`
|
||||
)
|
||||
|
||||
return {
|
||||
installed: installedVersion,
|
||||
latest: latestVersion,
|
||||
needsUpdate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get npm registry URL based on user location
|
||||
*/
|
||||
private async getNpmRegistryUrl(): Promise<string> {
|
||||
try {
|
||||
const inChina = await isUserInChina()
|
||||
if (inChina) {
|
||||
logger.info('User in China, using Taobao npm mirror')
|
||||
return 'https://registry.npmmirror.com'
|
||||
} else {
|
||||
logger.info('User not in China, using default npm mirror')
|
||||
return 'https://registry.npmjs.org'
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to detect user location, using default npm mirror')
|
||||
return 'https://registry.npmjs.org'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a CLI tool to the latest version
|
||||
*/
|
||||
public async updatePackage(cliTool: string): Promise<{ success: boolean; message: string }> {
|
||||
logger.info(`Starting update process for ${cliTool}`)
|
||||
try {
|
||||
const packageName = await this.getPackageName(cliTool)
|
||||
const bunPath = await this.getBunPath()
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
const registryUrl = await this.getNpmRegistryUrl()
|
||||
|
||||
const installEnvPrefix =
|
||||
process.platform === 'win32'
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
|
||||
const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||
logger.info(`Executing update command: ${updateCommand}`)
|
||||
|
||||
await execAsync(updateCommand, { timeout: 60000 })
|
||||
logger.info(`Successfully executed update command for ${cliTool}`)
|
||||
|
||||
// Clear version cache for this package
|
||||
const cacheKey = `${packageName}-latest`
|
||||
this.versionCache.delete(cacheKey)
|
||||
logger.debug(`Cleared version cache for ${packageName}`)
|
||||
|
||||
const successMessage = `Successfully updated ${cliTool} to the latest version`
|
||||
logger.info(successMessage)
|
||||
return {
|
||||
success: true,
|
||||
message: successMessage
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const failureMessage = `Failed to update ${cliTool}: ${errorMessage}`
|
||||
logger.error(failureMessage, error as Error)
|
||||
return {
|
||||
success: false,
|
||||
message: failureMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
cliTool: string,
|
||||
_model: string,
|
||||
directory: string,
|
||||
env: Record<string, string>,
|
||||
options: { autoUpdateToLatest?: boolean } = {}
|
||||
) {
|
||||
logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`)
|
||||
logger.debug(`Environment variables:`, Object.keys(env))
|
||||
logger.debug(`Options:`, options)
|
||||
|
||||
const packageName = await this.getPackageName(cliTool)
|
||||
const bunPath = await this.getBunPath()
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
|
||||
logger.debug(`Package name: ${packageName}`)
|
||||
logger.debug(`Bun path: ${bunPath}`)
|
||||
logger.debug(`Executable name: ${executableName}`)
|
||||
logger.debug(`Executable path: ${executablePath}`)
|
||||
|
||||
// Check if package is already installed
|
||||
const isInstalled = await this.isPackageInstalled(cliTool)
|
||||
|
||||
// Check for updates and auto-update if requested
|
||||
let updateMessage = ''
|
||||
if (isInstalled && options.autoUpdateToLatest) {
|
||||
logger.info(`Auto update to latest enabled for ${cliTool}`)
|
||||
try {
|
||||
const versionInfo = await this.getVersionInfo(cliTool)
|
||||
if (versionInfo.needsUpdate) {
|
||||
logger.info(`Update available for ${cliTool}: ${versionInfo.installed} -> ${versionInfo.latest}`)
|
||||
logger.info(`Auto-updating ${cliTool} to latest version`)
|
||||
updateMessage = ` && echo "Updating ${cliTool} from ${versionInfo.installed} to ${versionInfo.latest}..."`
|
||||
const updateResult = await this.updatePackage(cliTool)
|
||||
if (updateResult.success) {
|
||||
logger.info(`Update completed successfully for ${cliTool}`)
|
||||
updateMessage += ` && echo "Update completed successfully"`
|
||||
} else {
|
||||
logger.error(`Update failed for ${cliTool}: ${updateResult.message}`)
|
||||
updateMessage += ` && echo "Update failed: ${updateResult.message}"`
|
||||
}
|
||||
} else if (versionInfo.installed && versionInfo.latest) {
|
||||
logger.info(`${cliTool} is already up to date (${versionInfo.installed})`)
|
||||
updateMessage = ` && echo "${cliTool} is up to date (${versionInfo.installed})"`
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check version for ${cliTool}:`, error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Select different terminal based on operating system
|
||||
const platform = process.platform
|
||||
let terminalCommand: string
|
||||
let terminalArgs: string[]
|
||||
|
||||
// Build environment variable prefix (based on platform)
|
||||
const buildEnvPrefix = (isWindows: boolean) => {
|
||||
if (Object.keys(env).length === 0) return ''
|
||||
|
||||
if (isWindows) {
|
||||
// Windows uses set command
|
||||
return Object.entries(env)
|
||||
.map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`)
|
||||
.join(' && ')
|
||||
} else {
|
||||
// Unix-like systems use export command
|
||||
return Object.entries(env)
|
||||
.map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`)
|
||||
.join(' && ')
|
||||
}
|
||||
}
|
||||
|
||||
// Build command to execute
|
||||
let baseCommand: string
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
|
||||
if (isInstalled) {
|
||||
// If already installed, run executable directly (with optional update message)
|
||||
baseCommand = `"${executablePath}"`
|
||||
if (updateMessage) {
|
||||
baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}`
|
||||
}
|
||||
} else {
|
||||
// If not installed, install first then run
|
||||
const registryUrl = await this.getNpmRegistryUrl()
|
||||
const installEnvPrefix =
|
||||
platform === 'win32'
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
|
||||
const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && "${executablePath}"`
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'darwin': {
|
||||
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
|
||||
const envPrefix = buildEnvPrefix(false)
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
terminalCommand = 'osascript'
|
||||
terminalArgs = [
|
||||
'-e',
|
||||
`tell application "Terminal"
|
||||
activate
|
||||
do script "cd '${directory.replace(/'/g, "\\'")}' && clear && ${command.replace(/"/g, '\\"')}"
|
||||
end tell`
|
||||
]
|
||||
break
|
||||
}
|
||||
case 'win32': {
|
||||
// Windows - Use temp bat file for debugging
|
||||
const envPrefix = buildEnvPrefix(true)
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
// Create temp bat file for debugging and avoid complex command line escaping issues
|
||||
const tempDir = path.join(os.tmpdir(), 'cherrystudio')
|
||||
const timestamp = Date.now()
|
||||
const batFileName = `launch_${cliTool}_${timestamp}.bat`
|
||||
const batFilePath = path.join(tempDir, batFileName)
|
||||
|
||||
// Ensure temp directory exists
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Build bat file content, including debug information
|
||||
const batContent = [
|
||||
'@echo off',
|
||||
`title ${cliTool} - Cherry Studio`, // Set window title in bat file
|
||||
'echo ================================================',
|
||||
'echo Cherry Studio CLI Tool Launcher',
|
||||
`echo Tool: ${cliTool}`,
|
||||
`echo Directory: ${directory}`,
|
||||
`echo Time: ${new Date().toLocaleString()}`,
|
||||
'echo ================================================',
|
||||
'',
|
||||
':: Change to target directory',
|
||||
`cd /d "${directory}" || (`,
|
||||
' echo ERROR: Failed to change directory',
|
||||
` echo Target directory: ${directory}`,
|
||||
' pause',
|
||||
' exit /b 1',
|
||||
')',
|
||||
'',
|
||||
':: Clear screen',
|
||||
'cls',
|
||||
'',
|
||||
':: Execute command (without displaying environment variable settings)',
|
||||
command,
|
||||
'',
|
||||
':: Command execution completed',
|
||||
'echo.',
|
||||
'echo Command execution completed.',
|
||||
'echo Press any key to close this window...',
|
||||
'pause >nul'
|
||||
].join('\r\n')
|
||||
|
||||
// Write to bat file
|
||||
try {
|
||||
fs.writeFileSync(batFilePath, batContent, 'utf8')
|
||||
logger.info(`Created temp bat file: ${batFilePath}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create bat file: ${error}`)
|
||||
throw new Error(`Failed to create launch script: ${error}`)
|
||||
}
|
||||
|
||||
// Launch bat file - Use safest start syntax, no title parameter
|
||||
terminalCommand = 'cmd'
|
||||
terminalArgs = ['/c', 'start', batFilePath]
|
||||
|
||||
// Set cleanup task (delete temp file after 5 minutes)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fs.existsSync(batFilePath) && fs.unlinkSync(batFilePath)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to cleanup temp bat file: ${error}`)
|
||||
}
|
||||
}, 10 * 1000) // Delete temp file after 10 seconds
|
||||
|
||||
break
|
||||
}
|
||||
case 'linux': {
|
||||
// Linux - Try to use common terminal emulators
|
||||
const envPrefix = buildEnvPrefix(false)
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
const linuxTerminals = ['gnome-terminal', 'konsole', 'xterm', 'x-terminal-emulator']
|
||||
let foundTerminal = 'xterm' // Default to xterm
|
||||
|
||||
for (const terminal of linuxTerminals) {
|
||||
try {
|
||||
// Check if terminal exists
|
||||
const checkResult = spawn('which', [terminal], { stdio: 'pipe' })
|
||||
await new Promise((resolve) => {
|
||||
checkResult.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
foundTerminal = terminal
|
||||
}
|
||||
resolve(code)
|
||||
})
|
||||
})
|
||||
if (foundTerminal === terminal) break
|
||||
} catch (error) {
|
||||
// Continue trying next terminal
|
||||
}
|
||||
}
|
||||
|
||||
if (foundTerminal === 'gnome-terminal') {
|
||||
terminalCommand = 'gnome-terminal'
|
||||
terminalArgs = ['--working-directory', directory, '--', 'bash', '-c', `clear && ${command}; exec bash`]
|
||||
} else if (foundTerminal === 'konsole') {
|
||||
terminalCommand = 'konsole'
|
||||
terminalArgs = ['--workdir', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
|
||||
} else {
|
||||
// Default to xterm
|
||||
terminalCommand = 'xterm'
|
||||
terminalArgs = ['-e', `cd "${directory}" && clear && ${command} && bash`]
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported operating system: ${platform}`)
|
||||
}
|
||||
|
||||
const processEnv = { ...process.env, ...env }
|
||||
removeEnvProxy(processEnv as Record<string, string>)
|
||||
|
||||
// Launch terminal process
|
||||
try {
|
||||
logger.info(`Launching terminal with command: ${terminalCommand}`)
|
||||
logger.debug(`Terminal arguments:`, terminalArgs)
|
||||
logger.debug(`Working directory: ${directory}`)
|
||||
logger.debug(`Process environment keys: ${Object.keys(processEnv)}`)
|
||||
|
||||
spawn(terminalCommand, terminalArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: directory,
|
||||
env: processEnv
|
||||
})
|
||||
|
||||
const successMessage = `Launched ${cliTool} in new terminal window`
|
||||
logger.info(successMessage)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: successMessage,
|
||||
command: `${terminalCommand} ${terminalArgs.join(' ')}`
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const failureMessage = `Failed to launch terminal: ${errorMessage}`
|
||||
logger.error(failureMessage, error as Error)
|
||||
return {
|
||||
success: false,
|
||||
message: failureMessage,
|
||||
command: `${terminalCommand} ${terminalArgs.join(' ')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const codeToolsService = new CodeToolsService()
|
||||
@@ -21,15 +21,13 @@ import {
|
||||
import { dialog } from 'electron'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
import FileStorage from './FileStorage'
|
||||
import { fileStorage } from './FileStorage'
|
||||
|
||||
const logger = loggerService.withContext('ExportService')
|
||||
export class ExportService {
|
||||
private fileManager: FileStorage
|
||||
private md: MarkdownIt
|
||||
|
||||
constructor(fileManager: FileStorage) {
|
||||
this.fileManager = fileManager
|
||||
constructor() {
|
||||
this.md = new MarkdownIt()
|
||||
}
|
||||
|
||||
@@ -399,7 +397,7 @@ export class ExportService {
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
await this.fileManager.writeFile(_, filePath, buffer)
|
||||
await fileStorage.writeFile(_, filePath, buffer)
|
||||
logger.debug('Document exported successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -156,7 +156,8 @@ class FileStorage {
|
||||
}
|
||||
|
||||
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<FileMetadata> => {
|
||||
const duplicateFile = await this.findDuplicateFile(file.path)
|
||||
const filePath = file.path
|
||||
const duplicateFile = await this.findDuplicateFile(filePath)
|
||||
|
||||
if (duplicateFile) {
|
||||
return duplicateFile
|
||||
@@ -167,13 +168,13 @@ class FileStorage {
|
||||
const ext = path.extname(origin_name).toLowerCase()
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
logger.info(`[FileStorage] Uploading file: ${file.path}`)
|
||||
logger.info(`[FileStorage] Uploading file: ${filePath}`)
|
||||
|
||||
// 根据文件类型选择处理方式
|
||||
if (imageExts.includes(ext)) {
|
||||
await this.compressImage(file.path, destPath)
|
||||
await this.compressImage(filePath, destPath)
|
||||
} else {
|
||||
await fs.promises.copyFile(file.path, destPath)
|
||||
await fs.promises.copyFile(filePath, destPath)
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
@@ -624,6 +625,10 @@ class FileStorage {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public getFilePathById(file: FileMetadata): string {
|
||||
return path.join(this.storageDir, file.id + file.ext)
|
||||
}
|
||||
}
|
||||
|
||||
export default FileStorage
|
||||
export const fileStorage = new FileStorage()
|
||||
|
||||
@@ -27,6 +27,7 @@ import { addFileLoader } from '@main/knowledge/loader'
|
||||
import { NoteLoader } from '@main/knowledge/loader/noteLoader'
|
||||
import PreprocessProvider from '@main/knowledge/preprocess/PreprocessProvider'
|
||||
import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
@@ -689,15 +690,16 @@ class KnowledgeService {
|
||||
if (base.preprocessProvider && file.ext.toLowerCase() === '.pdf') {
|
||||
try {
|
||||
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
// Check if file has already been preprocessed
|
||||
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
|
||||
if (alreadyProcessed) {
|
||||
logger.debug(`File already preprocess processed, using cached result: ${file.path}`)
|
||||
logger.debug(`File already preprocess processed, using cached result: ${filePath}`)
|
||||
return alreadyProcessed
|
||||
}
|
||||
|
||||
// Execute preprocessing
|
||||
logger.debug(`Starting preprocess processing for scanned PDF: ${file.path}`)
|
||||
logger.debug(`Starting preprocess processing for scanned PDF: ${filePath}`)
|
||||
const { processedFile, quota } = await provider.parseFile(item.id, file)
|
||||
fileToProcess = processedFile
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
import { makeSureDirExists } from '@main/utils'
|
||||
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
|
||||
import { buildFunctionCallToolName } from '@main/utils/mcp'
|
||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
|
||||
@@ -280,7 +280,7 @@ class McpService {
|
||||
|
||||
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
||||
if (cmd.includes('bun')) {
|
||||
this.removeProxyEnv(loginShellEnv)
|
||||
removeEnvProxy(loginShellEnv)
|
||||
}
|
||||
|
||||
const transportOptions: any = {
|
||||
@@ -828,14 +828,6 @@ class McpService {
|
||||
}
|
||||
})
|
||||
|
||||
private removeProxyEnv(env: Record<string, string>) {
|
||||
delete env.HTTPS_PROXY
|
||||
delete env.HTTP_PROXY
|
||||
delete env.grpc_proxy
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
|
||||
// 实现 abortTool 方法
|
||||
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
|
||||
const activeToolCall = this.activeToolCalls.get(callId)
|
||||
|
||||
@@ -9,12 +9,64 @@ import { ProxyAgent } from 'proxy-agent'
|
||||
import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const logger = loggerService.withContext('ProxyManager')
|
||||
let byPassRules: string[] = []
|
||||
|
||||
const isByPass = (hostname: string) => {
|
||||
if (byPassRules.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return byPassRules.includes(hostname)
|
||||
}
|
||||
|
||||
class SelectiveDispatcher extends Dispatcher {
|
||||
private proxyDispatcher: Dispatcher
|
||||
private directDispatcher: Dispatcher
|
||||
|
||||
constructor(proxyDispatcher: Dispatcher, directDispatcher: Dispatcher) {
|
||||
super()
|
||||
this.proxyDispatcher = proxyDispatcher
|
||||
this.directDispatcher = directDispatcher
|
||||
}
|
||||
|
||||
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
|
||||
if (opts.origin) {
|
||||
const url = new URL(opts.origin)
|
||||
// 检查是否为 localhost 或本地地址
|
||||
if (isByPass(url.hostname)) {
|
||||
return this.directDispatcher.dispatch(opts, handler)
|
||||
}
|
||||
}
|
||||
|
||||
return this.proxyDispatcher.dispatch(opts, handler)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.proxyDispatcher.close()
|
||||
} catch (error) {
|
||||
logger.error('Failed to close dispatcher:', error as Error)
|
||||
this.proxyDispatcher.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
try {
|
||||
await this.proxyDispatcher.destroy()
|
||||
} catch (error) {
|
||||
logger.error('Failed to destroy dispatcher:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyManager {
|
||||
private config: ProxyConfig = { mode: 'direct' }
|
||||
private systemProxyInterval: NodeJS.Timeout | null = null
|
||||
private isSettingProxy = false
|
||||
|
||||
private proxyDispatcher: Dispatcher | null = null
|
||||
private proxyAgent: ProxyAgent | null = null
|
||||
|
||||
private originalGlobalDispatcher: Dispatcher
|
||||
private originalSocksDispatcher: Dispatcher
|
||||
// for http and https
|
||||
@@ -23,6 +75,8 @@ export class ProxyManager {
|
||||
private originalHttpsGet: typeof https.get
|
||||
private originalHttpsRequest: typeof https.request
|
||||
|
||||
private originalAxiosAdapter
|
||||
|
||||
constructor() {
|
||||
this.originalGlobalDispatcher = getGlobalDispatcher()
|
||||
this.originalSocksDispatcher = global[Symbol.for('undici.globalDispatcher.1')]
|
||||
@@ -30,6 +84,7 @@ export class ProxyManager {
|
||||
this.originalHttpRequest = http.request
|
||||
this.originalHttpsGet = https.get
|
||||
this.originalHttpsRequest = https.request
|
||||
this.originalAxiosAdapter = axios.defaults.adapter
|
||||
}
|
||||
|
||||
private async monitorSystemProxy(): Promise<void> {
|
||||
@@ -38,13 +93,15 @@ export class ProxyManager {
|
||||
// Set new interval
|
||||
this.systemProxyInterval = setInterval(async () => {
|
||||
const currentProxy = await getSystemProxy()
|
||||
if (currentProxy && currentProxy.proxyUrl.toLowerCase() === this.config?.proxyRules) {
|
||||
if (currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}`)
|
||||
await this.configureProxy({
|
||||
mode: 'system',
|
||||
proxyRules: currentProxy?.proxyUrl.toLowerCase()
|
||||
proxyRules: currentProxy?.proxyUrl.toLowerCase(),
|
||||
proxyBypassRules: undefined
|
||||
})
|
||||
}, 1000 * 60)
|
||||
}
|
||||
@@ -57,7 +114,8 @@ export class ProxyManager {
|
||||
}
|
||||
|
||||
async configureProxy(config: ProxyConfig): Promise<void> {
|
||||
logger.debug(`configureProxy: ${config?.mode} ${config?.proxyRules}`)
|
||||
logger.info(`configureProxy: ${config?.mode} ${config?.proxyRules} ${config?.proxyBypassRules}`)
|
||||
|
||||
if (this.isSettingProxy) {
|
||||
return
|
||||
}
|
||||
@@ -65,11 +123,6 @@ export class ProxyManager {
|
||||
this.isSettingProxy = true
|
||||
|
||||
try {
|
||||
if (config?.mode === this.config?.mode && config?.proxyRules === this.config?.proxyRules) {
|
||||
logger.debug('proxy config is the same, skip configure')
|
||||
return
|
||||
}
|
||||
|
||||
this.config = config
|
||||
this.clearSystemProxyMonitor()
|
||||
if (config.mode === 'system') {
|
||||
@@ -81,7 +134,8 @@ export class ProxyManager {
|
||||
this.monitorSystemProxy()
|
||||
}
|
||||
|
||||
this.setGlobalProxy()
|
||||
byPassRules = config.proxyBypassRules?.split(',') || []
|
||||
this.setGlobalProxy(this.config)
|
||||
} catch (error) {
|
||||
logger.error('Failed to config proxy:', error as Error)
|
||||
throw error
|
||||
@@ -115,12 +169,12 @@ export class ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
private setGlobalProxy() {
|
||||
this.setEnvironment(this.config.proxyRules || '')
|
||||
this.setGlobalFetchProxy(this.config)
|
||||
this.setSessionsProxy(this.config)
|
||||
private setGlobalProxy(config: ProxyConfig) {
|
||||
this.setEnvironment(config.proxyRules || '')
|
||||
this.setGlobalFetchProxy(config)
|
||||
this.setSessionsProxy(config)
|
||||
|
||||
this.setGlobalHttpProxy(this.config)
|
||||
this.setGlobalHttpProxy(config)
|
||||
}
|
||||
|
||||
private setGlobalHttpProxy(config: ProxyConfig) {
|
||||
@@ -129,21 +183,18 @@ export class ProxyManager {
|
||||
http.request = this.originalHttpRequest
|
||||
https.get = this.originalHttpsGet
|
||||
https.request = this.originalHttpsRequest
|
||||
|
||||
axios.defaults.proxy = undefined
|
||||
axios.defaults.httpAgent = undefined
|
||||
axios.defaults.httpsAgent = undefined
|
||||
try {
|
||||
this.proxyAgent?.destroy()
|
||||
} catch (error) {
|
||||
logger.error('Failed to destroy proxy agent:', error as Error)
|
||||
}
|
||||
this.proxyAgent = null
|
||||
return
|
||||
}
|
||||
|
||||
// ProxyAgent 从环境变量读取代理配置
|
||||
const agent = new ProxyAgent()
|
||||
|
||||
// axios 使用代理
|
||||
axios.defaults.proxy = false
|
||||
axios.defaults.httpAgent = agent
|
||||
axios.defaults.httpsAgent = agent
|
||||
|
||||
this.proxyAgent = agent
|
||||
http.get = this.bindHttpMethod(this.originalHttpGet, agent)
|
||||
http.request = this.bindHttpMethod(this.originalHttpRequest, agent)
|
||||
|
||||
@@ -176,16 +227,19 @@ export class ProxyManager {
|
||||
callback = args[1]
|
||||
}
|
||||
|
||||
// filter localhost
|
||||
if (url) {
|
||||
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname
|
||||
if (isByPass(hostname)) {
|
||||
return originalMethod(url, options, callback)
|
||||
}
|
||||
}
|
||||
|
||||
// for webdav https self-signed certificate
|
||||
if (options.agent instanceof https.Agent) {
|
||||
;(agent as https.Agent).options.rejectUnauthorized = options.agent.options.rejectUnauthorized
|
||||
}
|
||||
|
||||
// 确保只设置 agent,不修改其他网络选项
|
||||
if (!options.agent) {
|
||||
options.agent = agent
|
||||
}
|
||||
|
||||
options.agent = agent
|
||||
if (url) {
|
||||
return originalMethod(url, options, callback)
|
||||
}
|
||||
@@ -198,22 +252,33 @@ export class ProxyManager {
|
||||
if (config.mode === 'direct' || !proxyUrl) {
|
||||
setGlobalDispatcher(this.originalGlobalDispatcher)
|
||||
global[Symbol.for('undici.globalDispatcher.1')] = this.originalSocksDispatcher
|
||||
this.proxyDispatcher?.close()
|
||||
this.proxyDispatcher = null
|
||||
axios.defaults.adapter = this.originalAxiosAdapter
|
||||
return
|
||||
}
|
||||
|
||||
// axios 使用 fetch 代理
|
||||
axios.defaults.adapter = 'fetch'
|
||||
|
||||
const url = new URL(proxyUrl)
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent())
|
||||
this.proxyDispatcher = new SelectiveDispatcher(new EnvHttpProxyAgent(), this.originalGlobalDispatcher)
|
||||
setGlobalDispatcher(this.proxyDispatcher)
|
||||
return
|
||||
}
|
||||
|
||||
global[Symbol.for('undici.globalDispatcher.1')] = socksDispatcher({
|
||||
port: parseInt(url.port),
|
||||
type: url.protocol === 'socks4:' ? 4 : 5,
|
||||
host: url.hostname,
|
||||
userId: url.username || undefined,
|
||||
password: url.password || undefined
|
||||
})
|
||||
this.proxyDispatcher = new SelectiveDispatcher(
|
||||
socksDispatcher({
|
||||
port: parseInt(url.port),
|
||||
type: url.protocol === 'socks4:' ? 4 : 5,
|
||||
host: url.hostname,
|
||||
userId: url.username || undefined,
|
||||
password: url.password || undefined
|
||||
}),
|
||||
this.originalSocksDispatcher
|
||||
)
|
||||
global[Symbol.for('undici.globalDispatcher.1')] = this.proxyDispatcher
|
||||
}
|
||||
|
||||
private async setSessionsProxy(config: ProxyConfig): Promise<void> {
|
||||
|
||||
@@ -26,7 +26,7 @@ function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
}
|
||||
|
||||
// 需要使用 Virtual Host-Style 的服务商域名后缀白名单
|
||||
const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com']
|
||||
const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com', 'volces.com']
|
||||
|
||||
/**
|
||||
* 使用 AWS SDK v3 的简单 S3 封装,兼容之前 RemoteStorage 的最常用接口。
|
||||
|
||||
@@ -707,6 +707,10 @@ export class SelectionService {
|
||||
//use original point to get the display
|
||||
const display = screen.getDisplayNearestPoint(refPoint)
|
||||
|
||||
//check if the toolbar exceeds the top or bottom of the screen
|
||||
const exceedsTop = posPoint.y < display.workArea.y
|
||||
const exceedsBottom = posPoint.y > display.workArea.y + display.workArea.height - toolbarHeight
|
||||
|
||||
// Ensure toolbar stays within screen boundaries
|
||||
posPoint.x = Math.round(
|
||||
Math.max(display.workArea.x, Math.min(posPoint.x, display.workArea.x + display.workArea.width - toolbarWidth))
|
||||
@@ -715,6 +719,14 @@ export class SelectionService {
|
||||
Math.max(display.workArea.y, Math.min(posPoint.y, display.workArea.y + display.workArea.height - toolbarHeight))
|
||||
)
|
||||
|
||||
//adjust the toolbar position if it exceeds the top or bottom of the screen
|
||||
if (exceedsTop) {
|
||||
posPoint.y = posPoint.y + 32
|
||||
}
|
||||
if (exceedsBottom) {
|
||||
posPoint.y = posPoint.y - 32
|
||||
}
|
||||
|
||||
return posPoint
|
||||
}
|
||||
|
||||
|
||||
@@ -32,11 +32,6 @@ export class WindowService {
|
||||
private wasMainWindowFocused: boolean = false
|
||||
private lastRendererProcessCrashTime: number = 0
|
||||
|
||||
private miniWindowSize: { width: number; height: number } = {
|
||||
width: DEFAULT_MINIWINDOW_WIDTH,
|
||||
height: DEFAULT_MINIWINDOW_HEIGHT
|
||||
}
|
||||
|
||||
public static getInstance(): WindowService {
|
||||
if (!WindowService.instance) {
|
||||
WindowService.instance = new WindowService()
|
||||
@@ -196,8 +191,11 @@ export class WindowService {
|
||||
// the zoom factor is reset to cached value when window is resized after routing to other page
|
||||
// see: https://github.com/electron/electron/issues/10572
|
||||
//
|
||||
// and resize ipc
|
||||
//
|
||||
mainWindow.on('will-resize', () => {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize())
|
||||
})
|
||||
|
||||
// set the zoom factor again when the window is going to restore
|
||||
@@ -212,9 +210,18 @@ export class WindowService {
|
||||
if (isLinux) {
|
||||
mainWindow.on('resize', () => {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize())
|
||||
})
|
||||
}
|
||||
|
||||
mainWindow.on('unmaximize', () => {
|
||||
mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize())
|
||||
})
|
||||
|
||||
mainWindow.on('maximize', () => {
|
||||
mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize())
|
||||
})
|
||||
|
||||
// 添加Escape键退出全屏的支持
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
// 当按下Escape键且窗口处于全屏状态时退出全屏
|
||||
@@ -257,7 +264,9 @@ export class WindowService {
|
||||
'https://cloud.siliconflow.cn/expensebill',
|
||||
'https://aihubmix.com/token',
|
||||
'https://aihubmix.com/topup',
|
||||
'https://aihubmix.com/statistics'
|
||||
'https://aihubmix.com/statistics',
|
||||
'https://dash.302.ai/sso/login',
|
||||
'https://dash.302.ai/charge'
|
||||
]
|
||||
|
||||
if (oauthProviderUrls.some((link) => url.startsWith(link))) {
|
||||
@@ -448,9 +457,21 @@ export class WindowService {
|
||||
}
|
||||
|
||||
public createMiniWindow(isPreload: boolean = false): BrowserWindow {
|
||||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||||
return this.miniWindow
|
||||
}
|
||||
|
||||
const miniWindowState = windowStateKeeper({
|
||||
defaultWidth: DEFAULT_MINIWINDOW_WIDTH,
|
||||
defaultHeight: DEFAULT_MINIWINDOW_HEIGHT,
|
||||
file: 'miniWindow-state.json'
|
||||
})
|
||||
|
||||
this.miniWindow = new BrowserWindow({
|
||||
width: this.miniWindowSize.width,
|
||||
height: this.miniWindowSize.height,
|
||||
x: miniWindowState.x,
|
||||
y: miniWindowState.y,
|
||||
width: miniWindowState.width,
|
||||
height: miniWindowState.height,
|
||||
minWidth: 350,
|
||||
minHeight: 380,
|
||||
maxWidth: 1024,
|
||||
@@ -477,6 +498,8 @@ export class WindowService {
|
||||
}
|
||||
})
|
||||
|
||||
miniWindowState.manage(this.miniWindow)
|
||||
|
||||
//miniWindow should show in current desktop
|
||||
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||
//make miniWindow always on top of fullscreen apps with level set
|
||||
@@ -507,13 +530,6 @@ export class WindowService {
|
||||
this.miniWindow?.webContents.send(IpcChannel.HideMiniWindow)
|
||||
})
|
||||
|
||||
this.miniWindow.on('resized', () => {
|
||||
this.miniWindowSize = this.miniWindow?.getBounds() || {
|
||||
width: DEFAULT_MINIWINDOW_WIDTH,
|
||||
height: DEFAULT_MINIWINDOW_HEIGHT
|
||||
}
|
||||
})
|
||||
|
||||
this.miniWindow.on('show', () => {
|
||||
this.miniWindow?.webContents.send(IpcChannel.ShowMiniWindow)
|
||||
})
|
||||
@@ -559,9 +575,10 @@ export class WindowService {
|
||||
if (cursorDisplay.id !== miniWindowDisplay.id) {
|
||||
const workArea = cursorDisplay.bounds
|
||||
|
||||
// use remembered size to avoid the bug of Electron with screens of different scale factor
|
||||
const miniWindowWidth = this.miniWindowSize.width
|
||||
const miniWindowHeight = this.miniWindowSize.height
|
||||
// use current window size to avoid the bug of Electron with screens of different scale factor
|
||||
const currentBounds = this.miniWindow.getBounds()
|
||||
const miniWindowWidth = currentBounds.width
|
||||
const miniWindowHeight = currentBounds.height
|
||||
|
||||
// move to the center of the cursor's screen
|
||||
const miniWindowX = Math.round(workArea.x + (workArea.width - miniWindowWidth) / 2)
|
||||
@@ -582,7 +599,11 @@ export class WindowService {
|
||||
return
|
||||
}
|
||||
|
||||
this.miniWindow = this.createMiniWindow()
|
||||
if (!this.miniWindow || this.miniWindow.isDestroyed()) {
|
||||
this.miniWindow = this.createMiniWindow()
|
||||
}
|
||||
|
||||
this.miniWindow.show()
|
||||
}
|
||||
|
||||
public hideMiniWindow() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { File, Files, FileState, GoogleGenAI } from '@google/genai'
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -29,7 +30,7 @@ export class GeminiService extends BaseFileService {
|
||||
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
|
||||
try {
|
||||
const uploadResult = await this.fileManager.upload({
|
||||
file: file.path,
|
||||
file: fileStorage.getFilePathById(file),
|
||||
config: {
|
||||
mimeType: 'application/pdf',
|
||||
name: file.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { Mistral } from '@mistralai/mistralai'
|
||||
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||
|
||||
@@ -21,7 +22,7 @@ export class MistralService extends BaseFileService {
|
||||
|
||||
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(file.path)
|
||||
const fileBuffer = await fs.readFile(fileStorage.getFilePathById(file))
|
||||
const response = await this.client.files.upload({
|
||||
file: {
|
||||
fileName: file.origin_name,
|
||||
|
||||
@@ -70,3 +70,11 @@ export async function calculateDirectorySize(directoryPath: string): Promise<num
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
|
||||
export const removeEnvProxy = (env: Record<string, string>) => {
|
||||
delete env.HTTPS_PROXY
|
||||
delete env.HTTP_PROXY
|
||||
delete env.grpc_proxy
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('IpService')
|
||||
|
||||
/**
|
||||
* 获取用户的IP地址所在国家
|
||||
* @returns 返回国家代码,默认为'CN'
|
||||
*/
|
||||
export async function getIpCountry(): Promise<string> {
|
||||
try {
|
||||
// 添加超时控制
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const ipinfo = await fetch('https://ipinfo.io/json', {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const data = await ipinfo.json()
|
||||
const country = data.country || 'CN'
|
||||
logger.info(`Detected user IP address country: ${country}`)
|
||||
return country
|
||||
} catch (error) {
|
||||
logger.error('Failed to get IP address information:', error as Error)
|
||||
return 'CN'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否在中国
|
||||
* @returns 如果用户在中国返回true,否则返回false
|
||||
*/
|
||||
export async function isUserInChina(): Promise<boolean> {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn'
|
||||
}
|
||||
+13
-2
@@ -41,7 +41,8 @@ export function tracedInvoke(channel: string, spanContext: SpanContext | undefin
|
||||
const api = {
|
||||
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
|
||||
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
|
||||
setProxy: (proxy: string | undefined) => ipcRenderer.invoke(IpcChannel.App_Proxy, proxy),
|
||||
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
||||
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||
@@ -231,7 +232,8 @@ const api = {
|
||||
window: {
|
||||
setMinimumSize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
|
||||
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
|
||||
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize),
|
||||
getSize: (): Promise<[number, number]> => ipcRenderer.invoke(IpcChannel.Windows_GetSize)
|
||||
},
|
||||
fileService: {
|
||||
upload: (provider: Provider, file: FileMetadata): Promise<FileUploadResponse> =>
|
||||
@@ -392,6 +394,15 @@ const api = {
|
||||
cleanLocalData: () => ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_LOCAL_DATA),
|
||||
addStreamMessage: (spanId: string, modelName: string, context: string, message: any) =>
|
||||
ipcRenderer.invoke(IpcChannel.TRACE_ADD_STREAM_MESSAGE, spanId, modelName, context, message)
|
||||
},
|
||||
codeTools: {
|
||||
run: (
|
||||
cliTool: string,
|
||||
model: string,
|
||||
directory: string,
|
||||
env: Record<string, string>,
|
||||
options?: { autoUpdateToLatest?: boolean }
|
||||
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import TabsContainer from './components/Tab/TabContainer'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import { useNavbarPosition } from './hooks/useSettings'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import CodeToolsPage from './pages/code/CodeToolsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
@@ -30,6 +31,7 @@ const Router: FC = () => {
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<MinAppsPage />} />
|
||||
<Route path="/code" element={<CodeToolsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -82,8 +82,8 @@ export class AihubmixAPIClient extends MixedBaseAPIClient {
|
||||
return client
|
||||
}
|
||||
|
||||
// OpenAI系列模型
|
||||
if (isOpenAILLMModel(model)) {
|
||||
// OpenAI系列模型 不包含gpt-oss
|
||||
if (isOpenAILLMModel(model) && !model.id.includes('gpt-oss')) {
|
||||
const client = this.clients.get('openai')
|
||||
if (!client || !this.isValidClient(client)) {
|
||||
throw new Error('OpenAI client not properly initialized')
|
||||
|
||||
@@ -3,25 +3,29 @@ import {
|
||||
isFunctionCallingModel,
|
||||
isNotSupportTemperatureAndTopP,
|
||||
isOpenAIModel,
|
||||
isSupportedFlexServiceTier
|
||||
isSupportFlexServiceTierModel
|
||||
} from '@renderer/config/models'
|
||||
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||
import { isSupportServiceTierProvider } from '@renderer/config/providers'
|
||||
import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio'
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import { getAssistantSettings } from '@renderer/services/AssistantService'
|
||||
import { SettingsState } from '@renderer/store/settings'
|
||||
import {
|
||||
Assistant,
|
||||
FileTypes,
|
||||
GenerateImageParams,
|
||||
GroqServiceTiers,
|
||||
isGroqServiceTier,
|
||||
isOpenAIServiceTier,
|
||||
KnowledgeReference,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
MemoryItem,
|
||||
Model,
|
||||
OpenAIServiceTier,
|
||||
OpenAIServiceTiers,
|
||||
OpenAIVerbosity,
|
||||
Provider,
|
||||
SystemProviderIds,
|
||||
ToolCallResponse,
|
||||
WebSearchProviderResponse,
|
||||
WebSearchResponse
|
||||
@@ -201,29 +205,52 @@ export abstract class BaseApiClient<
|
||||
return assistantSettings?.enableTopP ? assistantSettings?.topP : undefined
|
||||
}
|
||||
|
||||
// NOTE: 这个也许可以迁移到OpenAIBaseClient
|
||||
protected getServiceTier(model: Model) {
|
||||
if (!isOpenAIModel(model) || model.provider === 'github' || model.provider === 'copilot') {
|
||||
const serviceTierSetting = this.provider.serviceTier
|
||||
|
||||
if (!isSupportServiceTierProvider(this.provider) || !isOpenAIModel(model) || !serviceTierSetting) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openAI = getStoreSetting('openAI') as SettingsState['openAI']
|
||||
let serviceTier = 'auto' as OpenAIServiceTier
|
||||
|
||||
if (openAI && openAI?.serviceTier === 'flex') {
|
||||
if (isSupportedFlexServiceTier(model)) {
|
||||
serviceTier = 'flex'
|
||||
} else {
|
||||
serviceTier = 'auto'
|
||||
// 处理不同供应商需要 fallback 到默认值的情况
|
||||
if (this.provider.id === SystemProviderIds.groq) {
|
||||
if (
|
||||
!isGroqServiceTier(serviceTierSetting) ||
|
||||
(serviceTierSetting === GroqServiceTiers.flex && !isSupportFlexServiceTierModel(model))
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
serviceTier = openAI.serviceTier
|
||||
// 其他 OpenAI 供应商,假设他们的服务层级设置和 OpenAI 完全相同
|
||||
if (
|
||||
!isOpenAIServiceTier(serviceTierSetting) ||
|
||||
(serviceTierSetting === OpenAIServiceTiers.flex && !isSupportFlexServiceTierModel(model))
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return serviceTier
|
||||
return serviceTierSetting
|
||||
}
|
||||
|
||||
protected getVerbosity(): OpenAIVerbosity {
|
||||
try {
|
||||
const state = window.store?.getState()
|
||||
const verbosity = state?.settings?.openAI?.verbosity
|
||||
|
||||
if (verbosity && ['low', 'medium', 'high'].includes(verbosity)) {
|
||||
return verbosity
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get verbosity from state:', error as Error)
|
||||
}
|
||||
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
protected getTimeout(model: Model) {
|
||||
if (isSupportedFlexServiceTier(model)) {
|
||||
if (isSupportFlexServiceTierModel(model)) {
|
||||
return 15 * 1000 * 60
|
||||
}
|
||||
return defaultTimeout
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import {
|
||||
ContentBlock,
|
||||
ContentBlockParam,
|
||||
MessageCreateParams,
|
||||
MessageCreateParamsBase,
|
||||
RedactedThinkingBlockParam,
|
||||
ServerToolUseBlockParam,
|
||||
@@ -69,6 +68,7 @@ import {
|
||||
mcpToolsToAnthropicTools
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { GenericChunk } from '../../middleware/schemas'
|
||||
import { BaseApiClient } from '../BaseApiClient'
|
||||
@@ -494,22 +494,14 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
system: systemMessage ? [systemMessage] : undefined,
|
||||
thinking: this.getBudgetToken(assistant, model),
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
stream: streamOutput,
|
||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||
// 注意:用户自定义参数总是应该覆盖其他参数
|
||||
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
|
||||
}
|
||||
|
||||
const finalParams: MessageCreateParams = streamOutput
|
||||
? {
|
||||
...commonParams,
|
||||
stream: true
|
||||
}
|
||||
: {
|
||||
...commonParams,
|
||||
stream: false
|
||||
}
|
||||
|
||||
const timeout = this.getTimeout(model)
|
||||
return { payload: finalParams, messages: sdkMessages, metadata: { timeout } }
|
||||
return { payload: commonParams, messages: sdkMessages, metadata: { timeout } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,6 +512,14 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
const toolCalls: Record<number, ToolUseBlock> = {}
|
||||
return {
|
||||
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
if (typeof rawChunk === 'string') {
|
||||
try {
|
||||
rawChunk = JSON.parse(rawChunk)
|
||||
} catch (error) {
|
||||
logger.error('invalid chunk', { rawChunk, error })
|
||||
throw new Error(t('error.chat.chunk.non_json'))
|
||||
}
|
||||
}
|
||||
switch (rawChunk.type) {
|
||||
case 'message': {
|
||||
let i = 0
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } from '@aws-sdk/client-bedrock'
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConverseCommand,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
mcpToolsToAwsBedrockTools
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { BaseApiClient } from '../BaseApiClient'
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
@@ -86,7 +88,15 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
})
|
||||
|
||||
this.sdkInstance = { client, region }
|
||||
const bedrockClient = new BedrockClient({
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey
|
||||
}
|
||||
})
|
||||
|
||||
this.sdkInstance = { client, bedrockClient, region }
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
@@ -131,6 +141,8 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
})
|
||||
}))
|
||||
|
||||
logger.info('Creating completions with model ID:', { modelId: payload.modelId })
|
||||
|
||||
const commonParams = {
|
||||
modelId: payload.modelId,
|
||||
messages: awsMessages as any,
|
||||
@@ -294,9 +306,76 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore sdk未提供
|
||||
override async listModels(): Promise<SdkModel[]> {
|
||||
return []
|
||||
try {
|
||||
const sdk = await this.getSdkInstance()
|
||||
|
||||
// 获取支持ON_DEMAND的基础模型列表
|
||||
const modelsCommand = new ListFoundationModelsCommand({
|
||||
byInferenceType: 'ON_DEMAND',
|
||||
byOutputModality: 'TEXT'
|
||||
})
|
||||
const modelsResponse = await sdk.bedrockClient.send(modelsCommand)
|
||||
|
||||
// 获取推理配置文件列表
|
||||
const profilesCommand = new ListInferenceProfilesCommand({})
|
||||
const profilesResponse = await sdk.bedrockClient.send(profilesCommand)
|
||||
|
||||
logger.info('Found ON_DEMAND foundation models:', { count: modelsResponse.modelSummaries?.length || 0 })
|
||||
logger.info('Found inference profiles:', { count: profilesResponse.inferenceProfileSummaries?.length || 0 })
|
||||
|
||||
const models: any[] = []
|
||||
|
||||
// 处理ON_DEMAND基础模型
|
||||
if (modelsResponse.modelSummaries) {
|
||||
for (const model of modelsResponse.modelSummaries) {
|
||||
if (!model.modelId || !model.modelName) continue
|
||||
|
||||
logger.info('Adding ON_DEMAND model', { modelId: model.modelId })
|
||||
models.push({
|
||||
id: model.modelId,
|
||||
name: model.modelName,
|
||||
display_name: model.modelName,
|
||||
description: `${model.providerName || 'AWS'} - ${model.modelName}`,
|
||||
owned_by: model.providerName || 'AWS',
|
||||
provider: this.provider.id,
|
||||
group: 'AWS Bedrock',
|
||||
isInferenceProfile: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理推理配置文件
|
||||
if (profilesResponse.inferenceProfileSummaries) {
|
||||
for (const profile of profilesResponse.inferenceProfileSummaries) {
|
||||
if (!profile.inferenceProfileArn || !profile.inferenceProfileName) continue
|
||||
|
||||
logger.info('Adding inference profile', {
|
||||
profileArn: profile.inferenceProfileArn,
|
||||
profileName: profile.inferenceProfileName
|
||||
})
|
||||
|
||||
models.push({
|
||||
id: profile.inferenceProfileArn,
|
||||
name: `${profile.inferenceProfileName} (Profile)`,
|
||||
display_name: `${profile.inferenceProfileName} (Profile)`,
|
||||
description: `AWS Inference Profile - ${profile.inferenceProfileName}`,
|
||||
owned_by: 'AWS',
|
||||
provider: this.provider.id,
|
||||
group: 'AWS Bedrock Profiles',
|
||||
isInferenceProfile: true,
|
||||
inferenceProfileId: profile.inferenceProfileId,
|
||||
inferenceProfileArn: profile.inferenceProfileArn
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Total models added to list', { count: models.length })
|
||||
return models
|
||||
} catch (error) {
|
||||
logger.error('Failed to list AWS Bedrock models:', error as Error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async convertMessageToSdkParam(message: Message): Promise<AwsBedrockSdkMessageParam> {
|
||||
@@ -417,7 +496,10 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
temperature: this.getTemperature(assistant, model),
|
||||
topP: this.getTopP(assistant, model),
|
||||
stream: streamOutput !== false,
|
||||
tools: tools.length > 0 ? tools : undefined
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||
// 注意:用户自定义参数总是应该覆盖其他参数
|
||||
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
|
||||
}
|
||||
|
||||
const timeout = this.getTimeout(model)
|
||||
@@ -436,6 +518,15 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
async transform(rawChunk: AwsBedrockSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
logger.silly('Processing AWS Bedrock chunk:', rawChunk)
|
||||
|
||||
if (typeof rawChunk === 'string') {
|
||||
try {
|
||||
rawChunk = JSON.parse(rawChunk)
|
||||
} catch (error) {
|
||||
logger.error('invalid chunk', { rawChunk, error })
|
||||
throw new Error(t('error.chat.chunk.non_json'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理消息开始事件
|
||||
if (rawChunk.messageStart) {
|
||||
controller.enqueue({
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { defaultTimeout, MB } from '@shared/config/constant'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { GenericChunk } from '../../middleware/schemas'
|
||||
import { BaseApiClient } from '../BaseApiClient'
|
||||
@@ -531,6 +532,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
...(enableGenerateImage ? this.getGenerateImageParameter() : {}),
|
||||
...this.getBudgetToken(assistant, model),
|
||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||
// 注意:用户自定义参数总是应该覆盖其他参数
|
||||
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
|
||||
}
|
||||
|
||||
@@ -557,6 +559,14 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
return () => ({
|
||||
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
logger.silly('chunk', chunk)
|
||||
if (typeof chunk === 'string') {
|
||||
try {
|
||||
chunk = JSON.parse(chunk)
|
||||
} catch (error) {
|
||||
logger.error('invalid chunk', { chunk, error })
|
||||
throw new Error(t('error.chat.chunk.non_json'))
|
||||
}
|
||||
}
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
for (const candidate of chunk.candidates) {
|
||||
if (candidate.content) {
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
findTokenLimit,
|
||||
GEMINI_FLASH_MODEL_REGEX,
|
||||
getOpenAIWebSearchParams,
|
||||
getThinkModelType,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isGPT5SeriesModel,
|
||||
isGrokReasoningModel,
|
||||
isNotSupportSystemMessageModel,
|
||||
isQwenAlwaysThinkModel,
|
||||
isQwenMTModel,
|
||||
isQwenReasoningModel,
|
||||
isReasoningModel,
|
||||
@@ -19,9 +22,16 @@ import {
|
||||
isSupportedThinkingTokenModel,
|
||||
isSupportedThinkingTokenQwenModel,
|
||||
isSupportedThinkingTokenZhipuModel,
|
||||
isVisionModel
|
||||
isVisionModel,
|
||||
MODEL_SUPPORTED_REASONING_EFFORT
|
||||
} from '@renderer/config/models'
|
||||
import { isSupportDeveloperRoleProvider } from '@renderer/config/providers'
|
||||
import {
|
||||
isSupportArrayContentProvider,
|
||||
isSupportDeveloperRoleProvider,
|
||||
isSupportEnableThinkingProvider,
|
||||
isSupportStreamOptionsProvider
|
||||
} from '@renderer/config/providers'
|
||||
import { mapLanguageToQwenMTModel } from '@renderer/config/translate'
|
||||
import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
// For Copilot token
|
||||
@@ -33,6 +43,7 @@ import {
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
Model,
|
||||
OpenAIServiceTier,
|
||||
Provider,
|
||||
ToolCallResponse,
|
||||
TranslateAssistant,
|
||||
@@ -48,7 +59,6 @@ import {
|
||||
OpenAISdkRawOutput,
|
||||
ReasoningEffortOptionalParams
|
||||
} from '@renderer/types/sdk'
|
||||
import { mapLanguageToQwenMTModel } from '@renderer/utils'
|
||||
import { addImageFileToContents } from '@renderer/utils/formats'
|
||||
import {
|
||||
isSupportedToolUse,
|
||||
@@ -57,6 +67,7 @@ import {
|
||||
openAIToolsToMcpTool
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { t } from 'i18next'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatCompletionTool } from 'openai/resources'
|
||||
|
||||
@@ -140,7 +151,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
}
|
||||
if (isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model)) {
|
||||
|
||||
if (
|
||||
isSupportEnableThinkingProvider(this.provider) &&
|
||||
(isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model))
|
||||
) {
|
||||
return { enable_thinking: false }
|
||||
}
|
||||
|
||||
@@ -169,6 +184,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// reasoningEffort有效的情况
|
||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||
const budgetTokens = Math.floor(
|
||||
(findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio + findTokenLimit(model.id)?.min!
|
||||
@@ -186,9 +203,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// Qwen models
|
||||
if (isSupportedThinkingTokenQwenModel(model)) {
|
||||
if (isQwenReasoningModel(model)) {
|
||||
const thinkConfig = {
|
||||
enable_thinking: true,
|
||||
enable_thinking:
|
||||
isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(this.provider) ? undefined : true,
|
||||
thinking_budget: budgetTokens
|
||||
}
|
||||
if (this.provider.id === 'dashscope') {
|
||||
@@ -201,7 +219,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// Hunyuan models
|
||||
if (isSupportedThinkingTokenHunyuanModel(model)) {
|
||||
if (isSupportedThinkingTokenHunyuanModel(model) && isSupportEnableThinkingProvider(this.provider)) {
|
||||
return {
|
||||
enable_thinking: true
|
||||
}
|
||||
@@ -209,8 +227,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// Grok models/Perplexity models/OpenAI models
|
||||
if (isSupportedReasoningEffortModel(model)) {
|
||||
return {
|
||||
reasoning_effort: reasoningEffort
|
||||
// 检查模型是否支持所选选项
|
||||
const modelType = getThinkModelType(model)
|
||||
const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType]
|
||||
if (supportedOptions.includes(reasoningEffort)) {
|
||||
return {
|
||||
reasoning_effort: reasoningEffort
|
||||
}
|
||||
} else {
|
||||
// 如果不支持,fallback到第一个支持的值
|
||||
return {
|
||||
reasoning_effort: supportedOptions[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,9 +304,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
return true
|
||||
}
|
||||
|
||||
const providers = ['deepseek', 'baichuan', 'minimax', 'xirang']
|
||||
|
||||
return providers.includes(this.provider.id)
|
||||
return !isSupportArrayContentProvider(this.provider)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,9 +392,13 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
): ToolCallResponse {
|
||||
let parsedArgs: any
|
||||
try {
|
||||
parsedArgs = JSON.parse(toolCall.function.arguments)
|
||||
if ('function' in toolCall) {
|
||||
parsedArgs = JSON.parse(toolCall.function.arguments)
|
||||
}
|
||||
} catch {
|
||||
parsedArgs = toolCall.function.arguments
|
||||
if ('function' in toolCall) {
|
||||
parsedArgs = toolCall.function.arguments
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: toolCall.id,
|
||||
@@ -391,7 +421,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
mcpToolResponse,
|
||||
resp,
|
||||
isVisionModel(model),
|
||||
this.provider.isNotSupportArrayContent ?? false
|
||||
!isSupportArrayContentProvider(this.provider)
|
||||
)
|
||||
} else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) {
|
||||
return {
|
||||
@@ -446,7 +476,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
if ('tool_calls' in message && message.tool_calls) {
|
||||
sum += message.tool_calls.reduce((acc, toolCall) => {
|
||||
return acc + estimateTextTokens(JSON.stringify(toolCall.function.arguments))
|
||||
if (toolCall.type === 'function' && 'function' in toolCall) {
|
||||
return acc + estimateTextTokens(JSON.stringify(toolCall.function.arguments))
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
}
|
||||
return sum
|
||||
@@ -485,6 +518,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
source_lang: 'auto',
|
||||
target_lang: mapLanguageToQwenMTModel(targetLanguage!)
|
||||
}
|
||||
if (!extra_body.translation_options.target_lang) {
|
||||
throw new Error(t('translate.error.not_supported', { language: targetLanguage?.value }))
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 处理系统消息
|
||||
@@ -520,7 +556,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
|
||||
if (lastUserMsg && isSupportedThinkingTokenQwenModel(model) && model.provider !== 'dashscope') {
|
||||
if (
|
||||
lastUserMsg &&
|
||||
isSupportedThinkingTokenQwenModel(model) &&
|
||||
!isSupportEnableThinkingProvider(this.provider)
|
||||
) {
|
||||
const postsuffix = '/no_think'
|
||||
const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
|
||||
const currentContent = lastUserMsg.content
|
||||
@@ -539,7 +579,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
reqMessages = processReqMessages(model, reqMessages)
|
||||
|
||||
// 5. 创建通用参数
|
||||
const commonParams = {
|
||||
// Create the appropriate parameters object based on whether streaming is enabled
|
||||
// Note: Some providers like Mistral don't support stream_options
|
||||
const shouldIncludeStreamOptions = streamOutput && isSupportStreamOptionsProvider(this.provider)
|
||||
|
||||
const reasoningEffort = this.getReasoningEffort(assistant, model)
|
||||
|
||||
// minimal cannot be used with web_search tool
|
||||
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort === 'minimal' && enableWebSearch) {
|
||||
reasoningEffort.reasoning_effort = 'low'
|
||||
}
|
||||
|
||||
const commonParams: OpenAISdkParams = {
|
||||
model: model.id,
|
||||
messages:
|
||||
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
|
||||
@@ -549,36 +600,24 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
top_p: this.getTopP(assistant, model),
|
||||
max_tokens: maxTokens,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
service_tier: this.getServiceTier(model),
|
||||
stream: streamOutput,
|
||||
...(shouldIncludeStreamOptions ? { stream_options: { include_usage: true } } : {}),
|
||||
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
||||
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
||||
...this.getProviderSpecificParameters(assistant, model),
|
||||
...this.getReasoningEffort(assistant, model),
|
||||
...reasoningEffort,
|
||||
...getOpenAIWebSearchParams(model, enableWebSearch),
|
||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {}),
|
||||
// OpenRouter usage tracking
|
||||
...(this.provider.id === 'openrouter' ? { usage: { include: true } } : {}),
|
||||
...(isQwenMTModel(model) ? extra_body : {})
|
||||
...(isQwenMTModel(model) ? extra_body : {}),
|
||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||
// 注意:用户自定义参数总是应该覆盖其他参数
|
||||
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
|
||||
}
|
||||
|
||||
// Create the appropriate parameters object based on whether streaming is enabled
|
||||
// Note: Some providers like Mistral don't support stream_options
|
||||
const mistralProviders = ['mistral']
|
||||
const shouldIncludeStreamOptions = streamOutput && !mistralProviders.includes(this.provider.id)
|
||||
|
||||
const sdkParams: OpenAISdkParams = streamOutput
|
||||
? {
|
||||
...commonParams,
|
||||
stream: true,
|
||||
...(shouldIncludeStreamOptions ? { stream_options: { include_usage: true } } : {})
|
||||
}
|
||||
: {
|
||||
...commonParams,
|
||||
stream: false
|
||||
}
|
||||
|
||||
const timeout = this.getTimeout(model)
|
||||
|
||||
return { payload: sdkParams, messages: reqMessages, metadata: { timeout } }
|
||||
return { payload: commonParams, messages: reqMessages, metadata: { timeout } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,16 +754,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
let isThinking = false
|
||||
let accumulatingText = false
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
const isOpenRouter = context.provider?.id === 'openrouter'
|
||||
|
||||
// 持续更新usage信息
|
||||
logger.silly('chunk', chunk)
|
||||
if (chunk.usage) {
|
||||
const usage = chunk.usage as any // OpenRouter may include additional fields like cost
|
||||
const usage = chunk.usage
|
||||
lastUsageInfo = {
|
||||
prompt_tokens: usage.prompt_tokens || 0,
|
||||
completion_tokens: usage.completion_tokens || 0,
|
||||
@@ -732,22 +769,23 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// Handle OpenRouter specific cost fields
|
||||
...(usage.cost !== undefined ? { cost: usage.cost } : {})
|
||||
}
|
||||
|
||||
// For OpenRouter, if we've seen finish_reason and now have usage, emit completion signals
|
||||
if (isOpenRouter && hasFinishReason && !isFinished) {
|
||||
emitCompletionSignals(controller)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For OpenRouter, if this chunk only contains usage without choices, emit completion signals
|
||||
if (isOpenRouter && chunk.usage && (!chunk.choices || chunk.choices.length === 0)) {
|
||||
if (!isFinished) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
// if we've already seen finish_reason, emit completion signals. No matter whether we get usage or not.
|
||||
if (hasFinishReason && !isFinished) {
|
||||
emitCompletionSignals(controller)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof chunk === 'string') {
|
||||
try {
|
||||
chunk = JSON.parse(chunk)
|
||||
} catch (error) {
|
||||
logger.error('invalid chunk', { chunk, error })
|
||||
throw new Error(t('error.chat.chunk.non_json'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理chunk
|
||||
if ('choices' in chunk && chunk.choices && chunk.choices.length > 0) {
|
||||
for (const choice of chunk.choices) {
|
||||
@@ -773,18 +811,23 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
contentSource = choice.message
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
if (!contentSource?.content) {
|
||||
accumulatingText = false
|
||||
}
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
if (!contentSource?.reasoning_content && !contentSource?.reasoning) {
|
||||
isThinking = false
|
||||
}
|
||||
|
||||
if (!contentSource) {
|
||||
if ('finish_reason' in choice && choice.finish_reason) {
|
||||
// For OpenRouter, don't emit completion signals immediately after finish_reason
|
||||
// Wait for the usage chunk that comes after
|
||||
if (isOpenRouter) {
|
||||
hasFinishReason = true
|
||||
// If we already have usage info, emit completion signals now
|
||||
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
} else {
|
||||
// For other providers, emit completion signals immediately
|
||||
// OpenAI Chat Completions API 在启用 stream_options: { include_usage: true } 以后
|
||||
// 包含 usage 的 chunk 会在包含 finish_reason: stop 的 chunk 之后
|
||||
// 所以试图等到拿到 usage 之后再发出结束信号
|
||||
hasFinishReason = true
|
||||
// If we already have usage info, emit completion signals now
|
||||
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
}
|
||||
@@ -810,30 +853,41 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
|
||||
if (reasoningText) {
|
||||
if (isFirstThinkingChunk) {
|
||||
// logger.silly('since reasoningText is trusy, try to enqueue THINKING_START AND THINKING_DELTA')
|
||||
if (!isThinking) {
|
||||
// logger.silly('since isThinking is falsy, try to enqueue THINKING_START')
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
isThinking = true
|
||||
}
|
||||
|
||||
// logger.silly('enqueue THINKING_DELTA')
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: reasoningText
|
||||
})
|
||||
} else {
|
||||
isThinking = false
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if (contentSource.content) {
|
||||
if (isFirstTextChunk) {
|
||||
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
|
||||
if (!accumulatingText) {
|
||||
// logger.silly('enqueue TEXT_START')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
accumulatingText = true
|
||||
}
|
||||
// logger.silly('enqueue TEXT_DELTA')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
})
|
||||
} else {
|
||||
accumulatingText = false
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
@@ -851,7 +905,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
type: 'function'
|
||||
}
|
||||
} else if (fun?.arguments) {
|
||||
toolCalls[index].function.arguments += fun.arguments
|
||||
if (toolCalls[index] && toolCalls[index].type === 'function' && 'function' in toolCalls[index]) {
|
||||
toolCalls[index].function.arguments += fun.arguments
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toolCalls.push(toolCall)
|
||||
@@ -877,16 +933,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
}
|
||||
|
||||
// For OpenRouter, don't emit completion signals immediately after finish_reason
|
||||
// Don't emit completion signals immediately after finish_reason
|
||||
// Wait for the usage chunk that comes after
|
||||
if (isOpenRouter) {
|
||||
hasFinishReason = true
|
||||
// If we already have usage info, emit completion signals now
|
||||
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
} else {
|
||||
// For other providers, emit completion signals immediately
|
||||
hasFinishReason = true
|
||||
// If we already have usage info, emit completion signals now
|
||||
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,18 +99,23 @@ export abstract class OpenAIBaseClient<
|
||||
override async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||
try {
|
||||
const sdk = await this.getSdkInstance()
|
||||
const response = await sdk.models.list()
|
||||
if (this.provider.id === 'github') {
|
||||
// GitHub Models 其 models 和 chat completions 两个接口的 baseUrl 不一样
|
||||
const baseUrl = 'https://models.github.ai/catalog/'
|
||||
const newSdk = sdk.withOptions({ baseURL: baseUrl })
|
||||
const response = await newSdk.models.list()
|
||||
|
||||
// @ts-ignore key is not typed
|
||||
return response?.body
|
||||
.map((model) => ({
|
||||
id: model.name,
|
||||
id: model.id,
|
||||
description: model.summary,
|
||||
object: 'model',
|
||||
owned_by: model.publisher
|
||||
}))
|
||||
.filter(isSupportedModel)
|
||||
}
|
||||
const response = await sdk.models.list()
|
||||
if (this.provider.id === 'together') {
|
||||
// @ts-ignore key is not typed
|
||||
return response?.body.map((model) => ({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas'
|
||||
import { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types'
|
||||
import {
|
||||
isGPT5SeriesModel,
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isOpenAILLMModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
isSupportVerbosityModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { isSupportDeveloperRoleProvider } from '@renderer/config/providers'
|
||||
@@ -13,6 +18,7 @@ import {
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
Model,
|
||||
OpenAIServiceTier,
|
||||
Provider,
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
@@ -36,16 +42,16 @@ import {
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { t } from 'i18next'
|
||||
import { isEmpty } from 'lodash'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
|
||||
import { GenericChunk } from '../../middleware/schemas'
|
||||
import { CompletionsContext } from '../../middleware/types'
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
import { OpenAIAPIClient } from './OpenAIApiClient'
|
||||
import { OpenAIBaseClient } from './OpenAIBaseClient'
|
||||
|
||||
const logger = loggerService.withContext('OpenAIResponseAPIClient')
|
||||
export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
OpenAI,
|
||||
OpenAIResponseSdkParams,
|
||||
@@ -300,8 +306,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
|
||||
const content = this.convertResponseToMessageContent(output)
|
||||
|
||||
const newReqMessages = [...currentReqMessages, ...content, ...(toolResults || [])]
|
||||
return newReqMessages
|
||||
return [...currentReqMessages, ...content, ...(toolResults || [])]
|
||||
}
|
||||
|
||||
override estimateMessageTokens(message: OpenAIResponseSdkMessageParam): number {
|
||||
@@ -338,8 +343,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
public extractMessagesFromSdkPayload(sdkPayload: OpenAIResponseSdkParams): OpenAIResponseSdkMessageParam[] {
|
||||
if (typeof sdkPayload.input === 'string') {
|
||||
return [{ role: 'user', content: sdkPayload.input }]
|
||||
if (!sdkPayload.input || typeof sdkPayload.input === 'string') {
|
||||
return [{ role: 'user', content: sdkPayload.input ?? '' }]
|
||||
}
|
||||
return sdkPayload.input
|
||||
}
|
||||
@@ -437,7 +442,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
tools = tools.concat(extraTools)
|
||||
const commonParams = {
|
||||
|
||||
const reasoningEffort = this.getReasoningEffort(assistant, model)
|
||||
|
||||
// minimal cannot be used with web_search tool
|
||||
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning?.effort === 'minimal' && enableWebSearch) {
|
||||
reasoningEffort.reasoning.effort = 'low'
|
||||
}
|
||||
|
||||
const commonParams: OpenAIResponseSdkParams = {
|
||||
model: model.id,
|
||||
input:
|
||||
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
|
||||
@@ -448,22 +461,22 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
max_output_tokens: maxTokens,
|
||||
stream: streamOutput,
|
||||
tools: !isEmpty(tools) ? tools : undefined,
|
||||
service_tier: this.getServiceTier(model),
|
||||
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
||||
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
||||
...(isSupportVerbosityModel(model)
|
||||
? {
|
||||
text: {
|
||||
verbosity: this.getVerbosity()
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(this.getReasoningEffort(assistant, model) as OpenAI.Reasoning),
|
||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||
// 注意:用户自定义参数总是应该覆盖其他参数
|
||||
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
|
||||
}
|
||||
const sdkParams: OpenAIResponseSdkParams = streamOutput
|
||||
? {
|
||||
...commonParams,
|
||||
stream: true
|
||||
}
|
||||
: {
|
||||
...commonParams,
|
||||
stream: false
|
||||
}
|
||||
const timeout = this.getTimeout(model)
|
||||
return { payload: sdkParams, messages: reqMessages, metadata: { timeout } }
|
||||
return { payload: commonParams, messages: reqMessages, metadata: { timeout } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,6 +490,14 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
let isFirstTextChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
if (typeof chunk === 'string') {
|
||||
try {
|
||||
chunk = JSON.parse(chunk)
|
||||
} catch (error) {
|
||||
logger.error('invalid chunk', { chunk, error })
|
||||
throw new Error(t('error.chat.chunk.non_json'))
|
||||
}
|
||||
}
|
||||
// 处理chunk
|
||||
if ('output' in chunk) {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
|
||||
@@ -85,9 +85,15 @@ const FinalChunkConsumerMiddleware: CompletionsMiddleware =
|
||||
logger.warn(`Received undefined chunk before stream was done.`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error consuming stream:`, error as Error)
|
||||
throw error
|
||||
// FIXME: 临时解决方案。该中间件的异常无法被 ErrorHandlerMiddleware捕获。
|
||||
if (params.onError) {
|
||||
params.onError(error)
|
||||
}
|
||||
if (params.shouldThrow) {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
if (params.onChunk && !isRecursiveCall) {
|
||||
params.onChunk({
|
||||
|
||||
@@ -70,12 +70,13 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
let hasThinkingContent = false
|
||||
let thinkingStartTime = 0
|
||||
|
||||
let isFirstTextChunk = true
|
||||
let accumulatingText = false
|
||||
let accumulatedThinkingContent = ''
|
||||
const processedStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
logger.silly('chunk', chunk)
|
||||
|
||||
if (chunk.type === ChunkType.TEXT_DELTA) {
|
||||
const textChunk = chunk as TextDeltaChunk
|
||||
|
||||
@@ -84,6 +85,13 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
|
||||
for (const extractionResult of extractionResults) {
|
||||
if (extractionResult.complete && extractionResult.tagContentExtracted?.trim()) {
|
||||
// 完成思考
|
||||
// logger.silly(
|
||||
// 'since extractionResult.complete and extractionResult.tagContentExtracted is not empty, THINKING_COMPLETE chunk is generated'
|
||||
// )
|
||||
// 如果完成思考,更新状态
|
||||
accumulatingText = false
|
||||
|
||||
// 生成 THINKING_COMPLETE 事件
|
||||
const thinkingCompleteChunk: ThinkingCompleteChunk = {
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
@@ -96,7 +104,13 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
hasThinkingContent = false
|
||||
thinkingStartTime = 0
|
||||
} else if (extractionResult.content.length > 0) {
|
||||
// logger.silly(
|
||||
// 'since extractionResult.content is not empty, try to generate THINKING_START/THINKING_DELTA chunk'
|
||||
// )
|
||||
if (extractionResult.isTagContent) {
|
||||
// 如果提取到思考内容,更新状态
|
||||
accumulatingText = false
|
||||
|
||||
// 第一次接收到思考内容时记录开始时间
|
||||
if (!hasThinkingContent) {
|
||||
hasThinkingContent = true
|
||||
@@ -116,11 +130,17 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
controller.enqueue(thinkingDeltaChunk)
|
||||
}
|
||||
} else {
|
||||
if (isFirstTextChunk) {
|
||||
// 如果没有思考内容,直接输出文本
|
||||
// logger.silly(
|
||||
// 'since extractionResult.isTagContent is falsy, try to generate TEXT_START/TEXT_DELTA chunk'
|
||||
// )
|
||||
// 在非组成文本状态下接收到非思考内容时,生成 TEXT_START chunk 并更新状态
|
||||
if (!accumulatingText) {
|
||||
// logger.silly('since accumulatingText is false, TEXT_START chunk is generated')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
accumulatingText = true
|
||||
}
|
||||
// 发送清理后的文本内容
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
@@ -129,11 +149,20 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
controller.enqueue(cleanTextChunk)
|
||||
}
|
||||
} else {
|
||||
// logger.silly('since both condition is false, skip')
|
||||
}
|
||||
}
|
||||
} else if (chunk.type !== ChunkType.TEXT_START) {
|
||||
// logger.silly('since chunk.type is not TEXT_START and not TEXT_DELTA, pass through')
|
||||
|
||||
// logger.silly('since chunk.type is not TEXT_START and not TEXT_DELTA, accumulatingText is set to false')
|
||||
accumulatingText = false
|
||||
// 其他类型的chunk直接传递(包括 THINKING_DELTA, THINKING_COMPLETE 等)
|
||||
controller.enqueue(chunk)
|
||||
} else {
|
||||
// 接收到的 TEXT_START chunk 直接丢弃
|
||||
// logger.silly('since chunk.type is TEXT_START, passed')
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
|
||||
@@ -253,16 +253,20 @@ export async function buildStreamTextParams(
|
||||
|
||||
// 这三个变量透传出来,交给下面动态启用插件/中间件
|
||||
// 也可以在外部构建好再传入buildStreamTextParams
|
||||
// FIXME: qwen3即使关闭思考仍然会导致enableReasoning的结果为true
|
||||
const enableReasoning =
|
||||
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
|
||||
reasoning_effort !== undefined) ||
|
||||
(isReasoningModel(model) && !isSupportedThinkingTokenModel(model) && !isSupportedReasoningEffortModel(model))
|
||||
(isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model)))
|
||||
|
||||
const enableWebSearch =
|
||||
(assistant.enableWebSearch && isWebSearchModel(model)) ||
|
||||
isOpenRouterBuiltInWebSearchModel(model) ||
|
||||
model.id.includes('sonar') ||
|
||||
false
|
||||
|
||||
const enableUrlContext = assistant.enableUrlContext || false
|
||||
|
||||
const enableGenerateImage =
|
||||
isGenerateImageModel(model) &&
|
||||
(isSupportedDisableGenerationModel(model) ? assistant.enableGenerateImage || false : true)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -53,3 +53,18 @@
|
||||
animation-fill-mode: both;
|
||||
animation-duration: 0.25s;
|
||||
}
|
||||
|
||||
// 旋转动画
|
||||
@keyframes animation-rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animation-rotate {
|
||||
transform-origin: center;
|
||||
animation: animation-rotate 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Align lucide icon in Button
|
||||
.ant-btn .ant-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-tabs-tabpane:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -84,6 +91,14 @@
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
// Align lucide icon in dropdown menu item extra
|
||||
.ant-dropdown-menu-submenu-expand-icon,
|
||||
.ant-dropdown-menu-item-extra {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.ant-dropdown-arrow + .ant-dropdown-menu {
|
||||
border: none;
|
||||
@@ -96,6 +111,10 @@
|
||||
background-color: var(--ant-color-bg-elevated);
|
||||
overflow: hidden;
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
|
||||
.ant-dropdown-menu-submenu-title {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-popover {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
--color-border: #ffffff19;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
--color-error: #ff4d50;
|
||||
--color-link: #338cff;
|
||||
--color-code-background: #323232;
|
||||
--color-hover: rgba(40, 40, 40, 1);
|
||||
@@ -73,8 +73,8 @@
|
||||
|
||||
--list-item-border-radius: 10px;
|
||||
|
||||
--color-status-success: #52c41a;
|
||||
--color-status-error: #ff4d4f;
|
||||
--color-status-success: green;
|
||||
--color-status-error: var(--color-error);
|
||||
--color-status-warning: #faad14;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
--color-border: #00000019;
|
||||
--color-border-soft: #00000010;
|
||||
--color-border-mute: #00000005;
|
||||
--color-error: #f44336;
|
||||
--color-error: #ff4d50;
|
||||
--color-link: #1677ff;
|
||||
--color-code-background: #e3e3e3;
|
||||
--color-hover: var(--color-white-mute);
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.markdown-alert,
|
||||
blockquote {
|
||||
margin: 1.5em 0;
|
||||
padding: 1em 1.5em;
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
--color-scrollbar-thumb: var(--color-scrollbar-thumb-dark);
|
||||
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-dark-hover);
|
||||
|
||||
--scrollbar-width: 6px;
|
||||
--scrollbar-height: 6px;
|
||||
--scrollbar-thumb-radius: 10px;
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
@@ -15,8 +19,8 @@ body[theme-mode='light'] {
|
||||
|
||||
/* 全局初始化滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: var(--scrollbar-width);
|
||||
height: var(--scrollbar-height);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track,
|
||||
@@ -25,7 +29,7 @@ body[theme-mode='light'] {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
border-radius: var(--scrollbar-thumb-radius);
|
||||
background: var(--color-scrollbar-thumb);
|
||||
&:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
@@ -57,3 +61,17 @@ pre:not(.shiki)::-webkit-scrollbar-thumb {
|
||||
.hide-scrollbar * {
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
|
||||
/* FIXME: antd select 启用 popupMatchSelectWidth 时,会给虚拟列表叠加一个滚动条,
|
||||
* 前面的样式会被覆盖,因此在此强制统一样式。 */
|
||||
.rc-virtual-list-scrollbar {
|
||||
width: var(--scrollbar-width) !important;
|
||||
}
|
||||
|
||||
.rc-virtual-list-scrollbar-thumb {
|
||||
border-radius: var(--scrollbar-thumb-radius) !important;
|
||||
background: var(--color-scrollbar-thumb) !important;
|
||||
&:hover {
|
||||
background: var(--color-scrollbar-thumb-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import { useImageTools } from '@renderer/components/ActionTools'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: (key: string) => key
|
||||
},
|
||||
svgToPngBlob: vi.fn(),
|
||||
svgToSvgBlob: vi.fn(),
|
||||
download: vi.fn(),
|
||||
ImagePreviewService: {
|
||||
show: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/image', () => ({
|
||||
svgToPngBlob: mocks.svgToPngBlob,
|
||||
svgToSvgBlob: mocks.svgToSvgBlob
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/download', () => ({
|
||||
download: mocks.download
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/services/ImagePreviewService', () => ({
|
||||
ImagePreviewService: mocks.ImagePreviewService
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/context/ThemeProvider', () => ({
|
||||
useTheme: () => ({
|
||||
theme: 'light'
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock navigator.clipboard
|
||||
const mockWrite = vi.fn()
|
||||
|
||||
// Mock window.message
|
||||
const mockMessage = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
|
||||
// Mock ClipboardItem
|
||||
class MockClipboardItem {
|
||||
constructor(items: any) {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
// Mock URL
|
||||
const mockCreateObjectURL = vi.fn(() => 'blob:test-url')
|
||||
const mockRevokeObjectURL = vi.fn()
|
||||
|
||||
describe('useImageTools', () => {
|
||||
beforeEach(() => {
|
||||
// Setup global mocks
|
||||
Object.defineProperty(global.navigator, 'clipboard', {
|
||||
value: { write: mockWrite },
|
||||
writable: true
|
||||
})
|
||||
|
||||
Object.defineProperty(global.window, 'message', {
|
||||
value: mockMessage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
// Mock ClipboardItem
|
||||
global.ClipboardItem = MockClipboardItem as any
|
||||
|
||||
// Mock URL
|
||||
global.URL = {
|
||||
createObjectURL: mockCreateObjectURL,
|
||||
revokeObjectURL: mockRevokeObjectURL
|
||||
} as any
|
||||
|
||||
// Mock DOMMatrix
|
||||
global.DOMMatrix = class DOMMatrix {
|
||||
m41 = 0
|
||||
m42 = 0
|
||||
a = 1
|
||||
d = 1
|
||||
|
||||
constructor(transform?: string) {
|
||||
if (transform) {
|
||||
// 简单解析 translate(x, y)
|
||||
const translateMatch = transform.match(/translate\(([^,]+),\s*([^)]+)\)/)
|
||||
if (translateMatch) {
|
||||
this.m41 = parseFloat(translateMatch[1])
|
||||
this.m42 = parseFloat(translateMatch[2])
|
||||
}
|
||||
|
||||
// 解析 scale(s)
|
||||
const scaleMatch = transform.match(/scale\(([^)]+)\)/)
|
||||
if (scaleMatch) {
|
||||
const scaleValue = parseFloat(scaleMatch[1])
|
||||
this.a = scaleValue
|
||||
this.d = scaleValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static fromMatrix() {
|
||||
return new DOMMatrix()
|
||||
}
|
||||
} as any
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// 创建模拟的 DOM 环境
|
||||
const createMockContainer = () => {
|
||||
const mockContainer = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
contains: vi.fn().mockReturnValue(true),
|
||||
style: {
|
||||
cursor: ''
|
||||
},
|
||||
querySelector: vi.fn(),
|
||||
shadowRoot: null
|
||||
} as unknown as HTMLDivElement
|
||||
|
||||
return mockContainer
|
||||
}
|
||||
|
||||
const createMockSvgElement = () => {
|
||||
const mockSvg = {
|
||||
style: {
|
||||
transform: '',
|
||||
transformOrigin: ''
|
||||
},
|
||||
cloneNode: vi.fn().mockReturnThis()
|
||||
} as unknown as SVGElement
|
||||
|
||||
return mockSvg
|
||||
}
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default scale', () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const transform = result.current.getCurrentTransform()
|
||||
expect(transform.scale).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pan function', () => {
|
||||
it('should pan with relative and absolute coordinates', () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 相对坐标平移
|
||||
act(() => {
|
||||
result.current.pan(10, 20)
|
||||
})
|
||||
expect(mockSvg.style.transform).toContain('translate(10px, 20px)')
|
||||
|
||||
// 绝对坐标平移
|
||||
act(() => {
|
||||
result.current.pan(50, 60, true)
|
||||
})
|
||||
expect(mockSvg.style.transform).toContain('translate(50px, 60px)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom function', () => {
|
||||
it('should zoom in/out and set absolute zoom level', () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 放大
|
||||
act(() => {
|
||||
result.current.zoom(0.5)
|
||||
})
|
||||
expect(result.current.getCurrentTransform().scale).toBe(1.5)
|
||||
expect(mockSvg.style.transform).toContain('scale(1.5)')
|
||||
|
||||
// 缩小
|
||||
act(() => {
|
||||
result.current.zoom(-0.3)
|
||||
})
|
||||
expect(result.current.getCurrentTransform().scale).toBe(1.2)
|
||||
expect(mockSvg.style.transform).toContain('scale(1.2)')
|
||||
|
||||
// 设置绝对缩放级别
|
||||
act(() => {
|
||||
result.current.zoom(2.5, true)
|
||||
})
|
||||
expect(result.current.getCurrentTransform().scale).toBe(2.5)
|
||||
})
|
||||
|
||||
it('should constrain zoom between 0.1 and 3', () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 尝试过度缩小
|
||||
act(() => {
|
||||
result.current.zoom(-10)
|
||||
})
|
||||
expect(result.current.getCurrentTransform().scale).toBe(0.1)
|
||||
|
||||
// 尝试过度放大
|
||||
act(() => {
|
||||
result.current.zoom(10)
|
||||
})
|
||||
expect(result.current.getCurrentTransform().scale).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy and download functions', () => {
|
||||
it('should copy image to clipboard successfully', async () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
// Mock svgToPngBlob to return a blob
|
||||
const mockBlob = new Blob(['test'], { type: 'image/png' })
|
||||
mocks.svgToPngBlob.mockResolvedValue(mockBlob)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.copy()
|
||||
})
|
||||
|
||||
expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvg)
|
||||
expect(mockWrite).toHaveBeenCalled()
|
||||
expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success')
|
||||
})
|
||||
|
||||
it('should download image as PNG and SVG', async () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
// Mock svgToPngBlob to return a blob
|
||||
const pngBlob = new Blob(['test'], { type: 'image/png' })
|
||||
mocks.svgToPngBlob.mockResolvedValue(pngBlob)
|
||||
|
||||
// Mock svgToSvgBlob to return a blob
|
||||
const svgBlob = new Blob(['<svg></svg>'], { type: 'image/svg+xml' })
|
||||
mocks.svgToSvgBlob.mockReturnValue(svgBlob)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 下载 PNG
|
||||
await act(async () => {
|
||||
await result.current.download('png')
|
||||
})
|
||||
expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvg)
|
||||
|
||||
// 下载 SVG
|
||||
await act(async () => {
|
||||
await result.current.download('svg')
|
||||
})
|
||||
expect(mocks.svgToSvgBlob).toHaveBeenCalledWith(mockSvg)
|
||||
|
||||
// 验证通用的下载流程
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.download).toHaveBeenCalledTimes(2)
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle copy/download failures and missing elements', async () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
|
||||
// 测试无元素情况
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(null)
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 复制无元素
|
||||
await act(async () => {
|
||||
await result.current.copy()
|
||||
})
|
||||
expect(mocks.svgToPngBlob).not.toHaveBeenCalled()
|
||||
|
||||
// 下载无元素
|
||||
await act(async () => {
|
||||
await result.current.download('png')
|
||||
})
|
||||
expect(mocks.svgToPngBlob).not.toHaveBeenCalled()
|
||||
|
||||
// 测试失败情况
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
mocks.svgToPngBlob.mockRejectedValue(new Error('Conversion failed'))
|
||||
|
||||
// 复制失败
|
||||
await act(async () => {
|
||||
await result.current.copy()
|
||||
})
|
||||
expect(mockMessage.error).toHaveBeenCalledWith('message.copy.failed')
|
||||
|
||||
// 下载失败
|
||||
await act(async () => {
|
||||
await result.current.download('png')
|
||||
})
|
||||
expect(mockMessage.error).toHaveBeenCalledWith('message.download.failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dialog function', () => {
|
||||
it('should preview image successfully', async () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
mocks.ImagePreviewService.show.mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dialog()
|
||||
})
|
||||
|
||||
expect(mocks.ImagePreviewService.show).toHaveBeenCalledWith(mockSvg, { format: 'svg' })
|
||||
})
|
||||
|
||||
it('should handle preview failure', async () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
mocks.ImagePreviewService.show.mockRejectedValue(new Error('Preview failed'))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dialog()
|
||||
})
|
||||
|
||||
expect(mockMessage.error).toHaveBeenCalledWith('message.dialog.failed')
|
||||
})
|
||||
|
||||
it('should do nothing when no element is found', async () => {
|
||||
const mockContainer = createMockContainer()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dialog()
|
||||
})
|
||||
|
||||
expect(mocks.ImagePreviewService.show).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event listener management', () => {
|
||||
it('should attach/remove event listeners based on options', () => {
|
||||
const mockContainer = createMockContainer()
|
||||
|
||||
// 启用拖拽和滚轮缩放
|
||||
renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg',
|
||||
enableDrag: true,
|
||||
enableWheelZoom: true
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockContainer.addEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function))
|
||||
expect(mockContainer.addEventListener).toHaveBeenCalledWith('wheel', expect.any(Function), { passive: true })
|
||||
|
||||
// 重置并测试禁用情况
|
||||
vi.clearAllMocks()
|
||||
|
||||
renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg',
|
||||
enableDrag: false,
|
||||
enableWheelZoom: false
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockContainer.addEventListener).not.toHaveBeenCalledWith('mousedown', expect.any(Function))
|
||||
expect(mockContainer.addEventListener).not.toHaveBeenCalledWith('wheel', expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCurrentTransform function', () => {
|
||||
it('should return current scale and position', () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 初始状态
|
||||
const initialTransform = result.current.getCurrentTransform()
|
||||
expect(initialTransform).toEqual({ scale: 1, x: 0, y: 0 })
|
||||
|
||||
// 缩放后状态
|
||||
act(() => {
|
||||
result.current.zoom(0.5)
|
||||
})
|
||||
const zoomedTransform = result.current.getCurrentTransform()
|
||||
expect(zoomedTransform.scale).toBe(1.5)
|
||||
expect(zoomedTransform.x).toBe(0)
|
||||
expect(zoomedTransform.y).toBe(0)
|
||||
|
||||
// 平移后状态
|
||||
act(() => {
|
||||
result.current.pan(10, 20)
|
||||
})
|
||||
const pannedTransform = result.current.getCurrentTransform()
|
||||
expect(pannedTransform.scale).toBe(1.5)
|
||||
expect(pannedTransform.x).toBe(10)
|
||||
expect(pannedTransform.y).toBe(20)
|
||||
})
|
||||
|
||||
it('should get position from DOMMatrix when element has transform', () => {
|
||||
const mockContainer = createMockContainer()
|
||||
const mockSvg = createMockSvgElement()
|
||||
mockSvg.style.transform = 'translate(30px, 40px) scale(2)'
|
||||
mockContainer.querySelector = vi.fn().mockReturnValue(mockSvg)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useImageTools(
|
||||
{ current: mockContainer },
|
||||
{
|
||||
prefix: 'test',
|
||||
imgSelector: 'svg'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 手动设置 transformRef 以匹配 DOM 状态
|
||||
act(() => {
|
||||
result.current.pan(30, 40, true)
|
||||
result.current.zoom(2, true)
|
||||
})
|
||||
|
||||
const transform = result.current.getCurrentTransform()
|
||||
expect(transform.scale).toBe(2)
|
||||
expect(transform.x).toBe(30)
|
||||
expect(transform.y).toBe(40)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import { ActionTool, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// 创建测试工具数据
|
||||
const createTestTool = (overrides: Partial<ActionTool> = {}): ActionTool => ({
|
||||
id: 'test-tool',
|
||||
type: 'core',
|
||||
order: 10,
|
||||
icon: 'TestIcon',
|
||||
tooltip: 'Test Tool',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('useToolManager', () => {
|
||||
describe('registerTool', () => {
|
||||
it('should register a new tool', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [tools, setTools] = useState<ActionTool[]>([])
|
||||
const { registerTool } = useToolManager(setTools)
|
||||
return { tools, registerTool }
|
||||
})
|
||||
|
||||
const testTool = createTestTool()
|
||||
|
||||
act(() => {
|
||||
result.current.registerTool(testTool)
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(1)
|
||||
expect(result.current.tools[0]).toEqual(testTool)
|
||||
})
|
||||
|
||||
it('should replace existing tool with same id', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [tools, setTools] = useState<ActionTool[]>([])
|
||||
const { registerTool } = useToolManager(setTools)
|
||||
return { tools, registerTool }
|
||||
})
|
||||
|
||||
const originalTool = createTestTool({ tooltip: 'Original' })
|
||||
const updatedTool = createTestTool({ tooltip: 'Updated' })
|
||||
|
||||
act(() => {
|
||||
result.current.registerTool(originalTool)
|
||||
result.current.registerTool(updatedTool)
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(1)
|
||||
expect(result.current.tools[0]).toEqual(updatedTool)
|
||||
})
|
||||
|
||||
it('should sort tools by order (descending)', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [tools, setTools] = useState<ActionTool[]>([])
|
||||
const { registerTool } = useToolManager(setTools)
|
||||
return { tools, registerTool }
|
||||
})
|
||||
|
||||
const tool1 = createTestTool({ id: 'tool1', order: 10 })
|
||||
const tool2 = createTestTool({ id: 'tool2', order: 30 })
|
||||
const tool3 = createTestTool({ id: 'tool3', order: 20 })
|
||||
|
||||
act(() => {
|
||||
result.current.registerTool(tool1)
|
||||
result.current.registerTool(tool2)
|
||||
result.current.registerTool(tool3)
|
||||
})
|
||||
|
||||
// 应该按 order 降序排列
|
||||
expect(result.current.tools[0].id).toBe('tool2') // order: 30
|
||||
expect(result.current.tools[1].id).toBe('tool3') // order: 20
|
||||
expect(result.current.tools[2].id).toBe('tool1') // order: 10
|
||||
})
|
||||
|
||||
it('should handle tools with children', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [tools, setTools] = useState<ActionTool[]>([])
|
||||
const { registerTool } = useToolManager(setTools)
|
||||
return { tools, registerTool }
|
||||
})
|
||||
|
||||
const childTool = createTestTool({ id: 'child-tool', order: 5 })
|
||||
const parentTool = createTestTool({
|
||||
id: 'parent-tool',
|
||||
order: 15,
|
||||
children: [childTool]
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.registerTool(parentTool)
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(1)
|
||||
expect(result.current.tools[0]).toEqual(parentTool)
|
||||
expect(result.current.tools[0].children).toEqual([childTool])
|
||||
})
|
||||
|
||||
it('should not modify state if setTools is not provided', () => {
|
||||
const { result } = renderHook(() => useToolManager(undefined))
|
||||
|
||||
// 不应该抛出错误
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.registerTool(createTestTool())
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeTool', () => {
|
||||
it('should remove tool by id', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [tools, setTools] = useState<ActionTool[]>([createTestTool()])
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
return { tools, registerTool, removeTool }
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
result.current.removeTool('test-tool')
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not affect other tools when removing one', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const toolsData = [
|
||||
createTestTool({ id: 'tool1' }),
|
||||
createTestTool({ id: 'tool2' }),
|
||||
createTestTool({ id: 'tool3' })
|
||||
]
|
||||
const [tools, setTools] = useState<ActionTool[]>(toolsData)
|
||||
const { removeTool } = useToolManager(setTools)
|
||||
return { tools, removeTool }
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(3)
|
||||
|
||||
act(() => {
|
||||
result.current.removeTool('tool2')
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(2)
|
||||
expect(result.current.tools[0].id).toBe('tool1')
|
||||
expect(result.current.tools[1].id).toBe('tool3')
|
||||
})
|
||||
|
||||
it('should handle removing non-existent tool', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [tools, setTools] = useState<ActionTool[]>([createTestTool()])
|
||||
const { removeTool } = useToolManager(setTools)
|
||||
return { tools, removeTool }
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
result.current.removeTool('non-existent-tool')
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(1) // 应该没有变化
|
||||
})
|
||||
|
||||
it('should not modify state if setTools is not provided', () => {
|
||||
const { result } = renderHook(() => useToolManager(undefined))
|
||||
|
||||
// 不应该抛出错误
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.removeTool('test-tool')
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration', () => {
|
||||
it('should handle register and remove operations together', () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [tools, setTools] = useState<ActionTool[]>([])
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
return { tools, registerTool, removeTool }
|
||||
})
|
||||
|
||||
const tool1 = createTestTool({ id: 'tool1' })
|
||||
const tool2 = createTestTool({ id: 'tool2' })
|
||||
|
||||
// 注册两个工具
|
||||
act(() => {
|
||||
result.current.registerTool(tool1)
|
||||
result.current.registerTool(tool2)
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(2)
|
||||
|
||||
// 移除一个工具
|
||||
act(() => {
|
||||
result.current.removeTool('tool1')
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(1)
|
||||
expect(result.current.tools[0].id).toBe('tool2')
|
||||
|
||||
// 再次注册被移除的工具
|
||||
act(() => {
|
||||
result.current.registerTool(tool1)
|
||||
})
|
||||
|
||||
expect(result.current.tools).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { CodeToolSpec } from './types'
|
||||
import { ActionToolSpec } from './types'
|
||||
|
||||
export const TOOL_SPECS: Record<string, CodeToolSpec> = {
|
||||
export const TOOL_SPECS: Record<string, ActionToolSpec> = {
|
||||
// Core tools
|
||||
copy: {
|
||||
id: 'copy',
|
||||
@@ -0,0 +1,292 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ImagePreviewService } from '@renderer/services/ImagePreviewService'
|
||||
import { download as downloadFile } from '@renderer/utils/download'
|
||||
import { svgToPngBlob, svgToSvgBlob } from '@renderer/utils/image'
|
||||
import { RefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('usePreviewToolHandlers')
|
||||
|
||||
/**
|
||||
* 使用图像处理工具的自定义Hook
|
||||
* 提供图像缩放、复制和下载功能
|
||||
*/
|
||||
export const useImageTools = (
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
options: {
|
||||
prefix: string
|
||||
imgSelector: string
|
||||
enableDrag?: boolean
|
||||
enableWheelZoom?: boolean
|
||||
}
|
||||
) => {
|
||||
const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态
|
||||
const { imgSelector, prefix, enableDrag, enableWheelZoom } = options
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
|
||||
// 创建选择器函数
|
||||
const getImgElement = useCallback(() => {
|
||||
if (!containerRef.current) return null
|
||||
|
||||
// 优先尝试从 Shadow DOM 中查找
|
||||
const shadowRoot = containerRef.current.shadowRoot
|
||||
if (shadowRoot) {
|
||||
return shadowRoot.querySelector(imgSelector) as SVGElement | null
|
||||
}
|
||||
|
||||
// 降级到常规 DOM 查找
|
||||
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
||||
}, [containerRef, imgSelector])
|
||||
|
||||
// 获取原始图像元素(移除所有变换)
|
||||
const getCleanImgElement = useCallback((): SVGElement | null => {
|
||||
const imgElement = getImgElement()
|
||||
if (!imgElement) return null
|
||||
|
||||
const clonedElement = imgElement.cloneNode(true) as SVGElement
|
||||
clonedElement.style.transform = ''
|
||||
clonedElement.style.transformOrigin = ''
|
||||
return clonedElement
|
||||
}, [getImgElement])
|
||||
|
||||
// 查询当前位置
|
||||
const getCurrentPosition = useCallback(() => {
|
||||
const imgElement = getImgElement()
|
||||
if (!imgElement) return transformRef.current
|
||||
|
||||
const transform = imgElement.style.transform
|
||||
if (!transform || transform === 'none') return transformRef.current
|
||||
|
||||
// 使用CSS矩阵解析
|
||||
const matrix = new DOMMatrix(transform)
|
||||
return { x: matrix.m41, y: matrix.m42 }
|
||||
}, [getImgElement])
|
||||
|
||||
/**
|
||||
* 平移缩放变换
|
||||
* @param element 要应用变换的元素
|
||||
* @param x X轴偏移量
|
||||
* @param y Y轴偏移量
|
||||
* @param scale 缩放比例
|
||||
*/
|
||||
const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => {
|
||||
if (!element) return
|
||||
element.style.transformOrigin = 'top left'
|
||||
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 平移函数 - 按指定方向和距离移动图像
|
||||
* @param dx X轴偏移量(正数向右,负数向左)
|
||||
* @param dy Y轴偏移量(正数向下,负数向上)
|
||||
* @param absolute 是否为绝对位置(true)或相对偏移(false)
|
||||
*/
|
||||
const pan = useCallback(
|
||||
(dx: number, dy: number, absolute = false) => {
|
||||
const currentPos = getCurrentPosition()
|
||||
const newX = absolute ? dx : currentPos.x + dx
|
||||
const newY = absolute ? dy : currentPos.y + dy
|
||||
|
||||
transformRef.current.x = newX
|
||||
transformRef.current.y = newY
|
||||
|
||||
const imgElement = getImgElement()
|
||||
applyTransform(imgElement, newX, newY, transformRef.current.scale)
|
||||
},
|
||||
[getCurrentPosition, getImgElement, applyTransform]
|
||||
)
|
||||
|
||||
// 拖拽平移支持
|
||||
useEffect(() => {
|
||||
if (!enableDrag || !containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
const startPos = { x: 0, y: 0 }
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - startPos.x
|
||||
const dy = e.clientY - startPos.y
|
||||
|
||||
// 直接使用 transformRef 中的初始偏移量进行计算
|
||||
const newX = transformRef.current.x + dx
|
||||
const newY = transformRef.current.y + dy
|
||||
|
||||
const imgElement = getImgElement()
|
||||
// 实时应用变换,但不更新 ref,避免累积误差
|
||||
applyTransform(imgElement, newX, newY, transformRef.current.scale)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
|
||||
container.style.cursor = 'default'
|
||||
|
||||
// 拖拽结束后,计算最终位置并更新 ref
|
||||
const dx = e.clientX - startPos.x
|
||||
const dy = e.clientY - startPos.y
|
||||
transformRef.current.x += dx
|
||||
transformRef.current.y += dy
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return // 只响应左键
|
||||
|
||||
// 每次拖拽开始时,都以 ref 中当前的位置为基准
|
||||
const currentPos = getCurrentPosition()
|
||||
transformRef.current.x = currentPos.x
|
||||
transformRef.current.y = currentPos.y
|
||||
|
||||
startPos.x = e.clientX
|
||||
startPos.y = e.clientY
|
||||
|
||||
container.style.cursor = 'grabbing'
|
||||
e.preventDefault()
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
container.addEventListener('mousedown', handleMouseDown)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousedown', handleMouseDown)
|
||||
// 清理以防万一,例如组件在拖拽过程中被卸载
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [containerRef, getImgElement, applyTransform, getCurrentPosition, enableDrag])
|
||||
|
||||
/**
|
||||
* 缩放
|
||||
* @param delta 缩放增量(正值放大,负值缩小)
|
||||
*/
|
||||
const zoom = useCallback(
|
||||
(delta: number, absolute = false) => {
|
||||
const newScale = absolute
|
||||
? Math.max(0.1, Math.min(3, delta))
|
||||
: Math.max(0.1, Math.min(3, transformRef.current.scale + delta))
|
||||
|
||||
transformRef.current.scale = newScale
|
||||
|
||||
const imgElement = getImgElement()
|
||||
applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale)
|
||||
},
|
||||
[getImgElement, applyTransform]
|
||||
)
|
||||
|
||||
// 滚轮缩放支持
|
||||
useEffect(() => {
|
||||
if (!enableWheelZoom || !containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.target) {
|
||||
// 确认事件发生在容器内部
|
||||
if (container.contains(e.target as Node)) {
|
||||
const delta = e.deltaY < 0 ? 0.1 : -0.1
|
||||
zoom(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: true })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [containerRef, zoom, enableWheelZoom])
|
||||
|
||||
/**
|
||||
* 复制图像
|
||||
*
|
||||
* 目前使用了清理变换后的图像,因此不适用于画布
|
||||
*/
|
||||
const copy = useCallback(async () => {
|
||||
try {
|
||||
const imgElement = getCleanImgElement()
|
||||
if (!imgElement) return
|
||||
|
||||
const blob = await svgToPngBlob(imgElement)
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
window.message.success(t('message.copy.success'))
|
||||
} catch (error) {
|
||||
logger.error('Copy failed:', error as Error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
}, [getCleanImgElement, t])
|
||||
|
||||
/**
|
||||
* 下载图像
|
||||
*
|
||||
* 目前使用了清理变换后的图像,因此不适用于画布
|
||||
*/
|
||||
const download = useCallback(
|
||||
async (format: 'svg' | 'png') => {
|
||||
try {
|
||||
const imgElement = getCleanImgElement()
|
||||
if (!imgElement) return
|
||||
|
||||
const timestamp = Date.now()
|
||||
|
||||
if (format === 'svg') {
|
||||
const blob = svgToSvgBlob(imgElement)
|
||||
const url = URL.createObjectURL(blob)
|
||||
downloadFile(url, `${prefix}-${timestamp}.svg`)
|
||||
URL.revokeObjectURL(url)
|
||||
} else {
|
||||
const blob = await svgToPngBlob(imgElement)
|
||||
const pngUrl = URL.createObjectURL(blob)
|
||||
downloadFile(pngUrl, `${prefix}-${timestamp}.png`)
|
||||
URL.revokeObjectURL(pngUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Download failed:', error as Error)
|
||||
window.message.error(t('message.download.failed'))
|
||||
}
|
||||
},
|
||||
[getCleanImgElement, prefix, t]
|
||||
)
|
||||
|
||||
/**
|
||||
* 预览 dialog
|
||||
*
|
||||
* 目前使用了清理变换后的图像,因此不适用于画布
|
||||
*/
|
||||
const dialog = useCallback(async () => {
|
||||
try {
|
||||
const imgElement = getCleanImgElement()
|
||||
if (!imgElement) return
|
||||
|
||||
await ImagePreviewService.show(imgElement, { format: 'svg' })
|
||||
} catch (error) {
|
||||
logger.error('Dialog preview failed:', error as Error)
|
||||
window.message.error(t('message.dialog.failed'))
|
||||
}
|
||||
}, [getCleanImgElement, t])
|
||||
|
||||
// 获取当前变换状态
|
||||
const getCurrentTransform = useCallback(() => {
|
||||
return {
|
||||
scale: transformRef.current.scale,
|
||||
x: transformRef.current.x,
|
||||
y: transformRef.current.y
|
||||
}
|
||||
}, [transformRef])
|
||||
|
||||
// 切换主题时重置变换
|
||||
useEffect(() => {
|
||||
pan(0, 0, true)
|
||||
zoom(1, true)
|
||||
}, [pan, zoom, theme])
|
||||
|
||||
return {
|
||||
zoom,
|
||||
pan,
|
||||
copy,
|
||||
download,
|
||||
dialog,
|
||||
getCurrentTransform
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { CodeTool } from './types'
|
||||
import { ActionTool, ToolRegisterProps } from '../types'
|
||||
|
||||
export const useCodeTool = (setTools?: (value: React.SetStateAction<CodeTool[]>) => void) => {
|
||||
export const useToolManager = (setTools?: ToolRegisterProps['setTools']) => {
|
||||
// 注册工具,如果已存在同ID工具则替换
|
||||
const registerTool = useCallback(
|
||||
(tool: CodeTool) => {
|
||||
(tool: ActionTool) => {
|
||||
setTools?.((prev) => {
|
||||
const filtered = prev.filter((t) => t.id !== tool.id)
|
||||
return [...filtered, tool].sort((a, b) => b.order - a.order)
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './constants'
|
||||
export * from './hooks/useImageTools'
|
||||
export * from './hooks/useToolManager'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 动作工具基本信息
|
||||
*/
|
||||
export interface ActionToolSpec {
|
||||
id: string
|
||||
type: 'core' | 'quick'
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作工具定义接口
|
||||
* @param id 唯一标识符
|
||||
* @param type 工具类型
|
||||
* @param order 显示顺序,越小越靠右
|
||||
* @param icon 按钮图标
|
||||
* @param tooltip 提示文本
|
||||
* @param visible 显示条件
|
||||
* @param onClick 点击动作
|
||||
* @param children 子工具(例如 more 下拉菜单)
|
||||
*/
|
||||
export interface ActionTool extends ActionToolSpec {
|
||||
icon: React.ReactNode
|
||||
tooltip?: string
|
||||
visible?: () => boolean
|
||||
onClick?: () => void
|
||||
children?: Omit<ActionTool, 'children'>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 子组件向父组件注册工具所需的 props
|
||||
*/
|
||||
export interface ToolRegisterProps {
|
||||
setTools?: (value: React.SetStateAction<ActionTool[]>) => void
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
|
||||
import { Flex, Spin } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
// 管理 viz 实例
|
||||
const vizInitializer = new AsyncInitializer(async () => {
|
||||
const module = await import('@viz-js/viz')
|
||||
return await module.instance()
|
||||
})
|
||||
|
||||
/** 预览 Graphviz 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
*/
|
||||
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const graphvizRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
|
||||
imgSelector: 'svg',
|
||||
prefix: 'graphviz',
|
||||
enableWheelZoom: true
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
// 实际的渲染函数
|
||||
const renderGraphviz = useCallback(async (content: string) => {
|
||||
if (!content || !graphvizRef.current) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const viz = await vizInitializer.get()
|
||||
const svgElement = viz.renderSVGElement(content)
|
||||
|
||||
// 清空容器并添加新的 SVG
|
||||
graphvizRef.current.innerHTML = ''
|
||||
graphvizRef.current.appendChild(svgElement)
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message || 'DOT syntax error or rendering failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderGraphviz(content))
|
||||
}, 300),
|
||||
[renderGraphviz]
|
||||
)
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (children) {
|
||||
setIsLoading(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, debouncedRender])
|
||||
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{error && <PreviewError>{error}</PreviewError>}
|
||||
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledGraphviz = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default memo(GraphvizPreview)
|
||||
@@ -22,45 +22,51 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
const [currentHtml, setCurrentHtml] = useState(html)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// 预览刷新相关状态
|
||||
// Preview refresh related state
|
||||
const [previewHtml, setPreviewHtml] = useState(html)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const latestHtmlRef = useRef(html)
|
||||
const currentPreviewHtmlRef = useRef(html)
|
||||
|
||||
// 当外部html更新时,同步更新内部状态
|
||||
// Sync internal state when external html updates
|
||||
useEffect(() => {
|
||||
setCurrentHtml(html)
|
||||
latestHtmlRef.current = html
|
||||
}, [html])
|
||||
|
||||
// 当内部编辑的html更新时,更新引用
|
||||
// Update reference when internally edited html changes
|
||||
useEffect(() => {
|
||||
latestHtmlRef.current = currentHtml
|
||||
}, [currentHtml])
|
||||
|
||||
// 2秒定时检查并刷新预览(仅在内容变化时)
|
||||
// Update reference when preview content changes
|
||||
useEffect(() => {
|
||||
currentPreviewHtmlRef.current = previewHtml
|
||||
}, [previewHtml])
|
||||
|
||||
// Check and refresh preview every 2 seconds (only when content changes)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
// 立即设置初始预览内容
|
||||
setPreviewHtml(currentHtml)
|
||||
// Set initial preview content immediately
|
||||
setPreviewHtml(latestHtmlRef.current)
|
||||
|
||||
// 设置定时器,每2秒检查一次内容是否有变化
|
||||
// Set timer to check for content changes every 2 seconds
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (latestHtmlRef.current !== previewHtml) {
|
||||
if (latestHtmlRef.current !== currentPreviewHtmlRef.current) {
|
||||
setPreviewHtml(latestHtmlRef.current)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// 清理函数
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [currentHtml, open, previewHtml])
|
||||
}, [open])
|
||||
|
||||
// 全屏时防止 body 滚动
|
||||
// Prevent body scroll when fullscreen
|
||||
useEffect(() => {
|
||||
if (!open || !isFullscreen) return
|
||||
|
||||
@@ -127,7 +133,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
open={open}
|
||||
afterClose={onClose}
|
||||
centered={!isFullscreen}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
mask={!isFullscreen}
|
||||
maskClosable={false}
|
||||
width={isFullscreen ? '100vw' : '90vw'}
|
||||
@@ -147,9 +153,10 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
editable={true}
|
||||
onSave={setCurrentHtml}
|
||||
style={{ height: '100%' }}
|
||||
expanded
|
||||
unwrapped={false}
|
||||
options={{
|
||||
stream: false,
|
||||
collapsible: false
|
||||
stream: false
|
||||
}}
|
||||
/>
|
||||
</CodeSection>
|
||||
@@ -159,7 +166,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
<PreviewSection>
|
||||
{previewHtml.trim() ? (
|
||||
<PreviewFrame
|
||||
key={previewHtml} // 强制重新创建iframe当预览内容变化时
|
||||
key={previewHtml} // Force recreate iframe when preview content changes
|
||||
srcDoc={previewHtml}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
@@ -176,7 +183,6 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
)
|
||||
}
|
||||
|
||||
// 简化的样式组件
|
||||
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
${(props) =>
|
||||
props.$isFullscreen
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { Flex, Spin } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/** 预览 Mermaid 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
||||
*/
|
||||
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isRendering, setIsRendering] = useState(false)
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, {
|
||||
imgSelector: 'svg',
|
||||
prefix: 'mermaid',
|
||||
enableWheelZoom: true
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
// 实际的渲染函数
|
||||
const renderMermaid = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content || !mermaidRef.current) return
|
||||
|
||||
try {
|
||||
setIsRendering(true)
|
||||
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(content)
|
||||
|
||||
const { svg } = await mermaid.render(diagramId, content, mermaidRef.current)
|
||||
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
mermaidRef.current.innerHTML = fixedSvg
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message)
|
||||
} finally {
|
||||
setIsRendering(false)
|
||||
}
|
||||
},
|
||||
[diagramId, mermaid]
|
||||
)
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderMermaid(content))
|
||||
}, 300),
|
||||
[renderMermaid]
|
||||
)
|
||||
|
||||
/**
|
||||
* 监听可见性变化,用于触发重新渲染。
|
||||
* 这是为了解决 `MessageGroup` 组件的 `fold` 布局中被 `display: none` 隐藏的图标无法正确渲染的问题。
|
||||
* 监听时向上遍历到第一个有 `fold` className 的父节点为止(也就是目前的 `MessageWrapper`)。
|
||||
* FIXME: 将来 mermaid-js 修复此问题后可以移除这里的相关逻辑。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!mermaidRef.current) return
|
||||
|
||||
const checkVisibility = () => {
|
||||
const element = mermaidRef.current
|
||||
if (!element) return
|
||||
|
||||
const currentlyVisible = element.offsetParent !== null
|
||||
setIsVisible(currentlyVisible)
|
||||
}
|
||||
|
||||
// 初始检查
|
||||
checkVisibility()
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
checkVisibility()
|
||||
})
|
||||
|
||||
let targetElement = mermaidRef.current.parentElement
|
||||
while (targetElement) {
|
||||
observer.observe(targetElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style']
|
||||
})
|
||||
|
||||
if (targetElement.className?.includes('fold')) {
|
||||
break
|
||||
}
|
||||
|
||||
targetElement = targetElement.parentElement
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (isLoadingMermaid) return
|
||||
|
||||
if (mermaidRef.current?.offsetParent === null) return
|
||||
|
||||
if (children) {
|
||||
setIsRendering(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsRendering(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, isLoadingMermaid, debouncedRender, isVisible])
|
||||
|
||||
const isLoading = isLoadingMermaid || isRendering
|
||||
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid special-preview" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledMermaid = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default memo(MermaidPreview)
|
||||
@@ -1,192 +0,0 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { Spin } from 'antd'
|
||||
import pako from 'pako'
|
||||
import React, { memo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
|
||||
function encode64(data: Uint8Array) {
|
||||
let r = ''
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
if (i + 2 === data.length) {
|
||||
r += append3bytes(data[i], data[i + 1], 0)
|
||||
} else if (i + 1 === data.length) {
|
||||
r += append3bytes(data[i], 0, 0)
|
||||
} else {
|
||||
r += append3bytes(data[i], data[i + 1], data[i + 2])
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
function encode6bit(b: number) {
|
||||
if (b < 10) {
|
||||
return String.fromCharCode(48 + b)
|
||||
}
|
||||
b -= 10
|
||||
if (b < 26) {
|
||||
return String.fromCharCode(65 + b)
|
||||
}
|
||||
b -= 26
|
||||
if (b < 26) {
|
||||
return String.fromCharCode(97 + b)
|
||||
}
|
||||
b -= 26
|
||||
if (b === 0) {
|
||||
return '-'
|
||||
}
|
||||
if (b === 1) {
|
||||
return '_'
|
||||
}
|
||||
return '?'
|
||||
}
|
||||
|
||||
function append3bytes(b1: number, b2: number, b3: number) {
|
||||
const c1 = b1 >> 2
|
||||
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
|
||||
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
|
||||
const c4 = b3 & 0x3f
|
||||
let r = ''
|
||||
r += encode6bit(c1 & 0x3f)
|
||||
r += encode6bit(c2 & 0x3f)
|
||||
r += encode6bit(c3 & 0x3f)
|
||||
r += encode6bit(c4 & 0x3f)
|
||||
return r
|
||||
}
|
||||
/**
|
||||
* https://plantuml.com/zh/code-javascript-synchronous
|
||||
* To use PlantUML image generation, a text diagram description have to be :
|
||||
1. Encoded in UTF-8
|
||||
2. Compressed using Deflate algorithm
|
||||
3. Reencoded in ASCII using a transformation _close_ to base64
|
||||
*/
|
||||
function encodeDiagram(diagram: string): string {
|
||||
const utf8text = new TextEncoder().encode(diagram)
|
||||
const compressed = pako.deflateRaw(utf8text)
|
||||
return encode64(compressed)
|
||||
}
|
||||
|
||||
async function downloadUrl(url: string, filename: string) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
window.message.warning({ content: response.statusText, duration: 1.5 })
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
type PlantUMLServerImageProps = {
|
||||
format: 'png' | 'svg'
|
||||
diagram: string
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
className?: string
|
||||
}
|
||||
|
||||
function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) {
|
||||
const encodedDiagram = encodeDiagram(diagram)
|
||||
if (isDark) {
|
||||
return `${PlantUMLServer}/d${format}/${encodedDiagram}`
|
||||
}
|
||||
return `${PlantUMLServer}/${format}/${encodedDiagram}`
|
||||
}
|
||||
|
||||
const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagram, onClick, className }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
// FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景
|
||||
const url = getPlantUMLImageUrl(format, diagram, false)
|
||||
return (
|
||||
<StyledPlantUML onClick={onClick} className={className}>
|
||||
<Spin
|
||||
spinning={loading}
|
||||
indicator={
|
||||
<LoadingOutlined
|
||||
spin
|
||||
style={{
|
||||
fontSize: 32
|
||||
}}
|
||||
/>
|
||||
}>
|
||||
<img
|
||||
src={url}
|
||||
onLoad={() => {
|
||||
setLoading(false)
|
||||
}}
|
||||
onError={(e) => {
|
||||
setLoading(false)
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.opacity = '0.5'
|
||||
target.style.filter = 'blur(2px)'
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</StyledPlantUML>
|
||||
)
|
||||
}
|
||||
|
||||
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const encodedDiagram = encodeDiagram(children)
|
||||
|
||||
// 自定义 PlantUML 下载方法
|
||||
const customDownload = useCallback(
|
||||
(format: 'svg' | 'png') => {
|
||||
const timestamp = Date.now()
|
||||
const url = `${PlantUMLServer}/${format}/${encodedDiagram}`
|
||||
const filename = `plantuml-diagram-${timestamp}.${format}`
|
||||
downloadUrl(url, filename).catch(() => {
|
||||
window.message.error(t('code_block.download.failed.network'))
|
||||
})
|
||||
},
|
||||
[encodedDiagram, t]
|
||||
)
|
||||
|
||||
// 使用通用图像工具,提供自定义下载方法
|
||||
const { handleZoom, handleCopyImage } = usePreviewToolHandlers(containerRef, {
|
||||
imgSelector: '.plantuml-preview img',
|
||||
prefix: 'plantuml-diagram',
|
||||
enableWheelZoom: true,
|
||||
customDownloader: customDownload
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload: customDownload
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledPlantUML = styled.div`
|
||||
max-height: calc(80vh - 100px);
|
||||
text-align: left;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: 100px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(PlantUmlPreview)
|
||||
@@ -1,14 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
const PreviewError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(PreviewError)
|
||||
@@ -18,6 +18,7 @@ const Container = styled(Flex)`
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
text-wrap: wrap;
|
||||
border-radius: 0 0 8px 8px;
|
||||
`
|
||||
|
||||
export default memo(StatusBar)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = svgContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const shadowRoot = container.shadowRoot || container.attachShadow({ mode: 'open' })
|
||||
|
||||
// 添加基础样式
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
:host {
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
|
||||
// 清空并重新添加内容
|
||||
shadowRoot.innerHTML = ''
|
||||
shadowRoot.appendChild(style)
|
||||
|
||||
const svgContainer = document.createElement('div')
|
||||
svgContainer.innerHTML = children
|
||||
shadowRoot.appendChild(svgContainer)
|
||||
}, [children])
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
|
||||
imgSelector: 'svg',
|
||||
prefix: 'svg-image'
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return <div ref={svgContainerRef} className="svg-preview special-preview" />
|
||||
}
|
||||
|
||||
export default memo(SvgPreview)
|
||||
@@ -1,7 +1,4 @@
|
||||
import GraphvizPreview from './GraphvizPreview'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import SvgPreview from './SvgPreview'
|
||||
import { GraphvizPreview, MermaidPreview, PlantUmlPreview, SvgPreview } from '@renderer/components/Preview'
|
||||
|
||||
/**
|
||||
* 特殊视图语言列表
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { CodeTool } from '@renderer/components/CodeToolbar'
|
||||
|
||||
/**
|
||||
* 预览组件的基本 props
|
||||
*/
|
||||
export interface BasicPreviewProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图模式
|
||||
*/
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { ActionTool } from '@renderer/components/ActionTools'
|
||||
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import {
|
||||
CodeToolbar,
|
||||
useCopyTool,
|
||||
useDownloadTool,
|
||||
useExpandTool,
|
||||
useRunTool,
|
||||
useSaveTool,
|
||||
useSplitViewTool,
|
||||
useViewSourceTool,
|
||||
useWrapTool
|
||||
} from '@renderer/components/CodeToolbar'
|
||||
import CodeViewer from '@renderer/components/CodeViewer'
|
||||
import ImageViewer from '@renderer/components/ImageViewer'
|
||||
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { getExtensionByLanguage, isHtmlCode, isValidPlantUML } from '@renderer/utils/markdown'
|
||||
import { getExtensionByLanguage, isHtmlCode } from '@renderer/utils/markdown'
|
||||
import dayjs from 'dayjs'
|
||||
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
import ImageViewer from '../ImageViewer'
|
||||
import CodePreview from './CodePreview'
|
||||
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
||||
import HtmlArtifactsCard from './HtmlArtifactsCard'
|
||||
import StatusBar from './StatusBar'
|
||||
@@ -45,31 +56,83 @@ interface Props {
|
||||
*/
|
||||
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
||||
const { t } = useTranslation()
|
||||
const { codeEditor, codeExecution } = useSettings()
|
||||
const { codeEditor, codeExecution, codeImageTools, codeCollapsible, codeWrappable } = useSettings()
|
||||
|
||||
const [viewState, setViewState] = useState({
|
||||
mode: 'special' as ViewMode,
|
||||
previousMode: 'special' as ViewMode
|
||||
})
|
||||
const { mode: viewMode } = viewState
|
||||
|
||||
const setViewMode = useCallback((newMode: ViewMode) => {
|
||||
setViewState((current) => ({
|
||||
mode: newMode,
|
||||
// 当新模式不是 'split' 时才更新
|
||||
previousMode: newMode !== 'split' ? newMode : current.previousMode
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const toggleSplitView = useCallback(() => {
|
||||
setViewState((current) => {
|
||||
// 如果当前是 split 模式,恢复到上一个模式
|
||||
if (current.mode === 'split') {
|
||||
return { ...current, mode: current.previousMode }
|
||||
}
|
||||
return { mode: 'split', previousMode: current.mode }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('special')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [executionResult, setExecutionResult] = useState<{ text: string; image?: string } | null>(null)
|
||||
|
||||
const [tools, setTools] = useState<CodeTool[]>([])
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
const [tools, setTools] = useState<ActionTool[]>([])
|
||||
|
||||
const isExecutable = useMemo(() => {
|
||||
return codeExecution.enabled && language === 'python'
|
||||
}, [codeExecution.enabled, language])
|
||||
|
||||
const sourceViewRef = useRef<CodeEditorHandles>(null)
|
||||
const specialViewRef = useRef<BasicPreviewHandles>(null)
|
||||
|
||||
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
|
||||
|
||||
const isInSpecialView = useMemo(() => {
|
||||
return hasSpecialView && viewMode === 'special'
|
||||
}, [hasSpecialView, viewMode])
|
||||
|
||||
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
|
||||
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
|
||||
|
||||
// 重置用户操作
|
||||
useEffect(() => {
|
||||
setExpandOverride(!codeCollapsible)
|
||||
}, [codeCollapsible])
|
||||
|
||||
// 重置用户操作
|
||||
useEffect(() => {
|
||||
setUnwrapOverride(!codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride])
|
||||
const shouldUnwrap = useMemo(() => !codeWrappable || unwrapOverride, [codeWrappable, unwrapOverride])
|
||||
|
||||
const [sourceScrollHeight, setSourceScrollHeight] = useState(0)
|
||||
const expandable = useMemo(() => {
|
||||
return codeCollapsible && sourceScrollHeight > MAX_COLLAPSED_CODE_HEIGHT
|
||||
}, [codeCollapsible, sourceScrollHeight])
|
||||
|
||||
const handleHeightChange = useCallback((height: number) => {
|
||||
startTransition(() => {
|
||||
setSourceScrollHeight((prev) => (prev === height ? prev : height))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCopySource = useCallback(() => {
|
||||
navigator.clipboard.writeText(children)
|
||||
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
|
||||
}, [children, t])
|
||||
|
||||
const handleDownloadSource = useCallback(async () => {
|
||||
const handleDownloadSource = useCallback(() => {
|
||||
let fileName = ''
|
||||
|
||||
// 尝试提取 HTML 标题
|
||||
@@ -82,7 +145,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
fileName = `${dayjs().format('YYYYMMDDHHmm')}`
|
||||
}
|
||||
|
||||
const ext = await getExtensionByLanguage(language)
|
||||
const ext = getExtensionByLanguage(language)
|
||||
window.api.file.save(`${fileName}${ext}`, children)
|
||||
}, [children, language])
|
||||
|
||||
@@ -106,101 +169,103 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
})
|
||||
}, [children, codeExecution.timeoutMinutes])
|
||||
|
||||
useEffect(() => {
|
||||
// 复制按钮
|
||||
registerTool({
|
||||
...TOOL_SPECS.copy,
|
||||
icon: <Copy className="icon" />,
|
||||
tooltip: t('code_block.copy.source'),
|
||||
onClick: handleCopySource
|
||||
})
|
||||
const showPreviewTools = useMemo(() => {
|
||||
return viewMode !== 'source' && hasSpecialView
|
||||
}, [hasSpecialView, viewMode])
|
||||
|
||||
// 下载按钮
|
||||
registerTool({
|
||||
...TOOL_SPECS.download,
|
||||
icon: <Download className="icon" />,
|
||||
tooltip: t('code_block.download.source'),
|
||||
onClick: handleDownloadSource
|
||||
})
|
||||
return () => {
|
||||
removeTool(TOOL_SPECS.copy.id)
|
||||
removeTool(TOOL_SPECS.download.id)
|
||||
}
|
||||
}, [handleCopySource, handleDownloadSource, registerTool, removeTool, t])
|
||||
// 复制按钮
|
||||
useCopyTool({
|
||||
showPreviewTools,
|
||||
previewRef: specialViewRef,
|
||||
onCopySource: handleCopySource,
|
||||
setTools
|
||||
})
|
||||
|
||||
// 特殊视图的编辑按钮,在分屏模式下不可用
|
||||
useEffect(() => {
|
||||
if (!hasSpecialView || viewMode === 'split') return
|
||||
// 下载按钮
|
||||
useDownloadTool({
|
||||
showPreviewTools,
|
||||
previewRef: specialViewRef,
|
||||
onDownloadSource: handleDownloadSource,
|
||||
setTools
|
||||
})
|
||||
|
||||
const viewSourceToolSpec = codeEditor.enabled ? TOOL_SPECS.edit : TOOL_SPECS['view-source']
|
||||
// 特殊视图的编辑/查看源码按钮,在分屏模式下不可用
|
||||
useViewSourceTool({
|
||||
enabled: hasSpecialView,
|
||||
editable: codeEditor.enabled,
|
||||
viewMode,
|
||||
onViewModeChange: setViewMode,
|
||||
setTools
|
||||
})
|
||||
|
||||
if (codeEditor.enabled) {
|
||||
registerTool({
|
||||
...viewSourceToolSpec,
|
||||
icon: viewMode === 'source' ? <Eye className="icon" /> : <SquarePen className="icon" />,
|
||||
tooltip: viewMode === 'source' ? t('code_block.preview.label') : t('code_block.edit.label'),
|
||||
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
|
||||
})
|
||||
} else {
|
||||
registerTool({
|
||||
...viewSourceToolSpec,
|
||||
icon: viewMode === 'source' ? <Eye className="icon" /> : <CodeXml className="icon" />,
|
||||
tooltip: viewMode === 'source' ? t('code_block.preview.label') : t('code_block.preview.source'),
|
||||
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
|
||||
})
|
||||
}
|
||||
|
||||
return () => removeTool(viewSourceToolSpec.id)
|
||||
}, [codeEditor.enabled, hasSpecialView, viewMode, registerTool, removeTool, t])
|
||||
|
||||
// 特殊视图的分屏按钮
|
||||
useEffect(() => {
|
||||
if (!hasSpecialView) return
|
||||
|
||||
registerTool({
|
||||
...TOOL_SPECS['split-view'],
|
||||
icon: viewMode === 'split' ? <Square className="icon" /> : <SquareSplitHorizontal className="icon" />,
|
||||
tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split.label'),
|
||||
onClick: () => setViewMode(viewMode === 'split' ? 'special' : 'split')
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS['split-view'].id)
|
||||
}, [hasSpecialView, viewMode, registerTool, removeTool, t])
|
||||
// 特殊视图存在时的分屏按钮
|
||||
useSplitViewTool({
|
||||
enabled: hasSpecialView,
|
||||
viewMode,
|
||||
onToggleSplitView: toggleSplitView,
|
||||
setTools
|
||||
})
|
||||
|
||||
// 运行按钮
|
||||
useEffect(() => {
|
||||
if (!isExecutable) return
|
||||
useRunTool({
|
||||
enabled: isExecutable,
|
||||
isRunning,
|
||||
onRun: handleRunScript,
|
||||
setTools
|
||||
})
|
||||
|
||||
registerTool({
|
||||
...TOOL_SPECS.run,
|
||||
icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />,
|
||||
tooltip: t('code_block.run'),
|
||||
onClick: () => !isRunning && handleRunScript()
|
||||
})
|
||||
// 源代码视图的展开/折叠按钮
|
||||
useExpandTool({
|
||||
enabled: !isInSpecialView,
|
||||
expanded: shouldExpand,
|
||||
expandable,
|
||||
toggle: useCallback(() => setExpandOverride((prev) => !prev), []),
|
||||
setTools
|
||||
})
|
||||
|
||||
return () => isExecutable && removeTool(TOOL_SPECS.run.id)
|
||||
}, [isExecutable, isRunning, handleRunScript, registerTool, removeTool, t])
|
||||
// 源代码视图的自动换行按钮
|
||||
useWrapTool({
|
||||
enabled: !isInSpecialView,
|
||||
unwrapped: shouldUnwrap,
|
||||
wrappable: codeWrappable,
|
||||
toggle: useCallback(() => setUnwrapOverride((prev) => !prev), []),
|
||||
setTools
|
||||
})
|
||||
|
||||
// 代码编辑器的保存按钮
|
||||
useSaveTool({
|
||||
enabled: codeEditor.enabled && !isInSpecialView,
|
||||
sourceViewRef,
|
||||
setTools
|
||||
})
|
||||
|
||||
// 源代码视图组件
|
||||
const sourceView = useMemo(() => {
|
||||
if (codeEditor.enabled) {
|
||||
return (
|
||||
const sourceView = useMemo(
|
||||
() =>
|
||||
codeEditor.enabled ? (
|
||||
<CodeEditor
|
||||
className="source-view"
|
||||
ref={sourceViewRef}
|
||||
value={children}
|
||||
language={language}
|
||||
onSave={onSave}
|
||||
onHeightChange={handleHeightChange}
|
||||
options={{ stream: true }}
|
||||
setTools={setTools}
|
||||
expanded={shouldExpand}
|
||||
unwrapped={shouldUnwrap}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<CodePreview language={language} setTools={setTools}>
|
||||
) : (
|
||||
<CodeViewer
|
||||
className="source-view"
|
||||
language={language}
|
||||
expanded={shouldExpand}
|
||||
unwrapped={shouldUnwrap}
|
||||
onHeightChange={handleHeightChange}>
|
||||
{children}
|
||||
</CodePreview>
|
||||
)
|
||||
}
|
||||
}, [children, codeEditor.enabled, language, onSave, setTools])
|
||||
</CodeViewer>
|
||||
),
|
||||
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldUnwrap]
|
||||
)
|
||||
|
||||
// 特殊视图组件映射
|
||||
const specialView = useMemo(() => {
|
||||
@@ -208,13 +273,12 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
|
||||
if (!SpecialView) return null
|
||||
|
||||
// PlantUML 语法验证
|
||||
if (language === 'plantuml' && !isValidPlantUML(children)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <SpecialView setTools={setTools}>{children}</SpecialView>
|
||||
}, [children, language])
|
||||
return (
|
||||
<SpecialView ref={specialViewRef} enableToolbar={codeImageTools}>
|
||||
{children}
|
||||
</SpecialView>
|
||||
)
|
||||
}, [children, codeImageTools, language])
|
||||
|
||||
const renderHeader = useMemo(() => {
|
||||
const langTag = '<' + language.toUpperCase() + '>'
|
||||
@@ -223,11 +287,14 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
|
||||
// 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图
|
||||
const renderContent = useMemo(() => {
|
||||
const showSpecialView = specialView && ['special', 'split'].includes(viewMode)
|
||||
const showSpecialView = !!specialView && ['special', 'split'].includes(viewMode)
|
||||
const showSourceView = !specialView || viewMode !== 'special'
|
||||
|
||||
return (
|
||||
<SplitViewWrapper className="split-view-wrapper">
|
||||
<SplitViewWrapper
|
||||
className="split-view-wrapper"
|
||||
$isSpecialView={showSpecialView && !showSourceView}
|
||||
$isSplitView={showSpecialView && showSourceView}>
|
||||
{showSpecialView && specialView}
|
||||
{showSourceView && sourceView}
|
||||
</SplitViewWrapper>
|
||||
@@ -260,7 +327,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* FIXME: 最小宽度用于解决两个问题。
|
||||
* 一是 CodePreview 在气泡样式下的用户消息中无法撑开气泡,
|
||||
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
|
||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||
*/
|
||||
min-width: 45ch;
|
||||
@@ -295,9 +362,10 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
border-top-right-radius: 8px;
|
||||
margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')};
|
||||
height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')};
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
`
|
||||
|
||||
const SplitViewWrapper = styled.div`
|
||||
const SplitViewWrapper = styled.div<{ $isSpecialView: boolean; $isSplitView: boolean }>`
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
@@ -306,7 +374,27 @@ const SplitViewWrapper = styled.div`
|
||||
}
|
||||
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
// 特殊视图的 header 会隐藏,所以全都使用圆角
|
||||
border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 在 split 模式下添加中间分隔线
|
||||
${(props) =>
|
||||
props.$isSplitView &&
|
||||
css`
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background-color: var(--color-background-mute);
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
@@ -175,3 +175,26 @@ export function useBlurHandler({ onBlur }: UseBlurHandlerProps) {
|
||||
})
|
||||
}, [onBlur])
|
||||
}
|
||||
|
||||
interface UseHeightListenerProps {
|
||||
onHeightChange?: (scrollHeight: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* CodeMirror 扩展,用于监听编辑器高度变化
|
||||
* @param onHeightChange 高度变化时触发的回调函数
|
||||
* @returns 扩展或空数组
|
||||
*/
|
||||
export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
|
||||
return useMemo(() => {
|
||||
if (!onHeightChange) {
|
||||
return []
|
||||
}
|
||||
|
||||
return EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged || update.heightChanged) {
|
||||
onHeightChange(update.view.scrollDOM?.scrollHeight ?? 0)
|
||||
}
|
||||
})
|
||||
}, [onHeightChange])
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror'
|
||||
import diff from 'fast-diff'
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Save as SaveIcon,
|
||||
Text as UnWrapIcon,
|
||||
WrapText as WrapIcon
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useBlurHandler, useLanguageExtensions, useSaveKeymap } from './hooks'
|
||||
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
|
||||
|
||||
// 标记非用户编辑的变更
|
||||
const External = Annotation.define<boolean>()
|
||||
|
||||
interface Props {
|
||||
export interface CodeEditorHandles {
|
||||
save?: () => void
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
ref?: React.RefObject<CodeEditorHandles | null>
|
||||
value: string
|
||||
placeholder?: string | HTMLElement
|
||||
language: string
|
||||
onSave?: (newContent: string) => void
|
||||
onChange?: (newContent: string) => void
|
||||
onBlur?: (newContent: string) => void
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
onHeightChange?: (scrollHeight: number) => void
|
||||
height?: string
|
||||
minHeight?: string
|
||||
maxHeight?: string
|
||||
@@ -35,15 +32,16 @@ interface Props {
|
||||
options?: {
|
||||
stream?: boolean // 用于流式响应场景,默认 false
|
||||
lint?: boolean
|
||||
collapsible?: boolean
|
||||
wrappable?: boolean
|
||||
keymap?: boolean
|
||||
} & BasicSetupOptions
|
||||
/** 用于追加 extensions */
|
||||
extensions?: Extension[]
|
||||
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
editable?: boolean
|
||||
expanded?: boolean
|
||||
unwrapped?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,13 +50,14 @@ interface Props {
|
||||
* 目前必须和 CodeToolbar 配合使用。
|
||||
*/
|
||||
const CodeEditor = ({
|
||||
ref,
|
||||
value,
|
||||
placeholder,
|
||||
language,
|
||||
onSave,
|
||||
onChange,
|
||||
onBlur,
|
||||
setTools,
|
||||
onHeightChange,
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
@@ -66,17 +65,12 @@ const CodeEditor = ({
|
||||
options,
|
||||
extensions,
|
||||
style,
|
||||
editable = true
|
||||
}: Props) => {
|
||||
const {
|
||||
fontSize: _fontSize,
|
||||
codeShowLineNumbers: _lineNumbers,
|
||||
codeCollapsible: _collapsible,
|
||||
codeWrappable: _wrappable,
|
||||
codeEditor
|
||||
} = useSettings()
|
||||
const collapsible = useMemo(() => options?.collapsible ?? _collapsible, [options?.collapsible, _collapsible])
|
||||
const wrappable = useMemo(() => options?.wrappable ?? _wrappable, [options?.wrappable, _wrappable])
|
||||
className,
|
||||
editable = true,
|
||||
expanded = true,
|
||||
unwrapped = false
|
||||
}: CodeEditorProps) => {
|
||||
const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings()
|
||||
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
|
||||
|
||||
// 合并 codeEditor 和 options 的 basicSetup,options 优先
|
||||
@@ -91,63 +85,16 @@ const CodeEditor = ({
|
||||
const customFontSize = useMemo(() => fontSize ?? `${_fontSize - 1}px`, [fontSize, _fontSize])
|
||||
|
||||
const { activeCmTheme } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
|
||||
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
|
||||
const [editorReady, setEditorReady] = useState(false)
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const langExtensions = useLanguageExtensions(language, options?.lint)
|
||||
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
|
||||
// 展开/折叠工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.expand,
|
||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = editorViewRef?.current?.scrollDOM?.scrollHeight
|
||||
return collapsible && (scrollHeight ?? 0) > 350
|
||||
},
|
||||
onClick: () => setIsExpanded((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.expand.id)
|
||||
}, [collapsible, isExpanded, registerTool, removeTool, t, editorReady])
|
||||
|
||||
// 自动换行工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.wrap,
|
||||
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
visible: () => wrappable,
|
||||
onClick: () => setIsUnwrapped((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||
}, [wrappable, isUnwrapped, registerTool, removeTool, t])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
|
||||
onSave?.(currentDoc)
|
||||
}, [onSave])
|
||||
|
||||
// 保存按钮
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.save,
|
||||
icon: <SaveIcon className="icon" />,
|
||||
tooltip: t('code_block.edit.save.label'),
|
||||
onClick: handleSave
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.save.id)
|
||||
}, [handleSave, registerTool, removeTool, t])
|
||||
|
||||
// 流式响应过程中计算 changes 来更新 EditorView
|
||||
// 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理)
|
||||
useEffect(() => {
|
||||
@@ -166,26 +113,24 @@ const CodeEditor = ({
|
||||
}
|
||||
}, [options?.stream, value])
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(!collapsible)
|
||||
}, [collapsible])
|
||||
|
||||
useEffect(() => {
|
||||
setIsUnwrapped(!wrappable)
|
||||
}, [wrappable])
|
||||
|
||||
const saveKeymapExtension = useSaveKeymap({ onSave, enabled: enableKeymap })
|
||||
const blurExtension = useBlurHandler({ onBlur })
|
||||
const heightListenerExtension = useHeightListener({ onHeightChange })
|
||||
|
||||
const customExtensions = useMemo(() => {
|
||||
return [
|
||||
...(extensions ?? []),
|
||||
...langExtensions,
|
||||
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
|
||||
...(unwrapped ? [] : [EditorView.lineWrapping]),
|
||||
saveKeymapExtension,
|
||||
blurExtension
|
||||
blurExtension,
|
||||
heightListenerExtension
|
||||
].flat()
|
||||
}, [extensions, langExtensions, isUnwrapped, saveKeymapExtension, blurExtension])
|
||||
}, [extensions, langExtensions, unwrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: handleSave
|
||||
}))
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
@@ -195,14 +140,14 @@ const CodeEditor = ({
|
||||
width="100%"
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||
maxHeight={expanded ? 'none' : (maxHeight ?? `${MAX_COLLAPSED_CODE_HEIGHT}px`)}
|
||||
editable={editable}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
theme={activeCmTheme}
|
||||
extensions={customExtensions}
|
||||
onCreateEditor={(view: EditorView) => {
|
||||
editorViewRef.current = view
|
||||
setEditorReady(true)
|
||||
onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0)
|
||||
}}
|
||||
onChange={(value, viewUpdate) => {
|
||||
if (onChange && viewUpdate.docChanged) onChange(value)
|
||||
@@ -230,6 +175,7 @@ const CodeEditor = ({
|
||||
borderRadius: 'inherit',
|
||||
...style
|
||||
}}
|
||||
className={`code-editor ${className ?? ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ActionTool } from '@renderer/components/ActionTools'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CodeToolButton from '../button'
|
||||
|
||||
// Mock Antd components
|
||||
const mocks = vi.hoisted(() => ({
|
||||
Tooltip: vi.fn(({ children, title }) => (
|
||||
<div data-testid="tooltip" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
Dropdown: vi.fn(({ children, menu }) => (
|
||||
<div data-testid="dropdown" data-menu={JSON.stringify(menu)}>
|
||||
{children}
|
||||
</div>
|
||||
))
|
||||
}))
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tooltip: mocks.Tooltip,
|
||||
Dropdown: mocks.Dropdown
|
||||
}))
|
||||
|
||||
// Mock ToolWrapper
|
||||
vi.mock('../styles', () => ({
|
||||
ToolWrapper: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
|
||||
<button type="button" data-testid="tool-wrapper" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
// Helper function to create mock tools
|
||||
const createMockTool = (overrides: Partial<ActionTool> = {}): ActionTool => ({
|
||||
id: 'test-tool',
|
||||
type: 'core',
|
||||
order: 10,
|
||||
icon: <span data-testid="test-icon">Test Icon</span>,
|
||||
tooltip: 'Test Tool',
|
||||
onClick: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
const createMockChildTool = (id: string, tooltip: string): Omit<ActionTool, 'children'> => ({
|
||||
id,
|
||||
type: 'quick',
|
||||
order: 10,
|
||||
icon: <span data-testid={`${id}-icon`}>{tooltip} Icon</span>,
|
||||
tooltip,
|
||||
onClick: vi.fn()
|
||||
})
|
||||
|
||||
describe('CodeToolButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering modes', () => {
|
||||
it('should render as simple button when no children', () => {
|
||||
const tool = createMockTool()
|
||||
render(<CodeToolButton tool={tool} />)
|
||||
|
||||
// Should render button with tooltip
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-wrapper')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
|
||||
|
||||
// Should not render dropdown
|
||||
expect(screen.queryByTestId('dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as simple button when children array is empty', () => {
|
||||
const tool = createMockTool({ children: [] })
|
||||
render(<CodeToolButton tool={tool} />)
|
||||
|
||||
expect(screen.queryByTestId('dropdown')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as dropdown when has children', () => {
|
||||
const children = [createMockChildTool('child1', 'Child 1')]
|
||||
const tool = createMockTool({ children })
|
||||
render(<CodeToolButton tool={tool} />)
|
||||
|
||||
// Should render dropdown containing the main button
|
||||
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('user interactions', () => {
|
||||
it('should trigger onClick when simple button is clicked', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
const tool = createMockTool({ onClick: mockOnClick })
|
||||
render(<CodeToolButton tool={tool} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('tool-wrapper'))
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle missing onClick gracefully', () => {
|
||||
const tool = createMockTool({ onClick: undefined })
|
||||
render(<CodeToolButton tool={tool} />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByTestId('tool-wrapper'))
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropdown functionality', () => {
|
||||
it('should configure dropdown with correct menu structure', () => {
|
||||
const mockOnClick1 = vi.fn()
|
||||
const mockOnClick2 = vi.fn()
|
||||
const children = [createMockChildTool('child1', 'Child 1'), createMockChildTool('child2', 'Child 2')]
|
||||
children[0].onClick = mockOnClick1
|
||||
children[1].onClick = mockOnClick2
|
||||
|
||||
const tool = createMockTool({ children })
|
||||
render(<CodeToolButton tool={tool} />)
|
||||
|
||||
// Verify dropdown was called with correct menu structure
|
||||
expect(mocks.Dropdown).toHaveBeenCalled()
|
||||
const dropdownProps = mocks.Dropdown.mock.calls[0][0]
|
||||
|
||||
expect(dropdownProps.menu.items).toHaveLength(2)
|
||||
expect(dropdownProps.menu.items[0].key).toBe('child1')
|
||||
expect(dropdownProps.menu.items[0].label).toBe('Child 1')
|
||||
expect(dropdownProps.menu.items[0].onClick).toBe(mockOnClick1)
|
||||
expect(dropdownProps.trigger).toEqual(['click'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should provide accessible button element with tooltip', () => {
|
||||
const tool = createMockTool({ tooltip: 'Accessible Tool' })
|
||||
render(<CodeToolButton tool={tool} />)
|
||||
|
||||
const button = screen.getByTestId('tool-wrapper')
|
||||
expect(button.tagName).toBe('BUTTON')
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-title', 'Accessible Tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should render without crashing for minimal tool configuration', () => {
|
||||
const minimalTool: ActionTool = {
|
||||
id: 'minimal',
|
||||
type: 'core',
|
||||
order: 1,
|
||||
icon: null,
|
||||
tooltip: ''
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
render(<CodeToolButton tool={minimalTool} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { ActionTool } from '@renderer/components/ActionTools'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CodeToolbar from '../toolbar'
|
||||
|
||||
// Test constants
|
||||
const MORE_BUTTON_TOOLTIP = 'code_block.more'
|
||||
|
||||
// Mock components
|
||||
const mocks = vi.hoisted(() => ({
|
||||
CodeToolButton: vi.fn(({ tool }) => (
|
||||
<div data-testid={`tool-button-${tool.id}`} data-tool-id={tool.id} data-tool-type={tool.type}>
|
||||
{tool.icon}
|
||||
</div>
|
||||
)),
|
||||
Tooltip: vi.fn(({ children, title }) => (
|
||||
<div data-testid="tooltip" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
HStack: vi.fn(({ children, className }) => (
|
||||
<div data-testid="hstack" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
ToolWrapper: vi.fn(({ children, onClick, className }) => (
|
||||
<div data-testid="tool-wrapper" onClick={onClick} className={className} role="button" tabIndex={0}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
EllipsisVertical: vi.fn(() => <div data-testid="ellipsis-icon" className="tool-icon" />),
|
||||
useTranslation: vi.fn(() => ({
|
||||
t: vi.fn((key: string) => key)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('../button', () => ({
|
||||
default: mocks.CodeToolButton
|
||||
}))
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tooltip: mocks.Tooltip
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Layout', () => ({
|
||||
HStack: mocks.HStack
|
||||
}))
|
||||
|
||||
vi.mock('./styles', () => ({
|
||||
ToolWrapper: mocks.ToolWrapper
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
EllipsisVertical: mocks.EllipsisVertical
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: mocks.useTranslation
|
||||
}))
|
||||
|
||||
// Helper function to create mock tools
|
||||
const createMockTool = (overrides: Partial<ActionTool> = {}): ActionTool => ({
|
||||
id: 'test-tool',
|
||||
type: 'core',
|
||||
order: 1,
|
||||
icon: <div data-testid="test-icon">Icon</div>,
|
||||
tooltip: 'Test Tool',
|
||||
onClick: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Common test data
|
||||
const createMixedTools = () => [
|
||||
createMockTool({ id: 'quick1', type: 'quick' }),
|
||||
createMockTool({ id: 'quick2', type: 'quick' }),
|
||||
createMockTool({ id: 'core1', type: 'core' })
|
||||
]
|
||||
|
||||
const createCoreOnlyTools = () => [
|
||||
createMockTool({ id: 'core1', type: 'core' }),
|
||||
createMockTool({ id: 'core2', type: 'core' })
|
||||
]
|
||||
|
||||
// Helper function to click more button
|
||||
const clickMoreButton = () => {
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
fireEvent.click(tooltip.firstChild as Element)
|
||||
}
|
||||
|
||||
describe('CodeToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should match snapshot with mixed tools', () => {
|
||||
const { container } = render(<CodeToolbar tools={createMixedTools()} />)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should match snapshot with only core tools', () => {
|
||||
const { container } = render(<CodeToolbar tools={[createMockTool({ id: 'core1', type: 'core' })]} />)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should render nothing when no tools provided', () => {
|
||||
const { container } = render(<CodeToolbar tools={[]} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render nothing when all tools are not visible', () => {
|
||||
const tools = [
|
||||
createMockTool({ id: 'tool1', visible: () => false }),
|
||||
createMockTool({ id: 'tool2', visible: () => false })
|
||||
]
|
||||
const { container } = render(<CodeToolbar tools={tools} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool visibility filtering', () => {
|
||||
it('should only render visible tools', () => {
|
||||
const tools = [
|
||||
createMockTool({ id: 'visible-tool', visible: () => true }),
|
||||
createMockTool({ id: 'hidden-tool', visible: () => false }),
|
||||
createMockTool({ id: 'no-visible-prop' }) // Should be visible by default
|
||||
]
|
||||
render(<CodeToolbar tools={tools} />)
|
||||
|
||||
expect(screen.getByTestId('tool-button-visible-tool')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-no-visible-prop')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tool-button-hidden-tool')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tools without visible function by default', () => {
|
||||
const tools = [createMockTool({ id: 'default-visible' })]
|
||||
render(<CodeToolbar tools={tools} />)
|
||||
|
||||
expect(screen.getByTestId('tool-button-default-visible')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool type grouping and quick tools behavior', () => {
|
||||
it('should separate core and quick tools - show quick tools when expanded', () => {
|
||||
const tools = [
|
||||
createMockTool({ id: 'core1', type: 'core' }),
|
||||
createMockTool({ id: 'quick1', type: 'quick' }),
|
||||
createMockTool({ id: 'core2', type: 'core' }),
|
||||
createMockTool({ id: 'quick2', type: 'quick' })
|
||||
]
|
||||
render(<CodeToolbar tools={tools} />)
|
||||
|
||||
// Initial state: core tools visible, quick tools hidden
|
||||
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-core2')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tool-button-quick1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tool-button-quick2')).not.toBeInTheDocument()
|
||||
|
||||
// After clicking more button, quick tools should be visible
|
||||
clickMoreButton()
|
||||
|
||||
expect(screen.getByTestId('tool-button-quick1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-quick2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only core tools when no quick tools exist', () => {
|
||||
render(<CodeToolbar tools={createCoreOnlyTools()} />)
|
||||
|
||||
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-core2')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() // No more button
|
||||
})
|
||||
|
||||
it('should show single quick tool directly without more button', () => {
|
||||
const tools = [createMockTool({ id: 'quick1', type: 'quick' }), createMockTool({ id: 'core1', type: 'core' })]
|
||||
render(<CodeToolbar tools={tools} />)
|
||||
|
||||
expect(screen.getByTestId('tool-button-quick1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() // No more button
|
||||
})
|
||||
|
||||
it('should show more button when multiple quick tools exist', () => {
|
||||
render(<CodeToolbar tools={createMixedTools()} />)
|
||||
|
||||
// Initially quick tools should be hidden
|
||||
expect(screen.queryByTestId('tool-button-quick1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tool-button-quick2')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument() // More button exists
|
||||
})
|
||||
|
||||
it('should toggle quick tools visibility when more button is clicked', () => {
|
||||
render(<CodeToolbar tools={createMixedTools()} />)
|
||||
|
||||
// Initial state: quick tools hidden
|
||||
expect(screen.queryByTestId('tool-button-quick1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tool-button-quick2')).not.toBeInTheDocument()
|
||||
|
||||
// Click more button: quick tools visible
|
||||
clickMoreButton()
|
||||
expect(screen.getByTestId('tool-button-quick1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-quick2')).toBeInTheDocument()
|
||||
|
||||
// Click more button again: quick tools hidden
|
||||
clickMoreButton()
|
||||
expect(screen.queryByTestId('tool-button-quick1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tool-button-quick2')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply active class to more button when quick tools are shown', () => {
|
||||
const tools = [createMockTool({ id: 'quick1', type: 'quick' }), createMockTool({ id: 'quick2', type: 'quick' })]
|
||||
render(<CodeToolbar tools={tools} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const moreButton = tooltip.firstChild as Element
|
||||
|
||||
// Initial state: no active class
|
||||
expect(moreButton).not.toHaveClass('active')
|
||||
|
||||
// After click: has active class
|
||||
fireEvent.click(moreButton)
|
||||
expect(moreButton).toHaveClass('active')
|
||||
|
||||
// After second click: no active class
|
||||
fireEvent.click(moreButton)
|
||||
expect(moreButton).not.toHaveClass('active')
|
||||
})
|
||||
|
||||
it('should display correct tooltip and icon for more button', () => {
|
||||
render(<CodeToolbar tools={createMixedTools()} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toHaveAttribute('data-title', MORE_BUTTON_TOOLTIP)
|
||||
|
||||
expect(screen.getByTestId('ellipsis-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ellipsis-icon')).toHaveClass('tool-icon')
|
||||
})
|
||||
|
||||
it('should render core tools regardless of quick tools state', () => {
|
||||
const tools = [
|
||||
createMockTool({ id: 'quick1', type: 'quick' }),
|
||||
createMockTool({ id: 'quick2', type: 'quick' }),
|
||||
createMockTool({ id: 'core1', type: 'core' }),
|
||||
createMockTool({ id: 'core2', type: 'core' })
|
||||
]
|
||||
render(<CodeToolbar tools={tools} />)
|
||||
|
||||
// Core tools always visible
|
||||
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-core2')).toBeInTheDocument()
|
||||
|
||||
// After clicking more button, core tools still visible
|
||||
clickMoreButton()
|
||||
expect(screen.getByTestId('tool-button-core1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-button-core2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CodeToolbar > basic rendering > should match snapshot with mixed tools 1`] = `
|
||||
.c2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.c2:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
.c2:hover .tool-icon {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.c2.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.c2.active .tool-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.c2 .tool-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.c0 {
|
||||
position: sticky;
|
||||
top: 28px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
bottom: 0.3rem;
|
||||
right: 0.5rem;
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<div
|
||||
class="c1 code-toolbar"
|
||||
data-testid="hstack"
|
||||
>
|
||||
<div
|
||||
data-testid="tooltip"
|
||||
data-title="code_block.more"
|
||||
>
|
||||
<div
|
||||
class="c2"
|
||||
>
|
||||
<div
|
||||
class="tool-icon"
|
||||
data-testid="ellipsis-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="tool-button-core1"
|
||||
data-tool-id="core1"
|
||||
data-tool-type="core"
|
||||
>
|
||||
<div
|
||||
data-testid="test-icon"
|
||||
>
|
||||
Icon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`CodeToolbar > basic rendering > should match snapshot with only core tools 1`] = `
|
||||
.c0 {
|
||||
position: sticky;
|
||||
top: 28px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
bottom: 0.3rem;
|
||||
right: 0.5rem;
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<div
|
||||
class="c1 code-toolbar"
|
||||
data-testid="hstack"
|
||||
>
|
||||
<div
|
||||
data-testid="tool-button-core1"
|
||||
data-tool-id="core1"
|
||||
data-tool-type="core"
|
||||
>
|
||||
<div
|
||||
data-testid="test-icon"
|
||||
>
|
||||
Icon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useCopyTool } from '@renderer/components/CodeToolbar/hooks/useCopyTool'
|
||||
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: vi.fn((key: string) => key)
|
||||
},
|
||||
useTemporaryValue: vi.fn(),
|
||||
useToolManager: vi.fn(),
|
||||
TOOL_SPECS: {
|
||||
copy: {
|
||||
id: 'copy',
|
||||
type: 'core',
|
||||
order: 11
|
||||
},
|
||||
'copy-image': {
|
||||
id: 'copy-image',
|
||||
type: 'quick',
|
||||
order: 30
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: () => <div data-testid="check-icon" />,
|
||||
Image: () => <div data-testid="image-icon" />
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Icons', () => ({
|
||||
CopyIcon: () => <div data-testid="copy-icon" />
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/ActionTools', () => ({
|
||||
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||
useToolManager: mocks.useToolManager
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useTemporaryValue', () => ({
|
||||
useTemporaryValue: mocks.useTemporaryValue
|
||||
}))
|
||||
|
||||
// Mock useToolManager
|
||||
const mockRegisterTool = vi.fn()
|
||||
const mockRemoveTool = vi.fn()
|
||||
mocks.useToolManager.mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
removeTool: mockRemoveTool
|
||||
}))
|
||||
|
||||
// Mock useTemporaryValue setters
|
||||
const mockSetCopiedTemporarily = vi.fn()
|
||||
const mockSetCopiedImageTemporarily = vi.fn()
|
||||
|
||||
describe('useCopyTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mocks for each test to ensure isolation
|
||||
mocks.useTemporaryValue
|
||||
.mockImplementationOnce(() => [false, mockSetCopiedTemporarily])
|
||||
.mockImplementationOnce(() => [false, mockSetCopiedImageTemporarily])
|
||||
})
|
||||
|
||||
// Helper function to create mock props
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useCopyTool>[0]> = {}) => ({
|
||||
showPreviewTools: false,
|
||||
previewRef: { current: null },
|
||||
onCopySource: vi.fn(),
|
||||
setTools: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
const createMockPreviewHandles = (): BasicPreviewHandles => ({
|
||||
pan: vi.fn(),
|
||||
zoom: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
download: vi.fn()
|
||||
})
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('should register only the copy-source tool when showPreviewTools is false', () => {
|
||||
const props = createMockProps({ showPreviewTools: false })
|
||||
renderHook(() => useCopyTool(props))
|
||||
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'copy',
|
||||
tooltip: 'code_block.copy.source'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should register only the copy-source tool when previewRef is null', () => {
|
||||
const props = createMockProps({ showPreviewTools: true, previewRef: { current: null } })
|
||||
renderHook(() => useCopyTool(props))
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'copy'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should register both copy-source and copy-image tools when preview is available', () => {
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: createMockPreviewHandles() }
|
||||
})
|
||||
|
||||
renderHook(() => useCopyTool(props))
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Check first tool: copy source
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'copy',
|
||||
tooltip: 'code_block.copy.source',
|
||||
onClick: expect.any(Function)
|
||||
})
|
||||
)
|
||||
|
||||
// Check second tool: copy image
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'copy-image',
|
||||
tooltip: 'preview.copy.image',
|
||||
onClick: expect.any(Function)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy functionality', () => {
|
||||
it('should execute copy source behavior when copy-source tool is clicked', () => {
|
||||
const mockOnCopySource = vi.fn()
|
||||
const props = createMockProps({ onCopySource: mockOnCopySource })
|
||||
renderHook(() => useCopyTool(props))
|
||||
|
||||
const copySourceTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
copySourceTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockOnCopySource).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetCopiedTemporarily).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should execute copy image behavior when copy-image tool is clicked', () => {
|
||||
const mockPreviewHandles = createMockPreviewHandles()
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: mockPreviewHandles }
|
||||
})
|
||||
|
||||
renderHook(() => useCopyTool(props))
|
||||
|
||||
// The copy-image tool is the second one registered
|
||||
const copyImageTool = mockRegisterTool.mock.calls[1][0]
|
||||
act(() => {
|
||||
copyImageTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockPreviewHandles.copy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetCopiedImageTemporarily).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove both tools on unmount when both are registered', () => {
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: createMockPreviewHandles() }
|
||||
})
|
||||
const { unmount } = renderHook(() => useCopyTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('copy')
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('copy-image')
|
||||
})
|
||||
|
||||
it('should attempt to remove both tools on unmount even if only one is registered', () => {
|
||||
const props = createMockProps({ showPreviewTools: false })
|
||||
const { unmount } = renderHook(() => useCopyTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
// The cleanup function is static and always tries to remove both
|
||||
expect(mockRemoveTool).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('copy')
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('copy-image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle copy source failure gracefully', () => {
|
||||
const mockOnCopySource = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Copy failed')
|
||||
})
|
||||
const props = createMockProps({ onCopySource: mockOnCopySource })
|
||||
renderHook(() => useCopyTool(props))
|
||||
|
||||
const copySourceTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
copySourceTool.onClick()
|
||||
})
|
||||
}).toThrow('Copy failed')
|
||||
|
||||
expect(mockOnCopySource).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetCopiedTemporarily).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should handle copy image failure gracefully', () => {
|
||||
const mockPreviewHandles = createMockPreviewHandles()
|
||||
mockPreviewHandles.copy = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Image copy failed')
|
||||
})
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: mockPreviewHandles }
|
||||
})
|
||||
renderHook(() => useCopyTool(props))
|
||||
|
||||
const copyImageTool = mockRegisterTool.mock.calls[1][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
copyImageTool.onClick()
|
||||
})
|
||||
}).toThrow('Image copy failed')
|
||||
|
||||
expect(mockPreviewHandles.copy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetCopiedImageTemporarily).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useDownloadTool } from '@renderer/components/CodeToolbar/hooks/useDownloadTool'
|
||||
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: vi.fn((key: string) => key)
|
||||
},
|
||||
useToolManager: vi.fn(),
|
||||
TOOL_SPECS: {
|
||||
download: {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 10
|
||||
},
|
||||
'download-svg': {
|
||||
id: 'download-svg',
|
||||
type: 'quick',
|
||||
order: 31
|
||||
},
|
||||
'download-png': {
|
||||
id: 'download-png',
|
||||
type: 'quick',
|
||||
order: 32
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Icons', () => ({
|
||||
FilePngIcon: () => <div data-testid="file-png-icon" />,
|
||||
FileSvgIcon: () => <div data-testid="file-svg-icon" />
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/ActionTools', () => ({
|
||||
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||
useToolManager: mocks.useToolManager
|
||||
}))
|
||||
|
||||
// Mock useToolManager
|
||||
const mockRegisterTool = vi.fn()
|
||||
const mockRemoveTool = vi.fn()
|
||||
mocks.useToolManager.mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
removeTool: mockRemoveTool
|
||||
}))
|
||||
|
||||
describe('useDownloadTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Note: mock implementations are already set in vi.hoisted() above
|
||||
})
|
||||
|
||||
// Helper function to create mock props
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useDownloadTool>[0]> = {}) => {
|
||||
const defaultProps = {
|
||||
showPreviewTools: false,
|
||||
previewRef: { current: null },
|
||||
onDownloadSource: vi.fn(),
|
||||
setTools: vi.fn()
|
||||
}
|
||||
|
||||
return { ...defaultProps, ...overrides }
|
||||
}
|
||||
|
||||
// Helper function to create mock preview handles
|
||||
const createMockPreviewHandles = (): BasicPreviewHandles => ({
|
||||
pan: vi.fn(),
|
||||
zoom: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
download: vi.fn()
|
||||
})
|
||||
|
||||
// Helper function for tool registration assertions
|
||||
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||
if (times > 0 && toolConfig) {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||
}
|
||||
}
|
||||
|
||||
const expectNoChildren = () => {
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
expect(registeredTool).not.toHaveProperty('children')
|
||||
}
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('should register single download tool when showPreviewTools is false', () => {
|
||||
const props = createMockProps({ showPreviewTools: false })
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||
expectToolRegistration(1, {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 10,
|
||||
tooltip: 'code_block.download.source',
|
||||
onClick: expect.any(Function),
|
||||
icon: expect.any(Object)
|
||||
})
|
||||
expectNoChildren()
|
||||
})
|
||||
|
||||
it('should register single download tool when showPreviewTools is true but previewRef.current is null', () => {
|
||||
const props = createMockProps({ showPreviewTools: true, previewRef: { current: null } })
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 10,
|
||||
tooltip: 'code_block.download.source', // When previewRef.current is null, showPreviewTools is false
|
||||
onClick: expect.any(Function),
|
||||
icon: expect.any(Object)
|
||||
})
|
||||
expectNoChildren()
|
||||
})
|
||||
|
||||
it('should register download tool with children when showPreviewTools is true and previewRef.current is not null', () => {
|
||||
const mockPreviewHandles = createMockPreviewHandles()
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: mockPreviewHandles }
|
||||
})
|
||||
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 10,
|
||||
tooltip: undefined,
|
||||
icon: expect.any(Object),
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 10,
|
||||
tooltip: 'code_block.download.source',
|
||||
onClick: expect.any(Function),
|
||||
icon: expect.any(Object)
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'download-svg',
|
||||
type: 'quick',
|
||||
order: 31,
|
||||
tooltip: 'code_block.download.svg',
|
||||
onClick: expect.any(Function),
|
||||
icon: expect.any(Object)
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'download-png',
|
||||
type: 'quick',
|
||||
order: 32,
|
||||
tooltip: 'code_block.download.png',
|
||||
onClick: expect.any(Function),
|
||||
icon: expect.any(Object)
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('download functionality', () => {
|
||||
it('should execute download source behavior when tool is activated', () => {
|
||||
const mockOnDownloadSource = vi.fn()
|
||||
const props = createMockProps({ onDownloadSource: mockOnDownloadSource })
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
// Get the onClick handler from the registered tool
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockOnDownloadSource).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should execute download SVG behavior when SVG download tool is activated', () => {
|
||||
const mockPreviewHandles = createMockPreviewHandles()
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: mockPreviewHandles }
|
||||
})
|
||||
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
// Get the download-svg child tool
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
const downloadSvgTool = registeredTool.children?.find((child: any) => child.tooltip === 'code_block.download.svg')
|
||||
|
||||
expect(downloadSvgTool).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
downloadSvgTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockPreviewHandles.download).toHaveBeenCalledTimes(1)
|
||||
expect(mockPreviewHandles.download).toHaveBeenCalledWith('svg')
|
||||
})
|
||||
|
||||
it('should execute download PNG behavior when PNG download tool is activated', () => {
|
||||
const mockPreviewHandles = createMockPreviewHandles()
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: mockPreviewHandles }
|
||||
})
|
||||
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
// Get the download-png child tool
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
const downloadPngTool = registeredTool.children?.find((child: any) => child.tooltip === 'code_block.download.png')
|
||||
|
||||
expect(downloadPngTool).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
downloadPngTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockPreviewHandles.download).toHaveBeenCalledTimes(1)
|
||||
expect(mockPreviewHandles.download).toHaveBeenCalledWith('png')
|
||||
})
|
||||
|
||||
it('should execute download source behavior from child tool', () => {
|
||||
const mockOnDownloadSource = vi.fn()
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
onDownloadSource: mockOnDownloadSource,
|
||||
previewRef: { current: createMockPreviewHandles() }
|
||||
})
|
||||
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
// Get the download source child tool
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
const downloadSourceTool = registeredTool.children?.find(
|
||||
(child: any) => child.tooltip === 'code_block.download.source'
|
||||
)
|
||||
|
||||
expect(downloadSourceTool).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
downloadSourceTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockOnDownloadSource).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove tool on unmount', () => {
|
||||
const props = createMockProps()
|
||||
const { unmount } = renderHook(() => useDownloadTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('download')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing setTools gracefully', () => {
|
||||
const props = createMockProps({ setTools: undefined })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useDownloadTool(props))
|
||||
}).not.toThrow()
|
||||
|
||||
// Should still call useToolManager (but won't actually register)
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should handle missing previewRef.current gracefully', () => {
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: null }
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useDownloadTool(props))
|
||||
}).not.toThrow()
|
||||
|
||||
// Should register single tool without children
|
||||
expectToolRegistration(1)
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
expect(registeredTool).not.toHaveProperty('children')
|
||||
})
|
||||
|
||||
it('should handle download source operation failures gracefully', () => {
|
||||
const mockOnDownloadSource = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Download failed')
|
||||
})
|
||||
|
||||
const props = createMockProps({ onDownloadSource: mockOnDownloadSource })
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
// Errors should be propagated up
|
||||
expect(() => {
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
}).toThrow('Download failed')
|
||||
|
||||
// Callback should still be called
|
||||
expect(mockOnDownloadSource).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle download image operation failures gracefully', () => {
|
||||
const mockPreviewHandles = createMockPreviewHandles()
|
||||
mockPreviewHandles.download = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Image download failed')
|
||||
})
|
||||
|
||||
const props = createMockProps({
|
||||
showPreviewTools: true,
|
||||
previewRef: { current: mockPreviewHandles }
|
||||
})
|
||||
|
||||
renderHook(() => useDownloadTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
const downloadSvgTool = registeredTool.children?.find((child: any) => child.tooltip === 'code_block.download.svg')
|
||||
|
||||
expect(downloadSvgTool).toBeDefined()
|
||||
|
||||
// Errors should be propagated up
|
||||
expect(() => {
|
||||
act(() => {
|
||||
downloadSvgTool.onClick()
|
||||
})
|
||||
}).toThrow('Image download failed')
|
||||
|
||||
// Callback should still be called
|
||||
expect(mockPreviewHandles.download).toHaveBeenCalledTimes(1)
|
||||
expect(mockPreviewHandles.download).toHaveBeenCalledWith('svg')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useExpandTool } from '@renderer/components/CodeToolbar/hooks/useExpandTool'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: vi.fn((key: string) => key)
|
||||
},
|
||||
useToolManager: vi.fn(),
|
||||
TOOL_SPECS: {
|
||||
expand: {
|
||||
id: 'expand',
|
||||
type: 'quick',
|
||||
order: 12
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/ActionTools', () => ({
|
||||
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||
useToolManager: mocks.useToolManager
|
||||
}))
|
||||
|
||||
// Mock useToolManager
|
||||
const mockRegisterTool = vi.fn()
|
||||
const mockRemoveTool = vi.fn()
|
||||
mocks.useToolManager.mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
removeTool: mockRemoveTool
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
ChevronsDownUp: () => <div data-testid="chevrons-down-up" />,
|
||||
ChevronsUpDown: () => <div data-testid="chevrons-up-down" />
|
||||
}))
|
||||
|
||||
describe('useExpandTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper function to create mock props
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useExpandTool>[0]> = {}) => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
expandable: true,
|
||||
toggle: vi.fn(),
|
||||
setTools: vi.fn()
|
||||
}
|
||||
|
||||
return { ...defaultProps, ...overrides }
|
||||
}
|
||||
|
||||
// Helper function for tool registration assertions
|
||||
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||
if (times > 0 && toolConfig) {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('should register expand tool when enabled', () => {
|
||||
const props = createMockProps({ enabled: true })
|
||||
renderHook(() => useExpandTool(props))
|
||||
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||
expectToolRegistration(1, {
|
||||
id: 'expand',
|
||||
type: 'quick',
|
||||
order: 12,
|
||||
tooltip: 'code_block.expand',
|
||||
onClick: expect.any(Function),
|
||||
visible: expect.any(Function)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not register tool when disabled', () => {
|
||||
const props = createMockProps({ enabled: false })
|
||||
renderHook(() => useExpandTool(props))
|
||||
|
||||
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should re-register tool when expanded changes', () => {
|
||||
const props = createMockProps({ expanded: false })
|
||||
const { rerender } = renderHook((hookProps) => useExpandTool(hookProps), {
|
||||
initialProps: props
|
||||
})
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||
const firstCall = mockRegisterTool.mock.calls[0][0]
|
||||
expect(firstCall.tooltip).toBe('code_block.expand')
|
||||
|
||||
// Change expanded to true and rerender
|
||||
const newProps = { ...props, expanded: true }
|
||||
rerender(newProps)
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||
const secondCall = mockRegisterTool.mock.calls[1][0]
|
||||
expect(secondCall.tooltip).toBe('code_block.collapse')
|
||||
})
|
||||
})
|
||||
|
||||
describe('visibility behavior', () => {
|
||||
it('should be visible when expandable is true', () => {
|
||||
const props = createMockProps({ expandable: true })
|
||||
renderHook(() => useExpandTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
expect(registeredTool.visible()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not be visible when expandable is false', () => {
|
||||
const props = createMockProps({ expandable: false })
|
||||
renderHook(() => useExpandTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
expect(registeredTool.visible()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not be visible when expandable is undefined', () => {
|
||||
const props = createMockProps({ expandable: undefined })
|
||||
renderHook(() => useExpandTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
expect(registeredTool.visible()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle functionality', () => {
|
||||
it('should execute toggle function when tool is clicked', () => {
|
||||
const mockToggle = vi.fn()
|
||||
const props = createMockProps({ toggle: mockToggle })
|
||||
renderHook(() => useExpandTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove tool on unmount', () => {
|
||||
const props = createMockProps()
|
||||
const { unmount } = renderHook(() => useExpandTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('expand')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing setTools gracefully', () => {
|
||||
const props = createMockProps({ setTools: undefined })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useExpandTool(props))
|
||||
}).not.toThrow()
|
||||
|
||||
// Should still call useToolManager (but won't actually register)
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should not break when toggle is undefined', () => {
|
||||
const props = createMockProps({ toggle: undefined })
|
||||
renderHook(() => useExpandTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useRunTool } from '@renderer/components/CodeToolbar/hooks/useRunTool'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: vi.fn((key: string) => key)
|
||||
},
|
||||
useToolManager: vi.fn(),
|
||||
TOOL_SPECS: {
|
||||
run: {
|
||||
id: 'run',
|
||||
type: 'quick',
|
||||
order: 11
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
CirclePlay: () => <div>CirclePlay</div>
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Icons', () => ({
|
||||
LoadingIcon: () => <div>Loading</div>
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/ActionTools', () => ({
|
||||
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||
useToolManager: mocks.useToolManager
|
||||
}))
|
||||
|
||||
const mockRegisterTool = vi.fn()
|
||||
const mockRemoveTool = vi.fn()
|
||||
mocks.useToolManager.mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
removeTool: mockRemoveTool
|
||||
}))
|
||||
|
||||
describe('useRunTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useRunTool>[0]> = {}) => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
isRunning: false,
|
||||
onRun: vi.fn(),
|
||||
setTools: vi.fn()
|
||||
}
|
||||
|
||||
return { ...defaultProps, ...overrides }
|
||||
}
|
||||
|
||||
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||
if (times > 0 && toolConfig) {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('should not register tool when disabled', () => {
|
||||
const props = createMockProps({ enabled: false })
|
||||
renderHook(() => useRunTool(props))
|
||||
|
||||
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register run tool when enabled', () => {
|
||||
const props = createMockProps({ enabled: true })
|
||||
renderHook(() => useRunTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
id: 'run',
|
||||
type: 'quick',
|
||||
order: 11,
|
||||
tooltip: 'code_block.run'
|
||||
})
|
||||
})
|
||||
|
||||
it('should re-register tool when isRunning changes', () => {
|
||||
const props = createMockProps({ isRunning: false })
|
||||
const { rerender } = renderHook((hookProps) => useRunTool(hookProps), {
|
||||
initialProps: props
|
||||
})
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||
|
||||
const newProps = { ...props, isRunning: true }
|
||||
rerender(newProps)
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('run functionality', () => {
|
||||
it('should execute onRun when tool is clicked and not running', () => {
|
||||
const mockOnRun = vi.fn()
|
||||
const props = createMockProps({ onRun: mockOnRun, isRunning: false })
|
||||
renderHook(() => useRunTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not execute onRun when tool is clicked and already running', () => {
|
||||
const mockOnRun = vi.fn()
|
||||
const props = createMockProps({ onRun: mockOnRun, isRunning: true })
|
||||
renderHook(() => useRunTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockOnRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove tool on unmount', () => {
|
||||
const props = createMockProps()
|
||||
const { unmount } = renderHook(() => useRunTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('run')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing setTools gracefully', () => {
|
||||
const props = createMockProps({ setTools: undefined })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useRunTool(props))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not break when onRun is undefined', () => {
|
||||
const props = createMockProps({ onRun: undefined })
|
||||
renderHook(() => useRunTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useSaveTool } from '@renderer/components/CodeToolbar/hooks/useSaveTool'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: vi.fn((key: string) => key)
|
||||
},
|
||||
useToolManager: vi.fn(),
|
||||
useTemporaryValue: vi.fn(),
|
||||
TOOL_SPECS: {
|
||||
save: {
|
||||
id: 'save',
|
||||
type: 'core',
|
||||
order: 14
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/ActionTools', () => ({
|
||||
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||
useToolManager: mocks.useToolManager
|
||||
}))
|
||||
|
||||
// Mock useTemporaryValue
|
||||
const mockSetTemporaryValue = vi.fn()
|
||||
mocks.useTemporaryValue.mockImplementation(() => [false, mockSetTemporaryValue])
|
||||
|
||||
vi.mock('@renderer/hooks/useTemporaryValue', () => ({
|
||||
useTemporaryValue: mocks.useTemporaryValue
|
||||
}))
|
||||
|
||||
// Mock useToolManager
|
||||
const mockRegisterTool = vi.fn()
|
||||
const mockRemoveTool = vi.fn()
|
||||
mocks.useToolManager.mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
removeTool: mockRemoveTool
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: () => <div data-testid="check-icon" />,
|
||||
SaveIcon: () => <div data-testid="save-icon" />
|
||||
}))
|
||||
|
||||
describe('useSaveTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset to default values
|
||||
mocks.useTemporaryValue.mockImplementation(() => [false, mockSetTemporaryValue])
|
||||
})
|
||||
|
||||
// Helper function to create mock props
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useSaveTool>[0]> = {}) => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
sourceViewRef: { current: null },
|
||||
setTools: vi.fn()
|
||||
}
|
||||
|
||||
return { ...defaultProps, ...overrides }
|
||||
}
|
||||
|
||||
// Helper function for tool registration assertions
|
||||
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||
if (times > 0 && toolConfig) {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('should register save tool when enabled', () => {
|
||||
const props = createMockProps({ enabled: true })
|
||||
renderHook(() => useSaveTool(props))
|
||||
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||
expectToolRegistration(1, {
|
||||
id: 'save',
|
||||
type: 'core',
|
||||
order: 14,
|
||||
tooltip: 'code_block.edit.save.label',
|
||||
onClick: expect.any(Function)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not register tool when disabled', () => {
|
||||
const props = createMockProps({ enabled: false })
|
||||
renderHook(() => useSaveTool(props))
|
||||
|
||||
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should re-register tool when saved state changes', () => {
|
||||
// Initially not saved
|
||||
mocks.useTemporaryValue.mockImplementation(() => [false, mockSetTemporaryValue])
|
||||
const props = createMockProps()
|
||||
const { rerender } = renderHook(() => useSaveTool(props))
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Change to saved state and rerender
|
||||
mocks.useTemporaryValue.mockImplementation(() => [true, mockSetTemporaryValue])
|
||||
rerender()
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('save functionality', () => {
|
||||
it('should execute save behavior when tool is clicked', () => {
|
||||
const mockSave = vi.fn()
|
||||
const mockEditorHandles = { save: mockSave }
|
||||
const props = createMockProps({
|
||||
sourceViewRef: { current: mockEditorHandles }
|
||||
})
|
||||
renderHook(() => useSaveTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockSave).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetTemporaryValue).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle when sourceViewRef.current is null', () => {
|
||||
const props = createMockProps({
|
||||
sourceViewRef: { current: null }
|
||||
})
|
||||
renderHook(() => useSaveTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockSetTemporaryValue).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle when sourceViewRef.current.save is undefined', () => {
|
||||
const props = createMockProps({
|
||||
sourceViewRef: { current: {} }
|
||||
})
|
||||
renderHook(() => useSaveTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockSetTemporaryValue).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove tool on unmount', () => {
|
||||
const props = createMockProps()
|
||||
const { unmount } = renderHook(() => useSaveTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('save')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing setTools gracefully', () => {
|
||||
const props = createMockProps({ setTools: undefined })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useSaveTool(props))
|
||||
}).not.toThrow()
|
||||
|
||||
// Should still call useToolManager (but won't actually register)
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ViewMode } from '@renderer/components/CodeBlockView/types'
|
||||
import { useSplitViewTool } from '@renderer/components/CodeToolbar/hooks/useSplitViewTool'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: vi.fn((key: string) => key)
|
||||
},
|
||||
useToolManager: vi.fn(),
|
||||
TOOL_SPECS: {
|
||||
'split-view': {
|
||||
id: 'split-view',
|
||||
type: 'quick',
|
||||
order: 10
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/ActionTools', () => ({
|
||||
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||
useToolManager: mocks.useToolManager
|
||||
}))
|
||||
|
||||
// Mock useToolManager
|
||||
const mockRegisterTool = vi.fn()
|
||||
const mockRemoveTool = vi.fn()
|
||||
mocks.useToolManager.mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
removeTool: mockRemoveTool
|
||||
}))
|
||||
|
||||
describe('useSplitViewTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper function to create mock props
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useSplitViewTool>[0]> = {}) => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
viewMode: 'special' as ViewMode,
|
||||
onToggleSplitView: vi.fn(),
|
||||
setTools: vi.fn()
|
||||
}
|
||||
|
||||
return { ...defaultProps, ...overrides }
|
||||
}
|
||||
|
||||
// Helper function for tool registration assertions
|
||||
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||
if (times > 0 && toolConfig) {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('should not register tool when disabled', () => {
|
||||
const props = createMockProps({ enabled: false })
|
||||
renderHook(() => useSplitViewTool(props))
|
||||
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register split view tool when enabled', () => {
|
||||
const props = createMockProps({ enabled: true })
|
||||
renderHook(() => useSplitViewTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
id: 'split-view',
|
||||
type: 'quick',
|
||||
order: 10,
|
||||
tooltip: 'code_block.split.label',
|
||||
onClick: expect.any(Function),
|
||||
icon: expect.any(Object)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show different tooltip when in split mode', () => {
|
||||
const props = createMockProps({ viewMode: 'split' })
|
||||
renderHook(() => useSplitViewTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
tooltip: 'code_block.split.restore'
|
||||
})
|
||||
})
|
||||
|
||||
it('should show different tooltip when not in split mode', () => {
|
||||
const props = createMockProps({ viewMode: 'special' })
|
||||
renderHook(() => useSplitViewTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
tooltip: 'code_block.split.label'
|
||||
})
|
||||
})
|
||||
|
||||
it('should re-register tool when viewMode changes', () => {
|
||||
const props = createMockProps({ viewMode: 'special' })
|
||||
const { rerender } = renderHook((hookProps) => useSplitViewTool(hookProps), {
|
||||
initialProps: props
|
||||
})
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Change viewMode and rerender
|
||||
const newProps = { ...props, viewMode: 'split' as ViewMode }
|
||||
rerender(newProps)
|
||||
|
||||
// Should register tool again with updated state
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Verify the new registration has correct tooltip
|
||||
const secondRegistration = mockRegisterTool.mock.calls[1][0]
|
||||
expect(secondRegistration.tooltip).toBe('code_block.split.restore')
|
||||
})
|
||||
})
|
||||
|
||||
describe('view mode switching', () => {
|
||||
it('should call onToggleSplitView when tool is clicked', () => {
|
||||
const mockOnToggleSplitView = vi.fn()
|
||||
const props = createMockProps({
|
||||
onToggleSplitView: mockOnToggleSplitView
|
||||
})
|
||||
renderHook(() => useSplitViewTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockOnToggleSplitView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove tool on unmount', () => {
|
||||
const props = createMockProps()
|
||||
const { unmount } = renderHook(() => useSplitViewTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('split-view')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing setTools gracefully', () => {
|
||||
const props = createMockProps({ setTools: undefined })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useSplitViewTool(props))
|
||||
}).not.toThrow()
|
||||
|
||||
// Should still call useToolManager (but won't actually register)
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should not break when onToggleSplitView is undefined', () => {
|
||||
const props = createMockProps({ onToggleSplitView: undefined })
|
||||
renderHook(() => useSplitViewTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { ViewMode } from '@renderer/components/CodeBlockView/types'
|
||||
import { useViewSourceTool } from '@renderer/components/CodeToolbar/hooks/useViewSourceTool'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: vi.fn((key: string) => key)
|
||||
},
|
||||
useToolManager: vi.fn(),
|
||||
TOOL_SPECS: {
|
||||
edit: {
|
||||
id: 'edit',
|
||||
type: 'core',
|
||||
order: 12
|
||||
},
|
||||
'view-source': {
|
||||
id: 'view-source',
|
||||
type: 'core',
|
||||
order: 12
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/ActionTools', () => ({
|
||||
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||
useToolManager: mocks.useToolManager
|
||||
}))
|
||||
|
||||
const mockRegisterTool = vi.fn()
|
||||
const mockRemoveTool = vi.fn()
|
||||
mocks.useToolManager.mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
removeTool: mockRemoveTool
|
||||
}))
|
||||
|
||||
describe('useViewSourceTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useViewSourceTool>[0]> = {}) => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
editable: false,
|
||||
viewMode: 'special' as ViewMode,
|
||||
onViewModeChange: vi.fn(),
|
||||
setTools: vi.fn()
|
||||
}
|
||||
|
||||
return { ...defaultProps, ...overrides }
|
||||
}
|
||||
|
||||
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||
if (times > 0 && toolConfig) {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('should not register tool when disabled', () => {
|
||||
const props = createMockProps({ enabled: false })
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not register tool when in split mode', () => {
|
||||
const props = createMockProps({ viewMode: 'split' })
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register view-source tool when not editable', () => {
|
||||
const props = createMockProps({ editable: false })
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
id: 'view-source',
|
||||
type: 'core',
|
||||
order: 12
|
||||
})
|
||||
})
|
||||
|
||||
it('should register edit tool when editable', () => {
|
||||
const props = createMockProps({ editable: true })
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
id: 'edit',
|
||||
type: 'core',
|
||||
order: 12
|
||||
})
|
||||
})
|
||||
|
||||
it('should re-register tool when editable changes', () => {
|
||||
const props = createMockProps({ editable: false })
|
||||
const { rerender } = renderHook((hookProps) => useViewSourceTool(hookProps), {
|
||||
initialProps: props
|
||||
})
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||
|
||||
const newProps = { ...props, editable: true }
|
||||
rerender(newProps)
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('view-source')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tooltip variations', () => {
|
||||
it('should show correct tooltips for edit mode', () => {
|
||||
const props = createMockProps({ editable: true, viewMode: 'source' })
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
tooltip: 'preview.label'
|
||||
})
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
const propsSpecial = createMockProps({ editable: true, viewMode: 'special' })
|
||||
renderHook(() => useViewSourceTool(propsSpecial))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
tooltip: 'code_block.edit.label'
|
||||
})
|
||||
})
|
||||
|
||||
it('should show correct tooltips for view-source mode', () => {
|
||||
const props = createMockProps({ editable: false, viewMode: 'source' })
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
tooltip: 'preview.label'
|
||||
})
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
const propsSpecial = createMockProps({ editable: false, viewMode: 'special' })
|
||||
renderHook(() => useViewSourceTool(propsSpecial))
|
||||
|
||||
expectToolRegistration(1, {
|
||||
tooltip: 'preview.source'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('view mode switching', () => {
|
||||
it('should switch from special to source when tool is clicked', () => {
|
||||
const mockOnViewModeChange = vi.fn()
|
||||
const props = createMockProps({
|
||||
viewMode: 'special',
|
||||
onViewModeChange: mockOnViewModeChange
|
||||
})
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockOnViewModeChange).toHaveBeenCalledWith('source')
|
||||
})
|
||||
|
||||
it('should switch from source to special when tool is clicked', () => {
|
||||
const mockOnViewModeChange = vi.fn()
|
||||
const props = createMockProps({
|
||||
viewMode: 'source',
|
||||
onViewModeChange: mockOnViewModeChange
|
||||
})
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockOnViewModeChange).toHaveBeenCalledWith('special')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove tool on unmount', () => {
|
||||
const props = createMockProps()
|
||||
const { unmount } = renderHook(() => useViewSourceTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('view-source')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing setTools gracefully', () => {
|
||||
const props = createMockProps({ setTools: undefined })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not break when onViewModeChange is undefined', () => {
|
||||
const props = createMockProps({ onViewModeChange: undefined })
|
||||
renderHook(() => useViewSourceTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useWrapTool } from '@renderer/components/CodeToolbar/hooks/useWrapTool'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18n: {
|
||||
t: vi.fn((key: string) => key)
|
||||
},
|
||||
useToolManager: vi.fn(),
|
||||
TOOL_SPECS: {
|
||||
wrap: {
|
||||
id: 'wrap',
|
||||
type: 'quick',
|
||||
order: 13
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mocks.i18n.t
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/ActionTools', () => ({
|
||||
TOOL_SPECS: mocks.TOOL_SPECS,
|
||||
useToolManager: mocks.useToolManager
|
||||
}))
|
||||
|
||||
// Mock useToolManager
|
||||
const mockRegisterTool = vi.fn()
|
||||
const mockRemoveTool = vi.fn()
|
||||
mocks.useToolManager.mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
removeTool: mockRemoveTool
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Text: () => <div data-testid="text-icon" />,
|
||||
WrapText: () => <div data-testid="wrap-text-icon" />
|
||||
}))
|
||||
|
||||
describe('useWrapTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper function to create mock props
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useWrapTool>[0]> = {}) => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
unwrapped: false,
|
||||
wrappable: true,
|
||||
toggle: vi.fn(),
|
||||
setTools: vi.fn()
|
||||
}
|
||||
|
||||
return { ...defaultProps, ...overrides }
|
||||
}
|
||||
|
||||
// Helper function for tool registration assertions
|
||||
const expectToolRegistration = (times: number, toolConfig?: object) => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(times)
|
||||
if (times > 0 && toolConfig) {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(expect.objectContaining(toolConfig))
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('should register wrap tool when enabled', () => {
|
||||
const props = createMockProps({ enabled: true })
|
||||
renderHook(() => useWrapTool(props))
|
||||
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(props.setTools)
|
||||
expectToolRegistration(1, {
|
||||
id: 'wrap',
|
||||
type: 'quick',
|
||||
order: 13,
|
||||
tooltip: 'code_block.wrap.off',
|
||||
onClick: expect.any(Function),
|
||||
visible: expect.any(Function)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not register tool when disabled', () => {
|
||||
const props = createMockProps({ enabled: false })
|
||||
renderHook(() => useWrapTool(props))
|
||||
|
||||
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should re-register tool when unwrapped changes', () => {
|
||||
const props = createMockProps({ unwrapped: false })
|
||||
const { rerender } = renderHook((hookProps) => useWrapTool(hookProps), {
|
||||
initialProps: props
|
||||
})
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(1)
|
||||
const firstCall = mockRegisterTool.mock.calls[0][0]
|
||||
expect(firstCall.tooltip).toBe('code_block.wrap.off')
|
||||
|
||||
// Change unwrapped to true and rerender
|
||||
const newProps = { ...props, unwrapped: true }
|
||||
rerender(newProps)
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||
const secondCall = mockRegisterTool.mock.calls[1][0]
|
||||
expect(secondCall.tooltip).toBe('code_block.wrap.on')
|
||||
})
|
||||
})
|
||||
|
||||
describe('visibility behavior', () => {
|
||||
it('should be visible when wrappable is true', () => {
|
||||
const props = createMockProps({ wrappable: true })
|
||||
renderHook(() => useWrapTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
expect(registeredTool.visible()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not be visible when wrappable is false', () => {
|
||||
const props = createMockProps({ wrappable: false })
|
||||
renderHook(() => useWrapTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
expect(registeredTool.visible()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not be visible when wrappable is undefined', () => {
|
||||
const props = createMockProps({ wrappable: undefined })
|
||||
renderHook(() => useWrapTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
expect(registeredTool.visible()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle functionality', () => {
|
||||
it('should execute toggle function when tool is clicked', () => {
|
||||
const mockToggle = vi.fn()
|
||||
const props = createMockProps({ toggle: mockToggle })
|
||||
renderHook(() => useWrapTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
|
||||
expect(mockToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove tool on unmount', () => {
|
||||
const props = createMockProps()
|
||||
const { unmount } = renderHook(() => useWrapTool(props))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith('wrap')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing setTools gracefully', () => {
|
||||
const props = createMockProps({ setTools: undefined })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useWrapTool(props))
|
||||
}).not.toThrow()
|
||||
|
||||
// Should still call useToolManager (but won't actually register)
|
||||
expect(mocks.useToolManager).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should not break when toggle is undefined', () => {
|
||||
const props = createMockProps({ toggle: undefined })
|
||||
renderHook(() => useWrapTool(props))
|
||||
|
||||
const registeredTool = mockRegisterTool.mock.calls[0][0]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
registeredTool.onClick()
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ActionTool } from '@renderer/components/ActionTools'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { memo, useMemo } from 'react'
|
||||
|
||||
import { ToolWrapper } from './styles'
|
||||
|
||||
interface CodeToolButtonProps {
|
||||
tool: ActionTool
|
||||
}
|
||||
|
||||
const CodeToolButton = ({ tool }: CodeToolButtonProps) => {
|
||||
const mainTool = useMemo(
|
||||
() => (
|
||||
<Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
<ToolWrapper onClick={tool.onClick}>{tool.icon}</ToolWrapper>
|
||||
</Tooltip>
|
||||
),
|
||||
[tool]
|
||||
)
|
||||
|
||||
if (tool.children?.length && tool.children.length > 0) {
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: tool.children.map((child) => ({
|
||||
key: child.id,
|
||||
label: child.tooltip,
|
||||
icon: child.icon,
|
||||
onClick: child.onClick
|
||||
}))
|
||||
}}
|
||||
trigger={['click']}>
|
||||
{mainTool}
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
return mainTool
|
||||
}
|
||||
|
||||
export default memo(CodeToolButton)
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './useCopyTool'
|
||||
export * from './useDownloadTool'
|
||||
export * from './useExpandTool'
|
||||
export * from './useRunTool'
|
||||
export * from './useSaveTool'
|
||||
export * from './useSplitViewTool'
|
||||
export * from './useViewSourceTool'
|
||||
export * from './useWrapTool'
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { CopyIcon } from '@renderer/components/Icons'
|
||||
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import { Check, Image } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseCopyToolProps {
|
||||
showPreviewTools?: boolean
|
||||
previewRef: React.RefObject<BasicPreviewHandles | null>
|
||||
onCopySource: () => void
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useCopyTool = ({ showPreviewTools, previewRef, onCopySource, setTools }: UseCopyToolProps) => {
|
||||
const [copied, setCopiedTemporarily] = useTemporaryValue(false)
|
||||
const [copiedImage, setCopiedImageTemporarily] = useTemporaryValue(false)
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
const handleCopySource = useCallback(() => {
|
||||
try {
|
||||
onCopySource()
|
||||
setCopiedTemporarily(true)
|
||||
} catch (error) {
|
||||
setCopiedTemporarily(false)
|
||||
throw error
|
||||
}
|
||||
}, [onCopySource, setCopiedTemporarily])
|
||||
|
||||
const handleCopyImage = useCallback(() => {
|
||||
try {
|
||||
previewRef.current?.copy()
|
||||
setCopiedImageTemporarily(true)
|
||||
} catch (error) {
|
||||
setCopiedImageTemporarily(false)
|
||||
throw error
|
||||
}
|
||||
}, [previewRef, setCopiedImageTemporarily])
|
||||
|
||||
useEffect(() => {
|
||||
const includePreviewTools = showPreviewTools && previewRef.current !== null
|
||||
|
||||
const baseTool = {
|
||||
...TOOL_SPECS.copy,
|
||||
icon: copied ? (
|
||||
<Check className="tool-icon" color="var(--color-status-success)" />
|
||||
) : (
|
||||
<CopyIcon className="tool-icon" />
|
||||
),
|
||||
tooltip: t('code_block.copy.source'),
|
||||
onClick: handleCopySource
|
||||
}
|
||||
|
||||
const copyImageTool = {
|
||||
...TOOL_SPECS['copy-image'],
|
||||
icon: copiedImage ? (
|
||||
<Check className="tool-icon" color="var(--color-status-success)" />
|
||||
) : (
|
||||
<Image className="tool-icon" />
|
||||
),
|
||||
tooltip: t('preview.copy.image'),
|
||||
onClick: handleCopyImage
|
||||
}
|
||||
|
||||
registerTool(baseTool)
|
||||
|
||||
if (includePreviewTools) {
|
||||
registerTool(copyImageTool)
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeTool(TOOL_SPECS.copy.id)
|
||||
removeTool(TOOL_SPECS['copy-image'].id)
|
||||
}
|
||||
}, [
|
||||
onCopySource,
|
||||
registerTool,
|
||||
removeTool,
|
||||
t,
|
||||
copied,
|
||||
copiedImage,
|
||||
handleCopySource,
|
||||
handleCopyImage,
|
||||
showPreviewTools,
|
||||
previewRef
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { FilePngIcon, FileSvgIcon } from '@renderer/components/Icons'
|
||||
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||
import { Download, FileCode } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseDownloadToolProps {
|
||||
showPreviewTools?: boolean
|
||||
previewRef: React.RefObject<BasicPreviewHandles | null>
|
||||
onDownloadSource: () => void
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useDownloadTool = ({ showPreviewTools, previewRef, onDownloadSource, setTools }: UseDownloadToolProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
useEffect(() => {
|
||||
const includePreviewTools = showPreviewTools && previewRef.current !== null
|
||||
|
||||
const baseTool = {
|
||||
...TOOL_SPECS.download,
|
||||
icon: <Download className="tool-icon" />,
|
||||
tooltip: includePreviewTools ? undefined : t('code_block.download.source')
|
||||
}
|
||||
|
||||
if (includePreviewTools) {
|
||||
registerTool({
|
||||
...baseTool,
|
||||
children: [
|
||||
{
|
||||
...TOOL_SPECS.download,
|
||||
icon: <FileCode size={'1rem'} />,
|
||||
tooltip: t('code_block.download.source'),
|
||||
onClick: onDownloadSource
|
||||
},
|
||||
{
|
||||
...TOOL_SPECS['download-svg'],
|
||||
icon: <FileSvgIcon size={'1rem'} className="lucide" />,
|
||||
tooltip: t('code_block.download.svg'),
|
||||
onClick: () => previewRef.current?.download('svg')
|
||||
},
|
||||
{
|
||||
...TOOL_SPECS['download-png'],
|
||||
icon: <FilePngIcon size={'1rem'} className="lucide" />,
|
||||
tooltip: t('code_block.download.png'),
|
||||
onClick: () => previewRef.current?.download('png')
|
||||
}
|
||||
]
|
||||
})
|
||||
} else {
|
||||
registerTool({
|
||||
...baseTool,
|
||||
onClick: onDownloadSource
|
||||
})
|
||||
}
|
||||
|
||||
return () => removeTool(TOOL_SPECS.download.id)
|
||||
}, [onDownloadSource, registerTool, removeTool, t, showPreviewTools, previewRef])
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { ChevronsDownUp, ChevronsUpDown } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseExpandToolProps {
|
||||
enabled?: boolean
|
||||
expanded?: boolean
|
||||
expandable?: boolean
|
||||
toggle: () => void
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useExpandTool = ({ enabled, expanded, expandable, toggle, setTools }: UseExpandToolProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
toggle?.()
|
||||
}, [toggle])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
registerTool({
|
||||
...TOOL_SPECS.expand,
|
||||
icon: expanded ? <ChevronsDownUp className="tool-icon" /> : <ChevronsUpDown className="tool-icon" />,
|
||||
tooltip: expanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => expandable ?? false,
|
||||
onClick: handleToggle
|
||||
})
|
||||
}
|
||||
|
||||
return () => removeTool(TOOL_SPECS.expand.id)
|
||||
}, [enabled, expandable, expanded, handleToggle, registerTool, removeTool, t])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import { CirclePlay } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseRunToolProps {
|
||||
enabled: boolean
|
||||
isRunning: boolean
|
||||
onRun: () => void
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useRunTool = ({ enabled, isRunning, onRun, setTools }: UseRunToolProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
registerTool({
|
||||
...TOOL_SPECS.run,
|
||||
icon: isRunning ? <LoadingIcon className="tool-icon" /> : <CirclePlay className="tool-icon" />,
|
||||
tooltip: t('code_block.run'),
|
||||
onClick: () => !isRunning && onRun?.()
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.run.id)
|
||||
}, [enabled, isRunning, onRun, registerTool, removeTool, t])
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import { Check, SaveIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseSaveToolProps {
|
||||
enabled?: boolean
|
||||
sourceViewRef: React.RefObject<CodeEditorHandles | null>
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useSaveTool = ({ enabled, sourceViewRef, setTools }: UseSaveToolProps) => {
|
||||
const [saved, setSavedTemporarily] = useTemporaryValue(false)
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
sourceViewRef.current?.save?.()
|
||||
setSavedTemporarily(true)
|
||||
}, [sourceViewRef, setSavedTemporarily])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
registerTool({
|
||||
...TOOL_SPECS.save,
|
||||
icon: saved ? (
|
||||
<Check className="tool-icon" color="var(--color-status-success)" />
|
||||
) : (
|
||||
<SaveIcon className="tool-icon" />
|
||||
),
|
||||
tooltip: t('code_block.edit.save.label'),
|
||||
onClick: handleSave
|
||||
})
|
||||
}
|
||||
|
||||
return () => removeTool(TOOL_SPECS.save.id)
|
||||
}, [enabled, handleSave, registerTool, removeTool, saved, t])
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { ViewMode } from '@renderer/components/CodeBlockView/types'
|
||||
import { Square, SquareSplitHorizontal } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseSplitViewToolProps {
|
||||
enabled: boolean
|
||||
viewMode: ViewMode
|
||||
onToggleSplitView: () => void
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useSplitViewTool = ({ enabled, viewMode, onToggleSplitView, setTools }: UseSplitViewToolProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
const handleToggleSplitView = useCallback(() => {
|
||||
onToggleSplitView?.()
|
||||
}, [onToggleSplitView])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
registerTool({
|
||||
...TOOL_SPECS['split-view'],
|
||||
icon: viewMode === 'split' ? <Square className="tool-icon" /> : <SquareSplitHorizontal className="tool-icon" />,
|
||||
tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split.label'),
|
||||
onClick: handleToggleSplitView
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS['split-view'].id)
|
||||
}, [enabled, viewMode, registerTool, removeTool, t, handleToggleSplitView])
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { ViewMode } from '@renderer/components/CodeBlockView/types'
|
||||
import { CodeXml, Eye, SquarePen } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseViewSourceToolProps {
|
||||
enabled: boolean
|
||||
editable: boolean
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useViewSourceTool = ({
|
||||
enabled,
|
||||
editable,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
setTools
|
||||
}: UseViewSourceToolProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
const handleToggleViewMode = useCallback(() => {
|
||||
const newMode = viewMode === 'source' ? 'special' : 'source'
|
||||
onViewModeChange?.(newMode)
|
||||
}, [viewMode, onViewModeChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || viewMode === 'split') return
|
||||
|
||||
const toolSpec = editable ? TOOL_SPECS.edit : TOOL_SPECS['view-source']
|
||||
|
||||
if (editable) {
|
||||
registerTool({
|
||||
...toolSpec,
|
||||
icon: viewMode === 'source' ? <Eye className="tool-icon" /> : <SquarePen className="tool-icon" />,
|
||||
tooltip: viewMode === 'source' ? t('preview.label') : t('code_block.edit.label'),
|
||||
onClick: handleToggleViewMode
|
||||
})
|
||||
} else {
|
||||
registerTool({
|
||||
...toolSpec,
|
||||
icon: viewMode === 'source' ? <Eye className="tool-icon" /> : <CodeXml className="tool-icon" />,
|
||||
tooltip: viewMode === 'source' ? t('preview.label') : t('preview.source'),
|
||||
onClick: handleToggleViewMode
|
||||
})
|
||||
}
|
||||
|
||||
return () => removeTool(toolSpec.id)
|
||||
}, [enabled, editable, viewMode, registerTool, removeTool, t, handleToggleViewMode])
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
|
||||
import { Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseWrapToolProps {
|
||||
enabled?: boolean
|
||||
unwrapped?: boolean
|
||||
wrappable?: boolean
|
||||
toggle: () => void
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: UseWrapToolProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
toggle?.()
|
||||
}, [toggle])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
registerTool({
|
||||
...TOOL_SPECS.wrap,
|
||||
icon: unwrapped ? <WrapIcon className="tool-icon" /> : <UnWrapIcon className="tool-icon" />,
|
||||
tooltip: unwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
visible: () => wrappable ?? false,
|
||||
onClick: handleToggle
|
||||
})
|
||||
}
|
||||
|
||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||
}, [enabled, handleToggle, registerTool, removeTool, t, unwrapped, wrappable])
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
export * from './constants'
|
||||
export * from './hook'
|
||||
export * from './toolbar'
|
||||
export * from './types'
|
||||
export * from './usePreviewTools'
|
||||
export { default as CodeToolButton } from './button'
|
||||
export * from './hooks'
|
||||
export { default as CodeToolbar } from './toolbar'
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const ToolWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.tool-icon {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
.tool-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* For Lucide icons */
|
||||
.tool-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
`
|
||||
@@ -1,25 +1,15 @@
|
||||
import { ActionTool } from '@renderer/components/ActionTools'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { Tooltip } from 'antd'
|
||||
import { EllipsisVertical } from 'lucide-react'
|
||||
import React, { memo, useMemo, useState } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { CodeTool } from './types'
|
||||
import CodeToolButton from './button'
|
||||
import { ToolWrapper } from './styles'
|
||||
|
||||
interface CodeToolButtonProps {
|
||||
tool: CodeTool
|
||||
}
|
||||
|
||||
const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => {
|
||||
return (
|
||||
<Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5}>
|
||||
<ToolWrapper onClick={() => tool.onClick()}>{tool.icon}</ToolWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) => {
|
||||
const CodeToolbar = ({ tools }: { tools: ActionTool[] }) => {
|
||||
const [showQuickTools, setShowQuickTools] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -51,7 +41,7 @@ export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) =>
|
||||
{quickTools.length > 1 && (
|
||||
<Tooltip title={t('code_block.more')} mouseEnterDelay={0.5}>
|
||||
<ToolWrapper onClick={() => setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}>
|
||||
<EllipsisVertical className="icon" />
|
||||
<EllipsisVertical className="tool-icon" />
|
||||
</ToolWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -63,7 +53,7 @@ export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) =>
|
||||
</ToolbarWrapper>
|
||||
</StickyWrapper>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const StickyWrapper = styled.div`
|
||||
position: sticky;
|
||||
@@ -80,36 +70,4 @@ const ToolbarWrapper = styled(HStack)`
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const ToolWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.icon {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
.icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* For Lucide icons */
|
||||
.icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
`
|
||||
export default memo(CodeToolbar)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 代码块工具基本信息
|
||||
*/
|
||||
export interface CodeToolSpec {
|
||||
id: string
|
||||
type: 'core' | 'quick'
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块工具定义接口
|
||||
* @param id 唯一标识符
|
||||
* @param type 工具类型
|
||||
* @param icon 按钮图标
|
||||
* @param tooltip 提示文本
|
||||
* @param condition 显示条件
|
||||
* @param onClick 点击动作
|
||||
* @param order 显示顺序,越小越靠右
|
||||
*/
|
||||
export interface CodeTool extends CodeToolSpec {
|
||||
icon: React.ReactNode
|
||||
tooltip: string
|
||||
visible?: () => boolean
|
||||
onClick: () => void
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { FileImage, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
|
||||
import { TOOL_SPECS } from './constants'
|
||||
import { useCodeTool } from './hook'
|
||||
import { CodeTool } from './types'
|
||||
|
||||
const logger = loggerService.withContext('usePreviewToolHandlers')
|
||||
|
||||
// 预编译正则表达式用于查询位置
|
||||
const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
|
||||
|
||||
/**
|
||||
* 使用图像处理工具的自定义Hook
|
||||
* 提供图像缩放、复制和下载功能
|
||||
*/
|
||||
export const usePreviewToolHandlers = (
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
options: {
|
||||
prefix: string
|
||||
imgSelector: string
|
||||
enableWheelZoom?: boolean
|
||||
customDownloader?: (format: 'svg' | 'png') => void
|
||||
}
|
||||
) => {
|
||||
const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态
|
||||
const [renderTrigger, setRenderTrigger] = useState(0) // 仅用于触发组件重渲染的状态
|
||||
const { imgSelector, prefix, customDownloader, enableWheelZoom } = options
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 创建选择器函数
|
||||
const getImgElement = useCallback(() => {
|
||||
if (!containerRef.current) return null
|
||||
|
||||
// 优先尝试从 Shadow DOM 中查找
|
||||
const shadowRoot = containerRef.current.shadowRoot
|
||||
if (shadowRoot) {
|
||||
return shadowRoot.querySelector(imgSelector) as SVGElement | null
|
||||
}
|
||||
|
||||
// 降级到常规 DOM 查找
|
||||
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
||||
}, [containerRef, imgSelector])
|
||||
|
||||
// 查询当前位置
|
||||
const getCurrentPosition = useCallback(() => {
|
||||
const imgElement = getImgElement()
|
||||
if (!imgElement) return { x: transformRef.current.x, y: transformRef.current.y }
|
||||
|
||||
const transform = imgElement.style.transform
|
||||
if (!transform || transform === 'none') return { x: transformRef.current.x, y: transformRef.current.y }
|
||||
|
||||
const match = transform.match(TRANSFORM_REGEX)
|
||||
if (match && match.length >= 3) {
|
||||
return {
|
||||
x: parseFloat(match[1]),
|
||||
y: parseFloat(match[2])
|
||||
}
|
||||
}
|
||||
|
||||
return { x: transformRef.current.x, y: transformRef.current.y }
|
||||
}, [getImgElement])
|
||||
|
||||
// 平移缩放变换
|
||||
const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => {
|
||||
if (!element) return
|
||||
element.style.transformOrigin = 'top left'
|
||||
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
|
||||
}, [])
|
||||
|
||||
// 拖拽平移支持
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let isDragging = false
|
||||
const startPos = { x: 0, y: 0 }
|
||||
const startOffset = { x: 0, y: 0 }
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return // 只响应左键
|
||||
|
||||
// 更新当前实际位置
|
||||
const position = getCurrentPosition()
|
||||
transformRef.current.x = position.x
|
||||
transformRef.current.y = position.y
|
||||
|
||||
isDragging = true
|
||||
startPos.x = e.clientX
|
||||
startPos.y = e.clientY
|
||||
startOffset.x = position.x
|
||||
startOffset.y = position.y
|
||||
|
||||
container.style.cursor = 'grabbing'
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const dx = e.clientX - startPos.x
|
||||
const dy = e.clientY - startPos.y
|
||||
const newX = startOffset.x + dx
|
||||
const newY = startOffset.y + dy
|
||||
|
||||
const imgElement = getImgElement()
|
||||
applyTransform(imgElement, newX, newY, transformRef.current.scale)
|
||||
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
if (!isDragging) return
|
||||
|
||||
// 更新位置但不立即触发状态变更
|
||||
const position = getCurrentPosition()
|
||||
transformRef.current.x = position.x
|
||||
transformRef.current.y = position.y
|
||||
|
||||
// 只触发一次渲染以保持组件状态同步
|
||||
setRenderTrigger((prev) => prev + 1)
|
||||
|
||||
isDragging = false
|
||||
container.style.cursor = 'default'
|
||||
}
|
||||
|
||||
// 绑定到document以确保拖拽可以在鼠标离开容器后继续
|
||||
container.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
}
|
||||
}, [containerRef, getCurrentPosition, getImgElement, applyTransform])
|
||||
|
||||
// 缩放处理函数
|
||||
const handleZoom = useCallback(
|
||||
(delta: number) => {
|
||||
const newScale = Math.max(0.1, Math.min(3, transformRef.current.scale + delta))
|
||||
transformRef.current.scale = newScale
|
||||
|
||||
const imgElement = getImgElement()
|
||||
applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale)
|
||||
|
||||
// 触发重渲染以保持组件状态同步
|
||||
setRenderTrigger((prev) => prev + 1)
|
||||
},
|
||||
[getImgElement, applyTransform]
|
||||
)
|
||||
|
||||
// 滚轮缩放支持
|
||||
useEffect(() => {
|
||||
if (!enableWheelZoom || !containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.target) {
|
||||
// 确认事件发生在容器内部
|
||||
if (container.contains(e.target as Node)) {
|
||||
const delta = e.deltaY < 0 ? 0.1 : -0.1
|
||||
handleZoom(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: true })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [containerRef, handleZoom, enableWheelZoom])
|
||||
|
||||
// 复制图像处理函数
|
||||
const handleCopyImage = useCallback(async () => {
|
||||
try {
|
||||
const imgElement = getImgElement()
|
||||
if (!imgElement) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
|
||||
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
|
||||
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
||||
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
|
||||
|
||||
img.onload = async () => {
|
||||
const scale = 3
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
|
||||
if (ctx) {
|
||||
ctx.scale(scale, scale)
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
window.message.success(t('message.copy.success'))
|
||||
}
|
||||
}
|
||||
img.src = svgBase64
|
||||
} catch (error) {
|
||||
logger.error('Copy failed:', error as Error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
}, [getImgElement, t])
|
||||
|
||||
// 下载处理函数
|
||||
const handleDownload = useCallback(
|
||||
(format: 'svg' | 'png') => {
|
||||
// 如果有自定义下载器,使用自定义实现
|
||||
if (customDownloader) {
|
||||
customDownloader(format)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const imgElement = getImgElement()
|
||||
if (!imgElement) return
|
||||
|
||||
const timestamp = Date.now()
|
||||
|
||||
if (format === 'svg') {
|
||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
download(url, `${prefix}-${timestamp}.svg`)
|
||||
URL.revokeObjectURL(url)
|
||||
} else if (format === 'png') {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
|
||||
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
|
||||
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(imgElement)
|
||||
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
|
||||
|
||||
img.onload = () => {
|
||||
const scale = 3
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
|
||||
if (ctx) {
|
||||
ctx.scale(scale, scale)
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
}
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const pngUrl = URL.createObjectURL(blob)
|
||||
download(pngUrl, `${prefix}-${timestamp}.png`)
|
||||
URL.revokeObjectURL(pngUrl)
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
img.src = svgBase64
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Download failed:', error as Error)
|
||||
}
|
||||
},
|
||||
[getImgElement, prefix, customDownloader]
|
||||
)
|
||||
|
||||
return {
|
||||
scale: transformRef.current.scale,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload,
|
||||
renderTrigger // 导出渲染触发器,万一要用
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreviewToolsOptions {
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
handleZoom?: (delta: number) => void
|
||||
handleCopyImage?: () => Promise<void>
|
||||
handleDownload?: (format: 'svg' | 'png') => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供预览组件通用工具栏功能的自定义Hook
|
||||
*/
|
||||
export const usePreviewTools = ({ setTools, handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
|
||||
useEffect(() => {
|
||||
// 根据提供的功能有选择性地注册工具
|
||||
if (handleZoom) {
|
||||
// 放大工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['zoom-in'],
|
||||
icon: <ZoomIn className="icon" />,
|
||||
tooltip: t('code_block.preview.zoom_in'),
|
||||
onClick: () => handleZoom(0.1)
|
||||
})
|
||||
|
||||
// 缩小工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['zoom-out'],
|
||||
icon: <ZoomOut className="icon" />,
|
||||
tooltip: t('code_block.preview.zoom_out'),
|
||||
onClick: () => handleZoom(-0.1)
|
||||
})
|
||||
}
|
||||
|
||||
if (handleCopyImage) {
|
||||
// 复制图片工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['copy-image'],
|
||||
icon: <FileImage className="icon" />,
|
||||
tooltip: t('code_block.preview.copy.image'),
|
||||
onClick: handleCopyImage
|
||||
})
|
||||
}
|
||||
|
||||
if (handleDownload) {
|
||||
// 下载 SVG 工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['download-svg'],
|
||||
icon: <DownloadSvgIcon />,
|
||||
tooltip: t('code_block.download.svg'),
|
||||
onClick: () => handleDownload('svg')
|
||||
})
|
||||
|
||||
// 下载 PNG 工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['download-png'],
|
||||
icon: <DownloadPngIcon />,
|
||||
tooltip: t('code_block.download.png'),
|
||||
onClick: () => handleDownload('png')
|
||||
})
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (handleZoom) {
|
||||
removeTool(TOOL_SPECS['zoom-in'].id)
|
||||
removeTool(TOOL_SPECS['zoom-out'].id)
|
||||
}
|
||||
if (handleCopyImage) {
|
||||
removeTool(TOOL_SPECS['copy-image'].id)
|
||||
}
|
||||
if (handleDownload) {
|
||||
removeTool(TOOL_SPECS['download-svg'].id)
|
||||
removeTool(TOOL_SPECS['download-png'].id)
|
||||
}
|
||||
}
|
||||
}, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t])
|
||||
}
|
||||
+38
-69
@@ -1,4 +1,4 @@
|
||||
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -6,82 +6,34 @@ import { uuid } from '@renderer/utils'
|
||||
import { getReactStyleFromToken } from '@renderer/utils/shiki'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { debounce } from 'lodash'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
interface CodePreviewProps extends BasicPreviewProps {
|
||||
interface CodeViewerProps {
|
||||
language: string
|
||||
children: string
|
||||
expanded?: boolean
|
||||
unwrapped?: boolean
|
||||
onHeightChange?: (scrollHeight: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MAX_COLLAPSE_HEIGHT = 350
|
||||
|
||||
/**
|
||||
* Shiki 流式代码高亮组件
|
||||
* - 通过 shiki tokenizer 处理流式响应,高性能
|
||||
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
*/
|
||||
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, className }: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers, fontSize } = useSettings()
|
||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
|
||||
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
|
||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
|
||||
const rawLines = useMemo(() => (typeof children === 'string' ? children.trimEnd().split('\n') : []), [children])
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
|
||||
// 展开/折叠工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.expand,
|
||||
icon: expandOverride ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: expandOverride ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = scrollerRef.current?.scrollHeight
|
||||
return codeCollapsible && (scrollHeight ?? 0) > MAX_COLLAPSE_HEIGHT
|
||||
},
|
||||
onClick: () => setExpandOverride((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.expand.id)
|
||||
}, [codeCollapsible, expandOverride, registerTool, removeTool, t])
|
||||
|
||||
// 自动换行工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.wrap,
|
||||
icon: unwrapOverride ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||
tooltip: unwrapOverride ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
visible: () => codeWrappable,
|
||||
onClick: () => setUnwrapOverride((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||
}, [codeWrappable, unwrapOverride, registerTool, removeTool, t])
|
||||
|
||||
// 重置用户操作(可以考虑移除,保持用户操作结果)
|
||||
useEffect(() => {
|
||||
setExpandOverride(!codeCollapsible)
|
||||
}, [codeCollapsible])
|
||||
|
||||
// 重置用户操作(可以考虑移除,保持用户操作结果)
|
||||
useEffect(() => {
|
||||
setUnwrapOverride(!codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
const shouldCollapse = useMemo(() => codeCollapsible && !expandOverride, [codeCollapsible, expandOverride])
|
||||
const shouldWrap = useMemo(() => codeWrappable && !unwrapOverride, [codeWrappable, unwrapOverride])
|
||||
|
||||
// 计算行号数字位数
|
||||
const gutterDigits = useMemo(
|
||||
() => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
||||
@@ -90,20 +42,26 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
|
||||
// 设置 pre 标签属性
|
||||
useLayoutEffect(() => {
|
||||
let mounted = true
|
||||
getShikiPreProperties(language).then((properties) => {
|
||||
if (!mounted) return
|
||||
const shikiTheme = shikiThemeRef.current
|
||||
if (shikiTheme) {
|
||||
shikiTheme.className = `${properties.class || 'shiki'}`
|
||||
shikiTheme.className = `${properties.class || 'shiki'} code-viewer ${className ?? ''}`
|
||||
// 滚动条适应 shiki 主题变化而非应用主题
|
||||
shikiTheme.classList.add(isShikiThemeDark ? 'shiki-dark' : 'shiki-light')
|
||||
|
||||
if (properties.style) {
|
||||
shikiTheme.style.cssText += `${properties.style}`
|
||||
}
|
||||
shikiTheme.tabIndex = properties.tabindex
|
||||
// FIXME: 临时解决 SelectionToolbar 无法弹出,走剪贴板回退的问题
|
||||
// shikiTheme.tabIndex = properties.tabindex
|
||||
}
|
||||
})
|
||||
}, [language, getShikiPreProperties, isShikiThemeDark])
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [language, getShikiPreProperties, isShikiThemeDark, className])
|
||||
|
||||
// Virtualizer 配置
|
||||
const getScrollElement = useCallback(() => scrollerRef.current, [])
|
||||
@@ -140,19 +98,25 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
}
|
||||
}, [virtualItems, debouncedHighlightLines])
|
||||
|
||||
// Report scrollHeight when it might change
|
||||
useLayoutEffect(() => {
|
||||
onHeightChange?.(scrollerRef.current?.scrollHeight ?? 0)
|
||||
}, [rawLines.length, onHeightChange])
|
||||
|
||||
return (
|
||||
<div ref={shikiThemeRef}>
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="shiki-scroller"
|
||||
$wrap={shouldWrap}
|
||||
$wrap={!unwrapped}
|
||||
$expanded={expanded}
|
||||
$lineHeight={estimateSize()}
|
||||
style={
|
||||
{
|
||||
'--gutter-width': `${gutterDigits}ch`,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined,
|
||||
overflowY: shouldCollapse ? 'auto' : 'hidden'
|
||||
maxHeight: expanded ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
|
||||
overflowY: expanded ? 'hidden' : 'auto'
|
||||
} as React.CSSProperties
|
||||
}>
|
||||
<div
|
||||
@@ -170,7 +134,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||
{virtualItems.map((virtualItem) => (
|
||||
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement}>
|
||||
<VirtualizedRow
|
||||
rawLine={rawLines[virtualItem.index]}
|
||||
@@ -187,7 +151,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
CodePreview.displayName = 'CodePreview'
|
||||
CodeViewer.displayName = 'CodeViewer'
|
||||
|
||||
const plainTokenStyle = {
|
||||
color: 'inherit',
|
||||
@@ -259,20 +223,24 @@ VirtualizedRow.displayName = 'VirtualizedRow'
|
||||
|
||||
const ScrollContainer = styled.div<{
|
||||
$wrap?: boolean
|
||||
$expanded?: boolean
|
||||
$lineHeight?: number
|
||||
}>`
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
padding: 0.5em 1em;
|
||||
/* padding right 下沉到 line-content 中 */
|
||||
padding: 0.5em 0 0.5em 1em;
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
line-height: ${(props) => props.$lineHeight}px;
|
||||
contain: content;
|
||||
/* contain 优化 wrap 时滚动性能,will-change 优化 unwrap 时滚动性能 */
|
||||
contain: ${(props) => (props.$wrap ? 'content' : 'none')};
|
||||
will-change: ${(props) => (!props.$wrap && !props.$expanded ? 'transform' : 'auto')};
|
||||
|
||||
.line-number {
|
||||
width: var(--gutter-width, 1.2ch);
|
||||
@@ -288,6 +256,7 @@ const ScrollContainer = styled.div<{
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
padding-right: 1em;
|
||||
* {
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
@@ -296,4 +265,4 @@ const ScrollContainer = styled.div<{
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodePreview)
|
||||
export default memo(CodeViewer)
|
||||
@@ -78,7 +78,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
style={collapseStyle}
|
||||
defaultActiveKey={defaultActiveKey}
|
||||
activeKey={activeKey}
|
||||
destroyInactivePanel={destroyInactivePanel}
|
||||
destroyOnHidden={destroyInactivePanel}
|
||||
collapsible={collapsible}
|
||||
onChange={(keys) => {
|
||||
setActiveKeys(keys)
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DraggableList } from '../DraggableList'
|
||||
import { DraggableList } from '../'
|
||||
|
||||
// mock @hello-pangea/dnd 组件
|
||||
vi.mock('@hello-pangea/dnd', () => {
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DraggableVirtualList from '../DraggableList/virtual-list'
|
||||
import { DraggableVirtualList } from '../'
|
||||
|
||||
// Mock 依赖项
|
||||
vi.mock('@hello-pangea/dnd', () => ({
|
||||
@@ -0,0 +1,151 @@
|
||||
import { DropResult } from '@hello-pangea/dnd'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useDraggableReorder } from '../useDraggableReorder'
|
||||
|
||||
// 辅助函数和模拟数据
|
||||
const createMockItem = (id: number) => ({ id: `item-${id}`, name: `Item ${id}` })
|
||||
const mockOriginalList = [createMockItem(1), createMockItem(2), createMockItem(3), createMockItem(4), createMockItem(5)]
|
||||
|
||||
/**
|
||||
* 创建一个符合 DropResult 类型的模拟对象。
|
||||
* @param sourceIndex - 拖拽源的视图索引
|
||||
* @param destIndex - 拖拽目标的视图索引
|
||||
* @param draggableId - 被拖拽项的唯一 ID,应与其 itemKey 对应
|
||||
*/
|
||||
const createMockDropResult = (sourceIndex: number, destIndex: number | null, draggableId: string): DropResult => ({
|
||||
reason: 'DROP',
|
||||
source: { index: sourceIndex, droppableId: 'droppable' },
|
||||
destination: destIndex !== null ? { index: destIndex, droppableId: 'droppable' } : null,
|
||||
combine: null,
|
||||
mode: 'FLUID',
|
||||
draggableId,
|
||||
type: 'DEFAULT'
|
||||
})
|
||||
|
||||
describe('useDraggableReorder', () => {
|
||||
describe('reorder', () => {
|
||||
it('should correctly reorder the list when it is not filtered', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableReorder({
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList, // 列表未过滤
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
// 模拟将第一项 (视图索引 0, 原始索引 0) 拖到第三项的位置 (视图索引 2)
|
||||
// 在未过滤列表中,itemKey(0) 返回 0
|
||||
const dropResult = createMockDropResult(0, 2, '0')
|
||||
|
||||
act(() => {
|
||||
result.current.onDragEnd(dropResult)
|
||||
})
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
const newList = onUpdate.mock.calls[0][0]
|
||||
// 原始: [1, 2, 3, 4, 5] -> 拖拽后预期: [2, 3, 1, 4, 5]
|
||||
expect(newList.map((i) => i.id)).toEqual(['item-2', 'item-3', 'item-1', 'item-4', 'item-5'])
|
||||
})
|
||||
|
||||
it('should correctly reorder the original list when the list is filtered', () => {
|
||||
const onUpdate = vi.fn()
|
||||
// 过滤后只剩下奇数项: [item-1, item-3, item-5]
|
||||
const filteredList = [mockOriginalList[0], mockOriginalList[2], mockOriginalList[4]]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableReorder({
|
||||
originalList: mockOriginalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
// 在过滤后的列表中,将最后一项 'item-5' (视图索引 2) 拖到第一项 'item-1' (视图索引 0) 的位置
|
||||
// 'item-5' 的原始索引是 4, 所以 itemKey(2) 返回 4
|
||||
const dropResult = createMockDropResult(2, 0, '4')
|
||||
|
||||
act(() => {
|
||||
result.current.onDragEnd(dropResult)
|
||||
})
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
const newList = onUpdate.mock.calls[0][0]
|
||||
// 原始: [1, 2, 3, 4, 5]
|
||||
// 拖拽后预期: 'item-5' 移动到 'item-1' 的位置 -> [5, 1, 2, 3, 4]
|
||||
expect(newList.map((i) => i.id)).toEqual(['item-5', 'item-1', 'item-2', 'item-3', 'item-4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onUpdate', () => {
|
||||
it('should not call onUpdate if destination is null', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableReorder({
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
// 模拟拖拽到列表外
|
||||
const dropResult = createMockDropResult(0, null, '0')
|
||||
|
||||
act(() => {
|
||||
result.current.onDragEnd(dropResult)
|
||||
})
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onUpdate if source and destination are the same', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableReorder({
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
// 模拟拖拽后放回原位
|
||||
const dropResult = createMockDropResult(1, 1, '1')
|
||||
|
||||
act(() => {
|
||||
result.current.onDragEnd(dropResult)
|
||||
})
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemKey', () => {
|
||||
it('should return the correct original index from a filtered list index', () => {
|
||||
const onUpdate = vi.fn()
|
||||
// 过滤后只剩下奇数项: [item-1, item-3, item-5]
|
||||
const filteredList = [mockOriginalList[0], mockOriginalList[2], mockOriginalList[4]]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableReorder({
|
||||
originalList: mockOriginalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
// 视图索引 0 -> 'item-1' -> 原始索引 0
|
||||
expect(result.current.itemKey(0)).toBe(0)
|
||||
// 视图索引 1 -> 'item-3' -> 原始索引 2
|
||||
expect(result.current.itemKey(1)).toBe(2)
|
||||
// 视图索引 2 -> 'item-5' -> 原始索引 4
|
||||
expect(result.current.itemKey(2)).toBe(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as DraggableList } from './list'
|
||||
export { useDraggableReorder } from './useDraggableReorder'
|
||||
export { default as DraggableVirtualList } from './virtual-list'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { DropResult } from '@hello-pangea/dnd'
|
||||
import { Key, useCallback, useMemo } from 'react'
|
||||
|
||||
interface UseDraggableReorderParams<T> {
|
||||
/** 原始的、完整的数据列表 */
|
||||
originalList: T[]
|
||||
/** 当前在界面上渲染的、可能被过滤的列表 */
|
||||
filteredList: T[]
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强拖拽排序能力,处理“过滤后列表”与“原始列表”的索引映射问题。
|
||||
*
|
||||
* @template T 列表项的类型
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 DraggableVirtualList 的 props: { onDragEnd, itemKey }
|
||||
*/
|
||||
export function useDraggableReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDraggableReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
const map = new Map<Key, number>()
|
||||
originalList.forEach((item, index) => {
|
||||
map.set(getId(item), index)
|
||||
})
|
||||
return map
|
||||
}, [originalList, getId])
|
||||
|
||||
// 创建一个函数,将 *过滤后列表* 的视图索引转换为 *原始列表* 的数据索引
|
||||
const getItemKey = useCallback(
|
||||
(index: number): Key => {
|
||||
const item = filteredList[index]
|
||||
// 如果找不到item,返回视图索引兜底
|
||||
if (!item) return index
|
||||
|
||||
const originalIndex = itemIndexMap.get(getId(item))
|
||||
return originalIndex ?? index
|
||||
},
|
||||
[filteredList, itemIndexMap, getId]
|
||||
)
|
||||
|
||||
// 创建 onDragEnd 回调,封装了所有重排逻辑
|
||||
const onDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
if (!result.destination) return
|
||||
|
||||
// 使用 getItemKey 将视图索引转换为数据索引
|
||||
const sourceOriginalIndex = getItemKey(result.source.index) as number
|
||||
const destOriginalIndex = getItemKey(result.destination.index) as number
|
||||
|
||||
if (sourceOriginalIndex === destOriginalIndex) return
|
||||
|
||||
// 操作原始列表的副本
|
||||
const newList = [...originalList]
|
||||
const [movedItem] = newList.splice(sourceOriginalIndex, 1)
|
||||
newList.splice(destOriginalIndex, 0, movedItem)
|
||||
|
||||
// 调用外部更新函数
|
||||
onUpdate(newList)
|
||||
},
|
||||
[originalList, onUpdate, getItemKey]
|
||||
)
|
||||
|
||||
return { onDragEnd, itemKey: getItemKey }
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { type Key, memo, useCallback, useRef } from 'react'
|
||||
* @property {React.CSSProperties} [itemStyle] 元素内容区域的附加样式
|
||||
* @property {React.CSSProperties} [itemContainerStyle] 元素拖拽容器的附加样式
|
||||
* @property {Partial<DroppableProps>} [droppableProps] 透传给 Droppable 的额外配置
|
||||
* @property {(list: T[]) => void} onUpdate 拖拽排序完成后的回调,返回新的列表顺序
|
||||
* @property {(list: T[]) => void} [onUpdate] 拖拽排序完成后的回调,返回新的列表顺序(可被 useDraggableReorder 替代)
|
||||
* @property {OnDragStartResponder} [onDragStart] 开始拖拽时的回调
|
||||
* @property {OnDragEndResponder} [onDragEnd] 结束拖拽时的回调
|
||||
* @property {T[]} list 渲染的数据源
|
||||
@@ -39,7 +39,7 @@ interface DraggableVirtualListProps<T> {
|
||||
itemStyle?: React.CSSProperties
|
||||
itemContainerStyle?: React.CSSProperties
|
||||
droppableProps?: Partial<DroppableProps>
|
||||
onUpdate: (list: T[]) => void
|
||||
onUpdate?: (list: T[]) => void
|
||||
onDragStart?: OnDragStartResponder
|
||||
onDragEnd?: OnDragEndResponder
|
||||
list: T[]
|
||||
@@ -48,6 +48,7 @@ interface DraggableVirtualListProps<T> {
|
||||
overscan?: number
|
||||
header?: React.ReactNode
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,11 +74,12 @@ function DraggableVirtualList<T>({
|
||||
estimateSize: _estimateSize,
|
||||
overscan = 5,
|
||||
header,
|
||||
children
|
||||
children,
|
||||
disabled
|
||||
}: DraggableVirtualListProps<T>): React.ReactElement {
|
||||
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
onDragEnd?.(result, provided)
|
||||
if (result.destination) {
|
||||
if (onUpdate && result.destination) {
|
||||
const sourceIndex = result.source.index
|
||||
const destIndex = result.destination.index
|
||||
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
|
||||
@@ -157,6 +159,7 @@ function DraggableVirtualList<T>({
|
||||
itemContainerStyle={itemContainerStyle}
|
||||
virtualizer={virtualizer}
|
||||
children={children}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -172,53 +175,56 @@ function DraggableVirtualList<T>({
|
||||
/**
|
||||
* 渲染单个可拖拽的虚拟列表项,高度为动态测量
|
||||
*/
|
||||
const VirtualRow = memo(({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer }: any) => {
|
||||
const item = list[virtualItem.index]
|
||||
const draggableId = String(virtualItem.key)
|
||||
return (
|
||||
<Draggable
|
||||
key={`draggable_${draggableId}_${virtualItem.index}`}
|
||||
draggableId={draggableId}
|
||||
index={virtualItem.index}>
|
||||
{(provided) => {
|
||||
const setDragRefs = (el: HTMLElement | null) => {
|
||||
provided.innerRef(el)
|
||||
virtualizer.measureElement(el)
|
||||
}
|
||||
const VirtualRow = memo(
|
||||
({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer, disabled }: any) => {
|
||||
const item = list[virtualItem.index]
|
||||
const draggableId = String(virtualItem.key)
|
||||
return (
|
||||
<Draggable
|
||||
key={`draggable_${draggableId}_${virtualItem.index}`}
|
||||
draggableId={draggableId}
|
||||
isDragDisabled={disabled}
|
||||
index={virtualItem.index}>
|
||||
{(provided) => {
|
||||
const setDragRefs = (el: HTMLElement | null) => {
|
||||
provided.innerRef(el)
|
||||
virtualizer.measureElement(el)
|
||||
}
|
||||
|
||||
const dndStyle = provided.draggableProps.style
|
||||
const virtualizerTransform = `translateY(${virtualItem.start}px)`
|
||||
const dndStyle = provided.draggableProps.style
|
||||
const virtualizerTransform = `translateY(${virtualItem.start}px)`
|
||||
|
||||
// dnd 的 transform 负责拖拽时的位移和让位动画,
|
||||
// virtualizer 的 translateY 负责将项定位到虚拟列表的正确位置,
|
||||
// 它们拼接起来可以同时实现拖拽视觉效果和虚拟化定位。
|
||||
const combinedTransform = dndStyle?.transform
|
||||
? `${dndStyle.transform} ${virtualizerTransform}`
|
||||
: virtualizerTransform
|
||||
// dnd 的 transform 负责拖拽时的位移和让位动画,
|
||||
// virtualizer 的 translateY 负责将项定位到虚拟列表的正确位置,
|
||||
// 它们拼接起来可以同时实现拖拽视觉效果和虚拟化定位。
|
||||
const combinedTransform = dndStyle?.transform
|
||||
? `${dndStyle.transform} ${virtualizerTransform}`
|
||||
: virtualizerTransform
|
||||
|
||||
return (
|
||||
<div
|
||||
{...provided.draggableProps}
|
||||
ref={setDragRefs}
|
||||
className="draggable-item"
|
||||
data-index={virtualItem.index}
|
||||
style={{
|
||||
...itemContainerStyle,
|
||||
...dndStyle,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: combinedTransform
|
||||
}}>
|
||||
<div {...provided.dragHandleProps} className="draggable-content" style={{ ...itemStyle }}>
|
||||
{item && children(item, virtualItem.index)}
|
||||
return (
|
||||
<div
|
||||
{...provided.draggableProps}
|
||||
ref={setDragRefs}
|
||||
className="draggable-item"
|
||||
data-index={virtualItem.index}
|
||||
style={{
|
||||
...itemContainerStyle,
|
||||
...dndStyle,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: combinedTransform
|
||||
}}>
|
||||
<div {...provided.dragHandleProps} className="draggable-content" style={itemStyle}>
|
||||
{item && children(item, virtualItem.index)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Draggable>
|
||||
)
|
||||
})
|
||||
)
|
||||
}}
|
||||
</Draggable>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default DraggableVirtualList
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { FC } from 'react'
|
||||
import { Copy } from 'lucide-react'
|
||||
|
||||
const CopyIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
|
||||
return <i {...props} className={`iconfont icon-copy ${props.className}`} />
|
||||
}
|
||||
const CopyIcon = (props: React.ComponentProps<typeof Copy>) => <Copy size="1rem" {...props} />
|
||||
|
||||
export default CopyIcon
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Trash } from 'lucide-react'
|
||||
|
||||
const DeleteIcon = (props: React.ComponentProps<typeof Trash>) => <Trash size="1rem" {...props} />
|
||||
|
||||
export default DeleteIcon
|
||||
@@ -1,68 +0,0 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
// 基础下载图标
|
||||
export const DownloadIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.1em"
|
||||
height="1.1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<path d="M12 15V3" />
|
||||
<polygon points="12,15 9,11 15,11" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// 带有文件类型的下载图标基础组件
|
||||
const DownloadTypeIconBase = ({ type, ...props }: SVGProps<SVGSVGElement> & { type: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.1em"
|
||||
height="1.1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}>
|
||||
<text
|
||||
x="12"
|
||||
y="7"
|
||||
fontSize="8"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.3"
|
||||
letterSpacing="1"
|
||||
fontFamily="Arial Black, sans-serif"
|
||||
style={{
|
||||
paintOrder: 'stroke',
|
||||
fontStretch: 'expanded',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
}}>
|
||||
{type}
|
||||
</text>
|
||||
<path d="M21 16v3a2 2 0 01-2 2H5a2 2 0 01-2-2v-3" />
|
||||
<path d="M12 17V10" />
|
||||
<polygon points="12,17 9.5,14 14.5,14" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// JPG 文件下载图标
|
||||
export const DownloadJpgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="JPG" {...props} />
|
||||
|
||||
// PNG 文件下载图标
|
||||
export const DownloadPngIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="PNG" {...props} />
|
||||
|
||||
// SVG 文件下载图标
|
||||
export const DownloadSvgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="SVG" {...props} />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user