feat: implement editor buffer service for improved content management and synchronization

This commit is contained in:
adnaan
2026-08-26 11:29:28 +08:00
parent d8beb86fa8
commit eaed03a646
20 changed files with 271 additions and 127 deletions
+22 -14
View File
@@ -27,6 +27,12 @@ import type { StreamingEditState } from '@renderer/agent/types'
import type { ThemeName } from '@store/slices/themeSlice'
import { useEditorBreakpoints } from '@hooks/useEditorBreakpoints'
import { consumePendingNavigation } from '@services/editorNavigation'
import {
commitEditorBufferSnapshot,
flushEditorBufferSnapshots,
replaceEditorBufferContent,
scheduleEditorBufferSnapshot,
} from '@services/editorBufferService'
// 子组件
import { EditorTabs } from './EditorTabs'
@@ -205,6 +211,7 @@ export default function Editor() {
useEffect(() => {
return () => {
flushEditorBufferSnapshots()
if (fontZoomRafRef.current != null) {
cancelAnimationFrame(fontZoomRafRef.current)
}
@@ -240,10 +247,13 @@ export default function Editor() {
// 同时检查是否有跨文件 Go-to-Definition 待处理的跳转定位
useEffect(() => {
clearLintErrors()
if (activeFile?.contentState === 'loaded' && !isPreviewDocument && !isPlanBoardDocument) {
notifyFileOpened(activeFile.path, activeFile.content)
const file = activeFilePath
? useStore.getState().openFiles.find(candidate => candidate.path === activeFilePath)
: undefined
if (file?.contentState === 'loaded' && !isPreviewDocument && !isPlanBoardDocument) {
notifyFileOpened(file.path, file.content)
// 检查是否有跨文件跳转定义的待定位请求
const nav = consumePendingNavigation(activeFile.path)
const nav = consumePendingNavigation(file.path)
if (nav && editorRef.current) {
setTimeout(() => {
editorRef.current?.setPosition({ lineNumber: nav.line, column: nav.col })
@@ -252,7 +262,7 @@ export default function Editor() {
}, 80)
}
}
}, [activeFilePath, activeFile, clearLintErrors, notifyFileOpened, isPlanBoardDocument, isPreviewDocument])
}, [activeFilePath, activeFile?.contentState, activeFile?.contentLoadVersion, clearLintErrors, notifyFileOpened, isPlanBoardDocument, isPreviewDocument])
// 清理不再打开的文件的 Monaco Models,防止内存泄漏
useEffect(() => {
@@ -412,9 +422,7 @@ export default function Editor() {
const content = editorRef.current.getValue()
const success = await api.file.write(activeFile.path, content, activeFile.encoding)
if (success) {
if (config.formatOnSave && content !== activeFile.content) {
updateFileContent(activeFile.path, content)
}
commitEditorBufferSnapshot(activeFile.path, content)
// 保存时记录当前版本号
const model = editorRef.current.getModel()
const versionId = model?.getAlternativeVersionId()
@@ -424,7 +432,7 @@ export default function Editor() {
toast.error(language === 'zh' ? '保存失败' : 'Save Failed', language === 'zh' ? '无法写入文件' : 'Could not write to file')
}
}
}, [activeFile, isPlanBoardDocument, markFileSaved, language, updateFileContent])
}, [activeFile, isPlanBoardDocument, markFileSaved, language])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (keybindingService.matches(e.nativeEvent, 'editor.save')) {
@@ -485,7 +493,7 @@ export default function Editor() {
filePath={streamingEdit.filePath}
isStreaming={!streamingEdit.isComplete}
onAccept={() => {
updateFileContent(activeFile.path, streamingEdit.currentContent)
replaceEditorBufferContent(activeFile.path, streamingEdit.currentContent)
composerService.acceptChange(activeFile.path)
setShowDiffPreview(false)
}}
@@ -536,7 +544,7 @@ export default function Editor() {
const realPath = activeFile.path.replace(/^(git-)?diff:\/\//, '')
acceptChange(realPath)
await composerService.acceptChange(realPath)
updateFileContent(realPath, activeFile.content)
replaceEditorBufferContent(realPath, activeFile.content)
closeFile(activeFile.path)
}}
onReject={async () => {
@@ -597,13 +605,13 @@ export default function Editor() {
key={activeFile.path}
path={monaco.Uri.file(activeFile.path).toString()}
language={activeLanguage}
value={activeFile.content}
defaultValue={activeFile.content}
theme="adnify-dynamic"
beforeMount={handleBeforeMount}
onMount={handleEditorMount}
onChange={(value) => {
if (value !== undefined) {
updateFileContent(activeFile.path, value)
scheduleEditorBufferSnapshot(activeFile.path, value)
scheduleDidChangeDocument(activeFile.path, value)
}
}}
@@ -645,13 +653,13 @@ export default function Editor() {
key={activeFile.path}
path={monaco.Uri.file(activeFile.path).toString()}
language={activeLanguage}
value={activeFile.content}
defaultValue={activeFile.content}
theme="adnify-dynamic"
beforeMount={handleBeforeMount}
onMount={handleEditorMount}
onChange={(value) => {
if (value !== undefined) {
updateFileContent(activeFile.path, value)
scheduleEditorBufferSnapshot(activeFile.path, value)
scheduleDidChangeDocument(activeFile.path, value)
triggerAutoSave(activeFile.path)
}
@@ -5,7 +5,7 @@ import { memo } from 'react'
import { Home, ChevronRight, AlertTriangle } from 'lucide-react'
import { getPathSeparator } from '@shared/utils/pathUtils'
import { getLargeFileWarning } from '@renderer/services/largeFileService'
import type { LargeFileInfo } from '@renderer/services/largeFileService'
import type { LargeFileInfo } from '@shared/types/largeFile'
interface EditorBreadcrumbsProps {
filePath: string
+16 -14
View File
@@ -12,6 +12,7 @@ import { t } from '@renderer/i18n'
import { composerService } from '@renderer/agent/services/composerService'
import { buildFileChangeDescriptor } from '@renderer/agent/utils/fileChangeUtils'
import { toast } from '../common/ToastProvider'
import { getEditorBufferContent, replaceEditorBufferContent } from '@renderer/services/editorBufferService'
interface InlineEditProps {
position: { x: number; y: number }
@@ -35,7 +36,7 @@ export default function InlineEdit({
const [activeRequestId, setActiveRequestId] = useState<string | null>(null)
const [originalContent, setOriginalContent] = useState<string>('')
const inputRef = useRef<HTMLInputElement>(null)
const { llmConfig, language, updateFileContent, workspacePath } = useStore(useShallow(s => ({ llmConfig: s.llmConfig, language: s.language, updateFileContent: s.updateFileContent, workspacePath: s.workspacePath })))
const { llmConfig, language, workspacePath } = useStore(useShallow(s => ({ llmConfig: s.llmConfig, language: s.language, workspacePath: s.workspacePath })))
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -61,17 +62,18 @@ export default function InlineEdit({
onClose()
return
}
const baseContent = getEditorBufferContent(filePath, currentFile.content)
setState('generating')
setOriginalContent(currentFile.content)
setOriginalContent(baseContent)
// Create a composer session so inline diff rendering kicks in
composerService.ensureSession('Inline Edit', 'AI Inline Edit')
composerService.addChange(buildFileChangeDescriptor({
filePath,
workspacePath,
oldContent: currentFile.content,
newContent: currentFile.content,
oldContent: baseContent,
newContent: baseContent,
changeType: 'modify',
linesAdded: 0,
linesRemoved: 0
@@ -93,20 +95,20 @@ export default function InlineEdit({
cleanBlock = cleanBlock.replace(/^```\w*\n?/, '').replace(/\n?```$/, '')
}
const oldContentLines = currentFile.content.split('\n')
const oldContentLines = baseContent.split('\n')
const preContent = oldContentLines.slice(0, lineRange[0] - 1)
const postContent = oldContentLines.slice(lineRange[1])
const newFullContent = [...preContent, cleanBlock, ...postContent].join('\n')
// Update editor buffer in real-time -> triggers useComposerInlineDiff
updateFileContent(filePath, newFullContent)
replaceEditorBufferContent(filePath, newFullContent)
// Update composer service explicitly so diff logic has newContent
composerService.addChange(buildFileChangeDescriptor({
filePath,
workspacePath,
oldContent: currentFile.content,
oldContent: baseContent,
newContent: newFullContent,
changeType: 'modify',
linesAdded: 0,
@@ -131,7 +133,7 @@ export default function InlineEdit({
cleanup()
console.error('[InlineEdit] AI Edit stream error:', err)
toast.error(t('error', language) || 'Error', err.message || 'AI request failed')
updateFileContent(filePath, currentFile.content)
replaceEditorBufferContent(filePath, baseContent)
composerService.rejectChange(filePath)
setState('idle')
setActiveRequestId(null)
@@ -146,12 +148,12 @@ export default function InlineEdit({
} catch (err: any) {
console.error(err)
toast.error(t('error', language) || 'Error', err.message || 'Generation failed')
updateFileContent(filePath, currentFile.content)
replaceEditorBufferContent(filePath, baseContent)
composerService.rejectChange(filePath)
setState('idle')
setActiveRequestId(null)
}
}, [instruction, state, selectedCode, filePath, lineRange, llmConfig, updateFileContent, onClose, workspacePath])
}, [instruction, state, selectedCode, filePath, lineRange, llmConfig, onClose, workspacePath])
const handleAccept = useCallback(() => {
// Just clear composer change pending status by "accepting" it
@@ -161,20 +163,20 @@ export default function InlineEdit({
const handleReject = useCallback(() => {
// Restore original content
updateFileContent(filePath, originalContent)
replaceEditorBufferContent(filePath, originalContent)
composerService.rejectChange(filePath)
onClose()
}, [filePath, originalContent, updateFileContent, onClose])
}, [filePath, originalContent, onClose])
const handleCancelStream = useCallback(() => {
if (activeRequestId) {
api.llm.abort(activeRequestId)
updateFileContent(filePath, originalContent)
replaceEditorBufferContent(filePath, originalContent)
composerService.rejectChange(filePath)
setState('idle')
setActiveRequestId(null)
}
}, [activeRequestId, filePath, originalContent, updateFileContent])
}, [activeRequestId, filePath, originalContent])
// 当进入非 idle 状态(input 被摧毁)时,需要全局监听按键
useEffect(() => {
@@ -4,6 +4,7 @@ import BottomBarPopover from '../ui/BottomBarPopover'
import { applyFileEol } from '@services/fileFormatService'
import { toast } from '../common/ToastProvider'
import { api } from '@renderer/services/electronAPI'
import { applySavedEditorBufferContent } from '@renderer/services/editorBufferService'
const EOL_OPTIONS = [
{ id: 'LF', label: 'LF', descriptionZh: 'Unix / macOS', descriptionEn: 'Unix / macOS' },
@@ -47,7 +48,6 @@ export default function FileFormatControls() {
const activeFile = useStore(state => state.openFiles.find(file => file.path === state.activeFilePath))
const language = useStore(state => state.language)
const setFileEncoding = useStore(state => state.setFileEncoding)
const updateFileContent = useStore(state => state.updateFileContent)
if (!activeFile || activeFile.kind === 'preview') {
return null
@@ -80,7 +80,7 @@ export default function FileFormatControls() {
return
}
updateFileContent(activeFile.path, nextContent)
applySavedEditorBufferContent(activeFile.path, nextContent)
setFileEncoding(activeFile.path, nextEncoding)
toast.success(
language === 'zh' ? '文件编码已更新' : 'File encoding updated',
@@ -9,7 +9,7 @@ import { useStore } from '@store'
import { useShallow } from 'zustand/react/shallow'
import { t } from '@renderer/i18n'
import { getFileName, joinPath } from '@shared/utils/pathUtils'
import { scheduleSavedVersionSync } from '@renderer/services/fileSavedVersionSync'
import { applySavedEditorBufferContent } from '@renderer/services/editorBufferService'
import { globalConfirm } from '../../common/ConfirmDialog'
import { Input } from '../../ui'
import { toast } from '../../common/ToastProvider'
@@ -227,10 +227,9 @@ export function SearchView() {
if (newContent !== content) {
await api.file.write(filePath, newContent)
// 同步已打开的编辑器内容
const { openFiles, reloadFileFromDisk } = useStore.getState()
const { openFiles } = useStore.getState()
if (openFiles.some(f => f.path === filePath)) {
reloadFileFromDisk(filePath, newContent)
scheduleSavedVersionSync(filePath, newContent)
applySavedEditorBufferContent(filePath, newContent)
}
handleSearch()
}
@@ -289,10 +288,9 @@ export function SearchView() {
if (newContent !== content) {
await api.file.write(filePath, newContent)
// 同步已打开的编辑器内容
const { openFiles: currentOpenFiles, reloadFileFromDisk } = useStore.getState()
const { openFiles: currentOpenFiles } = useStore.getState()
if (currentOpenFiles.some(f => f.path === filePath)) {
reloadFileFromDisk(filePath, newContent)
scheduleSavedVersionSync(filePath, newContent)
applySavedEditorBufferContent(filePath, newContent)
}
replacedCount++
}
+2 -1
View File
@@ -5,7 +5,8 @@
import type { editor } from 'monaco-editor'
import { getEditorConfig } from '@renderer/settings'
import { LargeFileInfo, getLargeFileEditorOptions } from '@/renderer/services/largeFileService'
import { getLargeFileEditorOptions } from '@/renderer/services/largeFileService'
import type { LargeFileInfo } from '@shared/types/largeFile'
import type { EditorConfig } from '@shared/config/types'
/**
+25 -13
View File
@@ -4,7 +4,6 @@
*/
import { useCallback, useRef, useEffect } from 'react'
import { useStore } from '@store'
import { useShallow } from 'zustand/react/shallow'
import { api } from '@renderer/services/electronAPI'
import { getFileName } from '@shared/utils/pathUtils'
import { globalConfirm } from '@renderer/components/common/ConfirmDialog'
@@ -13,6 +12,7 @@ import { t } from '@renderer/i18n'
import { getEditorConfig } from '@renderer/settings'
import { monaco } from '@renderer/monacoWorker'
import type { FileMutationResult } from '@shared/types/fileMutation'
import { commitEditorBufferSnapshot, getEditorBufferContent } from '@renderer/services/editorBufferService'
function getSaveErrorMessage(result: FileMutationResult, language: 'zh' | 'en'): string {
if (result.success) return ''
@@ -46,16 +46,20 @@ function getModelVersionId(filePath: string): number | undefined {
}
export function useFileSave() {
const { openFiles, markFileSaved, closeFile, language } = useStore(useShallow(s => ({ openFiles: s.openFiles, markFileSaved: s.markFileSaved, closeFile: s.closeFile, language: s.language })))
const markFileSaved = useStore(state => state.markFileSaved)
const closeFile = useStore(state => state.closeFile)
const language = useStore(state => state.language)
// 保存单个文件
const saveFile = useCallback(async (filePath: string): Promise<boolean> => {
const file = openFiles.find(f => f.path === filePath)
const file = useStore.getState().openFiles.find(f => f.path === filePath)
if (!file || file.pinned) return false
try {
const result = await api.file.writeDetailed(file.path, file.content, file.encoding)
const content = getEditorBufferContent(file.path, file.content)
const result = await api.file.writeDetailed(file.path, content, file.encoding)
if (result.success) {
commitEditorBufferSnapshot(file.path, content)
// 获取当前版本号并保存
const versionId = getModelVersionId(file.path)
markFileSaved(file.path, versionId)
@@ -82,11 +86,11 @@ export function useFileSave() {
)
return false
}
}, [openFiles, markFileSaved, language])
}, [markFileSaved, language])
// 关闭文件(带保存提示)
const closeFileWithConfirm = useCallback(async (filePath: string) => {
const file = openFiles.find(f => f.path === filePath)
const file = useStore.getState().openFiles.find(f => f.path === filePath)
if (file?.pinned) return
if (file?.isDirty) {
const fileName = getFileName(filePath)
@@ -102,33 +106,36 @@ export function useFileSave() {
}
}
closeFile(filePath)
}, [openFiles, closeFile, saveFile, language])
}, [closeFile, saveFile, language])
// 关闭其他文件
const closeOtherFiles = useCallback(async (keepPath: string) => {
const openFiles = useStore.getState().openFiles
for (const file of openFiles) {
if (file.path !== keepPath) {
await closeFileWithConfirm(file.path)
}
}
}, [openFiles, closeFileWithConfirm])
}, [closeFileWithConfirm])
// 关闭所有文件
const closeAllFiles = useCallback(async () => {
const openFiles = useStore.getState().openFiles
for (const file of [...openFiles]) {
await closeFileWithConfirm(file.path)
}
}, [openFiles, closeFileWithConfirm])
}, [closeFileWithConfirm])
// 关闭右侧文件
const closeFilesToRight = useCallback(async (filePath: string) => {
const openFiles = useStore.getState().openFiles
const index = openFiles.findIndex(f => f.path === filePath)
if (index >= 0) {
for (let i = openFiles.length - 1; i > index; i--) {
await closeFileWithConfirm(openFiles[i].path)
}
}
}, [openFiles, closeFileWithConfirm])
}, [closeFileWithConfirm])
// 触发自动保存
// 触发自动保存 (使用 debounce 重构)
@@ -144,8 +151,10 @@ export function useFileSave() {
const { openFiles: currentFiles, markFileSaved: currentMarkSaved } = useStore.getState()
const file = currentFiles.find(f => f.path === fPath)
if (file?.isDirty) {
const success = await api.file.write(file.path, file.content, file.encoding)
const content = getEditorBufferContent(file.path, file.content)
const success = await api.file.write(file.path, content, file.encoding)
if (success) {
commitEditorBufferSnapshot(file.path, content)
const versionId = getModelVersionId(file.path)
currentMarkSaved(file.path, versionId)
}
@@ -175,10 +184,13 @@ export function useFileSave() {
if (config.autoSave !== 'onFocusChange') return
const handleBlur = async () => {
const openFiles = useStore.getState().openFiles
for (const file of openFiles) {
if (file.isDirty) {
const success = await api.file.write(file.path, file.content, file.encoding)
const content = getEditorBufferContent(file.path, file.content)
const success = await api.file.write(file.path, content, file.encoding)
if (success) {
commitEditorBufferSnapshot(file.path, content)
const versionId = getModelVersionId(file.path)
markFileSaved(file.path, versionId)
}
@@ -188,7 +200,7 @@ export function useFileSave() {
window.addEventListener('blur', handleBlur)
return () => window.removeEventListener('blur', handleBlur)
}, [openFiles, markFileSaved])
}, [markFileSaved])
// 清理定时器
useEffect(() => {
+6 -10
View File
@@ -6,12 +6,12 @@ import { globalConfirm } from '@renderer/components/common/ConfirmDialog'
import { getFileName, pathEquals } from '@shared/utils/pathUtils'
import { removeFileFromTypeService } from '@renderer/services/monacoTypeService'
import { internalWriteTracker } from '@renderer/services/internalWriteTracker'
import { scheduleSavedVersionSync } from '@renderer/services/fileSavedVersionSync'
import { applySavedEditorBufferContent } from '@renderer/services/editorBufferService'
export function useFileWatcher() {
useEffect(() => {
const unsubscribe = api.file.onChanged(async (event: { event: string; path: string }) => {
const { openFiles, reloadFileFromDisk, markFileDeleted, markFileRestored, language } = useStore.getState()
const { openFiles, markFileDeleted, markFileRestored, language } = useStore.getState()
if (event.event === 'delete') {
removeFileFromTypeService(event.path)
@@ -28,8 +28,7 @@ export function useFileWatcher() {
if (openFile?.isDeleted) {
const newContent = await api.file.readFull(event.path)
if (newContent !== null) {
reloadFileFromDisk(openFile.path, newContent)
scheduleSavedVersionSync(openFile.path, newContent)
applySavedEditorBufferContent(openFile.path, newContent)
} else {
markFileRestored(openFile.path)
}
@@ -48,8 +47,7 @@ export function useFileWatcher() {
const isInternal = internalWriteTracker.consume(event.path)
if (isInternal) {
reloadFileFromDisk(openFile.path, newContent)
scheduleSavedVersionSync(openFile.path, newContent)
applySavedEditorBufferContent(openFile.path, newContent)
return
}
@@ -63,14 +61,12 @@ export function useFileWatcher() {
})
if (confirmed) {
reloadFileFromDisk(openFile.path, newContent)
scheduleSavedVersionSync(openFile.path, newContent)
applySavedEditorBufferContent(openFile.path, newContent)
}
return
}
reloadFileFromDisk(openFile.path, newContent)
scheduleSavedVersionSync(openFile.path, newContent)
applySavedEditorBufferContent(openFile.path, newContent)
})
return unsubscribe
@@ -0,0 +1,61 @@
import { monaco } from '@renderer/monacoWorker'
import { useStore } from '@store'
const SNAPSHOT_DELAY_MS = 100
const pendingSnapshots = new Map<string, { content: string; timer: ReturnType<typeof setTimeout> }>()
function cancelPendingSnapshot(filePath: string): void {
const pending = pendingSnapshots.get(filePath)
if (!pending) return
clearTimeout(pending.timer)
pendingSnapshots.delete(filePath)
}
export function getEditorBufferContent(filePath: string, fallback: string): string {
return monaco.editor.getModel(monaco.Uri.file(filePath))?.getValue() ?? fallback
}
/** Persist a low-frequency React/store snapshot of Monaco's live buffer. */
export function scheduleEditorBufferSnapshot(filePath: string, content: string): void {
cancelPendingSnapshot(filePath)
const timer = setTimeout(() => {
pendingSnapshots.delete(filePath)
useStore.getState().updateFileContent(filePath, content)
}, SNAPSHOT_DELAY_MS)
pendingSnapshots.set(filePath, { content, timer })
}
export function commitEditorBufferSnapshot(filePath: string, content: string): void {
cancelPendingSnapshot(filePath)
useStore.getState().updateFileContent(filePath, content)
}
/** Apply an intentional programmatic edit to both the Monaco model and store. */
export function replaceEditorBufferContent(filePath: string, content: string): void {
cancelPendingSnapshot(filePath)
const model = monaco.editor.getModel(monaco.Uri.file(filePath))
if (model && model.getValue() !== content) model.setValue(content)
cancelPendingSnapshot(filePath)
useStore.getState().updateFileContent(filePath, content)
}
/** Apply authoritative disk content and reset the model's saved version. */
export function applySavedEditorBufferContent(filePath: string, content: string): void {
cancelPendingSnapshot(filePath)
const { reloadFileFromDisk, markFileSaved } = useStore.getState()
reloadFileFromDisk(filePath, content)
const model = monaco.editor.getModel(monaco.Uri.file(filePath))
if (!model) return
if (model.getValue() !== content) model.setValue(content)
cancelPendingSnapshot(filePath)
markFileSaved(filePath, model.getAlternativeVersionId())
}
export function flushEditorBufferSnapshots(): void {
for (const [filePath, pending] of pendingSnapshots) {
clearTimeout(pending.timer)
useStore.getState().updateFileContent(filePath, pending.content)
}
pendingSnapshots.clear()
}
@@ -1,30 +0,0 @@
import { useStore } from '@store'
import { monaco } from '@renderer/monacoWorker'
export function scheduleSavedVersionSync(filePath: string, expectedContent: string): void {
let attempts = 0
const maxAttempts = 20
const sync = () => {
const model = monaco.editor.getModel(monaco.Uri.file(filePath))
if (!model) {
// Model 不存在(文件不是当前活跃文件),不需要同步版本号。
// reloadFileFromDisk 已经重置了 savedVersionId
// 当文件变为活跃时 handleEditorMount 会重新初始化。
return
}
if (model.getValue() !== expectedContent) {
attempts += 1
if (attempts < maxAttempts) {
requestAnimationFrame(sync)
}
return
}
const { markFileSaved } = useStore.getState()
markFileSaved(filePath, model.getAlternativeVersionId())
}
requestAnimationFrame(sync)
}
+2 -1
View File
@@ -23,7 +23,8 @@ export { ignoreService } from './ignoreService'
export { completionService } from './completionService'
export { pathLinkService } from './pathLinkService'
export { getFileInfo, getLargeFileEditorOptions, getLargeFileWarning, isLargeFile, isVeryLargeFile } from './largeFileService'
export type { LargeFileInfo, FileChunk } from './largeFileService'
export type { FileChunk } from './largeFileService'
export type { LargeFileInfo } from '@shared/types/largeFile'
export { detectEolFromContent, getModelEol, syncFileEolFromModel, applyFileEol } from './fileFormatService'
export type { FileEol } from './fileFormatService'
+2 -9
View File
@@ -4,6 +4,8 @@
*/
import { getEditorConfig } from '@renderer/settings'
import type { LargeFileInfo } from '@shared/types/largeFile'
export type { LargeFileInfo } from '@shared/types/largeFile'
// 文件大小阈值(字节)- 从配置获取
function getLargeFileThreshold(): number {
@@ -30,15 +32,6 @@ export interface FileChunk {
endOffset: number
}
export interface LargeFileInfo {
path: string
size: number
lineCount: number
isLarge: boolean
isVeryLarge: boolean
reason?: 'size' | 'lines' | 'both'
}
/**
* 快速估算行数(不完全分割字符串)
*/
+2 -1
View File
@@ -18,7 +18,8 @@ import {
// 导出类型
export type { OpenFile, WorkspaceConfig, LargeFileInfo } from './slices'
export type { OpenFile, WorkspaceConfig } from './slices'
export type { LargeFileInfo } from '@shared/types/largeFile'
export type { ProviderModelConfig, SettingsState, SettingKey } from './slices'
// 类型从 shared/config/types 导入
export type { LLMConfig, AutoApproveSettings, AgentConfig } from '@shared/config/types'
+22 -22
View File
@@ -6,6 +6,7 @@ import type { FileItem } from '@shared/types'
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'
export interface WorkspaceConfig {
configPath: string | null
@@ -15,17 +16,6 @@ export interface WorkspaceConfig {
workspaceId?: string
}
/** 大文件信息 */
export interface LargeFileInfo {
isLarge: boolean
isVeryLarge: boolean
size: number
lineCount: number
path?: string
reason?: 'size' | 'lines' | 'both'
warning?: string
}
export interface OpenFile {
path: string
content: string
@@ -272,7 +262,7 @@ export const createFileSlice: StateCreator<FileSlice, [], [], FileSlice> = (set)
restoreOpenFiles: (files, activeFilePath) =>
set(() => {
const restoredFiles: OpenFile[] = files.map((file) => ({
const restoredFiles: OpenFile[] = files.map((file, index) => ({
path: normalizePath(file.path),
content: file.content,
contentState: 'loaded',
@@ -318,18 +308,28 @@ export const createFileSlice: StateCreator<FileSlice, [], [], FileSlice> = (set)
}),
updateFileContent: (path, content) =>
set((state) => ({
openFiles: state.openFiles.map((f) =>
f.path === path ? { ...f, content, contentState: 'loaded' } : f
),
})),
set((state) => {
const file = state.openFiles.find(candidate => candidate.path === path)
if (!file || (file.content === content && file.contentState === 'loaded')) return state
return {
openFiles: state.openFiles.map((candidate) =>
candidate.path === path ? { ...candidate, content, contentState: 'loaded' } : candidate
),
}
}),
updateFileDirtyState: (path, currentVersionId) =>
set((state) => ({
openFiles: state.openFiles.map((f) =>
f.path === path ? { ...f, isDirty: currentVersionId !== f.savedVersionId } : f
),
})),
set((state) => {
const file = state.openFiles.find(candidate => candidate.path === path)
if (!file) return state
const isDirty = currentVersionId !== file.savedVersionId
if (file.isDirty === isDirty) return state
return {
openFiles: state.openFiles.map((candidate) =>
candidate.path === path ? { ...candidate, isDirty } : candidate
),
}
}),
markFileSaved: (path, versionId) =>
set((state) => ({
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Store Slices 导出
*/
export { createFileSlice, type FileSlice, type OpenFile, type WorkspaceConfig, type LargeFileInfo } from './fileSlice'
export { createFileSlice, type FileSlice, type OpenFile, type WorkspaceConfig } from './fileSlice'
export { createSettingsSlice, type SettingsSlice, type SettingsState, type SettingKey, type ProviderModelConfig } from './settingsSlice'
export { createThemeSlice, type ThemeSlice, type ThemeName } from './themeSlice'
export { createLogSlice, type LogSlice, type ToolCallLogEntry } from './logSlice'
+2 -1
View File
@@ -5,7 +5,7 @@
import { api } from '@/renderer/services/electronAPI'
import { useStore } from '@store'
import { LargeFileInfo } from '@store/slices/fileSlice'
import type { LargeFileInfo } from '@shared/types/largeFile'
import {
getFileInfo,
getLargeFileWarning,
@@ -77,6 +77,7 @@ export function detectLargeFile(content: string, filePath: string, language: 'en
const warning = getLargeFileWarning(info, language)
return {
path: filePath,
isLarge: info.isLarge,
isVeryLarge: info.isVeryLarge,
size: info.size,
+1
View File
@@ -18,6 +18,7 @@ export * from './planActivity'
// 文档 / 图片读取类型
export * from './documentReader'
export * from './largeFile'
// ==========================================
// 基础类型
+10
View File
@@ -0,0 +1,10 @@
/** Metadata calculated once when a text buffer is loaded from disk. */
export interface LargeFileInfo {
path: string
size: number
lineCount: number
isLarge: boolean
isVeryLarge: boolean
reason?: 'size' | 'lines' | 'both'
warning?: string
}
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
updateFileContent: vi.fn(),
reloadFileFromDisk: vi.fn(),
markFileSaved: vi.fn(),
model: {
value: 'initial',
getValue: vi.fn(),
setValue: vi.fn(),
getAlternativeVersionId: vi.fn(() => 7),
},
}))
vi.mock('@renderer/monacoWorker', () => ({
monaco: {
Uri: { file: (path: string) => path },
editor: { getModel: vi.fn(() => mocks.model) },
},
}))
vi.mock('@store', () => ({
useStore: {
getState: () => ({
updateFileContent: mocks.updateFileContent,
reloadFileFromDisk: mocks.reloadFileFromDisk,
markFileSaved: mocks.markFileSaved,
}),
},
}))
describe('editorBufferService', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mocks.model.value = 'initial'
mocks.model.getValue.mockImplementation(() => mocks.model.value)
mocks.model.setValue.mockImplementation((value: string) => { mocks.model.value = value })
})
afterEach(async () => {
const { flushEditorBufferSnapshots } = await import('@renderer/services/editorBufferService')
flushEditorBufferSnapshots()
vi.useRealTimers()
})
it('coalesces high-frequency model snapshots into one store update', async () => {
const { scheduleEditorBufferSnapshot } = await import('@renderer/services/editorBufferService')
scheduleEditorBufferSnapshot('E:/app.ts', 'a')
scheduleEditorBufferSnapshot('E:/app.ts', 'ab')
scheduleEditorBufferSnapshot('E:/app.ts', 'abc')
await vi.advanceTimersByTimeAsync(99)
expect(mocks.updateFileContent).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(mocks.updateFileContent).toHaveBeenCalledTimes(1)
expect(mocks.updateFileContent).toHaveBeenCalledWith('E:/app.ts', 'abc')
})
it('reads the live Monaco buffer instead of a delayed store snapshot', async () => {
const { getEditorBufferContent } = await import('@renderer/services/editorBufferService')
mocks.model.value = 'live content'
expect(getEditorBufferContent('E:/app.ts', 'stale snapshot')).toBe('live content')
})
it('applies disk content to the store and model as one saved transition', async () => {
const { applySavedEditorBufferContent } = await import('@renderer/services/editorBufferService')
applySavedEditorBufferContent('E:/app.ts', 'from disk')
expect(mocks.reloadFileFromDisk).toHaveBeenCalledWith('E:/app.ts', 'from disk')
expect(mocks.model.setValue).toHaveBeenCalledWith('from disk')
expect(mocks.markFileSaved).toHaveBeenCalledWith('E:/app.ts', 7)
})
})
@@ -31,6 +31,20 @@ describe('fileSlice pinned tabs', () => {
})
describe('fileSlice content lifecycle', () => {
it('does not notify subscribers for repeated equivalent editor updates', () => {
const store = createFileStore()
store.getState().openFile('E:/workspace/app.ts', 'initial')
let notifications = 0
const unsubscribe = store.subscribe(() => { notifications += 1 })
store.getState().updateFileDirtyState('E:/workspace/app.ts', 2)
store.getState().updateFileDirtyState('E:/workspace/app.ts', 3)
store.getState().updateFileContent('E:/workspace/app.ts', 'initial')
unsubscribe()
expect(notifications).toBe(1)
})
it('marks an evicted clean buffer as unloaded and supports explicit rehydration', () => {
const store = createFileStore()