Skip to content

Commit 5362f74

Browse files
feat(autosave): files and chunk editor autosave with debounce + refetch (#3508)
* feat(files): debounced autosave while editing * address review comments * more comments
1 parent de36e33 commit 5362f74

File tree

7 files changed

+242
-57
lines changed

7 files changed

+242
-57
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useUpdateWorkspaceFileContent,
1111
useWorkspaceFileContent,
1212
} from '@/hooks/queries/workspace-files'
13+
import { useAutosave } from '@/hooks/use-autosave'
1314
import { PreviewPanel, resolvePreviewType } from './preview-panel'
1415

1516
const logger = createLogger('FileViewer')
@@ -60,6 +61,7 @@ interface FileViewerProps {
6061
showPreview?: boolean
6162
autoFocus?: boolean
6263
onDirtyChange?: (isDirty: boolean) => void
64+
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
6365
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
6466
}
6567

@@ -70,6 +72,7 @@ export function FileViewer({
7072
showPreview,
7173
autoFocus,
7274
onDirtyChange,
75+
onSaveStatusChange,
7376
saveRef,
7477
}: FileViewerProps) {
7578
const category = resolveFileCategory(file.type, file.name)
@@ -83,6 +86,7 @@ export function FileViewer({
8386
showPreview={showPreview}
8487
autoFocus={autoFocus}
8588
onDirtyChange={onDirtyChange}
89+
onSaveStatusChange={onSaveStatusChange}
8690
saveRef={saveRef}
8791
/>
8892
)
@@ -102,6 +106,7 @@ interface TextEditorProps {
102106
showPreview?: boolean
103107
autoFocus?: boolean
104108
onDirtyChange?: (isDirty: boolean) => void
109+
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
105110
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
106111
}
107112

@@ -112,6 +117,7 @@ function TextEditor({
112117
showPreview,
113118
autoFocus,
114119
onDirtyChange,
120+
onSaveStatusChange,
115121
saveRef,
116122
}: TextEditorProps) {
117123
const initializedRef = useRef(false)
@@ -126,59 +132,84 @@ function TextEditor({
126132
data: fetchedContent,
127133
isLoading,
128134
error,
135+
dataUpdatedAt,
129136
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
130137

131138
const updateContent = useUpdateWorkspaceFileContent()
132139

133140
const [content, setContent] = useState('')
134141
const [savedContent, setSavedContent] = useState('')
142+
const savedContentRef = useRef('')
135143

136144
useEffect(() => {
137-
if (fetchedContent !== undefined && !initializedRef.current) {
145+
if (fetchedContent === undefined) return
146+
147+
if (!initializedRef.current) {
138148
setContent(fetchedContent)
139149
setSavedContent(fetchedContent)
150+
savedContentRef.current = fetchedContent
140151
contentRef.current = fetchedContent
141152
initializedRef.current = true
142153

143154
if (autoFocus) {
144155
requestAnimationFrame(() => textareaRef.current?.focus())
145156
}
157+
return
146158
}
147-
}, [fetchedContent, autoFocus])
159+
160+
if (fetchedContent === savedContentRef.current) return
161+
const isClean = contentRef.current === savedContentRef.current
162+
if (isClean) {
163+
setContent(fetchedContent)
164+
setSavedContent(fetchedContent)
165+
savedContentRef.current = fetchedContent
166+
contentRef.current = fetchedContent
167+
}
168+
}, [fetchedContent, dataUpdatedAt, autoFocus])
148169

149170
const handleContentChange = useCallback((value: string) => {
150171
setContent(value)
151172
contentRef.current = value
152173
}, [])
153174

154-
const isDirty = initializedRef.current && content !== savedContent
155-
156-
useEffect(() => {
157-
onDirtyChange?.(isDirty)
158-
}, [isDirty, onDirtyChange])
159-
160-
const handleSave = useCallback(async () => {
175+
const onSave = useCallback(async () => {
161176
const currentContent = contentRef.current
162-
if (currentContent === savedContent) return
177+
if (currentContent === savedContentRef.current) return
163178

164179
await updateContent.mutateAsync({
165180
workspaceId,
166181
fileId: file.id,
167182
content: currentContent,
168183
})
169184
setSavedContent(currentContent)
170-
}, [savedContent, workspaceId, file.id])
185+
savedContentRef.current = currentContent
186+
}, [workspaceId, file.id, updateContent])
187+
188+
const { saveStatus, saveImmediately, isDirty } = useAutosave({
189+
content,
190+
savedContent,
191+
onSave,
192+
enabled: canEdit && initializedRef.current,
193+
})
194+
195+
useEffect(() => {
196+
onDirtyChange?.(isDirty)
197+
}, [isDirty, onDirtyChange])
198+
199+
useEffect(() => {
200+
onSaveStatusChange?.(saveStatus)
201+
}, [saveStatus, onSaveStatusChange])
171202

172203
useEffect(() => {
173204
if (saveRef) {
174-
saveRef.current = handleSave
205+
saveRef.current = saveImmediately
175206
}
176207
return () => {
177208
if (saveRef) {
178209
saveRef.current = null
179210
}
180211
}
181-
}, [saveRef, handleSave])
212+
}, [saveRef, saveImmediately])
182213

183214
useEffect(() => {
184215
if (!isResizing) return

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -315,14 +315,7 @@ export function Files() {
315315

316316
const handleSave = useCallback(async () => {
317317
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
318-
319-
setSaveStatus('saving')
320-
try {
321-
await saveRef.current()
322-
setSaveStatus('saved')
323-
} catch {
324-
setSaveStatus('error')
325-
}
318+
await saveRef.current()
326319
}, [isDirty, saveStatus])
327320

328321
const handleBackAttempt = useCallback(() => {
@@ -413,12 +406,6 @@ export function Files() {
413406
setShowPreview(true)
414407
}, [selectedFileId])
415408

416-
useEffect(() => {
417-
if (saveStatus !== 'saved' && saveStatus !== 'error') return
418-
const timer = setTimeout(() => setSaveStatus('idle'), 2000)
419-
return () => clearTimeout(timer)
420-
}, [saveStatus])
421-
422409
useEffect(() => {
423410
if (!selectedFile) return
424411
const handleKeyDown = (e: KeyboardEvent) => {
@@ -557,6 +544,7 @@ export function Files() {
557544
showPreview={showPreview && canPreview}
558545
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
559546
onDirtyChange={setIsDirty}
547+
onSaveStatusChange={setSaveStatus}
560548
saveRef={saveRef}
561549
/>
562550

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Label, Switch } from '@/components/emcn'
55
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
66
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
77
import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge'
8+
import { useAutosave } from '@/hooks/use-autosave'
89

910
const TOKEN_BG_COLORS = [
1011
'rgba(239, 68, 68, 0.55)',
@@ -27,6 +28,7 @@ interface ChunkEditorProps {
2728
canEdit: boolean
2829
maxChunkSize?: number
2930
onDirtyChange: (isDirty: boolean) => void
31+
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
3032
saveRef: React.MutableRefObject<(() => Promise<void>) | null>
3133
onCreated?: (chunkId: string) => void
3234
}
@@ -39,6 +41,7 @@ export function ChunkEditor({
3941
canEdit,
4042
maxChunkSize,
4143
onDirtyChange,
44+
onSaveStatusChange,
4245
saveRef,
4346
onCreated,
4447
}: ChunkEditorProps) {
@@ -50,30 +53,64 @@ export function ChunkEditor({
5053
const chunkContent = chunk?.content ?? ''
5154

5255
const [editedContent, setEditedContent] = useState(isCreateMode ? '' : chunkContent)
56+
const [savedContent, setSavedContent] = useState(chunkContent)
5357
const [tokenizerOn, setTokenizerOn] = useState(false)
5458
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
59+
const prevChunkIdRef = useRef(chunk?.id)
60+
const savedContentRef = useRef(chunkContent)
5561

5662
const editedContentRef = useRef(editedContent)
5763
editedContentRef.current = editedContent
5864

59-
const isDirty = isCreateMode ? editedContent.trim().length > 0 : editedContent !== chunkContent
60-
6165
useEffect(() => {
62-
if (!isCreateMode) {
66+
if (isCreateMode) return
67+
if (chunk?.id !== prevChunkIdRef.current) {
68+
prevChunkIdRef.current = chunk?.id
69+
savedContentRef.current = chunkContent
70+
setSavedContent(chunkContent)
6371
setEditedContent(chunkContent)
6472
}
6573
}, [isCreateMode, chunk?.id, chunkContent])
6674

6775
useEffect(() => {
68-
onDirtyChange(isDirty)
69-
}, [isDirty, onDirtyChange])
76+
if (isCreateMode || !chunk?.id) return
77+
const controller = new AbortController()
78+
const handleVisibility = async () => {
79+
if (document.visibilityState !== 'visible') return
80+
try {
81+
const res = await fetch(
82+
`/api/knowledge/${knowledgeBaseId}/documents/${documentData.id}/chunks/${chunk.id}`,
83+
{ signal: controller.signal }
84+
)
85+
if (!res.ok) return
86+
const json = await res.json()
87+
const serverContent: string = json.data?.content ?? ''
88+
if (serverContent === savedContentRef.current) return
89+
const isClean = editedContentRef.current === savedContentRef.current
90+
savedContentRef.current = serverContent
91+
setSavedContent(serverContent)
92+
if (isClean) {
93+
setEditedContent(serverContent)
94+
}
95+
} catch (err) {
96+
if ((err as Error).name === 'AbortError') return
97+
}
98+
}
99+
document.addEventListener('visibilitychange', handleVisibility)
100+
return () => {
101+
document.removeEventListener('visibilitychange', handleVisibility)
102+
controller.abort()
103+
}
104+
}, [isCreateMode, chunk?.id, knowledgeBaseId, documentData.id])
70105

71106
useEffect(() => {
72107
if (isCreateMode && textareaRef.current) {
73108
textareaRef.current.focus()
74109
}
75110
}, [isCreateMode])
76111

112+
const isConnectorDocument = Boolean(documentData.connectorId)
113+
77114
const handleSave = useCallback(async () => {
78115
const content = editedContentRef.current
79116
const trimmed = content.trim()
@@ -89,26 +126,59 @@ export function ChunkEditor({
89126
})
90127
onCreated?.(created.id)
91128
} else {
92-
if (!chunk || trimmed === chunk.content) return
129+
if (!chunk?.id) return
93130
await updateChunk({
94131
knowledgeBaseId,
95132
documentId: documentData.id,
96133
chunkId: chunk.id,
97134
content: trimmed,
98135
})
136+
savedContentRef.current = content
137+
setSavedContent(content)
99138
}
100-
}, [isCreateMode, chunk, knowledgeBaseId, documentData.id, updateChunk, createChunk, onCreated])
139+
}, [
140+
isCreateMode,
141+
chunk?.id,
142+
knowledgeBaseId,
143+
documentData.id,
144+
updateChunk,
145+
createChunk,
146+
onCreated,
147+
])
148+
149+
const {
150+
saveStatus,
151+
saveImmediately,
152+
isDirty: autosaveDirty,
153+
} = useAutosave({
154+
content: editedContent,
155+
savedContent,
156+
onSave: handleSave,
157+
enabled: canEdit && !isCreateMode && !isConnectorDocument,
158+
})
159+
160+
const isDirty = isCreateMode ? editedContent.trim().length > 0 : autosaveDirty
161+
162+
useEffect(() => {
163+
onDirtyChange(isDirty)
164+
}, [isDirty, onDirtyChange])
165+
166+
useEffect(() => {
167+
onSaveStatusChange?.(saveStatus)
168+
}, [saveStatus, onSaveStatusChange])
169+
170+
const saveFunction = isCreateMode ? handleSave : saveImmediately
101171

102172
useEffect(() => {
103173
if (saveRef) {
104-
saveRef.current = handleSave
174+
saveRef.current = saveFunction
105175
}
106176
return () => {
107177
if (saveRef) {
108178
saveRef.current = null
109179
}
110180
}
111-
}, [saveRef, handleSave])
181+
}, [saveRef, saveFunction])
112182

113183
const tokenStrings = useMemo(() => {
114184
if (!tokenizerOn || !editedContent) return []
@@ -121,8 +191,6 @@ export function ChunkEditor({
121191
return getAccurateTokenCount(editedContent)
122192
}, [editedContent, tokenizerOn, tokenStrings])
123193

124-
const isConnectorDocument = Boolean(documentData.connectorId)
125-
126194
return (
127195
<div className='flex flex-1 flex-col overflow-hidden'>
128196
<div

0 commit comments

Comments
 (0)