fix: harden and de-slop the merged sidebar/chat/settings batch
Post-merge follow-ups for #2740 #2735 #2734 #2690 #2676 #2738 #2684 #2689 #2733 #2739 #2462 #2687 #2736 #2618 #2697, plus three regressions found while reviewing them: - ctrl/cmd+digit while typing no longer switches session tabs (#2503 was still open in practice: the guard only covered the mod+alt surface binding) - Shiki template-call sanitizer now covers every bundled grammar, including the js/ts aliases and embedding grammars; timed-out highlight requests are memoized and no longer cancel unrelated in-flight requests - settings flush on suspend uses keepalive and also fires on Capacitor appStateChange; keeps the selected model persisted across mode switches - remote-only branches fetch before checkout; range helpers fail clearly - git status invalidation now fires for runtime adapters too - settings number inputs and select triggers size in ch so they scale with the interface font - recent-activity timestamps tick from one list-level ticker - Markdown preview find goes through the shared find_in_file keybind with containment, no longer counts its own bar, and debounces observer runs - #2676 reverted; #2524 fixed by fading the sticky header's own background instead of overlaying the content below it - sticky group headers in the model picker and sidebar render again (oc-sticky-fade-scroller class restored after9b9d7069c) - project switcher names are left-aligned again (wrapper lost in26dbc2f30) - tool card quick-open icon is always visible and opens the same line as the expanded card's button - tautological tests replaced or removed; new oxlint findings fixed
This commit is contained in:
@@ -1783,7 +1783,24 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
},
|
||||
find_in_file: (event) => {
|
||||
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
|
||||
if (!(event.target instanceof Node)) return false;
|
||||
|
||||
// Rendered Markdown preview: open the in-preview find bar instead of the
|
||||
// editor search. Registered through the keybind schema rather than a raw
|
||||
// window listener so it cannot swallow Cmd/Ctrl+F app-wide while a
|
||||
// Markdown file happens to be selected behind another panel tab.
|
||||
if (isMarkdown && getMdViewMode() === 'preview') {
|
||||
if (isMobile) return false;
|
||||
const previewContainer = isFullscreen
|
||||
? mdFullscreenPreviewContainerRef.current
|
||||
: mdPreviewContainerRef.current;
|
||||
if (!previewContainer?.contains(event.target)) return false;
|
||||
setMdPreviewFindOpen(true);
|
||||
setMdPreviewFindFocusNonce((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editorWrapperRef.current?.contains(event.target)) return false;
|
||||
setIsSearchOpen(true);
|
||||
},
|
||||
});
|
||||
@@ -2921,34 +2938,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
setIsGoToLineOpen(true);
|
||||
});
|
||||
|
||||
// Ctrl/Cmd+F opens the in-preview find bar for the rendered Markdown
|
||||
// preview. In edit mode CodeMirror owns the shortcut, so this handler is
|
||||
// active only while the preview is shown.
|
||||
React.useEffect(() => {
|
||||
if (!isMarkdown || getMdViewMode() !== 'preview') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key.toLowerCase() !== 'f') {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest('[role="dialog"]')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setMdPreviewFindOpen(true);
|
||||
setMdPreviewFindFocusNonce((value) => value + 1);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [getMdViewMode, isMarkdown]);
|
||||
|
||||
const editorFontSize = useUIStore((state) => state.editorFontSize);
|
||||
|
||||
const editorExtensions = React.useMemo(() => {
|
||||
@@ -4280,6 +4269,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
) : null}
|
||||
</div>
|
||||
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
||||
// The find bar is a sibling of the scroll container, never a child:
|
||||
// inside it, its own "1/3" and "No matches" text would be walked and
|
||||
// highlighted by the search it drives.
|
||||
<div className="relative h-full min-h-0">
|
||||
<div
|
||||
className="oc-file-preview h-full overflow-auto p-4"
|
||||
ref={(node) => {
|
||||
@@ -4287,13 +4280,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
mdFullscreenPreviewContainerRef.current = node;
|
||||
}}
|
||||
>
|
||||
<MarkdownPreviewSearch
|
||||
containerRef={mdFullscreenPreviewContainerRef}
|
||||
open={mdPreviewFindOpen}
|
||||
onOpenChange={setMdPreviewFindOpen}
|
||||
focusNonce={mdPreviewFindFocusNonce}
|
||||
className="right-4 top-16"
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<FilePreviewCommentMenu
|
||||
containerRef={markdownPreviewRef}
|
||||
@@ -4324,6 +4310,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<MarkdownPreviewSearch
|
||||
containerRef={mdFullscreenPreviewContainerRef}
|
||||
open={mdPreviewFindOpen}
|
||||
onOpenChange={setMdPreviewFindOpen}
|
||||
focusNonce={mdPreviewFindFocusNonce}
|
||||
className="right-4 top-16"
|
||||
/>
|
||||
</div>
|
||||
) : canUseShikiFileView && textViewMode === 'view' ? (
|
||||
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer)
|
||||
) : (
|
||||
|
||||
@@ -33,15 +33,29 @@ const isMarkElement = (node: Node): boolean => {
|
||||
return node instanceof Element && node.hasAttribute(MARK_ATTR);
|
||||
};
|
||||
|
||||
/** True when this widget's own highlight surgery produced the record. */
|
||||
const isSelfProducedMutation = (record: MutationRecord): boolean => {
|
||||
if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) {
|
||||
return true;
|
||||
}
|
||||
return [...record.addedNodes].some((node) => isMarkElement(node));
|
||||
};
|
||||
|
||||
const clearHighlights = (container: HTMLElement): void => {
|
||||
const touchedParents = new Set<Node>();
|
||||
container.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
|
||||
const parent = mark.parentNode;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark);
|
||||
parent.normalize();
|
||||
touchedParents.add(parent);
|
||||
});
|
||||
// Once per affected parent instead of once per mark. Merging the split text
|
||||
// nodes back together is safe under the renderer's morphdom path: it diffs
|
||||
// against a tree freshly parsed from HTML, where the merged single text node
|
||||
// is exactly the shape it expects.
|
||||
touchedParents.forEach((parent) => parent.normalize());
|
||||
};
|
||||
|
||||
const applySearch = (container: HTMLElement, query: string): HTMLElement[] => {
|
||||
@@ -143,7 +157,12 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
// Focus returns here when the bar closes, so Escape does not strand focus.
|
||||
const returnFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
const runSearch = React.useCallback((nextQuery: string) => {
|
||||
/**
|
||||
* `keepIndex` distinguishes a new query (start at match 1) from a re-search
|
||||
* of the same query after the renderer re-morphed the container: a theme
|
||||
* toggle or content refresh must not yank the reader back to match 1.
|
||||
*/
|
||||
const runSearch = React.useCallback((nextQuery: string, keepIndex = false) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
marksRef.current = [];
|
||||
@@ -152,17 +171,23 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
return;
|
||||
}
|
||||
marksRef.current = applySearch(container, nextQuery);
|
||||
setTotal(marksRef.current.length);
|
||||
setIndex(0);
|
||||
const nextTotal = marksRef.current.length;
|
||||
setTotal(nextTotal);
|
||||
setIndex((current) => {
|
||||
if (!keepIndex || nextTotal === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(current, nextTotal - 1);
|
||||
});
|
||||
}, [containerRef]);
|
||||
|
||||
const scheduleSearch = React.useCallback((nextQuery: string) => {
|
||||
const scheduleSearch = React.useCallback((nextQuery: string, keepIndex = false) => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
runSearch(nextQuery);
|
||||
runSearch(nextQuery, keepIndex);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}, [runSearch]);
|
||||
|
||||
@@ -190,23 +215,25 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
return;
|
||||
}
|
||||
const observer = new MutationObserver((records) => {
|
||||
const fromUs = records.some((record) => {
|
||||
if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) {
|
||||
return true;
|
||||
}
|
||||
return [...record.addedNodes].some((node) => isMarkElement(node));
|
||||
});
|
||||
if (fromUs) {
|
||||
if (!queryRef.current.trim()) {
|
||||
return;
|
||||
}
|
||||
runSearch(queryRef.current);
|
||||
// Per record, not per batch: the renderer can deliver a genuine mutation
|
||||
// in the same batch as one of ours, and `.some` would swallow it.
|
||||
const rendererTouched = records.some((record) => !isSelfProducedMutation(record));
|
||||
if (!rendererTouched) {
|
||||
return;
|
||||
}
|
||||
// Debounced like typing — a morph batch would otherwise pay a full
|
||||
// TreeWalker plus DOM surgery per mutation batch.
|
||||
scheduleSearch(queryRef.current, true);
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearHighlights(container);
|
||||
};
|
||||
}, [containerRef, open, runSearch]);
|
||||
}, [containerRef, open, scheduleSearch]);
|
||||
|
||||
// Focus the input when the bar opens, remembering what to restore on close.
|
||||
React.useEffect(() => {
|
||||
@@ -296,7 +323,7 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
aria-live="polite"
|
||||
aria-label={total > 0
|
||||
? t('filesView.preview.find.countAria', { current: index + 1, total })
|
||||
: undefined}
|
||||
: t('filesView.preview.find.noMatches')}
|
||||
>
|
||||
{query.trim() && total === 0
|
||||
? t('filesView.preview.find.noMatches')
|
||||
|
||||
Reference in New Issue
Block a user