mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(autosave): files and chunk editor autosave with debounce + refetch (#3508)
* feat(files): debounced autosave while editing * address review comments * more comments
This commit is contained in:
committed by
GitHub
parent
de36e332d7
commit
5362f7417f
@@ -10,6 +10,7 @@ import {
|
||||
useUpdateWorkspaceFileContent,
|
||||
useWorkspaceFileContent,
|
||||
} from '@/hooks/queries/workspace-files'
|
||||
import { useAutosave } from '@/hooks/use-autosave'
|
||||
import { PreviewPanel, resolvePreviewType } from './preview-panel'
|
||||
|
||||
const logger = createLogger('FileViewer')
|
||||
@@ -60,6 +61,7 @@ interface FileViewerProps {
|
||||
showPreview?: boolean
|
||||
autoFocus?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
}
|
||||
|
||||
@@ -70,6 +72,7 @@ export function FileViewer({
|
||||
showPreview,
|
||||
autoFocus,
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
}: FileViewerProps) {
|
||||
const category = resolveFileCategory(file.type, file.name)
|
||||
@@ -83,6 +86,7 @@ export function FileViewer({
|
||||
showPreview={showPreview}
|
||||
autoFocus={autoFocus}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
saveRef={saveRef}
|
||||
/>
|
||||
)
|
||||
@@ -102,6 +106,7 @@ interface TextEditorProps {
|
||||
showPreview?: boolean
|
||||
autoFocus?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
}
|
||||
|
||||
@@ -112,6 +117,7 @@ function TextEditor({
|
||||
showPreview,
|
||||
autoFocus,
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
}: TextEditorProps) {
|
||||
const initializedRef = useRef(false)
|
||||
@@ -126,40 +132,49 @@ function TextEditor({
|
||||
data: fetchedContent,
|
||||
isLoading,
|
||||
error,
|
||||
dataUpdatedAt,
|
||||
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
|
||||
|
||||
const updateContent = useUpdateWorkspaceFileContent()
|
||||
|
||||
const [content, setContent] = useState('')
|
||||
const [savedContent, setSavedContent] = useState('')
|
||||
const savedContentRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedContent !== undefined && !initializedRef.current) {
|
||||
if (fetchedContent === undefined) return
|
||||
|
||||
if (!initializedRef.current) {
|
||||
setContent(fetchedContent)
|
||||
setSavedContent(fetchedContent)
|
||||
savedContentRef.current = fetchedContent
|
||||
contentRef.current = fetchedContent
|
||||
initializedRef.current = true
|
||||
|
||||
if (autoFocus) {
|
||||
requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [fetchedContent, autoFocus])
|
||||
|
||||
if (fetchedContent === savedContentRef.current) return
|
||||
const isClean = contentRef.current === savedContentRef.current
|
||||
if (isClean) {
|
||||
setContent(fetchedContent)
|
||||
setSavedContent(fetchedContent)
|
||||
savedContentRef.current = fetchedContent
|
||||
contentRef.current = fetchedContent
|
||||
}
|
||||
}, [fetchedContent, dataUpdatedAt, autoFocus])
|
||||
|
||||
const handleContentChange = useCallback((value: string) => {
|
||||
setContent(value)
|
||||
contentRef.current = value
|
||||
}, [])
|
||||
|
||||
const isDirty = initializedRef.current && content !== savedContent
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty)
|
||||
}, [isDirty, onDirtyChange])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const onSave = useCallback(async () => {
|
||||
const currentContent = contentRef.current
|
||||
if (currentContent === savedContent) return
|
||||
if (currentContent === savedContentRef.current) return
|
||||
|
||||
await updateContent.mutateAsync({
|
||||
workspaceId,
|
||||
@@ -167,18 +182,34 @@ function TextEditor({
|
||||
content: currentContent,
|
||||
})
|
||||
setSavedContent(currentContent)
|
||||
}, [savedContent, workspaceId, file.id])
|
||||
savedContentRef.current = currentContent
|
||||
}, [workspaceId, file.id, updateContent])
|
||||
|
||||
const { saveStatus, saveImmediately, isDirty } = useAutosave({
|
||||
content,
|
||||
savedContent,
|
||||
onSave,
|
||||
enabled: canEdit && initializedRef.current,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty)
|
||||
}, [isDirty, onDirtyChange])
|
||||
|
||||
useEffect(() => {
|
||||
onSaveStatusChange?.(saveStatus)
|
||||
}, [saveStatus, onSaveStatusChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveRef) {
|
||||
saveRef.current = handleSave
|
||||
saveRef.current = saveImmediately
|
||||
}
|
||||
return () => {
|
||||
if (saveRef) {
|
||||
saveRef.current = null
|
||||
}
|
||||
}
|
||||
}, [saveRef, handleSave])
|
||||
}, [saveRef, saveImmediately])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
@@ -315,14 +315,7 @@ export function Files() {
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
|
||||
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
await saveRef.current()
|
||||
setSaveStatus('saved')
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
}
|
||||
await saveRef.current()
|
||||
}, [isDirty, saveStatus])
|
||||
|
||||
const handleBackAttempt = useCallback(() => {
|
||||
@@ -413,12 +406,6 @@ export function Files() {
|
||||
setShowPreview(true)
|
||||
}, [selectedFileId])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'saved' && saveStatus !== 'error') return
|
||||
const timer = setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [saveStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFile) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -557,6 +544,7 @@ export function Files() {
|
||||
showPreview={showPreview && canPreview}
|
||||
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
|
||||
onDirtyChange={setIsDirty}
|
||||
onSaveStatusChange={setSaveStatus}
|
||||
saveRef={saveRef}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Label, Switch } from '@/components/emcn'
|
||||
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
|
||||
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
|
||||
import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge'
|
||||
import { useAutosave } from '@/hooks/use-autosave'
|
||||
|
||||
const TOKEN_BG_COLORS = [
|
||||
'rgba(239, 68, 68, 0.55)',
|
||||
@@ -27,6 +28,7 @@ interface ChunkEditorProps {
|
||||
canEdit: boolean
|
||||
maxChunkSize?: number
|
||||
onDirtyChange: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
saveRef: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
onCreated?: (chunkId: string) => void
|
||||
}
|
||||
@@ -39,6 +41,7 @@ export function ChunkEditor({
|
||||
canEdit,
|
||||
maxChunkSize,
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
onCreated,
|
||||
}: ChunkEditorProps) {
|
||||
@@ -50,23 +53,55 @@ export function ChunkEditor({
|
||||
const chunkContent = chunk?.content ?? ''
|
||||
|
||||
const [editedContent, setEditedContent] = useState(isCreateMode ? '' : chunkContent)
|
||||
const [savedContent, setSavedContent] = useState(chunkContent)
|
||||
const [tokenizerOn, setTokenizerOn] = useState(false)
|
||||
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
|
||||
const prevChunkIdRef = useRef(chunk?.id)
|
||||
const savedContentRef = useRef(chunkContent)
|
||||
|
||||
const editedContentRef = useRef(editedContent)
|
||||
editedContentRef.current = editedContent
|
||||
|
||||
const isDirty = isCreateMode ? editedContent.trim().length > 0 : editedContent !== chunkContent
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
if (isCreateMode) return
|
||||
if (chunk?.id !== prevChunkIdRef.current) {
|
||||
prevChunkIdRef.current = chunk?.id
|
||||
savedContentRef.current = chunkContent
|
||||
setSavedContent(chunkContent)
|
||||
setEditedContent(chunkContent)
|
||||
}
|
||||
}, [isCreateMode, chunk?.id, chunkContent])
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(isDirty)
|
||||
}, [isDirty, onDirtyChange])
|
||||
if (isCreateMode || !chunk?.id) return
|
||||
const controller = new AbortController()
|
||||
const handleVisibility = async () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentData.id}/chunks/${chunk.id}`,
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const serverContent: string = json.data?.content ?? ''
|
||||
if (serverContent === savedContentRef.current) return
|
||||
const isClean = editedContentRef.current === savedContentRef.current
|
||||
savedContentRef.current = serverContent
|
||||
setSavedContent(serverContent)
|
||||
if (isClean) {
|
||||
setEditedContent(serverContent)
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') return
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibility)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility)
|
||||
controller.abort()
|
||||
}
|
||||
}, [isCreateMode, chunk?.id, knowledgeBaseId, documentData.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode && textareaRef.current) {
|
||||
@@ -74,6 +109,8 @@ export function ChunkEditor({
|
||||
}
|
||||
}, [isCreateMode])
|
||||
|
||||
const isConnectorDocument = Boolean(documentData.connectorId)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const content = editedContentRef.current
|
||||
const trimmed = content.trim()
|
||||
@@ -89,26 +126,59 @@ export function ChunkEditor({
|
||||
})
|
||||
onCreated?.(created.id)
|
||||
} else {
|
||||
if (!chunk || trimmed === chunk.content) return
|
||||
if (!chunk?.id) return
|
||||
await updateChunk({
|
||||
knowledgeBaseId,
|
||||
documentId: documentData.id,
|
||||
chunkId: chunk.id,
|
||||
content: trimmed,
|
||||
})
|
||||
savedContentRef.current = content
|
||||
setSavedContent(content)
|
||||
}
|
||||
}, [isCreateMode, chunk, knowledgeBaseId, documentData.id, updateChunk, createChunk, onCreated])
|
||||
}, [
|
||||
isCreateMode,
|
||||
chunk?.id,
|
||||
knowledgeBaseId,
|
||||
documentData.id,
|
||||
updateChunk,
|
||||
createChunk,
|
||||
onCreated,
|
||||
])
|
||||
|
||||
const {
|
||||
saveStatus,
|
||||
saveImmediately,
|
||||
isDirty: autosaveDirty,
|
||||
} = useAutosave({
|
||||
content: editedContent,
|
||||
savedContent,
|
||||
onSave: handleSave,
|
||||
enabled: canEdit && !isCreateMode && !isConnectorDocument,
|
||||
})
|
||||
|
||||
const isDirty = isCreateMode ? editedContent.trim().length > 0 : autosaveDirty
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(isDirty)
|
||||
}, [isDirty, onDirtyChange])
|
||||
|
||||
useEffect(() => {
|
||||
onSaveStatusChange?.(saveStatus)
|
||||
}, [saveStatus, onSaveStatusChange])
|
||||
|
||||
const saveFunction = isCreateMode ? handleSave : saveImmediately
|
||||
|
||||
useEffect(() => {
|
||||
if (saveRef) {
|
||||
saveRef.current = handleSave
|
||||
saveRef.current = saveFunction
|
||||
}
|
||||
return () => {
|
||||
if (saveRef) {
|
||||
saveRef.current = null
|
||||
}
|
||||
}
|
||||
}, [saveRef, handleSave])
|
||||
}, [saveRef, saveFunction])
|
||||
|
||||
const tokenStrings = useMemo(() => {
|
||||
if (!tokenizerOn || !editedContent) return []
|
||||
@@ -121,8 +191,6 @@ export function ChunkEditor({
|
||||
return getAccurateTokenCount(editedContent)
|
||||
}, [editedContent, tokenizerOn, tokenStrings])
|
||||
|
||||
const isConnectorDocument = Boolean(documentData.connectorId)
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
<div
|
||||
|
||||
@@ -339,15 +339,19 @@ export function Document({
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!saveRef.current || !isDirty || saveStatusRef.current === 'saving') return
|
||||
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
if (isCreatingNewChunk) {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
await saveRef.current()
|
||||
setSaveStatus('saved')
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
}
|
||||
} else {
|
||||
await saveRef.current()
|
||||
setSaveStatus('saved')
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
}
|
||||
}, [isDirty])
|
||||
}, [isDirty, isCreatingNewChunk])
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
setShowUnsavedChangesAlert(false)
|
||||
@@ -361,14 +365,6 @@ export function Document({
|
||||
}
|
||||
}, [pendingAction, closeEditor])
|
||||
|
||||
// Auto-clear save status after 2 seconds
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'saved' || saveStatus === 'error') {
|
||||
const timer = setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [saveStatus])
|
||||
|
||||
// Cmd+S keyboard shortcut
|
||||
useEffect(() => {
|
||||
if (!isInEditorView) return
|
||||
@@ -967,6 +963,7 @@ export function Document({
|
||||
canEdit
|
||||
maxChunkSize={knowledgeBase?.chunkingConfig?.maxSize}
|
||||
onDirtyChange={setIsDirty}
|
||||
onSaveStatusChange={setSaveStatus}
|
||||
saveRef={saveRef}
|
||||
onCreated={handleChunkCreated}
|
||||
/>
|
||||
@@ -1055,6 +1052,7 @@ export function Document({
|
||||
canEdit={canEdit && !isConnectorDocument}
|
||||
maxChunkSize={knowledgeBase?.chunkingConfig?.maxSize}
|
||||
onDirtyChange={setIsDirty}
|
||||
onSaveStatusChange={setSaveStatus}
|
||||
saveRef={saveRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,7 @@ export function useWorkspaceFileContent(workspaceId: string, fileId: string, key
|
||||
queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal),
|
||||
enabled: !!workspaceId && !!fileId && !!key,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnWindowFocus: 'always',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
99
apps/sim/hooks/use-autosave.ts
Normal file
99
apps/sim/hooks/use-autosave.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
interface UseAutosaveOptions {
|
||||
content: string
|
||||
savedContent: string
|
||||
onSave: () => Promise<void>
|
||||
delay?: number
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface UseAutosaveReturn {
|
||||
saveStatus: SaveStatus
|
||||
saveImmediately: () => Promise<void>
|
||||
isDirty: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared autosave hook that debounces content changes and persists them automatically.
|
||||
* Keeps Cmd+S / Save button working via `saveImmediately`, and flushes on unmount
|
||||
* so edits aren't lost when navigating away.
|
||||
*/
|
||||
export function useAutosave({
|
||||
content,
|
||||
savedContent,
|
||||
onSave,
|
||||
delay = 1500,
|
||||
enabled = true,
|
||||
}: UseAutosaveOptions): UseAutosaveReturn {
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const savingRef = useRef(false)
|
||||
const onSaveRef = useRef(onSave)
|
||||
onSaveRef.current = onSave
|
||||
const savedContentRef = useRef(savedContent)
|
||||
savedContentRef.current = savedContent
|
||||
const contentRef = useRef(content)
|
||||
contentRef.current = content
|
||||
|
||||
const isDirty = content !== savedContent
|
||||
const savingStartRef = useRef(0)
|
||||
const MIN_SAVING_DISPLAY_MS = 600
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (savingRef.current || contentRef.current === savedContentRef.current) return
|
||||
savingRef.current = true
|
||||
savingStartRef.current = Date.now()
|
||||
setSaveStatus('saving')
|
||||
let nextStatus: SaveStatus = 'saved'
|
||||
try {
|
||||
await onSaveRef.current()
|
||||
} catch {
|
||||
nextStatus = 'error'
|
||||
} finally {
|
||||
const elapsed = Date.now() - savingStartRef.current
|
||||
const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed)
|
||||
setTimeout(() => {
|
||||
setSaveStatus(nextStatus)
|
||||
savingRef.current = false
|
||||
if (nextStatus !== 'error' && contentRef.current !== savedContentRef.current) {
|
||||
save()
|
||||
}
|
||||
}, remaining)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isDirty || savingRef.current) return
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(save, delay)
|
||||
return () => clearTimeout(timerRef.current)
|
||||
}, [content, enabled, isDirty, delay, save])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'saved' || saveStatus === 'error') {
|
||||
const t = setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [saveStatus])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(timerRef.current)
|
||||
if (contentRef.current !== savedContentRef.current && !savingRef.current) {
|
||||
onSaveRef.current().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveImmediately = useCallback(async () => {
|
||||
clearTimeout(timerRef.current)
|
||||
await save()
|
||||
}, [save])
|
||||
|
||||
return { saveStatus, saveImmediately, isDirty }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Environment utility functions for consistent environment detection across the application
|
||||
*/
|
||||
import { env, isFalsy, isTruthy } from './env'
|
||||
import { env, getEnv, isFalsy, isTruthy } from './env'
|
||||
|
||||
/**
|
||||
* Is the application running in production mode
|
||||
@@ -21,9 +21,9 @@ export const isTest = env.NODE_ENV === 'test'
|
||||
/**
|
||||
* Is this the hosted version of the application
|
||||
*/
|
||||
export const isHosted = true
|
||||
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
|
||||
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
|
||||
export const isHosted =
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
|
||||
|
||||
/**
|
||||
* Is billing enforcement enabled
|
||||
|
||||
Reference in New Issue
Block a user