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:
adnaan
2026-08-26 12:22:37 +08:00
parent eaed03a646
commit bac696c7dd
33 changed files with 714 additions and 335 deletions
-10
View File
@@ -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
View File
@@ -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",
+200 -167
View File
File diff suppressed because it is too large Load Diff
+17 -27
View File
@@ -2,18 +2,19 @@
packages:
- '.'
# 与 .npmrc 双写:pnpm 9 读 .npmrcpnpm 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.overridespnpm 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
+2
View File
@@ -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),
+66 -5
View File
@@ -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 -3
View File
@@ -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'
+6 -11
View File
@@ -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) => {
+9 -3
View File
@@ -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 -2
View File
@@ -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)
+1
View File
@@ -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 -7
View File
@@ -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
+48 -2
View File
@@ -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)
+8 -1
View File
@@ -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,
+1
View File
@@ -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>
+43 -10
View File
@@ -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({
+5 -9
View File
@@ -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}`}>
+1
View File
@@ -0,0 +1 @@
export type EditorDocumentKind = 'file' | 'diff' | 'preview' | 'large-preview'
+7
View File
@@ -0,0 +1,7 @@
export interface TextFileChunk {
content: string
startOffset: number
nextOffset: number
totalSize: number
eof: boolean
}
+2
View File
@@ -19,6 +19,8 @@ export * from './planActivity'
// 文档 / 图片读取类型
export * from './documentReader'
export * from './largeFile'
export * from './fileChunk'
export * from './editorDocument'
// ==========================================
// 基础类型
+4
View File
@@ -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
+46 -9
View File
@@ -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 }),
}),
)
})
})