mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
feat: Add upload progress tracking and improve file upload handling
- Implemented upload progress listener in NotesPage to track file upload status. - Enhanced file upload functionality in useNotesFileUpload to support both file and folder uploads. - Updated NotesService to handle recursive uploads with preserved file paths. - Added new translations for upload-related messages in Japanese, Portuguese, and Russian. - Improved UI in NotesPage and NotesSidebar to display upload progress and enhance user experience. - Refactored file selection methods to utilize Electron's native dialog for better UX. - Adjusted styling in HeaderNavbar and NotesSidebar for improved aesthetics.
This commit is contained in:
parent
4e3026199c
commit
9cd5b2be95
@ -198,7 +198,10 @@ export enum IpcChannel {
|
||||
File_StopWatcher = 'file:stopWatcher',
|
||||
File_PauseWatcher = 'file:pauseWatcher',
|
||||
File_ResumeWatcher = 'file:resumeWatcher',
|
||||
File_BatchUploadMarkdown = 'file:batchUploadMarkdown',
|
||||
File_BatchUpload = 'file:batchUpload',
|
||||
File_UploadFolder = 'file:uploadFolder', // Upload entire folder with recursive structure
|
||||
File_UploadEntry = 'file:uploadEntry', // Single entry upload for drag-and-drop
|
||||
File_BatchUploadEntries = 'file:batchUploadEntries', // Batch entry upload (performance-optimized)
|
||||
File_ShowInFolder = 'file:showInFolder',
|
||||
|
||||
// file service
|
||||
|
||||
@ -597,7 +597,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_PauseWatcher, fileManager.pauseFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ResumeWatcher, fileManager.resumeFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_BatchUploadMarkdown, fileManager.batchUploadMarkdownFiles.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_BatchUpload, fileManager.batchUpload.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_UploadFolder, fileManager.uploadFolder.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_UploadEntry, fileManager.uploadFileEntry.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_BatchUploadEntries, fileManager.batchUploadEntries.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
|
||||
|
||||
// file service
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import {
|
||||
checkName,
|
||||
findCommonRoot,
|
||||
getFilesDir,
|
||||
getFileType,
|
||||
getName,
|
||||
@ -23,6 +24,7 @@ import { writeFileSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { isBinaryFile } from 'isbinaryfile'
|
||||
import officeParser from 'officeparser'
|
||||
import PQueue from 'p-queue'
|
||||
import * as path from 'path'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import { chdir } from 'process'
|
||||
@ -1645,13 +1647,23 @@ class FileStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch upload markdown files from native File objects
|
||||
* This handles all I/O operations in the Main process to avoid blocking Renderer
|
||||
* Generic batch upload files with structure preservation (VS Code-inspired)
|
||||
* Handles all I/O operations in Main process to avoid blocking Renderer
|
||||
*
|
||||
* @param filePaths - Array of source file paths to upload
|
||||
* @param targetPath - Destination directory
|
||||
* @param options - Upload options
|
||||
* @param options.fileFilter - Filter function (path => boolean).
|
||||
* @param options.fileNameTransform - Transform function (basename => newBasename).
|
||||
*/
|
||||
public batchUploadMarkdownFiles = async (
|
||||
public batchUpload = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
filePaths: string[],
|
||||
targetPath: string
|
||||
targetPath: string,
|
||||
options?: {
|
||||
allowedExtensions?: string[]
|
||||
fileNameTransform?: (fileName: string) => string
|
||||
}
|
||||
): Promise<{
|
||||
fileCount: number
|
||||
folderCount: number
|
||||
@ -1661,58 +1673,59 @@ class FileStorage {
|
||||
logger.info('Starting batch upload', { fileCount: filePaths.length, targetPath })
|
||||
|
||||
const basePath = path.resolve(targetPath)
|
||||
const MARKDOWN_EXTS = ['.md', '.markdown']
|
||||
|
||||
// Filter markdown files
|
||||
const markdownFiles = filePaths.filter((filePath) => {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return MARKDOWN_EXTS.includes(ext)
|
||||
})
|
||||
const allowedExtensions = options?.allowedExtensions
|
||||
const fileFilter = (filePath: string): boolean => {
|
||||
if (!allowedExtensions || allowedExtensions.length === 0) return true
|
||||
const lowerPath = filePath.toLowerCase()
|
||||
return allowedExtensions.some((ext) => lowerPath.endsWith(ext.toLowerCase()))
|
||||
}
|
||||
|
||||
const skippedFiles = filePaths.length - markdownFiles.length
|
||||
const fileNameTransform = options?.fileNameTransform || ((name) => name)
|
||||
|
||||
if (markdownFiles.length === 0) {
|
||||
// Filter files using custom or default filter
|
||||
const validFiles = filePaths.filter(fileFilter)
|
||||
const skippedFiles = filePaths.length - validFiles.length
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
return { fileCount: 0, folderCount: 0, skippedFiles }
|
||||
}
|
||||
|
||||
// Find common root for file paths to preserve relative structure
|
||||
const commonRoot = findCommonRoot(validFiles)
|
||||
logger.debug('Calculated common root:', { commonRoot })
|
||||
|
||||
// Collect unique folders needed
|
||||
const foldersSet = new Set<string>()
|
||||
const fileOperations: Array<{ sourcePath: string; targetPath: string }> = []
|
||||
|
||||
for (const filePath of markdownFiles) {
|
||||
for (const filePath of validFiles) {
|
||||
try {
|
||||
// Get relative path if file is from a directory upload
|
||||
const fileName = path.basename(filePath)
|
||||
const relativePath = path.dirname(filePath)
|
||||
// Calculate relative path from common root
|
||||
const relativePath = path.relative(commonRoot, filePath)
|
||||
const fileName = path.basename(relativePath)
|
||||
const relativeDir = path.dirname(relativePath)
|
||||
|
||||
// Determine target directory structure
|
||||
// Build target directory path preserving structure
|
||||
let targetDir = basePath
|
||||
const folderParts: string[] = []
|
||||
if (relativeDir && relativeDir !== '.') {
|
||||
targetDir = path.join(basePath, relativeDir)
|
||||
|
||||
// Extract folder structure from file path for nested uploads
|
||||
// This is a simplified version - in real scenario we'd need the original directory structure
|
||||
if (relativePath && relativePath !== '.') {
|
||||
const parts = relativePath.split(path.sep)
|
||||
// Get the last few parts that represent the folder structure within upload
|
||||
const relevantParts = parts.slice(Math.max(0, parts.length - 3))
|
||||
folderParts.push(...relevantParts)
|
||||
// Collect all parent directories
|
||||
let currentDir = basePath
|
||||
const dirParts = relativeDir.split(path.sep)
|
||||
for (const part of dirParts) {
|
||||
currentDir = path.join(currentDir, part)
|
||||
foldersSet.add(currentDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Build target directory path
|
||||
for (const part of folderParts) {
|
||||
targetDir = path.join(targetDir, part)
|
||||
foldersSet.add(targetDir)
|
||||
}
|
||||
|
||||
// Determine final file name
|
||||
const nameWithoutExt = fileName.endsWith('.md')
|
||||
? fileName.slice(0, -3)
|
||||
: fileName.endsWith('.markdown')
|
||||
? fileName.slice(0, -9)
|
||||
: fileName
|
||||
const transformedFileName = fileNameTransform(fileName)
|
||||
const nameWithoutExt = path.parse(transformedFileName).name
|
||||
|
||||
const { safeName } = await this.fileNameGuard(_, targetDir, nameWithoutExt, true)
|
||||
const finalPath = path.join(targetDir, safeName + '.md')
|
||||
const finalExt = path.extname(transformedFileName) || '.md'
|
||||
const finalPath = path.join(targetDir, safeName + finalExt)
|
||||
|
||||
fileOperations.push({ sourcePath: filePath, targetPath: finalPath })
|
||||
} catch (error) {
|
||||
@ -1720,6 +1733,8 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// No special folder root handling needed for simplified batchUpload
|
||||
|
||||
// Create folders in order (shallow to deep)
|
||||
const sortedFolders = Array.from(foldersSet).sort((a, b) => a.length - b.length)
|
||||
for (const folder of sortedFolders) {
|
||||
@ -1802,6 +1817,320 @@ class FileStorage {
|
||||
this.notifyChange('refresh', this.currentWatchPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method handles drag-and-drop file uploads by preserving the directory structure
|
||||
* using the FileSystemEntry.fullPath property, which contains the relative path from
|
||||
* the drag operation root.
|
||||
*
|
||||
* @param _ - IPC event (unused)
|
||||
* @param entryData - File entry information from FileSystemEntry API
|
||||
* @param entryData.fullPath - Relative path from drag root (e.g., "/tmp/xxx/file.md")
|
||||
* @param entryData.isFile - Whether this is a file
|
||||
* @param entryData.isDirectory - Whether this is a directory
|
||||
* @param entryData.systemPath - Absolute file system path (from webUtils.getPathForFile)
|
||||
* @param targetBasePath - Target directory where files should be uploaded
|
||||
* @returns Promise resolving to upload result with created path
|
||||
*
|
||||
* @example
|
||||
* // Drag ~/Users/me/tmp/xxx to Notes
|
||||
* // Entry: { fullPath: "/tmp/xxx/file.md", systemPath: "/Users/me/tmp/xxx/file.md" }
|
||||
* // Target: "/notes"
|
||||
* // Result: Creates /notes/tmp/xxx/file.md
|
||||
*/
|
||||
public uploadFileEntry = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
entryData: {
|
||||
fullPath: string
|
||||
isFile: boolean
|
||||
isDirectory: boolean
|
||||
systemPath: string
|
||||
},
|
||||
targetBasePath: string
|
||||
): Promise<{ success: boolean; targetPath: string }> => {
|
||||
try {
|
||||
// Normalize target base path
|
||||
const normalizedBasePath = targetBasePath.replace(/\\/g, '/')
|
||||
|
||||
// Build target path by joining base path with entry's relative fullPath
|
||||
// Remove leading slash from fullPath to avoid path.join issues
|
||||
const relativeFullPath = entryData.fullPath.startsWith('/') ? entryData.fullPath.slice(1) : entryData.fullPath
|
||||
|
||||
const targetPath = path.join(normalizedBasePath, relativeFullPath)
|
||||
|
||||
logger.debug('Uploading file entry:', {
|
||||
fullPath: entryData.fullPath,
|
||||
systemPath: entryData.systemPath,
|
||||
targetBasePath: normalizedBasePath,
|
||||
targetPath,
|
||||
isFile: entryData.isFile,
|
||||
isDirectory: entryData.isDirectory
|
||||
})
|
||||
|
||||
if (entryData.isFile) {
|
||||
// Ensure parent directory exists
|
||||
const targetDir = path.dirname(targetPath)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
await fs.promises.mkdir(targetDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Use fileNameGuard to ensure unique filename
|
||||
const originalFileName = path.basename(targetPath)
|
||||
const nameWithoutExt = path.parse(originalFileName).name
|
||||
const originalExt = path.parse(originalFileName).ext
|
||||
const { safeName } = await this.fileNameGuard(_, targetDir, nameWithoutExt, true)
|
||||
const finalExt = originalExt || '.md' // Only default to .md if no extension
|
||||
const finalPath = path.join(targetDir, safeName + finalExt)
|
||||
|
||||
// Copy file directly (works for both text and binary files)
|
||||
await fs.promises.copyFile(entryData.systemPath, finalPath)
|
||||
|
||||
logger.info('File uploaded successfully:', { source: entryData.systemPath, target: finalPath })
|
||||
|
||||
return { success: true, targetPath: finalPath }
|
||||
} else if (entryData.isDirectory) {
|
||||
// Create directory
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
await fs.promises.mkdir(targetPath, { recursive: true })
|
||||
logger.info('Directory created:', { targetPath })
|
||||
}
|
||||
|
||||
return { success: true, targetPath }
|
||||
} else {
|
||||
throw new Error('Entry is neither a file nor a directory')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to upload file entry:', error as Error, {
|
||||
fullPath: entryData.fullPath,
|
||||
systemPath: entryData.systemPath
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload entire folder with recursive structure preservation
|
||||
*
|
||||
* This is a VS Code-inspired approach that handles all recursion in the Main process,
|
||||
* providing better performance and cleaner architecture than the Renderer-based approach.
|
||||
*
|
||||
* @param _ - IPC event (unused)
|
||||
* @param folderPath - Source folder path to upload
|
||||
* @param targetPath - Destination directory
|
||||
* @param options - Upload options
|
||||
* @returns Promise resolving to upload result with statistics
|
||||
*/
|
||||
public uploadFolder = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
folderPath: string,
|
||||
targetPath: string,
|
||||
options?: {
|
||||
allowedExtensions?: string[]
|
||||
}
|
||||
): Promise<{
|
||||
fileCount: number
|
||||
folderCount: number
|
||||
skippedFiles: number
|
||||
}> => {
|
||||
try {
|
||||
logger.info('Starting folder upload', { folderPath, targetPath })
|
||||
|
||||
// Use existing listDirectory to get all files recursively
|
||||
const allFiles = await this.listDirectory(_, folderPath, {
|
||||
recursive: true,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
includeHidden: false
|
||||
})
|
||||
|
||||
const allowedExtensions = options?.allowedExtensions || ['.md', '.markdown']
|
||||
|
||||
// Filter by allowed extensions
|
||||
const validFiles = allFiles.filter((filePath: string) => {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return allowedExtensions.includes(ext)
|
||||
})
|
||||
|
||||
const skippedFiles = allFiles.length - validFiles.length
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
logger.warn('No valid files found in folder', { folderPath, allowedExtensions })
|
||||
return { fileCount: 0, folderCount: 0, skippedFiles: allFiles.length }
|
||||
}
|
||||
|
||||
logger.info('Found valid files in folder', {
|
||||
folderPath,
|
||||
totalFiles: allFiles.length,
|
||||
validFiles: validFiles.length,
|
||||
skippedFiles
|
||||
})
|
||||
|
||||
// Upload files with folder name preservation (VS Code behavior)
|
||||
// Example: /User/tmp → target/tmp/...
|
||||
const folderName = path.basename(folderPath)
|
||||
const targetFolderRoot = path.join(targetPath, folderName)
|
||||
|
||||
// Create target root folder
|
||||
if (!fs.existsSync(targetFolderRoot)) {
|
||||
await fs.promises.mkdir(targetFolderRoot, { recursive: true })
|
||||
}
|
||||
|
||||
const result = await this.batchUpload(_, validFiles, targetFolderRoot, {
|
||||
allowedExtensions
|
||||
})
|
||||
|
||||
logger.info('Folder upload completed', {
|
||||
folderPath,
|
||||
targetPath,
|
||||
result
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Folder upload failed:', error as Error, { folderPath, targetPath })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch upload file entries with dynamic parallel processing (p-queue optimized)
|
||||
*
|
||||
* This method uses p-queue for dynamic task scheduling, ensuring maximum throughput
|
||||
* by maintaining a constant level of concurrency. Unlike static batching, tasks are
|
||||
* processed as soon as a slot becomes available, preventing blocking by slow files.
|
||||
*
|
||||
* @param _ - IPC event (unused)
|
||||
* @param entryDataList - Array of file entry information from FileSystemEntry API
|
||||
* @param targetBasePath - Target directory where files should be uploaded
|
||||
* @returns Promise resolving to batch upload result with statistics
|
||||
*
|
||||
* @example
|
||||
* // Upload 100 files with dynamic scheduling (up to 20 concurrent)
|
||||
* const result = await batchUploadEntries(_, entries, '/notes')
|
||||
* // Result: { fileCount: 95, folderCount: 5, skippedFiles: 0 }
|
||||
*/
|
||||
public batchUploadEntries = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
entryDataList: Array<{
|
||||
fullPath: string
|
||||
isFile: boolean
|
||||
isDirectory: boolean
|
||||
systemPath: string
|
||||
}>,
|
||||
targetBasePath: string,
|
||||
options?: {
|
||||
allowedExtensions?: string[]
|
||||
}
|
||||
): Promise<{
|
||||
fileCount: number
|
||||
folderCount: number
|
||||
skippedFiles: number
|
||||
}> => {
|
||||
const CONCURRENCY = 20 // Maximum concurrent uploads (matching VS Code's approach)
|
||||
let fileCount = 0
|
||||
let folderCount = 0
|
||||
let skippedFiles = 0
|
||||
|
||||
logger.info('Starting batch upload of entries with p-queue:', {
|
||||
totalEntries: entryDataList.length,
|
||||
targetBasePath,
|
||||
concurrency: CONCURRENCY
|
||||
})
|
||||
|
||||
try {
|
||||
// Create queue with dynamic scheduling
|
||||
const queue = new PQueue({ concurrency: CONCURRENCY })
|
||||
|
||||
// Track progress
|
||||
let completed = 0
|
||||
const total = entryDataList.length
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
entryDataList.map((entryData) =>
|
||||
queue.add(
|
||||
async (): Promise<
|
||||
| { status: 'skipped'; reason: string }
|
||||
| { status: 'success'; isFile: boolean; isDirectory: boolean; targetPath: string }
|
||||
> => {
|
||||
try {
|
||||
// Filter: only upload allowed files
|
||||
if (entryData.isFile) {
|
||||
const allowedExtensions = options?.allowedExtensions
|
||||
if (allowedExtensions && allowedExtensions.length > 0) {
|
||||
const lowerPath = entryData.fullPath.toLowerCase()
|
||||
const isAllowed = allowedExtensions.some((ext) => lowerPath.endsWith(ext.toLowerCase()))
|
||||
if (!isAllowed) {
|
||||
return { status: 'skipped' as const, reason: 'extension not allowed' }
|
||||
}
|
||||
} else {
|
||||
// Fallback to default filter if no options provided (backward compatibility)
|
||||
const lowerPath = entryData.fullPath.toLowerCase()
|
||||
const isMarkdown = lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown')
|
||||
const isImage = imageExts.some((ext) => lowerPath.endsWith(ext))
|
||||
|
||||
if (!isMarkdown && !isImage) {
|
||||
return { status: 'skipped' as const, reason: 'not markdown or image' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload the entry using the existing single-entry method
|
||||
const result = await this.uploadFileEntry(_, entryData, targetBasePath)
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
isFile: entryData.isFile,
|
||||
isDirectory: entryData.isDirectory,
|
||||
targetPath: result.targetPath
|
||||
}
|
||||
} finally {
|
||||
// Send progress update (throttled to avoid flooding)
|
||||
completed++
|
||||
if (completed % 5 === 0 || completed === total) {
|
||||
_.sender.send('file-upload-progress', {
|
||||
completed,
|
||||
total,
|
||||
percentage: Math.round((completed / total) * 100)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Count results
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
const value = result.value
|
||||
if (value.status === 'skipped') {
|
||||
skippedFiles++
|
||||
} else if (value.status === 'success') {
|
||||
if (value.isFile) {
|
||||
fileCount++
|
||||
} else if (value.isDirectory) {
|
||||
folderCount++
|
||||
}
|
||||
}
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error('Failed to upload entry:', result.reason)
|
||||
skippedFiles++
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Batch upload completed:', {
|
||||
totalProcessed: entryDataList.length,
|
||||
fileCount,
|
||||
folderCount,
|
||||
skippedFiles
|
||||
})
|
||||
|
||||
return { fileCount, folderCount, skippedFiles }
|
||||
} catch (error) {
|
||||
logger.error('Batch upload entries failed:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fileStorage = new FileStorage()
|
||||
|
||||
133
src/main/services/__tests__/FileStorage.test.ts
Normal file
133
src/main/services/__tests__/FileStorage.test.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import * as fs from 'fs'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fileStorage } from '../FileStorage'
|
||||
|
||||
describe('FileStorage', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
vi.clearAllMocks()
|
||||
// Setup default fs mocks to prevent directory creation during tests
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.mkdirSync).mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('batchUpload', () => {
|
||||
const mockEvent = {} as Electron.IpcMainInvokeEvent
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup fs mocks
|
||||
vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue('# Test content')
|
||||
vi.mocked(fs.promises.writeFile).mockResolvedValue()
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should allow all files by default', async () => {
|
||||
const filePaths = ['/src/test.md', '/src/image.png', '/src/doc.markdown', '/src/script.js']
|
||||
|
||||
const result = await fileStorage.batchUpload(mockEvent, filePaths, '/target')
|
||||
|
||||
expect(result.fileCount).toBe(4)
|
||||
expect(result.skippedFiles).toBe(0)
|
||||
})
|
||||
|
||||
it('should filter by allowed extensions', async () => {
|
||||
const filePaths = ['/src/a.txt', '/src/b.txt', '/src/c.md']
|
||||
|
||||
const result = await fileStorage.batchUpload(mockEvent, filePaths, '/target', {
|
||||
allowedExtensions: ['.txt'],
|
||||
fileNameTransform: (name) => name // Keep original name
|
||||
})
|
||||
|
||||
expect(result.fileCount).toBe(2) // Only .txt files
|
||||
expect(result.skippedFiles).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve folder structure', async () => {
|
||||
const filePaths = ['/source/a.md', '/source/sub/b.md', '/source/sub/deep/c.md']
|
||||
|
||||
await fileStorage.batchUpload(mockEvent, filePaths, '/target')
|
||||
|
||||
// Check mkdir was called for subdirectories
|
||||
expect(fs.promises.mkdir).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// preserveFolderRoot functionality has been moved to uploadFolder API
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const result = await fileStorage.batchUpload(mockEvent, [], '/target')
|
||||
|
||||
expect(result.fileCount).toBe(0)
|
||||
expect(result.folderCount).toBe(0)
|
||||
expect(result.skippedFiles).toBe(0)
|
||||
})
|
||||
|
||||
it('should skip all files if allowed extensions do not match', async () => {
|
||||
const filePaths = ['/src/a.md', '/src/b.md']
|
||||
|
||||
const result = await fileStorage.batchUpload(mockEvent, filePaths, '/target', {
|
||||
allowedExtensions: ['.txt']
|
||||
})
|
||||
|
||||
expect(result.fileCount).toBe(0)
|
||||
expect(result.skippedFiles).toBe(2)
|
||||
})
|
||||
|
||||
it('should transform filenames', async () => {
|
||||
const filePaths = ['/src/test.txt']
|
||||
|
||||
await fileStorage.batchUpload(mockEvent, filePaths, '/target', {
|
||||
fileNameTransform: (name) => name.replace('.txt', '.md')
|
||||
})
|
||||
|
||||
// Check that writeFile was called with .md extension
|
||||
expect(fs.promises.writeFile).toHaveBeenCalled()
|
||||
const calls = vi.mocked(fs.promises.writeFile).mock.calls
|
||||
const targetPath = calls[0][0] as string
|
||||
expect(targetPath).toMatch(/\.md$/)
|
||||
})
|
||||
|
||||
it('should handle single file upload', async () => {
|
||||
const filePaths = ['/source/single.md']
|
||||
|
||||
const result = await fileStorage.batchUpload(mockEvent, filePaths, '/target')
|
||||
|
||||
expect(result.fileCount).toBe(1)
|
||||
expect(result.folderCount).toBe(0)
|
||||
expect(result.skippedFiles).toBe(0)
|
||||
})
|
||||
|
||||
it('should create nested directories', async () => {
|
||||
// Use multiple files at different depths to force nested directory creation
|
||||
const filePaths = ['/source/a/b/c/deep.md', '/source/shallow.md']
|
||||
|
||||
await fileStorage.batchUpload(mockEvent, filePaths, '/target')
|
||||
|
||||
// Should create nested directories (a, a/b, a/b/c)
|
||||
expect(fs.promises.mkdir).toHaveBeenCalled()
|
||||
expect(fs.promises.writeFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle file read/write errors gracefully', async () => {
|
||||
const filePaths = ['/source/test.md']
|
||||
|
||||
vi.spyOn(fs.promises, 'readFile').mockRejectedValueOnce(new Error('Read failed'))
|
||||
|
||||
const result = await fileStorage.batchUpload(mockEvent, filePaths, '/target')
|
||||
|
||||
// Should not throw, but report 0 successful uploads
|
||||
expect(result.fileCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should process files in batches', async () => {
|
||||
// Create 25 files (more than BATCH_SIZE of 10)
|
||||
const filePaths = Array.from({ length: 25 }, (_, i) => `/source/file${i}.md`)
|
||||
|
||||
await fileStorage.batchUpload(mockEvent, filePaths, '/target')
|
||||
|
||||
// All files should be processed
|
||||
expect(fs.promises.writeFile).toHaveBeenCalledTimes(25)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { readTextFileWithAutoEncoding } from '../file'
|
||||
import {
|
||||
findCommonRoot,
|
||||
getAllFiles,
|
||||
getAppConfigDir,
|
||||
getConfigDir,
|
||||
@ -480,4 +481,80 @@ describe('file', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findCommonRoot', () => {
|
||||
beforeEach(() => {
|
||||
// Mock path module for findCommonRoot tests
|
||||
vi.mocked(path.dirname).mockImplementation((filePath) => {
|
||||
const parts = filePath.split('/')
|
||||
parts.pop()
|
||||
return parts.join('/') || '/'
|
||||
})
|
||||
|
||||
// Mock path.sep as '/' for Unix tests
|
||||
Object.defineProperty(path, 'sep', { value: '/', writable: true, configurable: true })
|
||||
})
|
||||
|
||||
it('should return empty string for empty array', () => {
|
||||
const result = findCommonRoot([])
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return parent directory for single file', () => {
|
||||
const result = findCommonRoot(['/User/tmp/file.md'])
|
||||
expect(result).toBe('/User/tmp')
|
||||
})
|
||||
|
||||
it('should find common root for files in same directory', () => {
|
||||
const files = ['/User/tmp/a.md', '/User/tmp/b.md', '/User/tmp/c.md']
|
||||
const result = findCommonRoot(files)
|
||||
expect(result).toBe('/User/tmp')
|
||||
})
|
||||
|
||||
it('should find common root for nested files', () => {
|
||||
const files = ['/User/tmp/sub/1.md', '/User/tmp/2.md', '/User/tmp/sub/deep/3.md']
|
||||
const result = findCommonRoot(files)
|
||||
expect(result).toBe('/User/tmp')
|
||||
})
|
||||
|
||||
it('should find common root for files in different branches', () => {
|
||||
const files = ['/User/tmp/a/1.md', '/User/tmp/b/2.md', '/User/tmp/c/d/3.md']
|
||||
const result = findCommonRoot(files)
|
||||
expect(result).toBe('/User/tmp')
|
||||
})
|
||||
|
||||
it('should handle files with different directory depths', () => {
|
||||
const files = ['/a/b/c/d/e/file.md', '/a/b/x.md']
|
||||
const result = findCommonRoot(files)
|
||||
expect(result).toBe('/a/b')
|
||||
})
|
||||
|
||||
it('should handle root level files', () => {
|
||||
const files = ['/a.md', '/b.md']
|
||||
const result = findCommonRoot(files)
|
||||
expect(result).toBe('/')
|
||||
})
|
||||
|
||||
it('should handle Windows paths', () => {
|
||||
// Skip on non-Windows platforms as path.sep differs
|
||||
if (process.platform !== 'win32') {
|
||||
// On Unix, test Unix paths instead
|
||||
const files = ['/C/Users/tmp/a.md', '/C/Users/tmp/b.md']
|
||||
const result = findCommonRoot(files)
|
||||
expect(result).toBe('/C/Users/tmp')
|
||||
} else {
|
||||
// Mock for Windows
|
||||
Object.defineProperty(path, 'sep', { value: '\\', writable: true })
|
||||
vi.mocked(path.dirname).mockImplementation((filePath) => {
|
||||
const parts = filePath.split('\\')
|
||||
parts.pop()
|
||||
return parts.join('\\') || 'C:\\'
|
||||
})
|
||||
|
||||
const files = ['C:\\Users\\tmp\\a.md', 'C:\\Users\\tmp\\b.md']
|
||||
const result = findCommonRoot(files)
|
||||
expect(result).toBe('C:\\Users\\tmp')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,6 +5,7 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { audioExts, documentExts, HOME_CHERRY_DIR, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
|
||||
import type { FileMetadata, NotesTreeNode } from '@types'
|
||||
import { FileTypes } from '@types'
|
||||
@ -437,3 +438,49 @@ export function sanitizeFilename(fileName: string, replacement = '_'): string {
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the common root directory of multiple file paths
|
||||
*
|
||||
* Examples:
|
||||
* - [/a/b/c/1.md, /a/b/c/2.md] => /a/b/c
|
||||
* - [/a/b/c/1.md, /a/b/d/2.md] => /a/b
|
||||
* - [/a/b/c/sub/1.md, /a/b/c/2.md] => /a/b/c
|
||||
*/
|
||||
export function findCommonRoot(filePaths: string[]): string {
|
||||
if (filePaths.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (filePaths.length === 1) {
|
||||
// Single file: use its parent directory
|
||||
return path.dirname(filePaths[0])
|
||||
}
|
||||
|
||||
// Get all parent directories
|
||||
const allDirs = filePaths.map((p) => path.dirname(p))
|
||||
|
||||
// Split into path components
|
||||
const pathComponents = allDirs.map((dir) => dir.split(path.sep))
|
||||
|
||||
// Find common prefix
|
||||
const commonParts: string[] = []
|
||||
const minLength = Math.min(...pathComponents.map((parts) => parts.length))
|
||||
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
const part = pathComponents[0][i]
|
||||
const allMatch = pathComponents.every((parts) => parts[i] === part)
|
||||
|
||||
if (allMatch) {
|
||||
commonParts.push(part)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Join back to path
|
||||
const commonRoot = commonParts.join(path.sep)
|
||||
|
||||
// Ensure we return at least the root directory
|
||||
return commonRoot || (isWin ? pathComponents[0][0] : '/')
|
||||
}
|
||||
|
||||
@ -223,8 +223,44 @@ const api = {
|
||||
stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher),
|
||||
pauseFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_PauseWatcher),
|
||||
resumeFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_ResumeWatcher),
|
||||
batchUploadMarkdown: (filePaths: string[], targetPath: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_BatchUploadMarkdown, filePaths, targetPath),
|
||||
batchUpload: (
|
||||
filePaths: string[],
|
||||
targetPath: string,
|
||||
options?: {
|
||||
allowedExtensions?: string[]
|
||||
fileNameTransform?: (fileName: string) => string
|
||||
}
|
||||
) => ipcRenderer.invoke(IpcChannel.File_BatchUpload, filePaths, targetPath, options),
|
||||
uploadFolder: (
|
||||
folderPath: string,
|
||||
targetPath: string,
|
||||
options?: {
|
||||
allowedExtensions?: string[]
|
||||
}
|
||||
) => ipcRenderer.invoke(IpcChannel.File_UploadFolder, folderPath, targetPath, options),
|
||||
uploadEntry: (
|
||||
entryData: {
|
||||
fullPath: string
|
||||
isFile: boolean
|
||||
isDirectory: boolean
|
||||
systemPath: string
|
||||
},
|
||||
targetBasePath: string
|
||||
): Promise<{ success: boolean; targetPath: string }> =>
|
||||
ipcRenderer.invoke(IpcChannel.File_UploadEntry, entryData, targetBasePath),
|
||||
batchUploadEntries: (
|
||||
entryDataList: Array<{
|
||||
fullPath: string
|
||||
isFile: boolean
|
||||
isDirectory: boolean
|
||||
systemPath: string
|
||||
}>,
|
||||
targetBasePath: string,
|
||||
options?: {
|
||||
allowedExtensions?: string[]
|
||||
}
|
||||
): Promise<{ fileCount: number; folderCount: number; skippedFiles: number }> =>
|
||||
ipcRenderer.invoke(IpcChannel.File_BatchUploadEntries, entryDataList, targetBasePath, options),
|
||||
onFileChange: (callback: (data: FileChangeEvent) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: any) => {
|
||||
if (data && typeof data === 'object') {
|
||||
@ -234,6 +270,15 @@ const api = {
|
||||
ipcRenderer.on('file-change', listener)
|
||||
return () => ipcRenderer.off('file-change', listener)
|
||||
},
|
||||
onUploadProgress: (callback: (data: { completed: number; total: number; percentage: number }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: any) => {
|
||||
if (data && typeof data === 'object') {
|
||||
callback(data)
|
||||
}
|
||||
}
|
||||
ipcRenderer.on('file-upload-progress', listener)
|
||||
return () => ipcRenderer.off('file-upload-progress', listener)
|
||||
},
|
||||
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path)
|
||||
},
|
||||
fs: {
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
|
||||
/**
|
||||
* Resolves relative image paths to absolute file:// URLs dynamically during rendering
|
||||
* This keeps markdown files portable while allowing proper image display
|
||||
*/
|
||||
export const RelativeImageResolver = Extension.create({
|
||||
name: 'relativeImageResolver',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
// Current markdown file path for resolving relative paths
|
||||
currentFilePath: undefined as string | undefined
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { currentFilePath } = this.options
|
||||
|
||||
if (!currentFilePath) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
// Apply view plugin for post-render processing
|
||||
view(view) {
|
||||
const resolveImages = () => {
|
||||
const dom = view.dom
|
||||
const images = dom.querySelectorAll('img[src]')
|
||||
|
||||
images.forEach((img) => {
|
||||
if (img instanceof HTMLImageElement) {
|
||||
const src = img.getAttribute('src')
|
||||
if (src && isRelativePath(src)) {
|
||||
const resolvedSrc = resolveRelativePath(src, currentFilePath)
|
||||
img.setAttribute('src', resolvedSrc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initial resolution
|
||||
setTimeout(resolveImages, 0)
|
||||
|
||||
// Set up a mutation observer to handle dynamically added images
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldResolve = false
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
// Check if any added nodes contain images
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element
|
||||
if (element.tagName === 'IMG' || element.querySelector('img')) {
|
||||
shouldResolve = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldResolve) {
|
||||
setTimeout(resolveImages, 0)
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(view.dom, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Checks if a path is relative (not starting with http://, https://, file://, or /)
|
||||
*/
|
||||
function isRelativePath(path: string): boolean {
|
||||
return !path.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//) && !path.startsWith('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a relative path against a base directory to create an absolute file:// URL
|
||||
*/
|
||||
function resolveRelativePath(relativePath: string, baseFilePath: string): string {
|
||||
// Remove any './' prefix and normalize path separators
|
||||
const normalizedRelative = relativePath.replace(/^\.\//, '').replace(/\\/g, '/')
|
||||
|
||||
// Get the directory of the current file
|
||||
const baseDirectory = baseFilePath ? baseFilePath.substring(0, baseFilePath.lastIndexOf('/')) : ''
|
||||
|
||||
if (!baseDirectory) {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// Combine base directory with relative path
|
||||
const combinedPath = baseDirectory + '/' + normalizedRelative
|
||||
|
||||
// Handle '..' segments
|
||||
const pathSegments = combinedPath.split('/')
|
||||
const resolvedSegments: string[] = []
|
||||
|
||||
for (const segment of pathSegments) {
|
||||
if (segment === '..') {
|
||||
resolvedSegments.pop() // Remove the previous segment
|
||||
} else if (segment !== '') {
|
||||
resolvedSegments.push(segment)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct the path
|
||||
const resolvedPath = '/' + resolvedSegments.join('/')
|
||||
|
||||
// Convert to file:// URL with proper URL encoding
|
||||
const encodedPath = encodeURI(resolvedPath)
|
||||
return 'file://' + encodedPath
|
||||
}
|
||||
@ -200,7 +200,8 @@ const RichEditor = ({
|
||||
isFullWidth = false,
|
||||
fontFamily = 'default',
|
||||
fontSize = 16,
|
||||
enableSpellCheck = false
|
||||
enableSpellCheck = false,
|
||||
currentFilePath
|
||||
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
|
||||
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
|
||||
// Use the rich editor hook for complete editor management
|
||||
@ -225,6 +226,7 @@ const RichEditor = ({
|
||||
editable,
|
||||
enableSpellCheck,
|
||||
scrollParent: () => scrollContainerRef.current,
|
||||
currentFilePath,
|
||||
onShowTableActionMenu: ({ position, actions }) => {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
insertRowBefore: <ArrowUp size={16} />,
|
||||
|
||||
@ -55,13 +55,13 @@ export const ToolbarWrapper = styled.div`
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
background: var(--rich-editor-toolbar-bg, var(--color-background-soft));
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--color-background-soft);
|
||||
background: var(--rich-editor-toolbar-bg, var(--color-background-soft));
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
|
||||
@ -52,6 +52,8 @@ export interface RichEditorProps {
|
||||
fontSize?: number
|
||||
/** Whether to enable spell check */
|
||||
enableSpellCheck?: boolean
|
||||
/** Current markdown file path for resolving relative image paths */
|
||||
currentFilePath?: string
|
||||
}
|
||||
|
||||
export interface ToolbarItem {
|
||||
|
||||
@ -33,6 +33,7 @@ import { EnhancedImage } from './extensions/enhanced-image'
|
||||
import { EnhancedLink } from './extensions/enhanced-link'
|
||||
import { EnhancedMath } from './extensions/enhanced-math'
|
||||
import { Placeholder } from './extensions/placeholder'
|
||||
import { RelativeImageResolver } from './extensions/relative-image-resolver'
|
||||
import { YamlFrontMatter } from './extensions/yaml-front-matter'
|
||||
import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers/imageUtils'
|
||||
|
||||
@ -94,6 +95,8 @@ export interface UseRichEditorOptions {
|
||||
actions: { id: string; label: string; action: () => void }[]
|
||||
}) => void
|
||||
scrollParent?: () => HTMLElement | null
|
||||
/** Current markdown file path for resolving relative image paths */
|
||||
currentFilePath?: string
|
||||
}
|
||||
|
||||
export interface UseRichEditorReturn {
|
||||
@ -157,7 +160,8 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
editable = true,
|
||||
enableSpellCheck = false,
|
||||
onShowTableActionMenu,
|
||||
scrollParent
|
||||
scrollParent,
|
||||
currentFilePath
|
||||
} = options
|
||||
|
||||
const [markdown, setMarkdownState] = useState<string>(initialContent)
|
||||
@ -236,6 +240,9 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
onLinkHoverEnd: handleLinkHoverEnd,
|
||||
editable: editable
|
||||
}),
|
||||
RelativeImageResolver.configure({
|
||||
currentFilePath
|
||||
}),
|
||||
TableOfContents.configure({
|
||||
getIndex: getHierarchicalIndexes,
|
||||
onUpdate(content) {
|
||||
@ -383,7 +390,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
})
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[placeholder, activeShikiTheme, handleLinkHover, handleLinkHoverEnd]
|
||||
[placeholder, activeShikiTheme, handleLinkHover, handleLinkHoverEnd, currentFilePath]
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
|
||||
@ -97,6 +97,12 @@ export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions
|
||||
* Additional CSS class name for the container
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* Additional DOM attributes/handlers for the scroll container
|
||||
* e.g. onClick, onContextMenu for parent wrappers like antd Dropdown
|
||||
*/
|
||||
containerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}
|
||||
|
||||
function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
@ -114,9 +120,13 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
autoHideScrollbar = false,
|
||||
header,
|
||||
className,
|
||||
containerProps,
|
||||
...restOptions
|
||||
} = props
|
||||
|
||||
// Forward provided container props to the scroll container
|
||||
const domEventHandlers = containerProps ?? ({} as React.DOMAttributes<HTMLDivElement>)
|
||||
|
||||
const [showScrollbar, setShowScrollbar] = useState(!autoHideScrollbar)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const internalScrollerRef = useRef<HTMLDivElement>(null)
|
||||
@ -240,6 +250,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
|
||||
return (
|
||||
<ScrollContainer
|
||||
{...domEventHandlers}
|
||||
ref={scrollerRef}
|
||||
className={className ? `dynamic-virtual-list ${className}` : 'dynamic-virtual-list'}
|
||||
role="region"
|
||||
|
||||
@ -2134,12 +2134,15 @@
|
||||
"export_failed": "Failed to export to knowledge base",
|
||||
"export_knowledge": "Export notes to knowledge base",
|
||||
"export_success": "Successfully exported to the knowledge base",
|
||||
"failed_to_select_files": "Failed to select files",
|
||||
"failed_to_select_folder": "Failed to select folder",
|
||||
"folder": "folder",
|
||||
"new_folder": "New Folder",
|
||||
"new_note": "Create a new note",
|
||||
"no_content_to_copy": "No content to copy",
|
||||
"no_file_selected": "Please select the file to upload",
|
||||
"no_valid_files": "No valid file was uploaded",
|
||||
"no_markdown_files_in_folder": "No Markdown files in the selected folder",
|
||||
"no_valid_files": "No valid files found",
|
||||
"open_folder": "Open an external folder",
|
||||
"open_outside": "Open from external",
|
||||
"rename": "Rename",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "Searching...",
|
||||
"show_less": "Show less"
|
||||
},
|
||||
"select_files_to_upload": "Select files to upload",
|
||||
"select_folder_to_upload": "Select folder to upload",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "Apply",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "Unfavorite",
|
||||
"untitled_folder": "New Folder",
|
||||
"untitled_note": "Untitled Note",
|
||||
"upload": "Upload",
|
||||
"upload_failed": "Note upload failed",
|
||||
"upload_files": "Upload Files",
|
||||
"upload_folder": "Upload Folder",
|
||||
"upload_success": "Note uploaded success",
|
||||
"uploading": "Uploading",
|
||||
"uploading_files": "Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,12 +2134,15 @@
|
||||
"export_failed": "导出到知识库失败",
|
||||
"export_knowledge": "导出笔记到知识库",
|
||||
"export_success": "成功导出到知识库",
|
||||
"failed_to_select_files": "选择文件失败",
|
||||
"failed_to_select_folder": "选择文件夹失败",
|
||||
"folder": "文件夹",
|
||||
"new_folder": "新建文件夹",
|
||||
"new_note": "新建笔记",
|
||||
"no_content_to_copy": "没有内容可复制",
|
||||
"no_file_selected": "请选择要上传的文件",
|
||||
"no_valid_files": "没有上传有效的文件",
|
||||
"no_markdown_files_in_folder": "所选文件夹中没有 Markdown 文件",
|
||||
"no_valid_files": "没有找到有效文件",
|
||||
"open_folder": "打开外部文件夹",
|
||||
"open_outside": "从外部打开",
|
||||
"rename": "重命名",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "搜索中...",
|
||||
"show_less": "收起"
|
||||
},
|
||||
"select_files_to_upload": "选择要上传的文件",
|
||||
"select_folder_to_upload": "选择要上传的文件夹",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "应用",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "取消收藏",
|
||||
"untitled_folder": "新文件夹",
|
||||
"untitled_note": "无标题笔记",
|
||||
"upload": "上传",
|
||||
"upload_failed": "笔记上传失败",
|
||||
"upload_files": "上传文件",
|
||||
"upload_folder": "上传文件夹",
|
||||
"upload_success": "笔记上传成功",
|
||||
"uploading": "正在上传",
|
||||
"uploading_files": "正在上传 {{count}} 个文件..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,11 +2134,14 @@
|
||||
"export_failed": "匯出至知識庫失敗",
|
||||
"export_knowledge": "匯出筆記至知識庫",
|
||||
"export_success": "成功匯出至知識庫",
|
||||
"failed_to_select_files": "[to be translated]:Failed to select files",
|
||||
"failed_to_select_folder": "選擇資料夾失敗",
|
||||
"folder": "文件夹",
|
||||
"new_folder": "新建文件夾",
|
||||
"new_note": "新建筆記",
|
||||
"no_content_to_copy": "沒有內容可複制",
|
||||
"no_file_selected": "請選擇要上傳的文件",
|
||||
"no_markdown_files_in_folder": "所選資料夾中沒有 Markdown 檔案",
|
||||
"no_valid_files": "沒有上傳有效的檔案",
|
||||
"open_folder": "打開外部文件夾",
|
||||
"open_outside": "從外部打開",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "搜索中...",
|
||||
"show_less": "收起"
|
||||
},
|
||||
"select_files_to_upload": "[to be translated]:Select files to upload",
|
||||
"select_folder_to_upload": "選擇要上傳的資料夾",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "應用",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "取消收藏",
|
||||
"untitled_folder": "新資料夾",
|
||||
"untitled_note": "無標題筆記",
|
||||
"upload": "上傳",
|
||||
"upload_failed": "筆記上傳失敗",
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_files": "上傳文件",
|
||||
"upload_folder": "上傳資料夾",
|
||||
"upload_success": "筆記上傳成功",
|
||||
"uploading": "正在上傳",
|
||||
"uploading_files": "正在上傳 {{count}} 個檔案..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,11 +2134,14 @@
|
||||
"export_failed": "Export in Wissensdatenbank fehlgeschlagen",
|
||||
"export_knowledge": "Notiz in Wissensdatenbank exportieren",
|
||||
"export_success": "Erfolgreich in Wissensdatenbank exportiert",
|
||||
"failed_to_select_files": "[to be translated]:Failed to select files",
|
||||
"failed_to_select_folder": "[to be translated]:Failed to select folder",
|
||||
"folder": "Ordner",
|
||||
"new_folder": "Neuer Ordner",
|
||||
"new_note": "Neue Notiz",
|
||||
"no_content_to_copy": "Kein Inhalt zum Kopieren",
|
||||
"no_file_selected": "Bitte Datei zum Hochladen auswählen",
|
||||
"no_markdown_files_in_folder": "[to be translated]:No Markdown files in the selected folder",
|
||||
"no_valid_files": "Keine gültigen Dateien hochgeladen",
|
||||
"open_folder": "Externen Ordner öffnen",
|
||||
"open_outside": "Extern öffnen",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "Searching...",
|
||||
"show_less": "Weniger anzeigen"
|
||||
},
|
||||
"select_files_to_upload": "[to be translated]:Select files to upload",
|
||||
"select_folder_to_upload": "[to be translated]:Select folder to upload",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "Anwenden",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "Markierung aufheben",
|
||||
"untitled_folder": "Neuer Ordner",
|
||||
"untitled_note": "Unbenannte Notiz",
|
||||
"upload": "[to be translated]:Upload",
|
||||
"upload_failed": "Notizen-Upload fehlgeschlagen",
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Notizen erfolgreich hochgeladen",
|
||||
"uploading": "[to be translated]:Uploading",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,11 +2134,14 @@
|
||||
"export_failed": "Εξαγωγή στη βάση γνώσης απέτυχε",
|
||||
"export_knowledge": "εξαγωγή σημειώσεων στη βάση γνώσης",
|
||||
"export_success": "Επιτυχής εξαγωγή στην βάση γνώσης",
|
||||
"failed_to_select_files": "[to be translated]:Failed to select files",
|
||||
"failed_to_select_folder": "[to be translated]:Failed to select folder",
|
||||
"folder": "φάκελος",
|
||||
"new_folder": "Νέος φάκελος",
|
||||
"new_note": "Δημιουργία νέας σημείωσης",
|
||||
"no_content_to_copy": "Δεν υπάρχει περιεχόμενο προς αντιγραφή",
|
||||
"no_file_selected": "Επιλέξτε το αρχείο για μεταφόρτωση",
|
||||
"no_markdown_files_in_folder": "[to be translated]:No Markdown files in the selected folder",
|
||||
"no_valid_files": "Δεν ανέβηκε έγκυρο αρχείο",
|
||||
"open_folder": "Άνοιγμα εξωτερικού φακέλου",
|
||||
"open_outside": "Από το εξωτερικό",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "Αναζήτηση...",
|
||||
"show_less": "Κλείσιμο"
|
||||
},
|
||||
"select_files_to_upload": "[to be translated]:Select files to upload",
|
||||
"select_folder_to_upload": "[to be translated]:Select folder to upload",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "εφαρμογή",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "Αποσπάστε το αγαπημένο",
|
||||
"untitled_folder": "Νέος φάκελος",
|
||||
"untitled_note": "σημείωση χωρίς τίτλο",
|
||||
"upload": "[to be translated]:Upload",
|
||||
"upload_failed": "Η σημείωση δεν ανέβηκε",
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία",
|
||||
"uploading": "[to be translated]:Uploading",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,11 +2134,14 @@
|
||||
"export_failed": "Exportación a la base de conocimientos fallida",
|
||||
"export_knowledge": "exportar notas a la base de conocimientos",
|
||||
"export_success": "Exportado con éxito a la base de conocimientos",
|
||||
"failed_to_select_files": "[to be translated]:Failed to select files",
|
||||
"failed_to_select_folder": "[to be translated]:Failed to select folder",
|
||||
"folder": "carpeta",
|
||||
"new_folder": "Nueva carpeta",
|
||||
"new_note": "Crear nota nueva",
|
||||
"no_content_to_copy": "No hay contenido para copiar",
|
||||
"no_file_selected": "Por favor, seleccione el archivo a subir",
|
||||
"no_markdown_files_in_folder": "[to be translated]:No Markdown files in the selected folder",
|
||||
"no_valid_files": "No se ha cargado un archivo válido",
|
||||
"open_folder": "abrir carpeta externa",
|
||||
"open_outside": "Abrir desde el exterior",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "Buscando...",
|
||||
"show_less": "Recoger"
|
||||
},
|
||||
"select_files_to_upload": "[to be translated]:Select files to upload",
|
||||
"select_folder_to_upload": "[to be translated]:Select folder to upload",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "aplicación",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "Quitar de favoritos",
|
||||
"untitled_folder": "Nueva carpeta",
|
||||
"untitled_note": "Nota sin título",
|
||||
"upload": "[to be translated]:Upload",
|
||||
"upload_failed": "Error al cargar la nota",
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Nota cargada con éxito",
|
||||
"uploading": "[to be translated]:Uploading",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,11 +2134,14 @@
|
||||
"export_failed": "Échec de l'exportation vers la base de connaissances",
|
||||
"export_knowledge": "exporter la note vers la base de connaissances",
|
||||
"export_success": "Exporté avec succès vers la base de connaissances",
|
||||
"failed_to_select_files": "[to be translated]:Failed to select files",
|
||||
"failed_to_select_folder": "[to be translated]:Failed to select folder",
|
||||
"folder": "dossier",
|
||||
"new_folder": "Nouveau dossier",
|
||||
"new_note": "Nouvelle note",
|
||||
"no_content_to_copy": "Aucun contenu à copier",
|
||||
"no_file_selected": "Veuillez sélectionner le fichier à télécharger",
|
||||
"no_markdown_files_in_folder": "[to be translated]:No Markdown files in the selected folder",
|
||||
"no_valid_files": "Aucun fichier valide n’a été téléversé",
|
||||
"open_folder": "ouvrir le dossier externe",
|
||||
"open_outside": "Ouvrir depuis l'extérieur",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "Recherche en cours...",
|
||||
"show_less": "Replier"
|
||||
},
|
||||
"select_files_to_upload": "[to be translated]:Select files to upload",
|
||||
"select_folder_to_upload": "[to be translated]:Select folder to upload",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "application",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "annuler la mise en favori",
|
||||
"untitled_folder": "nouveau dossier",
|
||||
"untitled_note": "Note sans titre",
|
||||
"upload": "[to be translated]:Upload",
|
||||
"upload_failed": "Échec du téléchargement de la note",
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Note téléchargée avec succès",
|
||||
"uploading": "[to be translated]:Uploading",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,11 +2134,14 @@
|
||||
"export_failed": "知識ベースへのエクスポートに失敗しました",
|
||||
"export_knowledge": "ノートをナレッジベースにエクスポートする",
|
||||
"export_success": "知識ベースへのエクスポートが成功しました",
|
||||
"failed_to_select_files": "[to be translated]:Failed to select files",
|
||||
"failed_to_select_folder": "[to be translated]:Failed to select folder",
|
||||
"folder": "フォルダー",
|
||||
"new_folder": "新しいフォルダーを作成する",
|
||||
"new_note": "新規ノート作成",
|
||||
"no_content_to_copy": "コピーするコンテンツはありません",
|
||||
"no_file_selected": "アップロードするファイルを選択してください",
|
||||
"no_markdown_files_in_folder": "[to be translated]:No Markdown files in the selected folder",
|
||||
"no_valid_files": "有効なファイルがアップロードされていません",
|
||||
"open_folder": "外部フォルダーを開きます",
|
||||
"open_outside": "外部から開く",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "検索中...",
|
||||
"show_less": "閉じる"
|
||||
},
|
||||
"select_files_to_upload": "[to be translated]:Select files to upload",
|
||||
"select_folder_to_upload": "[to be translated]:Select folder to upload",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "応用",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "お気に入りを解除する",
|
||||
"untitled_folder": "新ファイル夹",
|
||||
"untitled_note": "無題のメモ",
|
||||
"upload": "[to be translated]:Upload",
|
||||
"upload_failed": "ノートのアップロードに失敗しました",
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "ノートのアップロードが成功しました",
|
||||
"uploading": "[to be translated]:Uploading",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,11 +2134,14 @@
|
||||
"export_failed": "Falha ao exportar para a base de conhecimento",
|
||||
"export_knowledge": "exportar anotações para a base de conhecimento",
|
||||
"export_success": "exportado com sucesso para a base de conhecimento",
|
||||
"failed_to_select_files": "[to be translated]:Failed to select files",
|
||||
"failed_to_select_folder": "[to be translated]:Failed to select folder",
|
||||
"folder": "pasta",
|
||||
"new_folder": "Nova pasta",
|
||||
"new_note": "Nova nota",
|
||||
"no_content_to_copy": "Não há conteúdo para copiar",
|
||||
"no_file_selected": "Selecione o arquivo a ser enviado",
|
||||
"no_markdown_files_in_folder": "[to be translated]:No Markdown files in the selected folder",
|
||||
"no_valid_files": "Nenhum arquivo válido foi carregado",
|
||||
"open_folder": "Abrir pasta externa",
|
||||
"open_outside": "Abrir externamente",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "Pesquisando...",
|
||||
"show_less": "Recolher"
|
||||
},
|
||||
"select_files_to_upload": "[to be translated]:Select files to upload",
|
||||
"select_folder_to_upload": "[to be translated]:Select folder to upload",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "aplicativo",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "cancelar favoritos",
|
||||
"untitled_folder": "Nova pasta",
|
||||
"untitled_note": "Nota sem título",
|
||||
"upload": "[to be translated]:Upload",
|
||||
"upload_failed": "Falha ao carregar a nota",
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Nota carregada com sucesso",
|
||||
"uploading": "[to be translated]:Uploading",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -2134,11 +2134,14 @@
|
||||
"export_failed": "Экспорт в базу знаний не выполнен",
|
||||
"export_knowledge": "Экспортировать заметки в базу знаний",
|
||||
"export_success": "Успешно экспортировано в базу знаний",
|
||||
"failed_to_select_files": "[to be translated]:Failed to select files",
|
||||
"failed_to_select_folder": "[to be translated]:Failed to select folder",
|
||||
"folder": "папка",
|
||||
"new_folder": "Новая папка",
|
||||
"new_note": "Создать заметку",
|
||||
"no_content_to_copy": "Нет контента для копирования",
|
||||
"no_file_selected": "Пожалуйста, выберите файл для загрузки",
|
||||
"no_markdown_files_in_folder": "[to be translated]:No Markdown files in the selected folder",
|
||||
"no_valid_files": "Не загружен действительный файл",
|
||||
"open_folder": "Откройте внешнюю папку",
|
||||
"open_outside": "открыть снаружи",
|
||||
@ -2153,6 +2156,8 @@
|
||||
"searching": "Идет поиск...",
|
||||
"show_less": "Свернуть"
|
||||
},
|
||||
"select_files_to_upload": "[to be translated]:Select files to upload",
|
||||
"select_folder_to_upload": "[to be translated]:Select folder to upload",
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "приложение",
|
||||
@ -2218,10 +2223,12 @@
|
||||
"unstar": "отменить избранное",
|
||||
"untitled_folder": "Новая папка",
|
||||
"untitled_note": "Незаглавленная заметка",
|
||||
"upload": "[to be translated]:Upload",
|
||||
"upload_failed": "Не удалось загрузить заметку",
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Заметка успешно загружена",
|
||||
"uploading": "[to be translated]:Uploading",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
|
||||
@ -171,7 +171,12 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
|
||||
return (
|
||||
<NavbarHeader
|
||||
className="home-navbar"
|
||||
style={{ justifyContent: 'flex-start', borderBottom: '0.5px solid var(--color-border)' }}>
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
borderBottom: '0.5px solid var(--color-border)',
|
||||
borderTopLeftRadius: 10,
|
||||
borderTopRightRadius: 10
|
||||
}}>
|
||||
<HStack alignItems="center" flex="0 0 auto">
|
||||
{showWorkspace && (
|
||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||
|
||||
@ -24,10 +24,11 @@ interface NotesEditorProps {
|
||||
editorRef: RefObject<RichEditorRef | null>
|
||||
codeEditorRef: RefObject<CodeEditorHandles | null>
|
||||
onMarkdownChange: (content: string) => void
|
||||
currentFilePath?: string
|
||||
}
|
||||
|
||||
const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef, codeEditorRef }) => {
|
||||
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef, codeEditorRef, currentFilePath }) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const { settings } = useNotesSettings()
|
||||
@ -90,6 +91,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
fontFamily={settings.fontFamily}
|
||||
fontSize={settings.fontSize}
|
||||
enableSpellCheck={enableSpellCheck}
|
||||
currentFilePath={currentFilePath}
|
||||
/>
|
||||
)}
|
||||
</RichEditorContainer>
|
||||
@ -156,6 +158,8 @@ const RichEditorContainer = styled.div`
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
.notes-rich-editor {
|
||||
/* Set RichEditor toolbar background for Notes context */
|
||||
--rich-editor-toolbar-bg: var(--color-background);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
|
||||
@ -75,7 +75,11 @@ const NotesPage: FC = () => {
|
||||
|
||||
const [tokenCount, setTokenCount] = useState(0)
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState<{ completed: number; total: number; percentage: number } | null>(
|
||||
null
|
||||
)
|
||||
const watcherRef = useRef<(() => void) | null>(null)
|
||||
const uploadProgressListenerRef = useRef<(() => void) | null>(null)
|
||||
const lastContentRef = useRef<string>('')
|
||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||
const isRenamingRef = useRef(false)
|
||||
@ -164,6 +168,34 @@ const NotesPage: FC = () => {
|
||||
refreshTree()
|
||||
}, [refreshTree])
|
||||
|
||||
// Setup upload progress listener
|
||||
useEffect(() => {
|
||||
// Clean up previous listener
|
||||
if (uploadProgressListenerRef.current) {
|
||||
uploadProgressListenerRef.current()
|
||||
uploadProgressListenerRef.current = null
|
||||
}
|
||||
|
||||
// Set up new listener
|
||||
uploadProgressListenerRef.current = window.api.file.onUploadProgress((data) => {
|
||||
setUploadProgress(data)
|
||||
|
||||
// Auto-hide progress after completion
|
||||
if (data.completed === data.total) {
|
||||
setTimeout(() => {
|
||||
setUploadProgress(null)
|
||||
}, 1500)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (uploadProgressListenerRef.current) {
|
||||
uploadProgressListenerRef.current()
|
||||
uploadProgressListenerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Re-merge tree state when starred or expanded paths change
|
||||
useEffect(() => {
|
||||
if (notesTree.length > 0) {
|
||||
@ -534,6 +566,7 @@ const NotesPage: FC = () => {
|
||||
logger.error('Failed to load note:', error as Error)
|
||||
}
|
||||
} else if (node.type === 'folder') {
|
||||
// 点击文件夹时不切换/清空当前笔记,仅折叠/展开并记录所选文件夹
|
||||
setSelectedFolderId(node.id)
|
||||
handleToggleExpanded(node.id)
|
||||
}
|
||||
@ -541,6 +574,12 @@ const NotesPage: FC = () => {
|
||||
[dispatch, handleToggleExpanded, invalidateFileContent]
|
||||
)
|
||||
|
||||
// 选中根(清空选择)
|
||||
const handleSelectRoot = useCallback(() => {
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
setSelectedFolderId(null)
|
||||
}, [dispatch])
|
||||
|
||||
// 删除节点
|
||||
const handleDeleteNode = useCallback(
|
||||
async (nodeId: string) => {
|
||||
@ -875,7 +914,17 @@ const NotesPage: FC = () => {
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('notes.title')}</NavbarCenter>
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
{uploadProgress && (
|
||||
<UploadProgressBar>
|
||||
<ProgressText>
|
||||
{t('notes.uploading')} {uploadProgress.completed} / {uploadProgress.total} ({uploadProgress.percentage}%)
|
||||
</ProgressText>
|
||||
<ProgressBarContainer>
|
||||
<ProgressBarFill style={{ width: `${uploadProgress.percentage}%` }} />
|
||||
</ProgressBarContainer>
|
||||
</UploadProgressBar>
|
||||
)}
|
||||
<ContentContainer id="content-container" style={{ background: 'var(--color-background-mute)' }}>
|
||||
<AnimatePresence initial={false}>
|
||||
{showWorkspace && (
|
||||
<motion.div
|
||||
@ -883,11 +932,12 @@ const NotesPage: FC = () => {
|
||||
animate={{ width: 250, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}>
|
||||
style={{ overflow: 'hidden', marginRight: 8, borderRadius: 10 }}>
|
||||
<NotesSidebar
|
||||
notesTree={notesTree}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectNode={handleSelectNode}
|
||||
onSelectRoot={handleSelectRoot}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onCreateNote={handleCreateNote}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
@ -897,6 +947,8 @@ const NotesPage: FC = () => {
|
||||
onMoveNode={handleMoveNode}
|
||||
onSortNodes={handleSortNodes}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
notesPath={notesPath}
|
||||
refreshTree={refreshTree}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -916,6 +968,7 @@ const NotesPage: FC = () => {
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
editorRef={editorRef}
|
||||
codeEditorRef={codeEditorRef}
|
||||
currentFilePath={activeFilePath}
|
||||
/>
|
||||
</EditorWrapper>
|
||||
</ContentContainer>
|
||||
@ -930,6 +983,35 @@ const Container = styled.div`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const UploadProgressBar = styled.div`
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const ProgressText = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const ProgressBarContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-background-mute);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const ProgressBarFill = styled.div`
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.2s ease;
|
||||
border-radius: 2px;
|
||||
`
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@ -948,6 +1030,8 @@ const EditorWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
export default NotesPage
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||
import { findNode } from '@renderer/services/NotesService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectSortType } from '@renderer/store/note'
|
||||
import type { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
@ -31,6 +32,7 @@ interface NotesSidebarProps {
|
||||
onCreateFolder: (name: string, targetFolderId?: string) => void
|
||||
onCreateNote: (name: string, targetFolderId?: string) => void
|
||||
onSelectNode: (node: NotesTreeNode) => void
|
||||
onSelectRoot: () => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onRenameNode: (nodeId: string, newName: string) => void
|
||||
onToggleExpanded: (nodeId: string) => void
|
||||
@ -40,12 +42,15 @@ interface NotesSidebarProps {
|
||||
onUploadFiles: (files: File[]) => void
|
||||
notesTree: NotesTreeNode[]
|
||||
selectedFolderId?: string | null
|
||||
notesPath?: string
|
||||
refreshTree?: () => Promise<void>
|
||||
}
|
||||
|
||||
const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onCreateFolder,
|
||||
onCreateNote,
|
||||
onSelectNode,
|
||||
onSelectRoot,
|
||||
onDeleteNode,
|
||||
onRenameNode,
|
||||
onToggleExpanded,
|
||||
@ -54,10 +59,13 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onSortNodes,
|
||||
onUploadFiles,
|
||||
notesTree,
|
||||
selectedFolderId
|
||||
selectedFolderId,
|
||||
notesPath,
|
||||
refreshTree
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const isRootSelected = !selectedFolderId && !activeNode?.id
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
|
||||
const [isShowStarred, setIsShowStarred] = useState(false)
|
||||
@ -87,7 +95,17 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
const { handleDropFiles, handleSelectFiles, handleSelectFolder } = useNotesFileUpload({
|
||||
onUploadFiles,
|
||||
setIsDragOverSidebar
|
||||
setIsDragOverSidebar,
|
||||
getTargetFolderPath: () => {
|
||||
if (selectedFolderId) {
|
||||
const selectedNode = findNode(notesTree, selectedFolderId)
|
||||
if (selectedNode && selectedNode.type === 'folder') {
|
||||
return selectedNode.externalPath
|
||||
}
|
||||
}
|
||||
return notesPath || ''
|
||||
},
|
||||
refreshTree
|
||||
})
|
||||
|
||||
const { getMenuItems } = useNotesMenu({
|
||||
@ -243,7 +261,24 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
return filteredNodes.map((node) => ({ node, depth: 0 }))
|
||||
}
|
||||
|
||||
return flattenForVirtualization(notesTree)
|
||||
const normalNodes = flattenForVirtualization(notesTree)
|
||||
|
||||
// Add hint-node to the end
|
||||
return [
|
||||
...normalNodes,
|
||||
{
|
||||
node: {
|
||||
id: 'hint-node',
|
||||
name: '',
|
||||
type: 'hint' as const,
|
||||
treePath: '',
|
||||
externalPath: '',
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
},
|
||||
depth: 0
|
||||
}
|
||||
]
|
||||
}, [notesTree, isShowStarred, isShowSearch, hasSearchKeyword, searchResults])
|
||||
|
||||
// Scroll to active node
|
||||
@ -349,6 +384,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
<NotesSearchContext value={searchValue}>
|
||||
<NotesUIContext value={{ openDropdownKey }}>
|
||||
<SidebarContainer
|
||||
$rootSelected={isRootSelected}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
if (!draggedNodeId) {
|
||||
@ -403,32 +439,38 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
<DynamicVirtualList
|
||||
ref={virtualListRef}
|
||||
list={flattenedNodes}
|
||||
size={0}
|
||||
estimateSize={() => 28}
|
||||
scrollerStyle={{ flex: 1, minHeight: 0, height: 'auto' }}
|
||||
itemContainerStyle={{ padding: '8px 8px 0 8px' }}
|
||||
overscan={10}
|
||||
isSticky={isSticky}
|
||||
getItemDepth={getItemDepth}>
|
||||
{({ node, depth }) => <TreeNode node={node} depth={depth} renderChildren={false} />}
|
||||
getItemDepth={getItemDepth}
|
||||
containerProps={{
|
||||
onContextMenu: (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('[data-index]')) {
|
||||
onSelectRoot()
|
||||
setOpenDropdownKey('empty-area')
|
||||
}
|
||||
},
|
||||
onClick: (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('[data-index]')) {
|
||||
onSelectRoot()
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{({ node, depth }) => (
|
||||
<TreeNode
|
||||
node={node}
|
||||
depth={depth}
|
||||
renderChildren={false}
|
||||
onHintClick={node.type === 'hint' ? handleSelectFolder : undefined}
|
||||
/>
|
||||
)}
|
||||
</DynamicVirtualList>
|
||||
</Dropdown>
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<div style={{ padding: '0 8px', marginTop: '6px', marginBottom: '20px' }}>
|
||||
<TreeNode
|
||||
node={{
|
||||
id: 'hint-node',
|
||||
name: '',
|
||||
type: 'hint',
|
||||
treePath: '',
|
||||
externalPath: '',
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}}
|
||||
depth={0}
|
||||
renderChildren={false}
|
||||
onHintClick={handleSelectFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</NotesTreeContainer>
|
||||
|
||||
{isDragOverSidebar && <DragOverIndicator />}
|
||||
@ -442,16 +484,19 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const SidebarContainer = styled.div`
|
||||
export const SidebarContainer = styled.div<{ $rootSelected?: boolean }>`
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
box-shadow: ${({ $rootSelected }) => ($rootSelected ? '0 0 0 2px var(--color-primary) inset' : 'none')};
|
||||
transition: box-shadow 0.15s ease;
|
||||
`
|
||||
|
||||
export const NotesTreeContainer = styled.div`
|
||||
@ -459,7 +504,7 @@ export const NotesTreeContainer = styled.div`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--navbar-height) - 45px);
|
||||
min-height: 0;
|
||||
`
|
||||
|
||||
export const DragOverIndicator = styled.div`
|
||||
|
||||
@ -1,49 +1,110 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('useNotesFileUpload')
|
||||
|
||||
interface UseNotesFileUploadProps {
|
||||
onUploadFiles: (files: File[]) => void
|
||||
setIsDragOverSidebar: (isDragOver: boolean) => void
|
||||
getTargetFolderPath?: () => string | null
|
||||
refreshTree?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useNotesFileUpload = ({ onUploadFiles, setIsDragOverSidebar }: UseNotesFileUploadProps) => {
|
||||
export const useNotesFileUpload = ({
|
||||
onUploadFiles,
|
||||
setIsDragOverSidebar,
|
||||
getTargetFolderPath,
|
||||
refreshTree
|
||||
}: UseNotesFileUploadProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* Handle drag-and-drop file uploads (VS Code-inspired approach)
|
||||
* Uses FileSystemEntry.fullPath to preserve the complete directory structure
|
||||
* This ensures dragging ~/Users/me/tmp/xxx creates target/tmp/xxx
|
||||
*/
|
||||
const handleDropFiles = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOverSidebar(false)
|
||||
|
||||
// 处理文件夹拖拽:从 dataTransfer.items 获取完整文件路径信息
|
||||
const items = Array.from(e.dataTransfer.items)
|
||||
const files: File[] = []
|
||||
if (items.length === 0) return
|
||||
|
||||
const processEntry = async (entry: FileSystemEntry, path: string = '') => {
|
||||
// Collect all entries with their fullPath preserved
|
||||
const entryDataList: Array<{
|
||||
fullPath: string
|
||||
isFile: boolean
|
||||
isDirectory: boolean
|
||||
systemPath: string
|
||||
}> = []
|
||||
|
||||
const processEntry = async (entry: FileSystemEntry): Promise<void> => {
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry
|
||||
return new Promise<void>((resolve) => {
|
||||
fileEntry.file((file) => {
|
||||
// 手动设置 webkitRelativePath 以保持文件夹结构
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: path + file.name,
|
||||
writable: false
|
||||
})
|
||||
files.push(file)
|
||||
fileEntry.file(async (file) => {
|
||||
// Get real system path using Electron's webUtils
|
||||
const systemPath = window.api.file.getPathForFile(file)
|
||||
if (systemPath) {
|
||||
entryDataList.push({
|
||||
fullPath: entry.fullPath, // e.g., "/tmp/xxx/subfolder/file.md"
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
systemPath
|
||||
})
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry
|
||||
const reader = dirEntry.createReader()
|
||||
return new Promise<void>((resolve) => {
|
||||
reader.readEntries(async (entries) => {
|
||||
const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/'))
|
||||
await Promise.all(promises)
|
||||
resolve()
|
||||
})
|
||||
|
||||
// Add directory entry
|
||||
entryDataList.push({
|
||||
fullPath: entry.fullPath,
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
systemPath: '' // Directories don't have systemPath (will be created)
|
||||
})
|
||||
|
||||
// IMPORTANT: readEntries() has a browser limit of ~100 entries per call
|
||||
// We need to call it repeatedly until it returns an empty array
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const readAllEntries = () => {
|
||||
reader.readEntries(
|
||||
async (entries) => {
|
||||
if (entries.length === 0) {
|
||||
// No more entries, we're done
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Process current batch
|
||||
const promises = entries.map((subEntry) => processEntry(subEntry))
|
||||
await Promise.all(promises)
|
||||
|
||||
// Read next batch
|
||||
readAllEntries()
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
readAllEntries()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果支持 DataTransferItem API(文件夹拖拽)
|
||||
if (items.length > 0 && items[0].webkitGetAsEntry()) {
|
||||
if (items[0]?.webkitGetAsEntry()) {
|
||||
const promises = items.map((item) => {
|
||||
const entry = item.webkitGetAsEntry()
|
||||
return entry ? processEntry(entry) : Promise.resolve()
|
||||
@ -51,10 +112,12 @@ export const useNotesFileUpload = ({ onUploadFiles, setIsDragOverSidebar }: UseN
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
if (files.length > 0) {
|
||||
onUploadFiles(files)
|
||||
if (entryDataList.length > 0) {
|
||||
// Pass entry data list to parent for recursive upload
|
||||
onUploadFiles(entryDataList as any)
|
||||
}
|
||||
} else {
|
||||
// Fallback for browsers without FileSystemEntry API
|
||||
const regularFiles = Array.from(e.dataTransfer.files)
|
||||
if (regularFiles.length > 0) {
|
||||
onUploadFiles(regularFiles)
|
||||
@ -64,45 +127,118 @@ export const useNotesFileUpload = ({ onUploadFiles, setIsDragOverSidebar }: UseN
|
||||
[onUploadFiles, setIsDragOverSidebar]
|
||||
)
|
||||
|
||||
const handleSelectFiles = useCallback(() => {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.multiple = true
|
||||
fileInput.accept = '.md,.markdown'
|
||||
fileInput.webkitdirectory = false
|
||||
|
||||
fileInput.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
const selectedFiles = Array.from(target.files)
|
||||
onUploadFiles(selectedFiles)
|
||||
/**
|
||||
* Handle file selection via native Electron dialog
|
||||
* Uses dialog.showOpenDialog in Main process for better UX and cross-platform consistency
|
||||
* Direct upload using file paths - no unnecessary File object conversion
|
||||
*/
|
||||
const handleSelectFiles = useCallback(async () => {
|
||||
try {
|
||||
// Get target folder path from parent context
|
||||
const targetFolderPath = getTargetFolderPath?.() || ''
|
||||
if (!targetFolderPath) {
|
||||
throw new Error('No target folder path available')
|
||||
}
|
||||
fileInput.remove()
|
||||
}
|
||||
|
||||
fileInput.click()
|
||||
}, [onUploadFiles])
|
||||
// Use Electron native dialog for better UX
|
||||
const files = await window.api.file.select({
|
||||
title: t('notes.select_files_to_upload'),
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
{ name: 'Markdown', extensions: ['md', 'markdown'] },
|
||||
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
|
||||
const handleSelectFolder = useCallback(() => {
|
||||
const folderInput = document.createElement('input')
|
||||
folderInput.type = 'file'
|
||||
// @ts-ignore - webkitdirectory is a non-standard attribute
|
||||
folderInput.webkitdirectory = true
|
||||
// @ts-ignore - directory is a non-standard attribute
|
||||
folderInput.directory = true
|
||||
folderInput.multiple = true
|
||||
if (files && files.length > 0) {
|
||||
// Extract file paths directly from FileMetadata
|
||||
const filePaths = files.map((fileMetadata) => fileMetadata.path)
|
||||
|
||||
folderInput.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
const selectedFiles = Array.from(target.files)
|
||||
onUploadFiles(selectedFiles)
|
||||
// Pause file watcher to prevent multiple refresh events
|
||||
await window.api.file.pauseFileWatcher()
|
||||
|
||||
try {
|
||||
// Use batchUpload with file paths (Main process handles everything)
|
||||
const result = await window.api.file.batchUpload(filePaths, targetFolderPath, {
|
||||
allowedExtensions: ['.md', '.markdown', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']
|
||||
})
|
||||
|
||||
logger.info('File selection upload completed:', result)
|
||||
|
||||
// Show success message
|
||||
if (result.fileCount > 0) {
|
||||
window.toast.success(t('notes.upload_success'))
|
||||
|
||||
// Trigger tree refresh if callback provided
|
||||
if (refreshTree) {
|
||||
await refreshTree()
|
||||
}
|
||||
} else {
|
||||
window.toast.warning(t('notes.no_valid_files'))
|
||||
}
|
||||
} finally {
|
||||
// Resume watcher and trigger single refresh
|
||||
await window.api.file.resumeFileWatcher()
|
||||
}
|
||||
}
|
||||
folderInput.remove()
|
||||
} catch (error) {
|
||||
logger.error('Failed to select files:', error as Error)
|
||||
window.toast.error(t('notes.failed_to_select_files'))
|
||||
}
|
||||
}, [t, getTargetFolderPath, refreshTree])
|
||||
|
||||
folderInput.click()
|
||||
}, [onUploadFiles])
|
||||
/**
|
||||
* Handle folder selection via native Electron dialog
|
||||
* Recursively lists all markdown files in the selected folder using Main process
|
||||
* This provides better performance and avoids non-standard webkitdirectory API
|
||||
*
|
||||
* Important: We need to preserve the folder name itself (VS Code behavior)
|
||||
* Example: Selecting /User/tmp should create targetPath/tmp/...
|
||||
*/
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
try {
|
||||
// Use Electron native dialog for folder selection
|
||||
const folderPath = await window.api.file.selectFolder({
|
||||
title: t('notes.select_folder_to_upload'),
|
||||
buttonLabel: t('notes.upload')
|
||||
})
|
||||
|
||||
if (!folderPath) {
|
||||
return // User cancelled
|
||||
}
|
||||
|
||||
logger.info('Selected folder for upload:', { folderPath })
|
||||
|
||||
// Get target folder path from parent context
|
||||
const targetFolderPath = getTargetFolderPath?.() || ''
|
||||
if (!targetFolderPath) {
|
||||
throw new Error('No target folder path available')
|
||||
}
|
||||
|
||||
// Use new uploadFolder API that handles everything in Main process
|
||||
const result = await window.api.file.uploadFolder(folderPath, targetFolderPath, {
|
||||
allowedExtensions: ['.md', '.markdown', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']
|
||||
})
|
||||
|
||||
logger.info('Folder upload completed:', result)
|
||||
|
||||
// Show success message
|
||||
if (result.fileCount > 0) {
|
||||
window.toast.success(t('notes.upload_success'))
|
||||
|
||||
// Trigger tree refresh if callback provided
|
||||
if (refreshTree) {
|
||||
await refreshTree()
|
||||
}
|
||||
} else {
|
||||
window.toast.warning(t('notes.no_markdown_files_in_folder'))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to select folder:', error as Error)
|
||||
window.toast.error(t('notes.failed_to_select_folder'))
|
||||
}
|
||||
}, [t, getTargetFolderPath, refreshTree])
|
||||
|
||||
return {
|
||||
handleDropFiles,
|
||||
|
||||
@ -5,6 +5,7 @@ import { getFileDirectory } from '@renderer/utils'
|
||||
const logger = loggerService.withContext('NotesService')
|
||||
|
||||
const MARKDOWN_EXT = '.md'
|
||||
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']
|
||||
|
||||
export interface UploadResult {
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
@ -82,9 +83,11 @@ export async function renameNode(node: NotesTreeNode, newName: string): Promise<
|
||||
return { path: `${parentDir}/${safeName}`, name: safeName }
|
||||
}
|
||||
|
||||
export async function uploadNotes(files: File[], targetPath: string): Promise<UploadResult> {
|
||||
export async function uploadNotes(
|
||||
files: File[] | Array<{ fullPath: string; isFile: boolean; isDirectory: boolean; systemPath: string }>,
|
||||
targetPath: string
|
||||
): Promise<UploadResult> {
|
||||
const basePath = normalizePath(targetPath)
|
||||
const totalFiles = files.length
|
||||
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
@ -96,35 +99,93 @@ export async function uploadNotes(files: File[], targetPath: string): Promise<Up
|
||||
}
|
||||
}
|
||||
|
||||
const firstItem = files[0]
|
||||
const isEntryDataList =
|
||||
typeof firstItem === 'object' && 'fullPath' in firstItem && 'systemPath' in firstItem && 'isFile' in firstItem
|
||||
|
||||
if (isEntryDataList) {
|
||||
const entries = files as Array<{ fullPath: string; isFile: boolean; isDirectory: boolean; systemPath: string }>
|
||||
return uploadNotesRecursive(entries, targetPath)
|
||||
}
|
||||
|
||||
// Legacy approach: File objects (for browser File API compatibility)
|
||||
const fileList = files as File[]
|
||||
const totalFiles = fileList.length
|
||||
|
||||
try {
|
||||
// Get file paths from File objects
|
||||
// For browser File objects from drag-and-drop, we need to use FileReader to save temporarily
|
||||
// However, for directory uploads, the files already have paths
|
||||
const filePaths: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
// @ts-ignore - webkitRelativePath exists on File objects from directory uploads
|
||||
if (file.path) {
|
||||
// @ts-ignore - Electron File objects have .path property
|
||||
filePaths.push(file.path)
|
||||
for (const file of fileList) {
|
||||
const filePath = window.api.file.getPathForFile(file)
|
||||
|
||||
if (filePath) {
|
||||
filePaths.push(filePath)
|
||||
} else {
|
||||
// For browser File API, we'd need to use FileReader and create temp files
|
||||
// For now, fall back to the old method for these cases
|
||||
logger.warn('File without path detected, using fallback method')
|
||||
return uploadNotesLegacy(files, targetPath)
|
||||
logger.warn('Failed to get system path for uploaded file:', { fileName: file.name })
|
||||
window.toast.warning(`Failed to get system path for file: ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (filePaths.length === 0) {
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles,
|
||||
skippedFiles: totalFiles,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Pause file watcher to prevent N refresh events
|
||||
await window.api.file.pauseFileWatcher()
|
||||
|
||||
// Use simplified batchUpload for File objects
|
||||
const result = await window.api.file.batchUpload(filePaths, basePath, {
|
||||
allowedExtensions: [MARKDOWN_EXT, ...IMAGE_EXTS]
|
||||
})
|
||||
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles,
|
||||
skippedFiles: result.skippedFiles,
|
||||
fileCount: result.fileCount,
|
||||
folderCount: result.folderCount
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Legacy file upload failed:', error as Error)
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles,
|
||||
skippedFiles: totalFiles,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive upload for drag-and-drop with fullPath preserved (VS Code approach)
|
||||
* Uses batch processing for better performance
|
||||
*/
|
||||
async function uploadNotesRecursive(
|
||||
entryDataList: Array<{ fullPath: string; isFile: boolean; isDirectory: boolean; systemPath: string }>,
|
||||
targetPath: string
|
||||
): Promise<UploadResult> {
|
||||
const basePath = normalizePath(targetPath)
|
||||
|
||||
try {
|
||||
// Pause file watcher to prevent N refresh events
|
||||
await window.api.file.pauseFileWatcher()
|
||||
|
||||
try {
|
||||
// Use the new optimized batch upload API that runs in Main process
|
||||
const result = await window.api.file.batchUploadMarkdown(filePaths, basePath)
|
||||
// Use batch upload API for better performance (parallel processing in Main process)
|
||||
const result = await window.api.file.batchUploadEntries(entryDataList, basePath, {
|
||||
allowedExtensions: [MARKDOWN_EXT, ...IMAGE_EXTS]
|
||||
})
|
||||
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles,
|
||||
totalFiles: result.fileCount + result.skippedFiles,
|
||||
skippedFiles: result.skippedFiles,
|
||||
fileCount: result.fileCount,
|
||||
folderCount: result.folderCount
|
||||
@ -134,75 +195,8 @@ export async function uploadNotes(files: File[], targetPath: string): Promise<Up
|
||||
await window.api.file.resumeFileWatcher()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Batch upload failed, falling back to legacy method:', error as Error)
|
||||
// Fall back to old method if new method fails
|
||||
return uploadNotesLegacy(files, targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy upload method using Renderer process
|
||||
* Kept as fallback for browser File API files without paths
|
||||
*/
|
||||
async function uploadNotesLegacy(files: File[], targetPath: string): Promise<UploadResult> {
|
||||
const basePath = normalizePath(targetPath)
|
||||
const markdownFiles = filterMarkdown(files)
|
||||
const skippedFiles = files.length - markdownFiles.length
|
||||
|
||||
if (markdownFiles.length === 0) {
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
const folders = collectFolders(markdownFiles, basePath)
|
||||
await createFolders(folders)
|
||||
|
||||
let fileCount = 0
|
||||
const BATCH_SIZE = 5 // Process 5 files concurrently to balance performance and responsiveness
|
||||
|
||||
// Process files in batches to avoid blocking the UI thread
|
||||
for (let i = 0; i < markdownFiles.length; i += BATCH_SIZE) {
|
||||
const batch = markdownFiles.slice(i, i + BATCH_SIZE)
|
||||
|
||||
// Process current batch in parallel
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (file) => {
|
||||
const { dir, name } = resolveFileTarget(file, basePath)
|
||||
const { safeName } = await window.api.file.checkFileName(dir, name, true)
|
||||
const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
const content = await file.text()
|
||||
await window.api.file.write(finalPath, content)
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
// Count successful uploads
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
fileCount += 1
|
||||
} else {
|
||||
logger.error('Failed to write uploaded file:', result.reason)
|
||||
}
|
||||
})
|
||||
|
||||
// Yield to the event loop between batches to keep UI responsive
|
||||
if (i + BATCH_SIZE < markdownFiles.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount,
|
||||
folderCount: folders.size
|
||||
logger.error('Recursive upload failed:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@ -233,57 +227,15 @@ function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function filterMarkdown(files: File[]): File[] {
|
||||
return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT))
|
||||
}
|
||||
|
||||
function collectFolders(files: File[], basePath: string): Set<string> {
|
||||
const folders = new Set<string>()
|
||||
|
||||
files.forEach((file) => {
|
||||
const relativePath = file.webkitRelativePath || ''
|
||||
if (!relativePath.includes('/')) {
|
||||
return
|
||||
export const findNode = (nodes: NotesTreeNode[], nodeId: string): NotesTreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId) {
|
||||
return node
|
||||
}
|
||||
|
||||
const parts = relativePath.split('/')
|
||||
parts.pop()
|
||||
|
||||
let current = basePath
|
||||
for (const part of parts) {
|
||||
current = `${current}/${part}`
|
||||
folders.add(current)
|
||||
}
|
||||
})
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
async function createFolders(folders: Set<string>): Promise<void> {
|
||||
const ordered = Array.from(folders).sort((a, b) => a.length - b.length)
|
||||
|
||||
for (const folder of ordered) {
|
||||
try {
|
||||
await window.api.file.mkdir(folder)
|
||||
} catch (error) {
|
||||
logger.debug('Skip existing folder while uploading notes', {
|
||||
folder,
|
||||
error: (error as Error).message
|
||||
})
|
||||
if (node.children) {
|
||||
const found = findNode(node.children, nodeId)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFileTarget(file: File, basePath: string): { dir: string; name: string } {
|
||||
if (!file.webkitRelativePath || !file.webkitRelativePath.includes('/')) {
|
||||
const nameWithoutExt = file.name.endsWith(MARKDOWN_EXT) ? file.name.slice(0, -MARKDOWN_EXT.length) : file.name
|
||||
return { dir: basePath, name: nameWithoutExt }
|
||||
}
|
||||
|
||||
const parts = file.webkitRelativePath.split('/')
|
||||
const fileName = parts.pop() || file.name
|
||||
const dirPath = `${basePath}/${parts.join('/')}`
|
||||
const nameWithoutExt = fileName.endsWith(MARKDOWN_EXT) ? fileName.slice(0, -MARKDOWN_EXT.length) : fileName
|
||||
|
||||
return { dir: dirPath, name: nameWithoutExt }
|
||||
return null
|
||||
}
|
||||
|
||||
@ -843,7 +843,6 @@ export const htmlToMarkdown = (html: string | null | undefined): string => {
|
||||
/**
|
||||
* Converts Markdown content to HTML
|
||||
* @param markdown - Markdown string to convert
|
||||
* @param options - Task list options
|
||||
* @returns HTML string
|
||||
*/
|
||||
export const markdownToHtml = (markdown: string | null | undefined): string => {
|
||||
@ -951,3 +950,7 @@ export const isMarkdownContent = (content: string): boolean => {
|
||||
|
||||
return markdownPatterns.some((pattern) => pattern.test(content))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
|
||||
@ -115,25 +115,30 @@ vi.mock('node:path', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
access: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
rmdir: vi.fn()
|
||||
},
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
rmdirSync: vi.fn(),
|
||||
createReadStream: vi.fn(),
|
||||
createWriteStream: vi.fn()
|
||||
}))
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
promises: {
|
||||
access: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
rmdir: vi.fn()
|
||||
},
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
rmdirSync: vi.fn(),
|
||||
createReadStream: vi.fn(),
|
||||
createWriteStream: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user