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 after 9b9d7069c)
- project switcher names are left-aligned again (wrapper lost in 26dbc2f30)
- 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:
Bohdan Triapitsyn
2026-08-29 01:06:43 +03:00
parent a182f4f4ff
commit 48bcac1758
53 changed files with 1125 additions and 375 deletions
+30 -36
View File
@@ -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')