mirror of
https://github.com/adnaan-worker/adnify.git
synced 2026-09-01 15:12:10 +08:00
feat: implement large file handling with safeOpenFile utility
- Introduced safeOpenFile utility to manage large file opening with chunked reading. - Updated FileChangeCard, TextWithFileLinks, QuickOpen, and other components to utilize safeOpenFile for file opening. - Added LargeFileViewer component for displaying large files in read-only mode. - Enhanced file reading logic to support large files by reading in chunks and providing user feedback. - Modified existing file handling methods to accommodate new large file policies and ensure smooth user experience. - Added tests for safeOpenFile functionality to verify large file handling behavior.
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
engine-strict=true
|
||||
package-manager-strict=true
|
||||
package-manager-strict-version=true
|
||||
|
||||
node-linker=hoisted
|
||||
public-hoist-pattern=*
|
||||
shamefully-hoist=true
|
||||
|
||||
supportedArchitectures.os=current
|
||||
supportedArchitectures.cpu=current
|
||||
+4
-23
@@ -5,10 +5,10 @@
|
||||
"productName": "Adnify",
|
||||
"author": "adnaan <adnaan.worker@gmail.com>",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"packageManager": "pnpm@11.22.0",
|
||||
"engines": {
|
||||
"node": "^24.19.0",
|
||||
"pnpm": "9.15.9"
|
||||
"pnpm": "11.22.0"
|
||||
},
|
||||
"homepage": "https://github.com/ad-naan/adnify",
|
||||
"repository": {
|
||||
@@ -40,26 +40,6 @@
|
||||
"rebuild": "npx electron-rebuild -f -w node-pty",
|
||||
"rebuild:all": "npx electron-rebuild -f -w"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"brace-expansion@<1.1.18": "^1.1.18",
|
||||
"brace-expansion@>=2.0.0 <2.1.4": "^2.1.4",
|
||||
"brace-expansion@>=4.0.0 <5.0.9": "^5.0.9",
|
||||
"fast-uri@>=3.0.0 <3.1.5": "^3.1.5",
|
||||
"hono@<4.12.34": "^4.12.34",
|
||||
"@hono/node-server@<2.0.5": "^2.0.5",
|
||||
"ip-address@<=10.3.0": "^10.3.1",
|
||||
"js-yaml@>=4.0.0 <4.3.1": "^4.3.1",
|
||||
"dompurify@<3.4.13": "^3.4.13",
|
||||
"sharp@<0.35.0": "^0.35.1",
|
||||
"tar@<7.5.22": "^7.5.22",
|
||||
"undici@<6.28.0": "^6.28.0",
|
||||
"protobufjs@<7.6.3": "^7.6.3"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"extract-zip@2.0.1": "patches/extract-zip@2.0.1.patch"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.23.0",
|
||||
"@parcel/watcher": "^2.4.1",
|
||||
@@ -95,7 +75,6 @@
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
@@ -118,6 +97,7 @@
|
||||
"electron": "^39.8.5",
|
||||
"electron-builder": "^26.15.3",
|
||||
"esbuild": "^0.28.1",
|
||||
"extract-zip": "2.0.1",
|
||||
"fast-check": "^4.4.0",
|
||||
"fast-json-stable-stringify": "^2.1.0",
|
||||
"framer-motion": "^12.25.0",
|
||||
@@ -126,6 +106,7 @@
|
||||
"katex": "^0.16.45",
|
||||
"lucide-react": "^0.562.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"onnx-proto": "4.0.4",
|
||||
"png2icons": "^2.0.1",
|
||||
"postcss": "^8.5.23",
|
||||
"react": "^18.3.1",
|
||||
|
||||
Generated
+200
-167
File diff suppressed because it is too large
Load Diff
+17
-27
@@ -2,18 +2,19 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
# 与 .npmrc 双写:pnpm 9 读 .npmrc;pnpm 11+ 以 workspace 为准。
|
||||
# pnpm 11 的唯一项目级配置入口。package.json 不再保留一份会被忽略的
|
||||
# overrides/patches 副本,避免安全约束看似存在、实际不生效。
|
||||
# 强制本机 Node/pnpm 与 package.json engines + packageManager 一致。
|
||||
engineStrict: true
|
||||
packageManagerStrict: true
|
||||
packageManagerStrictVersion: true
|
||||
|
||||
# 安装必须是显式步骤。运行 test/build 时发现依赖状态变化就直接执行目标,
|
||||
# 不让 pnpm 在后台隐式重装整棵依赖树。
|
||||
verifyDepsBeforeRun: false
|
||||
|
||||
# 安全 override:把传递依赖顶到已修复的补丁版本。
|
||||
#
|
||||
# 必须与 package.json 的 pnpm.overrides 保持一致 —— 两份不是冗余:
|
||||
# pnpm 9 只读 package.json 的 pnpm.overrides,pnpm 11 只读这里。
|
||||
# 本仓库用 packageManager 钉在 9.15.9;安装时 packageManagerStrictVersion
|
||||
# 会拒绝其它 pnpm 大/小版本。overrides 仍双写,避免钉子松动后静默失效。
|
||||
overrides:
|
||||
brace-expansion@<1.1.18: ^1.1.18
|
||||
brace-expansion@>=2.0.0 <2.1.4: ^2.1.4
|
||||
@@ -45,25 +46,14 @@ minimumReleaseAge: 0
|
||||
|
||||
# pnpm v11+ 默认会忽略某些包的构建脚本,在此明确批准
|
||||
allowBuilds:
|
||||
'0': '@parcel/watcher'
|
||||
'1': core-js
|
||||
'2': cpu-features
|
||||
'3': dugite
|
||||
'4': electron
|
||||
'5': electron-winstaller
|
||||
'6': esbuild
|
||||
'7': node-pty
|
||||
'8': protobufjs
|
||||
'9': sharp
|
||||
'10': ssh2
|
||||
'@parcel/watcher': set this to true or false
|
||||
core-js: set this to true or false
|
||||
cpu-features: set this to true or false
|
||||
dugite: set this to true or false
|
||||
electron: set this to true or false
|
||||
electron-winstaller: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
node-pty: set this to true or false
|
||||
protobufjs: set this to true or false
|
||||
sharp: set this to true or false
|
||||
ssh2: set this to true or false
|
||||
'@parcel/watcher': true
|
||||
core-js: true
|
||||
cpu-features: true
|
||||
dugite: true
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
ssh2: true
|
||||
|
||||
@@ -287,6 +287,7 @@ export interface ElectronAPI {
|
||||
readDir: (path: string) => Promise<{ name: string; path: string; isDirectory: boolean }[]>
|
||||
getFileTree: (path: string, maxDepth?: number) => Promise<string>
|
||||
readFile: (path: string, encoding?: string, options?: { full?: boolean }) => Promise<string | null>
|
||||
readTextChunk: (path: string, offset?: number, maxBytes?: number) => Promise<import('@shared/types/fileChunk').TextFileChunk | null>
|
||||
statFile: (path: string) => Promise<{ size: number; isDirectory: boolean; isFile: boolean; mtimeMs: number } | null>
|
||||
readRichContent: (path: string, options?: ReadRichContentOptions) => Promise<RichContentReadResult>
|
||||
readImageAnalysis: (request: ImageAnalysisRequest) => Promise<ImageAnalysisResult>
|
||||
@@ -627,6 +628,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
readDir: (path: string) => ipcRenderer.invoke('file:readDir', path),
|
||||
getFileTree: (path: string, maxDepth?: number) => ipcRenderer.invoke('file:getTree', path, maxDepth),
|
||||
readFile: (path: string, encoding?: string, options?: { full?: boolean }) => ipcRenderer.invoke('file:read', path, encoding, options),
|
||||
readTextChunk: (path: string, offset?: number, maxBytes?: number) => ipcRenderer.invoke('file:readTextChunk', path, offset, maxBytes),
|
||||
statFile: (path: string) => ipcRenderer.invoke('file:stat', path),
|
||||
readBinaryFile: (path: string) => ipcRenderer.invoke('file:readBinary', path),
|
||||
readRichContent: (path: string, options?: ReadRichContentOptions) => ipcRenderer.invoke('file:readRichContent', path, options),
|
||||
|
||||
@@ -2,6 +2,10 @@ import { promises as fsPromises } from 'fs'
|
||||
import type { FileHandle } from 'fs/promises'
|
||||
import { randomUUID } from 'crypto'
|
||||
import * as iconv from 'iconv-lite'
|
||||
import type { TextFileChunk } from '@shared/types/fileChunk'
|
||||
import { LARGE_FILE_PAGE_BYTES } from '@shared/types/largeFile'
|
||||
|
||||
const MAX_TEXT_CHUNK_BYTES = 4 * 1024 * 1024
|
||||
|
||||
export type SupportedEncoding = 'utf-8' | 'utf-8-bom' | 'gbk' | 'gb18030'
|
||||
|
||||
@@ -52,9 +56,66 @@ function encodeContent(content: string, encoding: SupportedEncoding): Buffer {
|
||||
return Buffer.from(content, 'utf-8')
|
||||
}
|
||||
|
||||
export async function readFileWithEncoding(filePath: string): Promise<string | null> {
|
||||
const result = await readFileWithEncodingInfo(filePath)
|
||||
return result.content
|
||||
function findCompleteUtf8Boundary(buffer: Buffer, end: number): number {
|
||||
let leadIndex = end - 1
|
||||
while (leadIndex >= 0 && (buffer[leadIndex] & 0xc0) === 0x80) leadIndex -= 1
|
||||
if (leadIndex < 0) return end
|
||||
|
||||
const leadByte = buffer[leadIndex]
|
||||
const expectedBytes = leadByte < 0x80 ? 1 : leadByte < 0xe0 ? 2 : leadByte < 0xf0 ? 3 : 4
|
||||
return end - leadIndex < expectedBytes ? leadIndex : end
|
||||
}
|
||||
|
||||
export async function readTextFileChunk(
|
||||
filePath: string,
|
||||
offset = 0,
|
||||
requestedBytes = LARGE_FILE_PAGE_BYTES,
|
||||
): Promise<TextFileChunk> {
|
||||
const stats = await fsPromises.stat(filePath)
|
||||
const totalSize = stats.size
|
||||
const finiteOffset = Number.isFinite(offset) ? offset : 0
|
||||
const finiteRequestedBytes = Number.isFinite(requestedBytes) ? requestedBytes : LARGE_FILE_PAGE_BYTES
|
||||
const startOffset = Math.min(Math.max(0, Math.floor(finiteOffset)), totalSize)
|
||||
// Four bytes are enough for one complete UTF-8 code point, so even a
|
||||
// pathological tiny request always makes progress without corrupt decoding.
|
||||
const chunkBytes = Math.min(Math.max(4, Math.floor(finiteRequestedBytes)), MAX_TEXT_CHUNK_BYTES)
|
||||
if (startOffset >= totalSize) {
|
||||
return { content: '', startOffset, nextOffset: startOffset, totalSize, eof: true }
|
||||
}
|
||||
|
||||
const bytesToRead = Math.min(chunkBytes, totalSize - startOffset)
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead)
|
||||
const handle = await fsPromises.open(filePath, 'r')
|
||||
try {
|
||||
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, startOffset)
|
||||
let end = bytesRead
|
||||
const reachesEof = startOffset + bytesRead >= totalSize
|
||||
|
||||
if (!reachesEof) {
|
||||
const newline = buffer.lastIndexOf(10, bytesRead - 1)
|
||||
if (newline >= 0) {
|
||||
end = newline + 1
|
||||
} else {
|
||||
end = findCompleteUtf8Boundary(buffer, bytesRead)
|
||||
if (end === 0) {
|
||||
const firstByte = buffer[0]
|
||||
const firstCharacterBytes = firstByte < 0x80 ? 1 : firstByte < 0xe0 ? 2 : firstByte < 0xf0 ? 3 : 4
|
||||
end = Math.min(firstCharacterBytes, bytesRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextOffset = startOffset + end
|
||||
return {
|
||||
content: decodeBuffer(buffer.subarray(0, end), 'utf-8'),
|
||||
startOffset,
|
||||
nextOffset,
|
||||
totalSize,
|
||||
eof: nextOffset >= totalSize,
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFileWithEncodingInfo(
|
||||
@@ -92,7 +153,7 @@ export async function readFileWithEncodingInfo(
|
||||
* parses the result or writes it back — must use `readFileWithEncodingInfo`,
|
||||
* otherwise a truncated read round-trips as a destructive write.
|
||||
*/
|
||||
export async function readLargeFile(
|
||||
async function readByteRange(
|
||||
filePath: string,
|
||||
start: number,
|
||||
maxLength: number,
|
||||
@@ -134,7 +195,7 @@ export async function readFileSized(
|
||||
const stats = await fsPromises.stat(filePath)
|
||||
|
||||
if (stats.size > previewByteLimit) {
|
||||
const content = await readLargeFile(filePath, 0, previewByteLimit)
|
||||
const content = await readByteRange(filePath, 0, previewByteLimit)
|
||||
return {
|
||||
content,
|
||||
encoding: normalizeEncoding(encoding),
|
||||
|
||||
@@ -16,7 +16,7 @@ import { getUserConfigDir } from '../services/configPath'
|
||||
import { fileApprovalScope, isRecentAgentApprovalProof, type AgentApprovalProof } from '@shared/security/executionPolicy'
|
||||
|
||||
// 导入拆分的模块
|
||||
import { readFileWithEncodingInfo, readFileSized, readLargeFile, writeFileAtomic, getFileStats } from './fileUtils'
|
||||
import { readFileWithEncodingInfo, readFileSized, readTextFileChunk, writeFileAtomic, getFileStats } from './fileUtils'
|
||||
import { systemPrivilegeService } from '../services/systemPrivilegeService'
|
||||
import { isSystemPermissionError } from '@shared/utils/permissionError'
|
||||
import { mutationFailure, mutationFailureFromError, mutationSuccess } from '../services/fileMutationResult'
|
||||
@@ -111,7 +111,6 @@ function canAccessFile(
|
||||
* a truncated parse there reads as "empty", and empty round-trips as deletion.
|
||||
*/
|
||||
const PREVIEW_BYTE_LIMIT = 5 * 1024 * 1024
|
||||
const PREVIEW_SLICE_BYTES = 10000
|
||||
|
||||
function readFileSingleFlight(
|
||||
filePath: string,
|
||||
@@ -344,6 +343,21 @@ export function registerSecureFileHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('file:readTextChunk', async (event, filePath: string, offset?: number, maxBytes?: number) => {
|
||||
if (!filePath || !canAccessFile(filePath, getWorkspaceSessionFn(event), 'read')) return null
|
||||
try {
|
||||
const chunk = await readTextFileChunk(filePath, offset, maxBytes)
|
||||
securityManager.logOperation(OperationType.FILE_READ, filePath, true, {
|
||||
size: chunk.nextOffset - chunk.startOffset,
|
||||
chunked: true,
|
||||
})
|
||||
return chunk
|
||||
} catch (err) {
|
||||
logger.security.error('[File] read text chunk failed:', filePath, toAppError(err).message)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// 读取二进制文件为 base64
|
||||
ipcMain.handle('file:readBinary', async (event, filePath: string) => {
|
||||
if (!filePath) return null
|
||||
@@ -971,4 +985,3 @@ export { securityManager }
|
||||
// 重新导出拆分模块的类型和函数,方便外部使用
|
||||
export type { FileWatcherEvent, WindowManagerContext }
|
||||
export { setupFileWatcher, cleanupFileWatcher } from './fileWatcher'
|
||||
export { readFileWithEncoding, readLargeFile } from './fileUtils'
|
||||
|
||||
@@ -47,6 +47,7 @@ import { SystemAlert, parseSystemAlert } from './SystemAlert'
|
||||
import { CompressionDigestCard } from './CompressionDigestCard'
|
||||
import { t } from '../../i18n'
|
||||
import { api } from '@/renderer/services/electronAPI'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
import { writeClipboardText } from '@/renderer/services/clipboardService'
|
||||
import { toFullPath, getFileName } from '@shared/utils/pathUtils'
|
||||
import { stripToolCallLeaks } from '@renderer/agent/utils/toolCallLeakFilter'
|
||||
@@ -276,10 +277,8 @@ const MessageMetaGroup = React.memo(({ autoSkills, manualSkills, searchContent,
|
||||
// ignore
|
||||
}
|
||||
|
||||
const content = await api.file.readFull(targetPath)
|
||||
if (content !== null) {
|
||||
openFile(targetPath, content)
|
||||
setActiveFile(targetPath)
|
||||
const result = await safeOpenFile(targetPath, { language, confirmLargeFile: false })
|
||||
if (result.success) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -613,22 +612,18 @@ const MarkdownContent = React.memo(({ content: rawContent, fontSize, isStreaming
|
||||
[keepStreamingLayout, smoothContent],
|
||||
)
|
||||
|
||||
const { workspacePath, openFile, setActiveFile } = useStore(useShallow(s => ({ workspacePath: s.workspacePath, openFile: s.openFile, setActiveFile: s.setActiveFile })))
|
||||
const workspacePath = useStore(s => s.workspacePath)
|
||||
|
||||
const handleOpenFile = React.useCallback(async (filePath: string) => {
|
||||
if (!workspacePath) return
|
||||
const resolvedPath = toFullPath(filePath, workspacePath)
|
||||
|
||||
try {
|
||||
const content = await api.file.readFull(resolvedPath)
|
||||
if (content !== null) {
|
||||
openFile(resolvedPath, content)
|
||||
setActiveFile(resolvedPath)
|
||||
}
|
||||
await safeOpenFile(resolvedPath, { showWarning: false, confirmLargeFile: false })
|
||||
} catch (err) {
|
||||
console.warn('Failed to open file from markdown:', err)
|
||||
}
|
||||
}, [workspacePath, openFile, setActiveFile])
|
||||
}, [workspacePath])
|
||||
|
||||
const markdownComponents = React.useMemo(() => ({
|
||||
code({ className, children, node, ...props }: any) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { toast } from '@components/common/ToastProvider'
|
||||
import { isCreateActionLabel, resolveFileChangeActionLabel } from '@renderer/agent/utils/fileWriteDisplay'
|
||||
import { RollingNumber } from '@components/ui'
|
||||
import { ToolApprovalActions } from './ToolApprovalActions'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
|
||||
interface FileChangeCardProps {
|
||||
toolCall: ToolCall
|
||||
@@ -126,10 +127,8 @@ function FileChangeCard({
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await api.file.readFull(absPath)
|
||||
if (content !== null) {
|
||||
openFile(absPath, content)
|
||||
setActiveFile(absPath)
|
||||
const result = await safeOpenFile(absPath, { language, confirmLargeFile: false })
|
||||
if (result.success) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
@@ -137,7 +136,7 @@ function FileChangeCard({
|
||||
}
|
||||
|
||||
toast.error(`Failed to open file: ${getFileName(absPath)}`)
|
||||
}, [filePath, workspacePath, openFile, setActiveFile])
|
||||
}, [filePath, workspacePath, language])
|
||||
|
||||
const diffStats = useMemo(() => {
|
||||
// Prefer precise stats returned by the tool when available.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { api } from '@/renderer/services/electronAPI'
|
||||
import { useStore } from '@store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { joinPath } from '@shared/utils/pathUtils'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
|
||||
interface TextWithFileLinksProps {
|
||||
text: string
|
||||
@@ -10,7 +9,7 @@ interface TextWithFileLinksProps {
|
||||
}
|
||||
|
||||
export function TextWithFileLinks({ text, className = '' }: TextWithFileLinksProps) {
|
||||
const { workspacePath, openFile, setActiveFile } = useStore(useShallow(s => ({ workspacePath: s.workspacePath, openFile: s.openFile, setActiveFile: s.setActiveFile })))
|
||||
const workspacePath = useStore(s => s.workspacePath)
|
||||
|
||||
const handleFileClick = useCallback(async (e: React.MouseEvent, filePath: string) => {
|
||||
e.stopPropagation()
|
||||
@@ -21,15 +20,11 @@ export function TextWithFileLinks({ text, className = '' }: TextWithFileLinksPro
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await api.file.readFull(absPath)
|
||||
if (content !== null) {
|
||||
openFile(absPath, content)
|
||||
setActiveFile(absPath)
|
||||
}
|
||||
await safeOpenFile(absPath, { showWarning: false, confirmLargeFile: false })
|
||||
} catch (error) {
|
||||
// Ignore if not a valid file
|
||||
}
|
||||
}, [workspacePath, openFile, setActiveFile])
|
||||
}, [workspacePath])
|
||||
|
||||
const elements = useMemo(() => {
|
||||
if (!text) return null
|
||||
|
||||
@@ -14,6 +14,7 @@ import { t } from '@renderer/i18n'
|
||||
import { Button } from '../ui'
|
||||
import FileIcon from '../common/FileIcon'
|
||||
import { useElevatedToastLayer } from '../common/toastLayerStore'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
|
||||
interface QuickOpenProps {
|
||||
onClose: () => void
|
||||
@@ -164,7 +165,7 @@ const FileMatchItem = memo(function FileMatchItem({
|
||||
|
||||
export default function QuickOpen({ onClose }: QuickOpenProps) {
|
||||
useElevatedToastLayer(true)
|
||||
const { workspacePath, openFile, language } = useStore(useShallow(s => ({ workspacePath: s.workspacePath, openFile: s.openFile, language: s.language })))
|
||||
const { workspacePath, language } = useStore(useShallow(s => ({ workspacePath: s.workspacePath, language: s.language })))
|
||||
const [query, setQuery] = useState('')
|
||||
const [allFiles, setAllFiles] = useState<string[]>([])
|
||||
const [matches, setMatches] = useState<FileMatch[]>([])
|
||||
@@ -252,13 +253,9 @@ export default function QuickOpen({ onClose }: QuickOpenProps) {
|
||||
if (!workspacePath) return
|
||||
|
||||
const fullPath = `${workspacePath}/${filePath}`
|
||||
const content = await api.file.readFull(fullPath)
|
||||
|
||||
if (content !== null) {
|
||||
openFile(fullPath, content)
|
||||
onClose()
|
||||
}
|
||||
}, [workspacePath, openFile, onClose])
|
||||
const result = await safeOpenFile(fullPath, { language, confirmLargeFile: false })
|
||||
if (result.success) onClose()
|
||||
}, [workspacePath, language, onClose])
|
||||
|
||||
// 键盘导航
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import { defineMonacoTheme } from './utils/monacoTheme'
|
||||
import { isPreviewDocumentPath } from '@shared/types/preview'
|
||||
import { PLAN_BOARD_PATH, isPlanBoardPath } from '@shared/types/planBoard'
|
||||
import type { EditorConfig as SharedEditorConfig } from '@shared/config/types'
|
||||
import LargeFileViewer from './LargeFileViewer'
|
||||
|
||||
loader.config({ monaco })
|
||||
|
||||
@@ -229,7 +230,10 @@ export default function Editor() {
|
||||
if (!file || file.contentState !== 'unloaded') return
|
||||
|
||||
let cancelled = false
|
||||
void api.file.readFull(file.path, file.encoding).then((content) => {
|
||||
const readContent = file.kind === 'large-preview'
|
||||
? api.file.readTextChunk(file.path, 0, file.largeFileView?.chunkSize).then(chunk => chunk?.content ?? null)
|
||||
: api.file.readFull(file.path, file.encoding)
|
||||
void readContent.then((content) => {
|
||||
if (cancelled) return
|
||||
if (content === null) {
|
||||
setFileContentState(file.path, 'error')
|
||||
@@ -250,7 +254,7 @@ export default function Editor() {
|
||||
const file = activeFilePath
|
||||
? useStore.getState().openFiles.find(candidate => candidate.path === activeFilePath)
|
||||
: undefined
|
||||
if (file?.contentState === 'loaded' && !isPreviewDocument && !isPlanBoardDocument) {
|
||||
if (file?.contentState === 'loaded' && file.kind !== 'large-preview' && !isPreviewDocument && !isPlanBoardDocument) {
|
||||
notifyFileOpened(file.path, file.content)
|
||||
// 检查是否有跨文件跳转定义的待定位请求
|
||||
const nav = consumePendingNavigation(file.path)
|
||||
@@ -272,7 +276,7 @@ export default function Editor() {
|
||||
|
||||
const validUris = new Set(
|
||||
useStore.getState().openFiles.map(f => {
|
||||
if (f.path.startsWith('diff://') || f.kind === 'preview' || f.contentState !== 'loaded') return ''
|
||||
if (f.path.startsWith('diff://') || f.kind === 'preview' || f.kind === 'large-preview' || f.contentState !== 'loaded') return ''
|
||||
return monacoInstance.Uri.file(f.path).toString()
|
||||
})
|
||||
)
|
||||
@@ -572,6 +576,8 @@ export default function Editor() {
|
||||
</div>
|
||||
) : activeFile && activeFile.contentState !== 'loaded' ? (
|
||||
<CodeSkeleton lines={12} />
|
||||
) : activeFile?.kind === 'large-preview' ? (
|
||||
<LargeFileViewer file={activeFile} language={language} />
|
||||
) : activeFile && (
|
||||
<>
|
||||
{/* Markdown 工具栏 */}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { editor } from 'monaco-editor'
|
||||
import { logger } from '@shared/utils/Logger'
|
||||
import { readClipboardText, writeClipboardText } from '@/renderer/services/clipboardService'
|
||||
import { navigateToDefinition } from './hooks/useEditorActions'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
|
||||
// 支持 Call Hierarchy 的语言(只有支持函数/方法调用的语言才有意义)
|
||||
const CALL_HIERARCHY_SUPPORTED_LANGUAGES = [
|
||||
@@ -69,7 +70,7 @@ interface EditorContextMenuProps {
|
||||
}
|
||||
|
||||
export default function EditorContextMenu({ x, y, editor, onClose }: EditorContextMenuProps) {
|
||||
const { language, activeFilePath, openFile, setActiveFile } = useStore(useShallow(s => ({ language: s.language, activeFilePath: s.activeFilePath, openFile: s.openFile, setActiveFile: s.setActiveFile })))
|
||||
const { language, activeFilePath } = useStore(useShallow(s => ({ language: s.language, activeFilePath: s.activeFilePath })))
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [callHierarchyResult, setCallHierarchyResult] = useState<CallHierarchyResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -297,10 +298,8 @@ export default function EditorContextMenu({ x, y, editor, onClose }: EditorConte
|
||||
const handleJumpToCall = async (item: CallHierarchyResult['items'][0]) => {
|
||||
const filePath = lspUriToPath(item.uri)
|
||||
if (filePath) {
|
||||
const content = await api.file.readFull(filePath)
|
||||
if (content === null) return
|
||||
openFile(filePath, content)
|
||||
setActiveFile(filePath)
|
||||
const opened = await safeOpenFile(filePath, { language, confirmLargeFile: false })
|
||||
if (!opened.success || opened.isLargeFile) return
|
||||
// 延迟设置光标位置,等待编辑器加载
|
||||
setTimeout(() => {
|
||||
editor.setPosition({ lineNumber: item.line + 1, column: item.character + 1 })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAgentStore } from '@renderer/agent/store/AgentStore'
|
||||
import { t } from '@renderer/i18n'
|
||||
import { isPreviewDocumentPath } from '@shared/types/preview'
|
||||
import { isPlanBoardPath } from '@shared/types/planBoard'
|
||||
import type { EditorDocumentKind } from '@shared/types/editorDocument'
|
||||
|
||||
interface EditorTabsProps {
|
||||
activeFilePath: string | null
|
||||
@@ -19,7 +20,7 @@ interface EditorTabsProps {
|
||||
lintWarningCount: number
|
||||
isLinting: boolean
|
||||
onRunLint: () => void
|
||||
activeFileKind?: 'file' | 'diff' | 'preview'
|
||||
activeFileKind?: EditorDocumentKind
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Loader2, Lock } from 'lucide-react'
|
||||
import type { OpenFile } from '@store/slices/fileSlice'
|
||||
import type { TextFileChunk } from '@shared/types/fileChunk'
|
||||
import { api } from '@renderer/services/electronAPI'
|
||||
|
||||
interface LargeFileViewerProps {
|
||||
file: OpenFile
|
||||
language: 'zh' | 'en'
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function LargeFileViewer({ file, language }: LargeFileViewerProps) {
|
||||
const metadata = file.largeFileView
|
||||
const [chunk, setChunk] = useState<TextFileChunk | null>(() => metadata ? {
|
||||
content: file.content,
|
||||
startOffset: metadata.startOffset,
|
||||
nextOffset: metadata.nextOffset,
|
||||
totalSize: metadata.totalSize,
|
||||
eof: metadata.eof,
|
||||
} : null)
|
||||
const [history, setHistory] = useState<number[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!metadata) return
|
||||
setChunk({
|
||||
content: file.content,
|
||||
startOffset: metadata.startOffset,
|
||||
nextOffset: metadata.nextOffset,
|
||||
totalSize: metadata.totalSize,
|
||||
eof: metadata.eof,
|
||||
})
|
||||
setHistory([])
|
||||
setError(false)
|
||||
}, [file.path, file.contentLoadVersion, metadata])
|
||||
|
||||
if (!metadata || !chunk) return null
|
||||
|
||||
const loadChunk = async (offset: number, previousHistory: number[]) => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const next = await api.file.readTextChunk(file.path, offset, metadata.chunkSize)
|
||||
if (!next) {
|
||||
setError(true)
|
||||
return
|
||||
}
|
||||
setChunk(next)
|
||||
setHistory(previousHistory)
|
||||
} catch {
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pageEnd = Math.min(chunk.nextOffset, chunk.totalSize)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border px-3 text-xs text-text-muted">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
<span>{language === 'zh' ? '超大文件只读模式' : 'Very large file · read only'}</span>
|
||||
<span className="ml-auto tabular-nums">
|
||||
{formatBytes(chunk.startOffset)}–{formatBytes(pageEnd)} / {formatBytes(chunk.totalSize)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || history.length === 0}
|
||||
className="rounded p-1 hover:bg-surface disabled:opacity-30"
|
||||
onClick={() => {
|
||||
const previousOffset = history[history.length - 1]
|
||||
void loadChunk(previousOffset, history.slice(0, -1))
|
||||
}}
|
||||
title={language === 'zh' ? '上一页' : 'Previous page'}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || chunk.eof}
|
||||
className="rounded p-1 hover:bg-surface disabled:opacity-30"
|
||||
onClick={() => void loadChunk(chunk.nextOffset, [...history, chunk.startOffset])}
|
||||
title={language === 'zh' ? '下一页' : 'Next page'}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="border-b border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||
{language === 'zh' ? '读取这一页失败,请重试。' : 'Could not read this page. Please retry.'}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
value={chunk.content}
|
||||
className="min-h-0 flex-1 resize-none whitespace-pre overflow-auto border-0 bg-background p-4 font-mono text-xs leading-5 text-text-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,18 +2,17 @@
|
||||
* 问题面板 - 显示所有诊断错误
|
||||
*/
|
||||
|
||||
import { api } from '@/renderer/services/electronAPI'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { ChevronRight, FileText, AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import { useStore } from '@store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { LspDiagnostic } from '@shared/types'
|
||||
import { useDiagnosticsStore } from '@services/diagnosticsStore'
|
||||
import { getFileName } from '@shared/utils/pathUtils'
|
||||
import { OtterAsset } from '@/renderer/components/brand/OtterAsset'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
|
||||
export function ProblemsView() {
|
||||
const { openFile, setActiveFile, language } = useStore(useShallow(s => ({ openFile: s.openFile, setActiveFile: s.setActiveFile, language: s.language })))
|
||||
const language = useStore(state => state.language)
|
||||
|
||||
// 从全局 store 获取诊断数据
|
||||
const diagnostics = useDiagnosticsStore(state => state.diagnostics)
|
||||
@@ -42,10 +41,8 @@ export function ProblemsView() {
|
||||
}
|
||||
}
|
||||
|
||||
const content = await api.file.readFull(filePath)
|
||||
if (content !== null) {
|
||||
openFile(filePath, content)
|
||||
setActiveFile(filePath)
|
||||
const opened = await safeOpenFile(filePath, { language, confirmLargeFile: false })
|
||||
if (opened.success && !opened.isLargeFile) {
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('editor:goto-line', {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { applySavedEditorBufferContent } from '@renderer/services/editorBufferSe
|
||||
import { globalConfirm } from '../../common/ConfirmDialog'
|
||||
import { Input } from '../../ui'
|
||||
import { toast } from '../../common/ToastProvider'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
|
||||
export function SearchView() {
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -46,7 +47,7 @@ export function SearchView() {
|
||||
/** 流式搜索 ID,用于匹配异步事件 */
|
||||
const searchIdRef = useRef<string | null>(null)
|
||||
|
||||
const { workspacePath, workspace, openFile, setActiveFile, language, openFiles, setActiveSidePanel } = useStore(useShallow(s => ({ workspacePath: s.workspacePath, workspace: s.workspace, openFile: s.openFile, setActiveFile: s.setActiveFile, language: s.language, openFiles: s.openFiles, setActiveSidePanel: s.setActiveSidePanel })))
|
||||
const { workspacePath, workspace, language, openFiles, setActiveSidePanel } = useStore(useShallow(s => ({ workspacePath: s.workspacePath, workspace: s.workspace, language: s.language, openFiles: s.openFiles, setActiveSidePanel: s.setActiveSidePanel })))
|
||||
|
||||
// 监听流式搜索结果事件
|
||||
useEffect(() => {
|
||||
@@ -169,10 +170,8 @@ export function SearchView() {
|
||||
filePath = joinPath(workspacePath, filePath)
|
||||
}
|
||||
|
||||
const content = await api.file.readFull(filePath)
|
||||
if (content !== null) {
|
||||
openFile(filePath, content)
|
||||
setActiveFile(filePath)
|
||||
const opened = await safeOpenFile(filePath, { language, confirmLargeFile: false })
|
||||
if (opened.success && !opened.isLargeFile) {
|
||||
|
||||
// 增加延迟,确保编辑器完全准备好
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { writeClipboardText } from '@/renderer/services/clipboardService'
|
||||
import FileIcon from '../common/FileIcon'
|
||||
import { getFileType } from '../editor/FilePreview'
|
||||
import type { TreeRefreshOptions } from '../sidebar/panels/ExplorerView'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
import { gitExcludeService } from '@services/gitExcludeService'
|
||||
|
||||
// 每个节点的高度(像素)
|
||||
@@ -582,11 +583,8 @@ export const VirtualFileTree = memo(function VirtualFileTree({
|
||||
openFile(node.item.path, '')
|
||||
setActiveFile(node.item.path)
|
||||
} else {
|
||||
const content = await api.file.readFull(node.item.path)
|
||||
if (content !== null) {
|
||||
openFile(node.item.path, content)
|
||||
setActiveFile(node.item.path)
|
||||
} else {
|
||||
const result = await safeOpenFile(node.item.path, { language, confirmLargeFile: false })
|
||||
if (!result.success) {
|
||||
// 文件读取失败,可能是二进制文件或权限问题
|
||||
toast.warning(t('error.fileNotFound', language, { path: node.item.name }))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ import { applySavedEditorBufferContent } from '@renderer/services/editorBufferSe
|
||||
|
||||
export function useFileWatcher() {
|
||||
useEffect(() => {
|
||||
const readOpenFileContent = async (file: { path: string; kind?: string; largeFileView?: { chunkSize: number } }) => {
|
||||
if (file.kind === 'large-preview') {
|
||||
const chunk = await api.file.readTextChunk(file.path, 0, file.largeFileView?.chunkSize)
|
||||
return chunk?.content ?? null
|
||||
}
|
||||
return api.file.readFull(file.path)
|
||||
}
|
||||
|
||||
const unsubscribe = api.file.onChanged(async (event: { event: string; path: string }) => {
|
||||
const { openFiles, markFileDeleted, markFileRestored, language } = useStore.getState()
|
||||
|
||||
@@ -26,7 +34,7 @@ export function useFileWatcher() {
|
||||
if (event.event === 'create') {
|
||||
const openFile = openFiles.find((file) => pathEquals(file.path, event.path))
|
||||
if (openFile?.isDeleted) {
|
||||
const newContent = await api.file.readFull(event.path)
|
||||
const newContent = await readOpenFileContent(openFile)
|
||||
if (newContent !== null) {
|
||||
applySavedEditorBufferContent(openFile.path, newContent)
|
||||
} else {
|
||||
@@ -41,7 +49,7 @@ export function useFileWatcher() {
|
||||
const openFile = openFiles.find((file) => pathEquals(file.path, event.path))
|
||||
if (!openFile) return
|
||||
|
||||
const newContent = await api.file.readFull(event.path)
|
||||
const newContent = await readOpenFileContent(openFile)
|
||||
if (newContent === null || newContent === openFile.content) return
|
||||
|
||||
const isInternal = internalWriteTracker.consume(event.path)
|
||||
|
||||
@@ -79,6 +79,7 @@ function createGroupedAPI() {
|
||||
// readPreview. There is deliberately no ambiguous `read` method here.
|
||||
readPreview: (path: string, encoding?: string) => raw.readFile(path, encoding),
|
||||
readFull: (path: string, encoding?: string) => raw.readFile(path, encoding, { full: true }),
|
||||
readTextChunk: (path: string, offset?: number, maxBytes?: number) => raw.readTextChunk(path, offset, maxBytes),
|
||||
stat: (path: string) => raw.statFile(path),
|
||||
readBinary: (path: string) => raw.readBinaryFile(path),
|
||||
readRichContent: (path: string, options?: Parameters<typeof raw.readRichContent>[1]) => raw.readRichContent(path, options),
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* 统一处理所有文件类型的路径跳转(import、href、src、url() 等)
|
||||
*/
|
||||
|
||||
import { api } from '@/renderer/services/electronAPI'
|
||||
import { logger } from '@utils/Logger'
|
||||
import type { languages, editor, IRange } from 'monaco-editor'
|
||||
import { getPathSeparator, getDirPath, joinPath } from '@shared/utils/pathUtils'
|
||||
import { useStore } from '@store'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
@@ -212,15 +212,11 @@ class PathLinkService {
|
||||
basePath: string,
|
||||
extensions: string[] = ['']
|
||||
): Promise<{ success: boolean; path?: string }> {
|
||||
const { openFile, setActiveFile } = useStore.getState()
|
||||
|
||||
for (const ext of extensions) {
|
||||
const fullPath = basePath + ext
|
||||
try {
|
||||
const content = await api.file.readFull(fullPath)
|
||||
if (content !== null) {
|
||||
openFile(fullPath, content)
|
||||
setActiveFile(fullPath)
|
||||
const result = await safeOpenFile(fullPath, { showWarning: false, confirmLargeFile: false })
|
||||
if (result.success) {
|
||||
return { success: true, path: fullPath }
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BufferedCommitQueue } from '@shared/persistence/BufferedCommitQueue'
|
||||
import { logger } from '@utils/Logger'
|
||||
import { getEditorConfig } from '@renderer/settings'
|
||||
import { persistenceCoordinator } from './persistence/PersistenceCoordinator'
|
||||
import type { EditorDocumentKind } from '@shared/types/editorDocument'
|
||||
|
||||
export const ADNIFY_DIR_NAME = '.adnify'
|
||||
|
||||
@@ -22,7 +23,7 @@ type AdnifyFile = typeof ADNIFY_FILES[keyof typeof ADNIFY_FILES]
|
||||
export interface WorkspaceStateData {
|
||||
openFiles: Array<string | {
|
||||
path: string
|
||||
kind?: 'file' | 'diff' | 'preview'
|
||||
kind?: EditorDocumentKind
|
||||
preview?: OpenPreviewMetadata
|
||||
}>
|
||||
activeFile: string | null
|
||||
|
||||
@@ -10,20 +10,66 @@ import type { OpenFile } from '@store'
|
||||
import { getEditorConfig } from '@renderer/settings'
|
||||
import { workspaceStateRepository, type WorkspaceStateData } from './workspaceStateRepository'
|
||||
import { isPreviewDocumentPath } from '@shared/types/preview'
|
||||
import { LARGE_FILE_PAGE_BYTES, MAX_EDITABLE_TEXT_FILE_BYTES } from '@shared/types/largeFile'
|
||||
import type { LargeFileInfo } from '@shared/types/largeFile'
|
||||
import type { TextFileChunk } from '@shared/types/fileChunk'
|
||||
|
||||
type PersistedWorkspaceOpenFile = Exclude<WorkspaceStateData['openFiles'][number], string>
|
||||
|
||||
async function readFilesWithConcurrency(
|
||||
filePaths: string[],
|
||||
concurrency = 4
|
||||
): Promise<Array<{ path: string; content: string }>> {
|
||||
const results: Array<{ path: string; content: string }> = []
|
||||
): Promise<Array<{
|
||||
path: string
|
||||
content: string
|
||||
options?: {
|
||||
kind: 'large-preview'
|
||||
largeFileInfo: LargeFileInfo
|
||||
largeFileView: Omit<TextFileChunk, 'content'> & { chunkSize: number }
|
||||
}
|
||||
}>> {
|
||||
const results: Array<{
|
||||
path: string
|
||||
content: string
|
||||
options?: {
|
||||
kind: 'large-preview'
|
||||
largeFileInfo: LargeFileInfo
|
||||
largeFileView: Omit<TextFileChunk, 'content'> & { chunkSize: number }
|
||||
}
|
||||
}> = []
|
||||
|
||||
for (let i = 0; i < filePaths.length; i += concurrency) {
|
||||
const batch = filePaths.slice(i, i + concurrency)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (filePath) => {
|
||||
try {
|
||||
const stats = await api.file.stat(filePath)
|
||||
if (stats && stats.size > MAX_EDITABLE_TEXT_FILE_BYTES) {
|
||||
const chunk = await api.file.readTextChunk(filePath, 0, LARGE_FILE_PAGE_BYTES)
|
||||
if (!chunk) return null
|
||||
return {
|
||||
path: filePath,
|
||||
content: chunk.content,
|
||||
options: {
|
||||
kind: 'large-preview' as const,
|
||||
largeFileInfo: {
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
lineCount: -1,
|
||||
isLarge: true,
|
||||
isVeryLarge: true,
|
||||
reason: 'size' as const,
|
||||
},
|
||||
largeFileView: {
|
||||
startOffset: chunk.startOffset,
|
||||
nextOffset: chunk.nextOffset,
|
||||
totalSize: chunk.totalSize,
|
||||
eof: chunk.eof,
|
||||
chunkSize: LARGE_FILE_PAGE_BYTES,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
// 恢复的内容会回填到编辑器缓冲区,随后可能被保存回磁盘,
|
||||
// 所以必须读完整文件而不是预览切片。
|
||||
const fileContent = await api.file.readFull(filePath)
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { OpenPreviewMetadata } from '@shared/types/preview'
|
||||
import { buildPreviewDocumentPath } from '@shared/types/preview'
|
||||
import { normalizePath } from '@shared/utils/pathUtils'
|
||||
import type { LargeFileInfo } from '@shared/types/largeFile'
|
||||
import type { TextFileChunk } from '@shared/types/fileChunk'
|
||||
import type { EditorDocumentKind } from '@shared/types/editorDocument'
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
configPath: string | null
|
||||
@@ -24,13 +26,14 @@ export interface OpenFile {
|
||||
contentState: 'loaded' | 'unloaded' | 'error'
|
||||
/** Changes only when text is loaded from an external source, not on edits. */
|
||||
contentLoadVersion: number
|
||||
kind?: 'file' | 'diff' | 'preview'
|
||||
kind?: EditorDocumentKind
|
||||
isDirty: boolean
|
||||
originalContent?: string
|
||||
/** 保存时的版本号(Monaco versionId) */
|
||||
savedVersionId?: number
|
||||
/** 大文件信息(如果是大文件) */
|
||||
largeFileInfo?: LargeFileInfo
|
||||
largeFileView?: Omit<TextFileChunk, 'content'> & { chunkSize: number }
|
||||
/** 文件编码 */
|
||||
encoding?: string
|
||||
/** 文件换行符 */
|
||||
@@ -68,6 +71,7 @@ export interface FileSlice {
|
||||
expandFolder: (path: string) => void
|
||||
openFile: (path: string, content: string, originalContent?: string, options?: {
|
||||
largeFileInfo?: LargeFileInfo
|
||||
largeFileView?: OpenFile['largeFileView']
|
||||
encoding?: string
|
||||
eol?: 'LF' | 'CRLF'
|
||||
remote?: OpenFile['remote']
|
||||
@@ -82,6 +86,7 @@ export interface FileSlice {
|
||||
originalContent?: string
|
||||
options?: {
|
||||
largeFileInfo?: LargeFileInfo
|
||||
largeFileView?: OpenFile['largeFileView']
|
||||
encoding?: string
|
||||
eol?: 'LF' | 'CRLF'
|
||||
remote?: OpenFile['remote']
|
||||
@@ -204,6 +209,7 @@ export const createFileSlice: StateCreator<FileSlice, [], [], FileSlice> = (set)
|
||||
originalContent,
|
||||
savedVersionId: 1,
|
||||
largeFileInfo: options?.largeFileInfo,
|
||||
largeFileView: options?.largeFileView,
|
||||
encoding: options?.encoding,
|
||||
eol: options?.eol,
|
||||
remote: options?.remote,
|
||||
@@ -272,6 +278,7 @@ export const createFileSlice: StateCreator<FileSlice, [], [], FileSlice> = (set)
|
||||
originalContent: file.originalContent,
|
||||
savedVersionId: 1,
|
||||
largeFileInfo: file.options?.largeFileInfo,
|
||||
largeFileView: file.options?.largeFileView,
|
||||
encoding: file.options?.encoding,
|
||||
eol: file.options?.eol,
|
||||
remote: file.options?.remote,
|
||||
|
||||
Vendored
+1
@@ -402,6 +402,7 @@ export interface ElectronAPI {
|
||||
readDir: (path: string) => Promise<FileItem[]>
|
||||
getFileTree: (path: string, maxDepth?: number) => Promise<string>
|
||||
readFile: (path: string, encoding?: string, options?: { full?: boolean }) => Promise<string | null>
|
||||
readTextChunk: (path: string, offset?: number, maxBytes?: number) => Promise<import('@shared/types/fileChunk').TextFileChunk | null>
|
||||
statFile: (path: string) => Promise<{ size: number; isDirectory: boolean; isFile: boolean; mtimeMs: number } | null>
|
||||
readBinaryFile: (path: string) => Promise<string | null>
|
||||
readRichContent: (path: string, options?: ReadRichContentOptions) => Promise<RichContentReadResult>
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
import { api } from '@/renderer/services/electronAPI'
|
||||
import { useStore } from '@store'
|
||||
import type { LargeFileInfo } from '@shared/types/largeFile'
|
||||
import {
|
||||
LARGE_FILE_CONFIRM_BYTES,
|
||||
LARGE_FILE_PAGE_BYTES,
|
||||
MAX_EDITABLE_TEXT_FILE_BYTES,
|
||||
type LargeFileInfo,
|
||||
} from '@shared/types/largeFile'
|
||||
import {
|
||||
getFileInfo,
|
||||
getLargeFileWarning,
|
||||
@@ -13,6 +18,7 @@ import {
|
||||
} from '@services/largeFileService'
|
||||
import { toast } from '@components/common/ToastProvider'
|
||||
import { globalConfirm } from '../components/common/ConfirmDialog'
|
||||
import { t } from '../i18n'
|
||||
import { getFileName } from '@shared/utils/pathUtils'
|
||||
import { detectEolFromContent } from '@services/fileFormatService'
|
||||
|
||||
@@ -20,9 +26,9 @@ import { detectEolFromContent } from '@services/fileFormatService'
|
||||
|
||||
const FILE_CONFIG = {
|
||||
/** 超大文件阈值(超过此大小需要确认) */
|
||||
confirmThreshold: 5 * 1024 * 1024, // 5MB
|
||||
confirmThreshold: LARGE_FILE_CONFIRM_BYTES,
|
||||
/** 最大文件大小(超过此大小拒绝打开) */
|
||||
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||||
maxFileSize: MAX_EDITABLE_TEXT_FILE_BYTES,
|
||||
/** 二进制文件扩展名 */
|
||||
binaryExtensions: new Set([
|
||||
'exe', 'dll', 'so', 'dylib', 'bin', 'obj', 'o', 'a', 'lib',
|
||||
@@ -124,18 +130,45 @@ export async function safeOpenFile(
|
||||
|
||||
// 3. 检查文件大小
|
||||
if (byteSize > FILE_CONFIG.maxFileSize) {
|
||||
const msg = language === 'zh'
|
||||
? '文件太大,无法打开'
|
||||
: 'File is too large to open'
|
||||
if (showWarning) {
|
||||
toast.error(msg, `${(byteSize / 1024 / 1024).toFixed(1)} MB`)
|
||||
const chunk = await api.file.readTextChunk(filePath, 0, LARGE_FILE_PAGE_BYTES)
|
||||
if (!chunk) {
|
||||
const msg = language === 'zh' ? '无法读取超大文件' : 'Could not read very large file'
|
||||
if (showWarning) toast.error(msg, filePath)
|
||||
return { success: false, error: msg, isLargeFile: true }
|
||||
}
|
||||
return { success: false, error: msg, isLargeFile: true }
|
||||
|
||||
openFile(filePath, chunk.content, originalContent, {
|
||||
kind: 'large-preview',
|
||||
encoding: 'utf-8',
|
||||
eol: detectEolFromContent(chunk.content),
|
||||
largeFileInfo: {
|
||||
path: filePath,
|
||||
size: byteSize,
|
||||
lineCount: -1,
|
||||
isLarge: true,
|
||||
isVeryLarge: true,
|
||||
reason: 'size',
|
||||
},
|
||||
largeFileView: {
|
||||
startOffset: chunk.startOffset,
|
||||
nextOffset: chunk.nextOffset,
|
||||
totalSize: chunk.totalSize,
|
||||
eof: chunk.eof,
|
||||
chunkSize: LARGE_FILE_PAGE_BYTES,
|
||||
},
|
||||
})
|
||||
setActiveFile(filePath)
|
||||
if (showWarning) {
|
||||
toast.warning(
|
||||
language === 'zh' ? '已使用超大文件查看器' : 'Opened in very large file viewer',
|
||||
`${(byteSize / 1024 / 1024).toFixed(1)} MB`,
|
||||
)
|
||||
}
|
||||
return { success: true, isLargeFile: true }
|
||||
}
|
||||
|
||||
// 4. 大文件确认
|
||||
if (confirmLargeFile && byteSize > FILE_CONFIG.confirmThreshold) {
|
||||
const { t } = await import('../i18n')
|
||||
const size = (byteSize / 1024 / 1024).toFixed(1)
|
||||
|
||||
const confirmed = await globalConfirm({
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { api } from '@/renderer/services/electronAPI'
|
||||
import { useStore } from '@store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { joinPath } from '@shared/utils/pathUtils'
|
||||
import { safeOpenFile } from '@renderer/utils/fileUtils'
|
||||
|
||||
export interface HighlightStyle {
|
||||
string: string
|
||||
@@ -203,7 +202,7 @@ export function JsonHighlight({
|
||||
return { content: text, truncated: false, originalLength: text.length }
|
||||
}, [data, maxLength])
|
||||
|
||||
const { workspacePath, openFile, setActiveFile } = useStore(useShallow(s => ({ workspacePath: s.workspacePath, openFile: s.openFile, setActiveFile: s.setActiveFile })))
|
||||
const workspacePath = useStore(s => s.workspacePath)
|
||||
|
||||
const handleFileClick = useCallback(async (filePath: string) => {
|
||||
let absPath = filePath
|
||||
@@ -214,18 +213,15 @@ export function JsonHighlight({
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await api.file.readFull(absPath)
|
||||
if (content !== null) {
|
||||
openFile(absPath, content)
|
||||
setActiveFile(absPath)
|
||||
} else {
|
||||
const result = await safeOpenFile(absPath, { showWarning: false, confirmLargeFile: false })
|
||||
if (!result.success) {
|
||||
// 如果不仅是个文件,就不报错默默忽略(因为可能是误判)
|
||||
// toast.warning('Not a valid readable file.')
|
||||
}
|
||||
} catch (error) {
|
||||
// 不弹出错误,因为这说明这个字符串实际上不是文件
|
||||
}
|
||||
}, [workspacePath, openFile, setActiveFile])
|
||||
}, [workspacePath])
|
||||
|
||||
return (
|
||||
<pre className={`text-xs font-mono overflow-auto ${maxHeight} ${className}`}>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type EditorDocumentKind = 'file' | 'diff' | 'preview' | 'large-preview'
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface TextFileChunk {
|
||||
content: string
|
||||
startOffset: number
|
||||
nextOffset: number
|
||||
totalSize: number
|
||||
eof: boolean
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export * from './planActivity'
|
||||
// 文档 / 图片读取类型
|
||||
export * from './documentReader'
|
||||
export * from './largeFile'
|
||||
export * from './fileChunk'
|
||||
export * from './editorDocument'
|
||||
|
||||
// ==========================================
|
||||
// 基础类型
|
||||
|
||||
@@ -8,3 +8,7 @@ export interface LargeFileInfo {
|
||||
reason?: 'size' | 'lines' | 'both'
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export const LARGE_FILE_CONFIRM_BYTES = 5 * 1024 * 1024
|
||||
export const MAX_EDITABLE_TEXT_FILE_BYTES = 50 * 1024 * 1024
|
||||
export const LARGE_FILE_PAGE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { readFileSized, readLargeFile } from '@main/security/fileUtils'
|
||||
import { readFileSized, readTextFileChunk } from '@main/security/fileUtils'
|
||||
|
||||
/**
|
||||
* Regression cover for the silent-truncation data loss.
|
||||
@@ -93,16 +93,53 @@ describe('readFileSized', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readLargeFile', () => {
|
||||
it('reads a byte range rather than a line range', async () => {
|
||||
const filePath = path.join(tempDir, 'bytes.txt')
|
||||
await fs.writeFile(filePath, 'abcdefghij')
|
||||
describe('readTextFileChunk', () => {
|
||||
it('reconstructs a UTF-8 file exactly from bounded sequential pages', async () => {
|
||||
const filePath = path.join(tempDir, 'paged.txt')
|
||||
const original = Array.from({ length: 3000 }, (_, index) => `${index}: 性能测试内容-${'x'.repeat(40)}\n`).join('')
|
||||
await fs.writeFile(filePath, original)
|
||||
|
||||
await expect(readLargeFile(filePath, 0, 4)).resolves.toBe('abcd')
|
||||
await expect(readLargeFile(filePath, 4, 3)).resolves.toBe('efg')
|
||||
let offset = 0
|
||||
let reconstructed = ''
|
||||
let pageCount = 0
|
||||
while (true) {
|
||||
const chunk = await readTextFileChunk(filePath, offset, 257)
|
||||
reconstructed += chunk.content
|
||||
pageCount += 1
|
||||
expect(chunk.nextOffset).toBeGreaterThan(offset)
|
||||
if (chunk.eof) break
|
||||
offset = chunk.nextOffset
|
||||
}
|
||||
|
||||
expect(pageCount).toBeGreaterThan(1)
|
||||
expect(reconstructed).toBe(original)
|
||||
})
|
||||
|
||||
it('returns null for a missing file instead of throwing', async () => {
|
||||
await expect(readLargeFile(path.join(tempDir, 'nope.txt'), 0, 16)).resolves.toBeNull()
|
||||
it('does not split UTF-8 characters in a very long line', async () => {
|
||||
const filePath = path.join(tempDir, 'unicode-line.txt')
|
||||
const original = '界'.repeat(1000)
|
||||
await fs.writeFile(filePath, original)
|
||||
|
||||
let offset = 0
|
||||
let reconstructed = ''
|
||||
while (true) {
|
||||
const chunk = await readTextFileChunk(filePath, offset, 101)
|
||||
reconstructed += chunk.content
|
||||
expect(chunk.content).not.toContain('\uFFFD')
|
||||
if (chunk.eof) break
|
||||
offset = chunk.nextOffset
|
||||
}
|
||||
|
||||
expect(reconstructed).toBe(original)
|
||||
})
|
||||
|
||||
it('enforces the per-request memory ceiling', async () => {
|
||||
const filePath = path.join(tempDir, 'bounded-page.txt')
|
||||
await fs.writeFile(filePath, 'x'.repeat(6 * 1024 * 1024))
|
||||
|
||||
const chunk = await readTextFileChunk(filePath, 0, 100 * 1024 * 1024)
|
||||
|
||||
expect(chunk.content.length).toBe(4 * 1024 * 1024)
|
||||
expect(chunk.eof).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
stat: vi.fn(),
|
||||
readTextChunk: vi.fn(),
|
||||
readFull: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
setActiveFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/services/electronAPI', () => ({
|
||||
api: {
|
||||
file: {
|
||||
stat: mocks.stat,
|
||||
readTextChunk: mocks.readTextChunk,
|
||||
readFull: mocks.readFull,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@store', () => ({
|
||||
useStore: {
|
||||
getState: () => ({ openFile: mocks.openFile, setActiveFile: mocks.setActiveFile }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@components/common/ToastProvider', () => ({
|
||||
toast: { warning: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@services/largeFileService', () => ({
|
||||
getFileInfo: vi.fn(),
|
||||
getLargeFileWarning: vi.fn(),
|
||||
isLargeFile: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock('@services/fileFormatService', () => ({
|
||||
detectEolFromContent: vi.fn(() => 'LF'),
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/common/ConfirmDialog', () => ({
|
||||
globalConfirm: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('safeOpenFile very large file policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.stat.mockResolvedValue({ size: 200 * 1024 * 1024, isFile: true, isDirectory: false, mtimeMs: 0 })
|
||||
mocks.readTextChunk.mockResolvedValue({
|
||||
content: 'first page',
|
||||
startOffset: 0,
|
||||
nextOffset: 1024,
|
||||
totalSize: 200 * 1024 * 1024,
|
||||
eof: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a bounded read-only page without loading the entire file', async () => {
|
||||
const { safeOpenFile } = await import('@renderer/utils/fileUtils')
|
||||
const result = await safeOpenFile('E:/workspace/huge.log', { showWarning: false })
|
||||
|
||||
expect(result).toEqual({ success: true, isLargeFile: true })
|
||||
expect(mocks.readFull).not.toHaveBeenCalled()
|
||||
expect(mocks.readTextChunk).toHaveBeenCalledWith('E:/workspace/huge.log', 0, 2 * 1024 * 1024)
|
||||
expect(mocks.openFile).toHaveBeenCalledWith(
|
||||
'E:/workspace/huge.log',
|
||||
'first page',
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
kind: 'large-preview',
|
||||
largeFileView: expect.objectContaining({ totalSize: 200 * 1024 * 1024, chunkSize: 2 * 1024 * 1024 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user