From 9e1a11de0b5357ef6d1dee6da13d8f5651fac51b Mon Sep 17 00:00:00 2001 From: winit Date: Fri, 13 Feb 2026 04:57:18 +0530 Subject: [PATCH] Add CodeMirror extension to display find match count in the editor. (#390) --- src-web/components/core/Editor/Editor.css | 45 ++++++- src-web/components/core/Editor/extensions.ts | 2 + .../core/Editor/searchMatchCount.ts | 115 ++++++++++++++++++ 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 src-web/components/core/Editor/searchMatchCount.ts diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index 34679684..b7183b10 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -434,11 +434,23 @@ input { @apply bg-surface border-border-subtle focus:border-border-focus; - @apply border outline-none cursor-text; + @apply border outline-none; } - label { - @apply focus-within:text-text; + input.cm-textfield { + @apply cursor-text; + } + + .cm-search label { + @apply inline-flex items-center h-6 px-1.5 rounded-sm border border-border-subtle cursor-default text-text-subtle text-xs; + + input[type="checkbox"] { + @apply hidden; + } + + &:has(:checked) { + @apply text-primary border-border; + } } /* Hide the "All" button */ @@ -446,4 +458,31 @@ button[name="select"] { @apply hidden; } + + /* Replace next/prev button text with chevron icons */ + + .cm-search button[name="next"], + .cm-search button[name="prev"] { + @apply text-[0px] w-7 h-6 inline-flex items-center justify-center border border-border-subtle mr-1; + } + + .cm-search button[name="prev"]::after, + .cm-search button[name="next"]::after { + @apply block w-3.5 h-3.5 bg-text; + content: ""; + } + + .cm-search button[name="prev"]::after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E"); + } + + .cm-search button[name="next"]::after { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E"); + } + + .cm-search-match-count { + @apply text-text-subtle text-xs font-mono whitespace-nowrap px-1.5 py-0.5 self-center; + } } diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 6919fe2e..1ec1eb07 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -67,6 +67,7 @@ import type { TwigCompletionOption } from './twig/completion'; import { twig } from './twig/extension'; import { pathParametersPlugin } from './twig/pathParameters'; import { url } from './url/extension'; +import { searchMatchCount } from './searchMatchCount'; export const syntaxHighlightStyle = HighlightStyle.define([ { @@ -256,6 +257,7 @@ export const readonlyExtensions = [ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [ search({ top: true }), + searchMatchCount(), hideGutter ? [] : [ diff --git a/src-web/components/core/Editor/searchMatchCount.ts b/src-web/components/core/Editor/searchMatchCount.ts new file mode 100644 index 00000000..9f4e8aff --- /dev/null +++ b/src-web/components/core/Editor/searchMatchCount.ts @@ -0,0 +1,115 @@ +import { getSearchQuery, searchPanelOpen } from '@codemirror/search'; +import type { Extension } from '@codemirror/state'; +import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view'; + +/** + * A CodeMirror extension that displays the total number of search matches + * inside the built-in search panel. + */ +export function searchMatchCount(): Extension { + return ViewPlugin.fromClass( + class { + private countEl: HTMLElement | null = null; + + constructor(private view: EditorView) { + this.updateCount(); + } + + update(update: ViewUpdate) { + // Recompute when doc changes, search state changes, or selection moves + const query = getSearchQuery(update.state); + const prevQuery = getSearchQuery(update.startState); + const open = searchPanelOpen(update.state); + const prevOpen = searchPanelOpen(update.startState); + + if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) { + this.updateCount(); + } + } + + private updateCount() { + const state = this.view.state; + const open = searchPanelOpen(state); + const query = getSearchQuery(state); + + if (!open) { + this.removeCountEl(); + return; + } + + this.ensureCountEl(); + + if (!query.search) { + if (this.countEl) { + this.countEl.textContent = '0/0'; + } + return; + } + + const selection = state.selection.main; + let count = 0; + let currentIndex = 0; + const MAX_COUNT = 9999; + const cursor = query.getCursor(state); + while (!cursor.next().done) { + count++; + if (cursor.value.from <= selection.from && cursor.value.to >= selection.to) { + currentIndex = count; + } + if (count > MAX_COUNT) break; + } + + if (this.countEl) { + if (count > MAX_COUNT) { + this.countEl.textContent = `${MAX_COUNT}+`; + } else if (count === 0) { + this.countEl.textContent = '0/0'; + } else if (currentIndex > 0) { + this.countEl.textContent = `${currentIndex}/${count}`; + } else { + this.countEl.textContent = `0/${count}`; + } + } + } + + private ensureCountEl() { + // Find the search panel in the editor DOM + const panel = this.view.dom.querySelector('.cm-search'); + if (!panel) { + this.countEl = null; + return; + } + + if (this.countEl && this.countEl.parentElement === panel) { + return; // Already attached + } + + this.countEl = document.createElement('span'); + this.countEl.className = 'cm-search-match-count'; + + // Reorder: insert prev button, then next button, then count after the search input + const searchInput = panel.querySelector('input'); + const prevBtn = panel.querySelector('button[name="prev"]'); + const nextBtn = panel.querySelector('button[name="next"]'); + if (searchInput && searchInput.parentElement === panel) { + searchInput.after(this.countEl); + if (prevBtn) this.countEl.after(prevBtn); + if (nextBtn && prevBtn) prevBtn.after(nextBtn); + } else { + panel.prepend(this.countEl); + } + } + + private removeCountEl() { + if (this.countEl) { + this.countEl.remove(); + this.countEl = null; + } + } + + destroy() { + this.removeCountEl(); + } + }, + ); +}