diff --git a/README.md b/README.md index 21056a25..7b5d2722 100644 --- a/README.md +++ b/README.md @@ -494,6 +494,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar | 操作 | 按键 | |---|---| | 保存编辑 | `Ctrl/Cmd + S` | +| 文件内查找 | `Ctrl/Cmd + F`(`Enter` / `Shift + Enter` 下一个 / 上一个,`Esc` 关闭) | | Git 提交 | `Ctrl + Enter` | | 关闭 Tab | 鼠标中键 | | Tab 右键菜单 | 关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签(当前标签组) | diff --git a/README_EN.md b/README_EN.md index 171d8120..cb72957d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -495,6 +495,7 @@ All changes since v0.14.0: | Action | Keys | |---|---| | Save edits | `Ctrl/Cmd + S` | +| Find in file | `Ctrl/Cmd + F` (`Enter` / `Shift + Enter` next / previous, `Esc` closes) | | Git commit | `Ctrl + Enter` | | Close tab | Middle mouse button | | Tab context menu (right-click) | Close / Close Other Tabs / Close Tabs to the Left / Close Tabs to the Right (current pane) | diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 8617274f..51344f99 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -26,6 +26,7 @@ import { markdownPreviewSource } from './markdown-frontmatter.ts' import { rewriteLocalImageUrls } from './markdown-images.ts' import { languageForPath } from './lang.ts' import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' +import { cmSearchExtensions, CmSearchPhrases } from './cm-search.ts' import { isDarkScheme, subscribeColorScheme } from './theme.ts' import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { appendToDraft } from './conversation-draft.ts' @@ -35,7 +36,7 @@ import { analyzeMarkdownHtml } from './markdown-html.ts' import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from './MarkdownHtml.tsx' import { MdToc } from './md-toc.tsx' import { splitMermaidBlocks } from './mermaid-blocks.ts' -import { t } from './locales.ts' +import { localeSignature, t } from './locales.ts' import { HTML_IFRAME_SANDBOX } from './html-preview.ts' import type { EditorToolbarState, FileViewerProps } from './service.ts' import css from './sidebar.module.css' @@ -63,6 +64,14 @@ export function TextEditor(props: FileViewerProps) { const savingRef = useRef(false) /** The theme compartment of the current view (reconfigured on scheme flip). */ const themeCompRef = useRef(null) + /** The search-phrases compartment of the current view (reconfigured on a + * language switch — the panel copy is baked into the EditorState). */ + const searchPhrasesRef = useRef(null) + /** The effective UI language (DSH locale id + better-locale override id). + * Read during render and subscribed below: the tab-cell memo only + * compares the DSH locale revision, so a better-locale override switch + * would otherwise never reach this component. */ + const [localeSig, setLocaleSig] = useState(() => localeSignature()) /** The app's resolved color scheme; the editor re-themes in place on flips. */ const [dark, setDark] = useState(() => isDarkScheme()) /** The markdown preview container (selection-containment + line lookup). */ @@ -101,6 +110,27 @@ export function TextEditor(props: FileViewerProps) { useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), []) + // Keep `localeSig` fresh from BOTH language sources: the DSH locale service + // and (when @huanlin/dsh-plugin-better-locale is installed) the override + // store. The sidebar root re-renders the tree on a DSH locale switch, but + // an override switch is not part of the tab-cell memo key, so this + // component subscribes directly instead of relying on a parent render. + useEffect(() => { + const sync = (): void => { setLocaleSig(localeSignature()) } + sync() + const locale = ctx.locale as { subscribe?: (cb: () => void) => () => void } | undefined + type BetterLocaleStore = { subscribe?(listener: () => void): () => void } + const betterLocale = typeof ctx.get === 'function' + ? (ctx as unknown as { get(name: 'betterLocale'): BetterLocaleStore | undefined }).get('betterLocale') + : undefined + const offLocale = locale?.subscribe?.(sync) + const offOverride = betterLocale?.subscribe?.(sync) + return () => { + offLocale?.() + offOverride?.() + } + }, [ctx]) + // A new file (tab switch) starts clean: fresh preview mode, no draft. useEffect(() => { setMode('preview') @@ -134,6 +164,8 @@ export function TextEditor(props: FileViewerProps) { const language = languageForPath(path) const themeComp = new CmThemeCompartment() themeCompRef.current = themeComp + const searchPhrases = new CmSearchPhrases() + searchPhrasesRef.current = searchPhrases const state = EditorState.create({ doc: content, extensions: [ @@ -144,6 +176,13 @@ export function TextEditor(props: FileViewerProps) { CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }), cmSurfaceTheme, themeComp.of(dark), + // Find-in-file (Cmd/Ctrl+F): the top-pinned search panel plus the + // upstream search keymap, registered BEFORE the editor's own keymap + // below so the search bindings (Escape in particular — the editor + // keymap's `simplifySelection` shares that key) win while the panel + // is open, and the save key stays in the editor keymap unchanged. + ...cmSearchExtensions(), + searchPhrases.of(), ...(language !== null ? [language] : []), CodeMirrorView.updateListener.of((update) => { if (update.docChanged) { @@ -210,6 +249,7 @@ export function TextEditor(props: FileViewerProps) { view.destroy() viewRef.current = null themeCompRef.current = null + searchPhrasesRef.current = null } // The keymap's save() reads live refs; scope/path are stable for a // tab's lifetime, and the dark flip is handled by the reconfigure @@ -226,6 +266,17 @@ export function TextEditor(props: FileViewerProps) { view.dispatch({ effects: themeComp.reconfigure(dark) }) }, [dark]) + // Language switch: re-resolve the search panel copy in place. CodeMirror + // reads the `phrases` facet when it builds the panel, so the compartment + // must be reconfigured for the new language (the document, history, + // scroll, and keymaps survive — same in-place pattern as the theme flip). + useEffect(() => { + const view = viewRef.current + const searchPhrases = searchPhrasesRef.current + if (view === null || searchPhrases === null) return + view.dispatch({ effects: searchPhrases.reconfigure() }) + }, [localeSig]) + // The editor may have been display:none while previewing; re-measure when // it becomes visible again (CodeMirror sizes itself on reveal). A mode // flip also invalidates any anchored selection popup. When entering edit diff --git a/src/client/cm-search.ts b/src/client/cm-search.ts new file mode 100644 index 00000000..7994fc22 --- /dev/null +++ b/src/client/cm-search.ts @@ -0,0 +1,141 @@ +/** + * Find-in-file for the sidebar editor: CodeMirror's own search extension, + * wired into every TextEditor view (code and markdown alike, edit and + * preview mode — the extension lives in the shared base extension list, not + * in an editable-only branch). + * + * Three pieces: + * - `search({ top: true })` pins the panel to the TOP of the editor + * (CodeMirror's default is the bottom), so it reads like a browser/IDE + * find bar and never covers the caret's line. + * - `keymap.of(searchKeymap)` binds the upstream default keys: Mod-f opens + * the panel, Mod-g / Shift-Mod-g jump next/previous, Mod-Alt-g goes to a + * line, Escape closes. The editor's own keymap is registered AFTER this + * one, so upstream bindings never shadow it (the only shared key is + * Escape, and `closeSearchPanel` returns false when no panel is open — + * the editor's `simplifySelection` still runs then). + * - the panel's copy comes from the `phrases` facet, which CodeMirror reads + * at panel-build time. The facet is part of the EditorState, so a language + * switch must reconfigure it — {@link CmSearchPhrases} is that + * compartment (the document, history, and keymaps survive). + * + * The panel chrome is re-themed with the app's design tokens + * ({@link cmSearchTheme}): CodeMirror's stock panel paints its own light + * palette (`&light` selectors resolve as light because the editor theme does + * not declare `dark`), which would sit as a gray/white box inside a + * dark-mode sidebar. Only the token-driven chrome is overridden — the match + * highlight overlays stay CodeMirror's translucent defaults, which read on + * both schemes. + */ +import { Compartment, EditorState, type Extension, type StateEffect } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { search, searchKeymap } from '@codemirror/search' +import { t } from './locales.ts' + +/** + * CodeMirror's own phrase keys → this plugin's dictionary keys. The keys are + * the exact literals @codemirror/search looks up (see its SearchPanel / + * announceMatch / gotoLine panel); `$` inside a value is CodeMirror's + * placeholder, replaced with the caller's argument when the phrase is + * resolved (`EditorState.phrase`), so the dictionary entries keep it + * verbatim. + */ +function searchPhrases(): Record { + return { + 'Find': t('searchFind'), + 'Replace': t('searchReplace'), + 'next': t('searchNext'), + 'previous': t('searchPrevious'), + 'all': t('searchAll'), + 'match case': t('searchMatchCase'), + 'by word': t('searchWholeWord'), + 'regexp': t('searchRegexp'), + 'replace': t('searchReplace'), + 'replace all': t('searchReplaceAll'), + // The close button is only an aria-label (the visible glyph is `×`) — + // the generic "close" key fits and needs no new dictionary entry. + 'close': t('close'), + 'current match': t('searchCurrentMatch'), + 'on line': t('searchOnLine'), + 'replaced $ matches': t('searchReplacedMatches'), + 'replaced match on line $': t('searchReplacedMatchOnLine'), + 'Go to line': t('searchGoToLine'), + 'go': t('searchGo'), + } +} + +/** The search panel chrome on design tokens (no hardcoded colors). */ +export const cmSearchTheme = EditorView.theme({ + '.cm-panels.cm-panels-top': { + backgroundColor: 'var(--dsw-alias-bg-layer-2)', + color: 'var(--dsw-alias-label-primary)', + borderBottom: '1px solid var(--dsw-alias-border-l1)', + }, + '.cm-panel.cm-search': { + backgroundColor: 'transparent', + color: 'var(--dsw-alias-label-primary)', + }, + '.cm-panel.cm-search input.cm-textfield': { + backgroundColor: 'var(--dsw-alias-bg-base)', + color: 'var(--dsw-alias-label-primary)', + border: '1px solid var(--dsw-alias-border-l1)', + borderRadius: '3px', + }, + '.cm-panel.cm-search input.cm-textfield:focus': { + border: '1px solid var(--dsw-alias-brand-primary)', + outline: 'none', + }, + '.cm-panel.cm-search button.cm-button': { + backgroundColor: 'var(--dsw-alias-interactive-bg-hover)', + backgroundImage: 'none', + color: 'var(--dsw-alias-label-primary)', + border: '1px solid var(--dsw-alias-border-l1)', + }, + '.cm-panel.cm-search button.cm-button:hover': { + backgroundColor: 'var(--dsw-alias-interactive-bg-hover-accent)', + }, + '.cm-panel.cm-search button.cm-button:active': { + backgroundColor: 'var(--dsw-alias-interactive-bg-active)', + backgroundImage: 'none', + }, + '.cm-panel.cm-search [name=close]': { + color: 'var(--dsw-alias-label-secondary)', + cursor: 'pointer', + }, + '.cm-panel.cm-search [name=close]:hover': { + color: 'var(--dsw-alias-label-primary)', + }, + '.cm-panel.cm-search label': { + color: 'var(--dsw-alias-label-secondary)', + }, +}) + +/** + * The scheme-independent part of the find-in-file wiring: the top-pinned + * panel, its theme, and the upstream search keymap. The localized phrases + * are added separately through {@link CmSearchPhrases} (they are the only + * piece a language switch reconfigures). + */ +export function cmSearchExtensions(): Extension[] { + return [search({ top: true }), cmSearchTheme, keymap.of(searchKeymap)] +} + +/** + * A Compartment holding the localized `phrases` facet. Created once per + * editor view; a language switch dispatches `reconfigure()` on it, which + * rebuilds the panel copy on its next open without touching the document, + * undo history, or keymaps. + */ +export class CmSearchPhrases { + private readonly compartment = new Compartment() + + /** `of(...)` payload for EditorState.create. */ + of(): Extension { + return this.compartment.of(EditorState.phrases.of(searchPhrases())) + } + + /** Re-resolve the panel copy for the current language. */ + reconfigure(): StateEffect { + return this.compartment.reconfigure(EditorState.phrases.of(searchPhrases())) + } +} diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 605f467c..d8389d1e 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -123,6 +123,21 @@ export const ar: Record = { unsaved: 'غير محفوظ', saveFailed: 'فشل الحفظ', truncation: 'الملف كبير جداً — عرض أول 512KB', + searchFind: 'بحث', + searchReplace: 'استبدال', + searchNext: 'التالي', + searchPrevious: 'السابق', + searchAll: 'تحديد الكل', + searchMatchCase: 'مطابقة حالة الأحرف', + searchWholeWord: 'كلمة كاملة', + searchRegexp: 'تعبير نمطي', + searchReplaceAll: 'استبدال الكل', + searchCurrentMatch: 'المطابقة الحالية', + searchOnLine: 'في السطر', + searchReplacedMatches: 'تم استبدال $ من المطابقات', + searchReplacedMatchOnLine: 'تم استبدال المطابقة في السطر $', + searchGoToLine: 'الانتقال إلى سطر', + searchGo: 'انتقال', binary: 'ملف ثنائي، المعاينة غير متاحة', loading: 'جارٍ التحميل…', error: 'فشل التحميل', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 0a3495c2..7b48cd12 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -108,6 +108,21 @@ export const de: Record = { unsaved: 'Nicht gespeichert', saveFailed: 'Speichern fehlgeschlagen', truncation: 'Datei zu groß – nur die ersten 512KB werden angezeigt', + searchFind: 'Suchen', + searchReplace: 'Ersetzen', + searchNext: 'Weiter', + searchPrevious: 'Zurück', + searchAll: 'Alle auswählen', + searchMatchCase: 'Groß-/Kleinschreibung beachten', + searchWholeWord: 'Ganzes Wort', + searchRegexp: 'Regexp', + searchReplaceAll: 'Alle ersetzen', + searchCurrentMatch: 'Aktuelle Übereinstimmung', + searchOnLine: 'in Zeile', + searchReplacedMatches: '$ Übereinstimmungen ersetzt', + searchReplacedMatchOnLine: 'Übereinstimmung in Zeile $ ersetzt', + searchGoToLine: 'Zu Zeile springen', + searchGo: 'Los', binary: 'Binärdatei, Vorschau nicht verfügbar', loading: 'Wird geladen…', error: 'Laden fehlgeschlagen', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 9e4c23c8..48569f54 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -115,6 +115,21 @@ export const fr: Record = { unsaved: 'Non enregistré', saveFailed: 'Échec de l’enregistrement', truncation: 'Fichier trop volumineux, seuls les 512 premiers Ko sont affichés', + searchFind: 'Rechercher', + searchReplace: 'Remplacer', + searchNext: 'Suivant', + searchPrevious: 'Précédent', + searchAll: 'Tout sélectionner', + searchMatchCase: 'Respecter la casse', + searchWholeWord: 'Mot entier', + searchRegexp: 'Regexp', + searchReplaceAll: 'Tout remplacer', + searchCurrentMatch: 'Correspondance actuelle', + searchOnLine: 'à la ligne', + searchReplacedMatches: '$ correspondances remplacées', + searchReplacedMatchOnLine: 'correspondance remplacée à la ligne $', + searchGoToLine: 'Aller à la ligne', + searchGo: 'Aller', binary: 'Fichier binaire, aperçu impossible', loading: 'Chargement…', error: 'Échec du chargement', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index f78e1c72..4e5a0e40 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -122,6 +122,21 @@ export const hi: Record = { unsaved: 'असहेजित', saveFailed: 'सहेजना विफल', truncation: 'फ़ाइल बहुत बड़ी — पहले 512KB दिखाए जा रहे', + searchFind: 'खोजें', + searchReplace: 'बदलें', + searchNext: 'अगला', + searchPrevious: 'पिछला', + searchAll: 'सभी चुनें', + searchMatchCase: 'केस का मिलान करें', + searchWholeWord: 'पूरा शब्द', + searchRegexp: 'रेगेक्स', + searchReplaceAll: 'सभी बदलें', + searchCurrentMatch: 'वर्तमान मिलान', + searchOnLine: 'पंक्ति में', + searchReplacedMatches: '$ मिलान बदले गए', + searchReplacedMatchOnLine: 'पंक्ति $ में मिलान बदला गया', + searchGoToLine: 'पंक्ति पर जाएँ', + searchGo: 'जाएँ', binary: 'बाइनरी फ़ाइल, पूर्वावलोकन अनुपलब्ध', loading: 'लोड हो रहा…', error: 'लोड विफल', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 33fc653a..8d9df02b 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -120,6 +120,21 @@ export const id: Record = { unsaved: 'Belum disimpan', saveFailed: 'Gagal menyimpan', truncation: 'Berkas terlalu besar — menampilkan 512KB pertama', + searchFind: 'Cari', + searchReplace: 'Ganti', + searchNext: 'Berikutnya', + searchPrevious: 'Sebelumnya', + searchAll: 'Pilih semua', + searchMatchCase: 'Bedakan huruf besar/kecil', + searchWholeWord: 'Seluruh kata', + searchRegexp: 'Regexp', + searchReplaceAll: 'Ganti semua', + searchCurrentMatch: 'Kecocokan saat ini', + searchOnLine: 'pada baris', + searchReplacedMatches: '$ kecocokan diganti', + searchReplacedMatchOnLine: 'kecocokan pada baris $ diganti', + searchGoToLine: 'Ke baris', + searchGo: 'Buka', binary: 'Berkas biner, pratinjau tidak tersedia', loading: 'Memuat…', error: 'Gagal memuat', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index f2629ce2..bcc1487d 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -113,6 +113,21 @@ export const it: Record = { unsaved: 'Non salvato', saveFailed: 'Salvataggio non riuscito', truncation: 'File troppo grande — vengono mostrati i primi 512KB', + searchFind: 'Trova', + searchReplace: 'Sostituisci', + searchNext: 'Avanti', + searchPrevious: 'Indietro', + searchAll: 'Seleziona tutto', + searchMatchCase: 'Distingui maiuscole/minuscole', + searchWholeWord: 'Parola intera', + searchRegexp: 'Regexp', + searchReplaceAll: 'Sostituisci tutto', + searchCurrentMatch: 'Corrispondenza corrente', + searchOnLine: 'alla riga', + searchReplacedMatches: '$ corrispondenze sostituite', + searchReplacedMatchOnLine: 'corrispondenza sostituita alla riga $', + searchGoToLine: 'Vai alla riga', + searchGo: 'Vai', binary: 'File binario, anteprima non disponibile', loading: 'Caricamento…', error: 'Caricamento non riuscito', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 7f91f50a..f9ee8e77 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -122,6 +122,21 @@ export const ja: Record = { unsaved: '未保存', saveFailed: '保存に失敗', truncation: 'ファイルが大きすぎます — 最初の 512KB のみ表示', + searchFind: '検索', + searchReplace: '置換', + searchNext: '次へ', + searchPrevious: '前へ', + searchAll: 'すべて選択', + searchMatchCase: '大文字と小文字を区別', + searchWholeWord: '単語単位', + searchRegexp: '正規表現', + searchReplaceAll: 'すべて置換', + searchCurrentMatch: '現在の一致', + searchOnLine: '行番号', + searchReplacedMatches: '$ 件を置換', + searchReplacedMatchOnLine: '$ 行目の一致を置換', + searchGoToLine: '行へ移動', + searchGo: '移動', binary: 'バイナリファイル、プレビュー不可', loading: '読み込み中…', error: '読み込みに失敗', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index f27bf130..cbc12265 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -114,6 +114,21 @@ export const ko: Record = { unsaved: '저장 안 됨', saveFailed: '저장 실패', truncation: '파일이 너무 커 앞의 512KB만 표시합니다', + searchFind: '찾기', + searchReplace: '바꾸기', + searchNext: '다음', + searchPrevious: '이전', + searchAll: '모두 선택', + searchMatchCase: '대소문자 구분', + searchWholeWord: '단어 단위', + searchRegexp: '정규식', + searchReplaceAll: '모두 바꾸기', + searchCurrentMatch: '현재 일치', + searchOnLine: '행', + searchReplacedMatches: '$개 일치 항목을 바꿨습니다', + searchReplacedMatchOnLine: '$행의 일치 항목을 바꿨습니다', + searchGoToLine: '줄로 이동', + searchGo: '이동', binary: '이진 파일이라 미리보기를 할 수 없습니다', loading: '불러오는 중…', error: '불러오기 실패', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 96d976ec..0dc35ae9 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -120,6 +120,21 @@ export const nl: Record = { unsaved: 'Niet opgeslagen', saveFailed: 'Opslaan mislukt', truncation: 'Bestand te groot — alleen de eerste 512KB worden getoond', + searchFind: 'Zoeken', + searchReplace: 'Vervangen', + searchNext: 'Volgende', + searchPrevious: 'Vorige', + searchAll: 'Alles selecteren', + searchMatchCase: 'Hoofdlettergevoelig', + searchWholeWord: 'Heel woord', + searchRegexp: 'Regexp', + searchReplaceAll: 'Alles vervangen', + searchCurrentMatch: 'Huidige overeenkomst', + searchOnLine: 'op regel', + searchReplacedMatches: '$ overeenkomsten vervangen', + searchReplacedMatchOnLine: 'overeenkomst op regel $ vervangen', + searchGoToLine: 'Naar regel', + searchGo: 'Ga', binary: 'Binair bestand, geen voorbeeld beschikbaar', loading: 'Laden…', error: 'Laden mislukt', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index f9d5999b..d067d280 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -124,6 +124,21 @@ export const pl: Record = { unsaved: 'Niezapisane', saveFailed: 'Zapis nie powiódł się', truncation: 'Plik zbyt duży — pokazuję pierwsze 512 KB', + searchFind: 'Szukaj', + searchReplace: 'Zamień', + searchNext: 'Następny', + searchPrevious: 'Poprzedni', + searchAll: 'Zaznacz wszystko', + searchMatchCase: 'Uwzględnij wielkość liter', + searchWholeWord: 'Całe słowo', + searchRegexp: 'Regexp', + searchReplaceAll: 'Zamień wszystko', + searchCurrentMatch: 'Bieżące dopasowanie', + searchOnLine: 'w wierszu', + searchReplacedMatches: 'zamieniono $ dopasowań', + searchReplacedMatchOnLine: 'zamieniono dopasowanie w wierszu $', + searchGoToLine: 'Przejdź do wiersza', + searchGo: 'Przejdź', binary: 'Plik binarny, podgląd niedostępny', loading: 'Ładowanie…', error: 'Ładowanie nie powiodło się', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 03e022a5..934a73f6 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -105,6 +105,21 @@ export const pt: Record = { unsaved: 'Não salvo', saveFailed: 'Falha ao salvar', truncation: 'Arquivo grande demais — exibindo os primeiros 512KB', + searchFind: 'Localizar', + searchReplace: 'Substituir', + searchNext: 'Seguinte', + searchPrevious: 'Anterior', + searchAll: 'Selecionar tudo', + searchMatchCase: 'Diferenciar maiúsculas', + searchWholeWord: 'Palavra inteira', + searchRegexp: 'Regexp', + searchReplaceAll: 'Substituir tudo', + searchCurrentMatch: 'Correspondência atual', + searchOnLine: 'na linha', + searchReplacedMatches: '$ correspondências substituídas', + searchReplacedMatchOnLine: 'correspondência substituída na linha $', + searchGoToLine: 'Ir para a linha', + searchGo: 'Ir', binary: 'Arquivo binário, pré-visualização indisponível', loading: 'Carregando…', error: 'Falha ao carregar', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index c75518c7..62d917ec 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -118,6 +118,21 @@ export const ru: Record = { unsaved: 'Не сохранено', saveFailed: 'Не удалось сохранить', truncation: 'Файл слишком велик — показаны первые 512 КБ', + searchFind: 'Найти', + searchReplace: 'Заменить', + searchNext: 'Далее', + searchPrevious: 'Назад', + searchAll: 'Выделить все', + searchMatchCase: 'Учитывать регистр', + searchWholeWord: 'Слово целиком', + searchRegexp: 'Регулярное выражение', + searchReplaceAll: 'Заменить все', + searchCurrentMatch: 'Текущее совпадение', + searchOnLine: 'в строке', + searchReplacedMatches: 'заменено совпадений: $', + searchReplacedMatchOnLine: 'совпадение в строке $ заменено', + searchGoToLine: 'Перейти к строке', + searchGo: 'Перейти', binary: 'Бинарный файл — предпросмотр недоступен', loading: 'Загрузка…', error: 'Ошибка загрузки', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 5b08a7b1..6758fb37 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -105,6 +105,21 @@ export const sv: Record = { unsaved: 'Osparad', saveFailed: 'Kunde inte spara', truncation: 'Filen är för stor — visar de första 512KB', + searchFind: 'Sök', + searchReplace: 'Ersätt', + searchNext: 'Nästa', + searchPrevious: 'Föregående', + searchAll: 'Markera alla', + searchMatchCase: 'Matcha skiftläge', + searchWholeWord: 'Hela ordet', + searchRegexp: 'Regexp', + searchReplaceAll: 'Ersätt alla', + searchCurrentMatch: 'Aktuell träff', + searchOnLine: 'på rad', + searchReplacedMatches: '$ träffar ersatta', + searchReplacedMatchOnLine: 'träff på rad $ ersatt', + searchGoToLine: 'Gå till rad', + searchGo: 'Gå', binary: 'Binärfil, förhandsgranskning otillgänglig', loading: 'Laddar…', error: 'Kunde inte ladda', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 009ceacb..122d487c 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -122,6 +122,21 @@ export const th: Record = { unsaved: 'ยังไม่ได้บันทึก', saveFailed: 'บันทึกล้มเหลว', truncation: 'ไฟล์ใหญ่เกินไป — แสดงเพียง 512KB แรก', + searchFind: 'ค้นหา', + searchReplace: 'แทนที่', + searchNext: 'ถัดไป', + searchPrevious: 'ก่อนหน้า', + searchAll: 'เลือกทั้งหมด', + searchMatchCase: 'จับคู่ตัวพิมพ์', + searchWholeWord: 'ทั้งคำ', + searchRegexp: 'รีเจกซ์', + searchReplaceAll: 'แทนที่ทั้งหมด', + searchCurrentMatch: 'ที่ตรงกันปัจจุบัน', + searchOnLine: 'ในบรรทัด', + searchReplacedMatches: 'แทนที่แล้ว $ รายการ', + searchReplacedMatchOnLine: 'แทนที่รายการในบรรทัด $ แล้ว', + searchGoToLine: 'ไปยังบรรทัด', + searchGo: 'ไป', binary: 'ไฟล์ไบนารี ไม่สามารถพรีวิวได้', loading: 'กำลังโหลด…', error: 'โหลดล้มเหลว', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 633374a4..d24b25c9 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -122,6 +122,21 @@ export const tr: Record = { unsaved: 'Kaydedilmedi', saveFailed: 'Kaydetme başarısız', truncation: 'Dosya çok büyük — ilk 512KB gösteriliyor', + searchFind: 'Bul', + searchReplace: 'Değiştir', + searchNext: 'Sonraki', + searchPrevious: 'Önceki', + searchAll: 'Tümünü seç', + searchMatchCase: 'Büyük/küçük harf eşleştir', + searchWholeWord: 'Tam sözcük', + searchRegexp: 'Regexp', + searchReplaceAll: 'Tümünü değiştir', + searchCurrentMatch: 'Geçerli eşleşme', + searchOnLine: 'satırda', + searchReplacedMatches: '$ eşleşme değiştirildi', + searchReplacedMatchOnLine: '$ satırındaki eşleşme değiştirildi', + searchGoToLine: 'Satıra git', + searchGo: 'Git', binary: 'İkili dosya, önizleme kullanılamıyor', loading: 'Yükleniyor…', error: 'Yükleme başarısız', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 03df336a..fcb1bccd 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -122,6 +122,21 @@ export const vi: Record = { unsaved: 'Chưa lưu', saveFailed: 'Lưu thất bại', truncation: 'Tệp quá lớn, chỉ hiển thị 512KB đầu', + searchFind: 'Tìm', + searchReplace: 'Thay thế', + searchNext: 'Tiếp theo', + searchPrevious: 'Trước đó', + searchAll: 'Chọn tất cả', + searchMatchCase: 'Phân biệt chữ hoa chữ thường', + searchWholeWord: 'Toàn bộ từ', + searchRegexp: 'Regexp', + searchReplaceAll: 'Thay thế tất cả', + searchCurrentMatch: 'Kết quả khớp hiện tại', + searchOnLine: 'tại dòng', + searchReplacedMatches: 'đã thay thế $ kết quả khớp', + searchReplacedMatchOnLine: 'đã thay thế kết quả khớp tại dòng $', + searchGoToLine: 'Đi tới dòng', + searchGo: 'Đi', binary: 'Tệp nhị phân, không xem trước được', loading: 'Đang tải…', error: 'Tải thất bại', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index ba55f2ec..6722e7db 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -137,6 +137,21 @@ export const zhHK: Record = { unsaved: '未儲存', saveFailed: '儲存失敗', truncation: '檔案過大,僅顯示前 512KB', + searchFind: '尋找', + searchReplace: '取代', + searchNext: '下一個', + searchPrevious: '上一個', + searchAll: '全選相符項', + searchMatchCase: '區分大小寫', + searchWholeWord: '全字匹配', + searchRegexp: '正則表達式', + searchReplaceAll: '全部取代', + searchCurrentMatch: '目前符合項', + searchOnLine: '所在行號', + searchReplacedMatches: '已取代 $ 處相符項', + searchReplacedMatchOnLine: '已取代第 $ 行的相符項', + searchGoToLine: '跳至行', + searchGo: '跳至', binary: '二進位檔案,無法預覽', loading: '載入中…', error: '載入失敗', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 21de4075..f86315ba 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -137,6 +137,21 @@ export const zhMO: Record = { unsaved: '未儲存', saveFailed: '儲存失敗', truncation: '檔案過大,僅顯示前 512KB', + searchFind: '尋找', + searchReplace: '取代', + searchNext: '下一個', + searchPrevious: '上一個', + searchAll: '全選相符項', + searchMatchCase: '區分大小寫', + searchWholeWord: '全字匹配', + searchRegexp: '正規表達式', + searchReplaceAll: '全部取代', + searchCurrentMatch: '目前相符項', + searchOnLine: '所在行號', + searchReplacedMatches: '已取代 $ 處相符項', + searchReplacedMatchOnLine: '已取代第 $ 行的相符項', + searchGoToLine: '跳至行', + searchGo: '跳至', binary: '二進位檔案,無法預覽', loading: '載入中…', error: '載入失敗', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index a1dfd608..1279af89 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -137,6 +137,21 @@ export const zhTW: Record = { unsaved: '未儲存', saveFailed: '儲存失敗', truncation: '檔案過大,僅顯示前 512KB', + searchFind: '尋找', + searchReplace: '取代', + searchNext: '下一個', + searchPrevious: '上一個', + searchAll: '選取全部', + searchMatchCase: '區分大小寫', + searchWholeWord: '全字匹配', + searchRegexp: '正規表達式', + searchReplaceAll: '全部取代', + searchCurrentMatch: '目前相符項', + searchOnLine: '所在行號', + searchReplacedMatches: '已取代 $ 處相符項', + searchReplacedMatchOnLine: '已取代第 $ 行的相符項', + searchGoToLine: '跳至行', + searchGo: '跳至', binary: '二進位檔案,無法預覽', loading: '載入中…', error: '載入失敗', diff --git a/src/client/locales.ts b/src/client/locales.ts index 07fca34d..83f5734c 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -133,6 +133,21 @@ export const zh = { unsaved: '未保存', saveFailed: '保存失败', truncation: '文件过大,仅显示前 512KB', + searchFind: '查找', + searchReplace: '替换', + searchNext: '下一个', + searchPrevious: '上一个', + searchAll: '全选匹配', + searchMatchCase: '区分大小写', + searchWholeWord: '全词匹配', + searchRegexp: '正则表达式', + searchReplaceAll: '全部替换', + searchCurrentMatch: '当前匹配', + searchOnLine: '所在行号', + searchReplacedMatches: '已替换 $ 处匹配', + searchReplacedMatchOnLine: '已替换第 $ 行的匹配', + searchGoToLine: '跳转到行', + searchGo: '跳转', binary: '二进制文件,无法预览', loading: '加载中…', error: '加载失败', @@ -582,6 +597,21 @@ export const en: Record = { unsaved: 'Unsaved', saveFailed: 'Save failed', truncation: 'File too large — showing the first 512KB', + searchFind: 'Find', + searchReplace: 'Replace', + searchNext: 'next', + searchPrevious: 'previous', + searchAll: 'all', + searchMatchCase: 'match case', + searchWholeWord: 'by word', + searchRegexp: 'regexp', + searchReplaceAll: 'replace all', + searchCurrentMatch: 'current match', + searchOnLine: 'on line', + searchReplacedMatches: 'replaced $ matches', + searchReplacedMatchOnLine: 'replaced match on line $', + searchGoToLine: 'Go to line', + searchGo: 'go', binary: 'Binary file, preview unavailable', loading: 'Loading…', error: 'Failed to load', @@ -1037,6 +1067,23 @@ export function isZh(): boolean { return activeLocale().toLowerCase().startsWith('zh') } +/** + * A signature of the effective UI language: the DSH locale service's active + * id plus the better-locale override id when one is actually in force (the + * override only borrows DSH's `en` slot, so it is inert while DSH is on + * `zh`). `t()` resolves copy from exactly these two inputs, so a component + * that caches localized text OUTSIDE React state — e.g. CodeMirror's + * `phrases` facet, which is baked into an EditorState — can use this string + * as an effect dependency to know when to re-resolve it. + */ +export function localeSignature(): string { + const dshActive = localeService?.getSnapshot().active ?? '' + const override = betterLocaleStore?.isOverrideActive(dshActive) === true + ? betterLocaleStore.active ?? '' + : '' + return `${dshActive}:${override}` +} + /** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */ export function relativeTime(iso: string): string { const then = Date.parse(iso) diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts index 126fc6ca..107133cc 100644 --- a/tests/e2e/mount.e2e.ts +++ b/tests/e2e/mount.e2e.ts @@ -411,6 +411,21 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ).toHaveCount(1) const pathInput = sidebar.locator('input[placeholder^="File path"]:visible') await expect(pathInput, 'the file tab header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`)) + // Find-in-file (Cmd/Ctrl+F): the plain-text tab is a code viewer with no + // edit toggle — the editor is mounted in its "preview" (read-mostly) + // surface, which must search too. The extension rides the SHARED base + // extension list, so this proves the search panel opens in a real browser + // against the lazily-loaded editor chunk (the jsdom spec covers the unit + // level). Mod is Cmd on macOS, Ctrl everywhere else. + await sidebar.locator('.cm-content:visible').first().click() + await page.keyboard.press(`${process.platform === 'darwin' ? 'Meta' : 'Control'}+f`) + const searchPanel = sidebar.locator('.cm-panels-top .cm-search') + await expect( + searchPanel, + 'Cmd/Ctrl+F must open the top-pinned search panel in the code viewer', + ).toHaveCount(1, { timeout: 10_000 }) + await page.keyboard.press('Escape') + await expect(searchPanel, 'Escape must close the search panel').toHaveCount(0, { timeout: 10_000 }) await page.waitForTimeout(1_500) await assertNoCrash() diff --git a/tests/editor-find.spec.tsx b/tests/editor-find.spec.tsx new file mode 100644 index 00000000..88b0e121 --- /dev/null +++ b/tests/editor-find.spec.tsx @@ -0,0 +1,151 @@ +/** + * Find-in-file spec (Cmd/Ctrl+F): the editor wires CodeMirror's search + * extension into EVERY TextEditor view — the extension list is shared by the + * code and markdown viewers, so "preview" (read-mostly) and "edit" mode both + * get the panel, and the read-only markdown preview cannot be the only mode + * without it. Covers the three promises of the feature: + * + * - the extensions carry the upstream search extension + keymap (Mod-f + * open, Mod-g / Shift-Mod-g next/previous, Escape close), + * - Mod-f really opens the TOP-pinned panel in a mounted editor, and Escape + * closes it (the editor keymap's own Escape binding — `simplifySelection` + * — must not shadow it), + * - the panel copy follows the plugin dictionary: it opens in the active + * language and re-resolves after a locale switch (the phrases facet is + * baked into the EditorState, so this proves the compartment + * reconfigure), while the editor's own Mod-s save binding still fires. + */ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { act } from 'react-dom/test-utils' +import { EditorState } from '@codemirror/state' +import { keymap } from '@codemirror/view' +import './browser-globals.ts' +import { renderRoot, setupReactAct } from './test-utils.ts' +import { TextEditor } from '../src/client/TextEditor.tsx' +import { cmSearchExtensions } from '../src/client/cm-search.ts' +import { createSidebarStore } from '../src/client/state.ts' +import { attachLocale } from '../src/client/locales.ts' +import { api } from '../src/client/api.ts' +import type { FileViewerProps } from '../src/client/service.ts' + +setupReactAct() + +/** Minimal structural fake of the DSH LocaleService face (live-switchable). */ +class FakeLocale { + active = 'zh' + private readonly listeners = new Set<() => void>() + + getSnapshot(): { active: string } { + return { active: this.active } + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** Switch the active locale and notify subscribers (the DSH live switch). */ + switchTo(id: string): void { + this.active = id + for (const listener of this.listeners) listener() + } +} + +let locale: FakeLocale + +beforeEach(() => { + locale = new FakeLocale() + attachLocale(locale) +}) + +afterEach(() => { + attachLocale(undefined) + vi.restoreAllMocks() + document.body.innerHTML = '' +}) + +/** Mount the real editor for a plain-text file (the code viewer). */ +function mountEditor(): { container: HTMLDivElement; unmount: () => void } { + const props: FileViewerProps = { + ctx: { locale, get: () => undefined } as unknown as FileViewerProps['ctx'], + store: createSidebarStore(), + scope: { sessionId: 's1', cwd: '/p' }, + path: '/p/hello.txt', + title: 'hello.txt', + viewerId: 'code', + content: 'alpha\nbeta\nalpha\n', + } + return renderRoot(createElement(TextEditor, props)) +} + +/** Dispatch one keydown on `target` (jsdom is non-mac, so Mod = Ctrl). */ +function press(target: Element, key: string, init: KeyboardEventInit = {}): void { + act(() => { + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })) + }) +} + +/** The editor's content DOM (CodeMirror's keydown listener target). */ +function contentOf(container: HTMLElement): Element { + const content = container.querySelector('.cm-content') + if (content === null) throw new Error('the mounted editor has no .cm-content') + return content +} + +/** The open search panel's search field (null while the panel is closed). */ +function searchField(container: HTMLElement): HTMLInputElement | null { + return container.querySelector('.cm-search input[name=search]') +} + +describe('editor find-in-file (Cmd/Ctrl+F)', () => { + it('carries the search extension and the upstream search keymap', () => { + const state = EditorState.create({ doc: 'alpha', extensions: cmSearchExtensions() }) + const keys = state.facet(keymap).flatMap(bindings => bindings.map(binding => binding.key)) + // Open, next/previous, go-to-line, close — the upstream searchKeymap. + expect(keys).toContain('Mod-f') + expect(keys).toContain('Mod-g') + expect(keys).toContain('Mod-Alt-g') + expect(keys).toContain('Escape') + }) + + it('opens the top-pinned panel on Mod-f and closes it with Escape', () => { + const { container, unmount } = mountEditor() + expect(searchField(container), 'no panel before the shortcut').toBeNull() + + press(contentOf(container), 'f', { ctrlKey: true }) + const panel = container.querySelector('.cm-panels.cm-panels-top .cm-search') + expect(panel, 'Mod-f must open the search panel pinned to the editor top').not.toBeNull() + // The panel copy comes from the plugin dictionary (zh is the active one). + expect(searchField(container)?.placeholder).toBe('查找') + + press(searchField(container)!, 'Escape') + expect(searchField(container), 'Escape must close the panel').toBeNull() + unmount() + }) + + it('keeps the editor\'s own Mod-s save binding working', () => { + const write = vi.spyOn(api, 'fsWrite').mockResolvedValue({ ok: true }) + const { container, unmount } = mountEditor() + + press(contentOf(container), 's', { ctrlKey: true }) + expect(write, 'the editor keymap still owns Mod-s').toHaveBeenCalledTimes(1) + unmount() + }) + + it('re-resolves the panel copy after a live locale switch', () => { + const { container, unmount } = mountEditor() + + press(contentOf(container), 'f', { ctrlKey: true }) + expect(searchField(container)?.placeholder).toBe('查找') + press(searchField(container)!, 'Escape') + + // The phrases facet is part of the EditorState: without the compartment + // reconfigure the reopened panel would still show the old language. + act(() => { locale.switchTo('en') }) + press(contentOf(container), 'f', { ctrlKey: true }) + expect(searchField(container)?.placeholder).toBe('Find') + unmount() + }) +})