feat: support Relative Path Input for Backup Directory (#8471)
* chore(env): add .env.example file and update .gitignore - Introduced a new .env.example file with NODE_OPTIONS configuration. - Updated .gitignore to exclude .env.example from being ignored. - Added instructions in dev.md for copying .env.example to .env. * fix(MessageTools): improve error handling and logging in message preview rendering (#8453) - Enhanced the rendering logic for message previews by adding a try-catch block to handle JSON parsing errors more gracefully. - Updated the error handling to provide clearer error messages in the preview when exceptions occur. - Added debug logging to track the rendering process of message content. * refactor(Theme): update theme management to use setTheme function - Replaced toggleTheme with setTheme for more explicit theme handling. - Removed unused SunMoon icon from TabContainer and Sidebar components. - Updated theme icon rendering logic to directly reflect the current theme state. - Adjusted ThemeProvider to include setTheme in context for better theme management. * refactor(ModelList): streamline button layout and improve accessibility - Removed tooltip wrappers from manage and add model buttons for a cleaner UI. - Introduced a new Flex container for primary and default buttons, enhancing layout consistency. - Updated button rendering to improve accessibility and user experience. * feat(ModelList): add bulk add/remove functionality for models with confirmation dialog - Implemented onAddAll and onRemoveAll functions to handle bulk actions for models. - Added confirmation dialog for adding all models to the list, enhancing user experience. - Updated translations for confirmation messages in multiple languages. * chore(languages): update languages with a script (#8445) * chore(languages): update languages with a script * refactor: update languages and merge it into constants * refactor: add usf and ush * refactor(ipc): enhance write permission check and add untildify utility - Updated the hasWritePermission function to resolve paths using the new untildify utility, improving path handling. - Modified IPC handler to await the permission check for better asynchronous handling. - Introduced a new untildify function to convert paths starting with '~' to the user's home directory. * fix(ModelEdit): enhance model type management and introduce new selection logic (#8420) * fix(ModelEdit): enhance model type management and introduce new selection logic - Added support for 'rerank' model type in the ModelEditContent component. - Refactored type selection logic to utilize new utility functions for finding differences and unions in model types. - Updated model type handling to include user selection status, improving user experience in type management. - Adjusted migration logic to initialize newType for existing models, ensuring backward compatibility. - Introduced isUserSelectedModelType utility to streamline model type checks across the application. * refactor(isFunctionCallingModel): simplify model type check logic - Replaced the inline check for 'function_calling' model type with a call to the new utility function isUserSelectedModelType, enhancing code clarity and maintainability. * feat(collection): add utility functions for array operations - Introduced `findIntersection`, `findDifference`, and `findUnion` functions to handle array operations with support for custom key selectors and comparison functions. - Removed previous implementations from `index.ts` to streamline utility exports. - Added comprehensive tests for new functions covering basic types and object types with various edge cases. * refactor(collection): rename utility functions for clarity - Renamed `findIntersection`, `findDifference`, and `findUnion` to `getIntersection`, `getDifference`, and `getUnion` respectively for improved clarity and consistency in naming. - Updated corresponding tests to reflect the new function names, ensuring all tests pass with the updated utility functions. * refactor(ModelEditContent): update model type management and improve selection logic - Replaced utility function calls to `findDifference` and `findUnion` with `getDifference` and `getUnion` for consistency. - Introduced temporary state management for model types to enhance user selection handling. - Added a reset functionality for model type selections, improving user experience. - Updated the rendering logic to conditionally disable certain model types based on user selections. * fix(ModelEditContent): enhance model type selection logic with conditional disabling - Introduced logic to conditionally disable 'rerank' and 'embedding' model types based on user selections. - Updated the state management for model types to ensure correct user selection handling. - Improved the confirmation modal to reflect the updated selection logic for better user experience. * fix(ModelEditContent): refine model type selection and update confirmation logic - Enhanced the logic for model type selection to ensure accurate user selections for 'rerank' and 'embedding'. - Updated the confirmation modal to reflect changes in selection handling, improving user experience. - Adjusted state management to correctly handle updates based on selected model types. * fix(models): update model support logic to include 'qwen3-235b-a22b-instruct' * refactor(models): rename 'newType' to 'capabilities' and update related logic in ModelEditContent and migration scripts * feat(ipc): add App_ResolvePath channel and update path handling - Introduced a new IPC channel `App_ResolvePath` to resolve file paths, enhancing path management. - Updated the `hasWritePermission` function to log the original directory instead of the resolved one. - Modified the `LocalBackupSettings` component to utilize the new `resolvePath` method for improved directory validation. * add ut * fix comments * fix clear manually * delete duplicate var --------- Co-authored-by: kangfenmao <kangfenmao@qq.com> Co-authored-by: SuYao <sy20010504@gmail.com> Co-authored-by: one <wangan.cs@gmail.com>
This commit is contained in:
@@ -20,6 +20,7 @@ export enum IpcChannel {
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
App_Select = 'app:select',
|
||||
App_HasWritePermission = 'app:has-write-permission',
|
||||
App_ResolvePath = 'app:resolve-path',
|
||||
App_Copy = 'app:copy',
|
||||
App_SetStopQuitApp = 'app:set-stop-quit-app',
|
||||
App_SetAppDataPath = 'app:set-app-data-path',
|
||||
|
||||
@@ -55,7 +55,7 @@ import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission } from './utils/file'
|
||||
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, untildify } from './utils/file'
|
||||
import { updateAppDataConfig } from './utils/init'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
@@ -286,7 +286,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_HasWritePermission, async (_, filePath: string) => {
|
||||
return hasWritePermission(filePath)
|
||||
const hasPermission = await hasWritePermission(filePath)
|
||||
return hasPermission
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_ResolvePath, async (_, filePath: string) => {
|
||||
return path.resolve(untildify(filePath))
|
||||
})
|
||||
|
||||
// Set app data path
|
||||
|
||||
@@ -9,7 +9,7 @@ import { detectAll as detectEncodingAll } from 'jschardet'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { readTextFileWithAutoEncoding } from '../file'
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir, untildify } from '../file'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs')
|
||||
@@ -296,4 +296,51 @@ describe('file', () => {
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe('untildify', () => {
|
||||
it('should replace ~ with home directory for paths starting with ~', () => {
|
||||
const mockHome = '/mock/home'
|
||||
|
||||
expect(untildify('~')).toBe(mockHome)
|
||||
expect(untildify('~/Documents')).toBe('/mock/home/Documents')
|
||||
expect(untildify('~\\Documents')).toBe('/mock/home\\Documents')
|
||||
expect(untildify('~/Documents/file.txt')).toBe('/mock/home/Documents/file.txt')
|
||||
expect(untildify('~\\Documents\\file.txt')).toBe('/mock/home\\Documents\\file.txt')
|
||||
})
|
||||
|
||||
it('should not replace ~ when not at the beginning', () => {
|
||||
expect(untildify('folder/~/file')).toBe('folder/~/file')
|
||||
expect(untildify('/home/user/~')).toBe('/home/user/~')
|
||||
expect(untildify('Documents/~backup')).toBe('Documents/~backup')
|
||||
})
|
||||
|
||||
it('should not replace ~ when not followed by path separator or end of string', () => {
|
||||
expect(untildify('~abc')).toBe('~abc')
|
||||
expect(untildify('~user')).toBe('~user')
|
||||
expect(untildify('~file.txt')).toBe('~file.txt')
|
||||
})
|
||||
|
||||
it('should handle paths that do not start with ~', () => {
|
||||
expect(untildify('/absolute/path')).toBe('/absolute/path')
|
||||
expect(untildify('./relative/path')).toBe('./relative/path')
|
||||
expect(untildify('../parent/path')).toBe('../parent/path')
|
||||
expect(untildify('relative/path')).toBe('relative/path')
|
||||
expect(untildify('C:\\Windows\\System32')).toBe('C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(untildify('')).toBe('')
|
||||
expect(untildify(' ')).toBe(' ')
|
||||
expect(untildify('~/')).toBe('/mock/home/')
|
||||
expect(untildify('~\\')).toBe('/mock/home\\')
|
||||
})
|
||||
|
||||
it('should handle special characters and unicode', () => {
|
||||
expect(untildify('~/文档')).toBe('/mock/home/文档')
|
||||
expect(untildify('~/папка')).toBe('/mock/home/папка')
|
||||
expect(untildify('~/folder with spaces')).toBe('/mock/home/folder with spaces')
|
||||
expect(untildify('~/folder-with-dashes')).toBe('/mock/home/folder-with-dashes')
|
||||
expect(untildify('~/folder_with_underscores')).toBe('/mock/home/folder_with_underscores')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,9 +28,18 @@ function initFileTypeMap() {
|
||||
// 初始化映射表
|
||||
initFileTypeMap()
|
||||
|
||||
export function hasWritePermission(path: string) {
|
||||
export function untildify(pathWithTilde: string) {
|
||||
if (pathWithTilde.startsWith('~')) {
|
||||
const homeDirectory = os.homedir()
|
||||
return pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory)
|
||||
}
|
||||
return pathWithTilde
|
||||
}
|
||||
|
||||
export async function hasWritePermission(dir: string) {
|
||||
try {
|
||||
fs.accessSync(path, fs.constants.W_OK)
|
||||
logger.info(`Checking write permission for ${dir}`)
|
||||
await fs.promises.access(dir, fs.constants.W_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
|
||||
@@ -59,6 +59,7 @@ const api = {
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
|
||||
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
|
||||
resolvePath: (path: string) => ipcRenderer.invoke(IpcChannel.App_ResolvePath, path),
|
||||
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
|
||||
getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs),
|
||||
copy: (oldPath: string, newPath: string, occupiedDirs: string[] = []) =>
|
||||
|
||||
@@ -71,22 +71,24 @@ const LocalBackupSettings: React.FC = () => {
|
||||
return false
|
||||
}
|
||||
|
||||
const resolvedDir = await window.api.resolvePath(dir)
|
||||
|
||||
// check new local backup dir is not in app data path
|
||||
// if is in app data path, show error
|
||||
if (dir.startsWith(appInfo!.appDataPath)) {
|
||||
if (resolvedDir.startsWith(appInfo!.appDataPath)) {
|
||||
window.message.error(t('settings.data.local.directory.select_error_app_data_path'))
|
||||
return false
|
||||
}
|
||||
|
||||
// check new local backup dir is not in app install path
|
||||
// if is in app install path, show error
|
||||
if (dir.startsWith(appInfo!.installPath)) {
|
||||
if (resolvedDir.startsWith(appInfo!.installPath)) {
|
||||
window.message.error(t('settings.data.local.directory.select_error_in_app_install_path'))
|
||||
return false
|
||||
}
|
||||
|
||||
// check new app data path has write permission
|
||||
const hasWritePermission = await window.api.hasWritePermission(dir)
|
||||
const hasWritePermission = await window.api.hasWritePermission(resolvedDir)
|
||||
if (!hasWritePermission) {
|
||||
window.message.error(t('settings.data.local.directory.select_error_write_permission'))
|
||||
return false
|
||||
@@ -96,6 +98,15 @@ const LocalBackupSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
const handleLocalBackupDirChange = async (value: string) => {
|
||||
if (value === localBackupDirSetting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
handleClearDirectory()
|
||||
return
|
||||
}
|
||||
|
||||
if (await checkLocalBackupDirValid(value)) {
|
||||
setLocalBackupDir(value)
|
||||
dispatch(_setLocalBackupDir(value))
|
||||
@@ -107,10 +118,10 @@ const LocalBackupSettings: React.FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
setLocalBackupDir('')
|
||||
dispatch(_setLocalBackupDir(''))
|
||||
dispatch(setLocalBackupAutoSync(false))
|
||||
stopAutoSync('local')
|
||||
if (localBackupDirSetting) {
|
||||
setLocalBackupDir(localBackupDirSetting)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const onMaxBackupsChange = (value: number) => {
|
||||
@@ -134,7 +145,7 @@ const LocalBackupSettings: React.FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
handleLocalBackupDirChange(newLocalBackupDir)
|
||||
await handleLocalBackupDirChange(newLocalBackupDir)
|
||||
} catch (error) {
|
||||
logger.error('Failed to select directory:', error as Error)
|
||||
}
|
||||
@@ -191,7 +202,8 @@ const LocalBackupSettings: React.FC = () => {
|
||||
<HStack gap="5px">
|
||||
<Input
|
||||
value={localBackupDir}
|
||||
readOnly
|
||||
onChange={(e) => setLocalBackupDir(e.target.value)}
|
||||
onBlur={(e) => handleLocalBackupDirChange(e.target.value)}
|
||||
placeholder={t('settings.data.local.directory.placeholder')}
|
||||
style={{ minWidth: 200, maxWidth: 400, flex: 1 }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user