-
Notifications
You must be signed in to change notification settings - Fork 16
feat: editor pane stat-polling auto-sync #259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a40d57a
6a69e1a
6c96e41
6e1c572
4b6360b
d59ae95
b1f228d
3b3019e
8193a6e
7546801
ed3e3da
fdde6ff
bd7736c
a240949
ca50ad0
c9460a6
13279f9
5b4eaad
8408ab6
461ea90
bdf68e6
9f742de
f4577dd
0f67a76
c21c473
d9da0e1
e8452d6
67e3287
2f373a1
b0a4455
572d822
dca0d9c
cfe933b
ee7516a
bb5fefd
e91e3e1
e71a33d
988aefa
ed423a9
f74b6c4
3803106
adc4b5a
9b25f5e
bf2be81
3b42d33
ad85bf9
4e6acb8
d418b4a
118efc9
43399ea
a2f55f7
1442277
97895c2
b9d1816
8cd9ea4
0ab0450
dd1c900
c71a8aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,4 +35,5 @@ artifacts/perf/ | |
|
|
||
| # Runtime / agent artifacts | ||
| .superpowers/ | ||
| .opencode/ | ||
| sdk.session.snapshot | ||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -165,6 +165,15 @@ export default function EditorPane({ | |
| const [terminalCwds, setTerminalCwds] = useState<Record<string, string>>({}) | ||
| const [filePickerMessage, setFilePickerMessage] = useState<string | null>(null) | ||
|
|
||
| const lastSavedContent = useRef<string>(content) | ||
| const lastKnownMtime = useRef<string | null>(null) | ||
| const pollIntervalRef = useRef<NodeJS.Timeout | null>(null) | ||
|
|
||
| const [conflictState, setConflictState] = useState<{ | ||
| diskContent: string | ||
| diskMtime: string | ||
| } | null>(null) | ||
|
|
||
| const firstTerminalCwd = useMemo( | ||
| () => (layout ? getFirstTerminalCwd(layout, terminalCwds) : null), | ||
| [layout, terminalCwds] | ||
|
|
@@ -351,6 +360,7 @@ export default function EditorPane({ | |
| content: string | ||
| language?: string | ||
| filePath?: string | ||
| modifiedAt?: string | ||
| }>(`/api/files/read?path=${encodeURIComponent(resolvedPath)}`) | ||
|
|
||
| const resolvedFilePath = response.filePath || resolvedPath | ||
|
|
@@ -370,6 +380,8 @@ export default function EditorPane({ | |
| setCurrentLanguage(resolvedLanguage) | ||
| setCurrentViewMode(nextViewMode) | ||
| pendingContent.current = response.content | ||
| lastSavedContent.current = response.content | ||
| lastKnownMtime.current = response.modifiedAt || null | ||
|
|
||
| if (editorRef.current) { | ||
| const model = editorRef.current.getModel() | ||
|
|
@@ -581,10 +593,12 @@ export default function EditorPane({ | |
| if (filePath) { | ||
| const resolved = resolvePath(filePath) | ||
| if (!resolved) return | ||
| await api.post('/api/files/write', { | ||
| const saveResult = await api.post<{ success: boolean; modifiedAt?: string }>('/api/files/write', { | ||
| path: resolved, | ||
| content: value, | ||
| }) | ||
| lastKnownMtime.current = saveResult?.modifiedAt || null | ||
| lastSavedContent.current = value | ||
| } | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err) | ||
|
|
@@ -616,10 +630,12 @@ export default function EditorPane({ | |
| if (filePath) { | ||
| const resolved = resolvePath(filePath) | ||
| if (!resolved) return | ||
| await api.post('/api/files/write', { | ||
| const saveResult = await api.post<{ success: boolean; modifiedAt?: string }>('/api/files/write', { | ||
| path: resolved, | ||
| content: value, | ||
| }) | ||
| lastKnownMtime.current = saveResult?.modifiedAt || null | ||
| lastSavedContent.current = value | ||
| } | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err) | ||
|
|
@@ -676,6 +692,106 @@ export default function EditorPane({ | |
| updateContent({ viewMode: nextMode }) | ||
| }, [currentViewMode, updateContent]) | ||
|
|
||
| const handleReloadFromDisk = useCallback(() => { | ||
| if (!conflictState) return | ||
| if (autoSaveTimer.current) { | ||
| clearTimeout(autoSaveTimer.current) | ||
| autoSaveTimer.current = null | ||
| } | ||
| setEditorValue(conflictState.diskContent) | ||
| pendingContent.current = conflictState.diskContent | ||
| lastSavedContent.current = conflictState.diskContent | ||
| lastKnownMtime.current = conflictState.diskMtime | ||
| updateContent({ content: conflictState.diskContent }) | ||
| setConflictState(null) | ||
| }, [conflictState, updateContent]) | ||
|
|
||
| const handleKeepLocal = useCallback(() => { | ||
| if (!conflictState) return | ||
| if (autoSaveTimer.current) { | ||
| clearTimeout(autoSaveTimer.current) | ||
| autoSaveTimer.current = null | ||
| } | ||
| lastKnownMtime.current = conflictState.diskMtime | ||
| setConflictState(null) | ||
| scheduleAutoSave(pendingContent.current) | ||
| }, [conflictState, scheduleAutoSave]) | ||
|
|
||
| useEffect(() => { | ||
| if (!filePath) return | ||
| if (fileHandleRef.current) return | ||
|
|
||
| const poll = async () => { | ||
| if (!mountedRef.current) return | ||
| if (conflictState) return | ||
|
|
||
| const resolved = resolvePath(filePath) | ||
| if (!resolved) return | ||
|
|
||
| try { | ||
| const statResult = await api.get<{ | ||
| exists: boolean | ||
| size: number | null | ||
| modifiedAt: string | null | ||
| }>(`/api/files/stat?path=${encodeURIComponent(resolved)}`) | ||
|
|
||
| if (!mountedRef.current) return | ||
|
|
||
| if (!statResult.exists || !statResult.modifiedAt) return | ||
| if (statResult.modifiedAt === lastKnownMtime.current) return | ||
|
|
||
| const wasClean = pendingContent.current === lastSavedContent.current | ||
| const response = await api.get<{ | ||
| content: string | ||
| language?: string | ||
| filePath?: string | ||
| modifiedAt?: string | ||
| }>(`/api/files/read?path=${encodeURIComponent(resolved)}`) | ||
|
|
||
| if (!mountedRef.current) return | ||
|
|
||
| const stillClean = pendingContent.current === lastSavedContent.current | ||
| if (wasClean && stillClean) { | ||
| setEditorValue(response.content) | ||
| pendingContent.current = response.content | ||
| lastSavedContent.current = response.content | ||
| lastKnownMtime.current = response.modifiedAt || null | ||
|
|
||
| updateContent({ | ||
| content: response.content, | ||
| }) | ||
| } else { | ||
| if (autoSaveTimer.current) { | ||
| clearTimeout(autoSaveTimer.current) | ||
| autoSaveTimer.current = null | ||
| } | ||
| setConflictState({ | ||
| diskContent: response.content, | ||
| diskMtime: response.modifiedAt || statResult.modifiedAt!, | ||
| }) | ||
| } | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err) | ||
| log.error( | ||
| JSON.stringify({ | ||
| severity: 'error', | ||
| event: 'editor_stat_poll_failed', | ||
| error: message, | ||
| }) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| pollIntervalRef.current = setInterval(poll, 3000) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The polling loop is scheduled with Useful? React with 👍 / 👎. |
||
|
|
||
| return () => { | ||
| if (pollIntervalRef.current) { | ||
| clearInterval(pollIntervalRef.current) | ||
| pollIntervalRef.current = null | ||
| } | ||
| } | ||
| }, [filePath, resolvePath, conflictState, updateContent]) | ||
|
|
||
| useEffect(() => { | ||
| return registerEditorActions(paneId, { | ||
| cut: () => editorRef.current?.getAction('editor.action.clipboardCutAction')?.run(), | ||
|
|
@@ -722,6 +838,31 @@ export default function EditorPane({ | |
| {filePickerMessage} | ||
| </div> | ||
| )} | ||
| {conflictState && ( | ||
| <div | ||
| className="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-sm" | ||
| role="alert" | ||
| data-testid="editor-conflict-banner" | ||
| > | ||
| <span className="flex-1 text-yellow-700 dark:text-yellow-400"> | ||
| File changed on disk | ||
| </span> | ||
| <button | ||
| className="rounded px-2 py-1 text-xs font-medium bg-yellow-500/20 hover:bg-yellow-500/30" | ||
| onClick={handleReloadFromDisk} | ||
| aria-label="Reload file from disk" | ||
| > | ||
| Reload | ||
| </button> | ||
| <button | ||
| className="rounded px-2 py-1 text-xs font-medium bg-muted hover:bg-muted/80" | ||
| onClick={handleKeepLocal} | ||
| aria-label="Keep local changes" | ||
| > | ||
| Keep Mine | ||
| </button> | ||
| </div> | ||
| )} | ||
| <div className="flex-1 relative min-h-0 overflow-hidden"> | ||
| {isLoading && ( | ||
| <div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10"> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a disk-conflict is active, selecting a new path through
handlePathSelectupdates the editor content but leavesconflictStateuntouched. In that state, the banner/actions still point to the previous file’s snapshot, so clicking “Reload” can inject old-file content into the newly selected file and later saves can overwrite the wrong path; polling also remains disabled becausepollreturns early whileconflictStateis set. Clear conflict state after a successful file load (or on file-path change).Useful? React with 👍 / 👎.