mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
ff16ab1565
commit
b3f99b5ae1
3 changed files with 103 additions and 23 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
Loading…
Reference in a new issue