fix: preserve selection during external markdown edits and sidebar interactions (#880)

* fix: preserve selection during external markdown edits and prevent blocked worktree switching

* fix: correct TypeScript types for usePreserveSectionDuringExternalEdit and add missing dependency
This commit is contained in:
Jinjing 2026-04-20 18:54:52 -07:00 committed by GitHub
parent ff16ab1565
commit b3f99b5ae1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 103 additions and 23 deletions

View file

@ -24,6 +24,7 @@ import {
isMarkdownPreviewFindShortcut,
setActiveMarkdownPreviewSearchMatch
} from './markdown-preview-search'
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
type MarkdownPreviewProps = {
content: string
@ -64,7 +65,9 @@ export default function MarkdownPreview({
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const frontMatter = useMemo(() => extractFrontMatter(content), [content])
const renderedContent = usePreserveSectionDuringExternalEdit(content, bodyRef)
const frontMatter = useMemo(() => extractFrontMatter(renderedContent), [renderedContent])
const frontMatterInner = useMemo(() => {
if (!frontMatter) {
return ''
@ -148,7 +151,7 @@ export default function MarkdownPreview({
// Why: content is included so the restore loop re-triggers when markdown
// content arrives or changes (e.g., async file load), since scrollHeight
// depends on rendered content and may not be large enough until then.
}, [scrollCacheKey, content])
}, [scrollCacheKey, renderedContent])
const moveToMatch = useCallback((direction: 1 | -1) => {
const matches = matchesRef.current
@ -205,7 +208,7 @@ export default function MarkdownPreview({
})
return () => clearMarkdownPreviewSearchHighlights(body)
}, [content, isSearchOpen, query])
}, [renderedContent, isSearchOpen, query])
useEffect(() => {
setActiveMarkdownPreviewSearchMatch(matchesRef.current, activeMatchIndex)
@ -465,7 +468,7 @@ export default function MarkdownPreview({
remarkPlugins={[remarkGfm, remarkFrontmatter]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
>
{content}
{renderedContent}
</Markdown>
</div>
</div>

View file

@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from 'react'
// Why: when the .md file is being modified externally (e.g. the AI is
// streaming writes), each external-change event replaces the `content`
// prop, which makes react-markdown replace the rendered text nodes. Any
// in-progress browser selection inside the preview collapses mid-drag
// because its anchor/focus nodes are detached. This hook holds back content
// updates while the user has an active selection inside the preview body
// and applies the latest pending content once the selection is released.
export function usePreserveSectionDuringExternalEdit(
content: string,
bodyRef: React.RefObject<HTMLDivElement | null>
): string {
const [renderedContent, setRenderedContent] = useState(content)
const pendingContentRef = useRef(content)
pendingContentRef.current = content
useEffect(() => {
if (content === renderedContent) {
return
}
const body = bodyRef.current
const hasSelectionInsideBody = (): boolean => {
if (!body) {
return false
}
const selection = window.getSelection()
if (!selection || selection.isCollapsed) {
return false
}
const anchor = selection.anchorNode
const focus = selection.focusNode
return (
(anchor instanceof Node && body.contains(anchor)) ||
(focus instanceof Node && body.contains(focus))
)
}
if (!hasSelectionInsideBody()) {
setRenderedContent(content)
return
}
// Why: cap the deferral so a forgotten selection (user walked away with
// text highlighted) can't freeze the preview indefinitely while the file
// keeps changing on disk. After the cap elapses we apply the pending
// content even if the selection is still held — the user loses a
// highlight they'd abandoned anyway, which is preferable to stale content.
const MAX_DEFER_MS = 3000
const deadline = performance.now() + MAX_DEFER_MS
let frameId = 0
const waitForSelectionRelease = (): void => {
if (performance.now() >= deadline || !hasSelectionInsideBody()) {
setRenderedContent(pendingContentRef.current)
return
}
frameId = window.requestAnimationFrame(waitForSelectionRelease)
}
frameId = window.requestAnimationFrame(waitForSelectionRelease)
return () => window.cancelAnimationFrame(frameId)
}, [bodyRef, content, renderedContent])
return renderedContent
}

View file

@ -161,25 +161,42 @@ const WorktreeCard = React.memo(function WorktreeCard({
// Stable click handler ignore clicks that are really text selections.
// Why: if the SSH connection is down, show a reconnect dialog instead of
// activating the worktree — all remote operations would fail anyway.
const handleClick = useCallback(() => {
const selection = window.getSelection()
if (selection && selection.toString().length > 0) {
return
}
if (useAppStore.getState().activeView !== 'terminal') {
// Why: the sidebar remains visible during the new-workspace flow, so
// clicking a real worktree should switch the main pane back to that
// worktree instead of leaving the create surface visible.
setActiveView('terminal')
}
// Why: always activate the worktree so the user can see terminal history,
// editor state, etc. even when SSH is disconnected. Show the reconnect
// dialog as a non-blocking overlay rather than a gate.
setActiveWorktree(worktree.id)
if (isSshDisconnected) {
setShowDisconnectedDialog(true)
}
}, [worktree.id, setActiveView, setActiveWorktree, isSshDisconnected])
const handleClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const selection = window.getSelection()
// Why: only suppress the click when the selection is *inside this card*
// (a real drag-select on the card's own text). A selection anchored
// elsewhere — e.g. inside the markdown preview while the AI is streaming
// writes — must not block worktree switching, otherwise the user can't
// leave the current worktree without first clicking into a terminal to
// clear the foreign selection.
if (selection && selection.toString().length > 0) {
const card = event.currentTarget
const anchor = selection.anchorNode
const focus = selection.focusNode
const selectionInsideCard =
(anchor instanceof Node && card.contains(anchor)) ||
(focus instanceof Node && card.contains(focus))
if (selectionInsideCard) {
return
}
}
if (useAppStore.getState().activeView !== 'terminal') {
// Why: the sidebar remains visible during the new-workspace flow, so
// clicking a real worktree should switch the main pane back to that
// worktree instead of leaving the create surface visible.
setActiveView('terminal')
}
// Why: always activate the worktree so the user can see terminal history,
// editor state, etc. even when SSH is disconnected. Show the reconnect
// dialog as a non-blocking overlay rather than a gate.
setActiveWorktree(worktree.id)
if (isSshDisconnected) {
setShowDisconnectedDialog(true)
}
},
[worktree.id, setActiveView, setActiveWorktree, isSshDisconnected]
)
const handleDoubleClick = useCallback(() => {
openModal('edit-meta', {