mirror of
https://github.com/adnaan-worker/adnify.git
synced 2026-09-01 15:12:10 +08:00
feat: implement editor buffer service for improved content management and synchronization
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速估算行数(不完全分割字符串)
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './planActivity'
|
||||
|
||||
// 文档 / 图片读取类型
|
||||
export * from './documentReader'
|
||||
export * from './largeFile'
|
||||
|
||||
// ==========================================
|
||||
// 基础类型
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user