Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 右键菜单 | 关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签(当前标签组) |
Expand Down
1 change: 1 addition & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
53 changes: 52 additions & 1 deletion src/client/TextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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<CmThemeCompartment | null>(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<CmSearchPhrases | null>(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). */
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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: [
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
141 changes: 141 additions & 0 deletions src/client/cm-search.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<unknown> {
return this.compartment.reconfigure(EditorState.phrases.of(searchPhrases()))
}
}
15 changes: 15 additions & 0 deletions src/client/locales-ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ export const ar: Record<string, string> = {
unsaved: 'غير محفوظ',
saveFailed: 'فشل الحفظ',
truncation: 'الملف كبير جداً — عرض أول 512KB',
searchFind: 'بحث',
searchReplace: 'استبدال',
searchNext: 'التالي',
searchPrevious: 'السابق',
searchAll: 'تحديد الكل',
searchMatchCase: 'مطابقة حالة الأحرف',
searchWholeWord: 'كلمة كاملة',
searchRegexp: 'تعبير نمطي',
searchReplaceAll: 'استبدال الكل',
searchCurrentMatch: 'المطابقة الحالية',
searchOnLine: 'في السطر',
searchReplacedMatches: 'تم استبدال $ من المطابقات',
searchReplacedMatchOnLine: 'تم استبدال المطابقة في السطر $',
searchGoToLine: 'الانتقال إلى سطر',
searchGo: 'انتقال',
binary: 'ملف ثنائي، المعاينة غير متاحة',
loading: 'جارٍ التحميل…',
error: 'فشل التحميل',
Expand Down
15 changes: 15 additions & 0 deletions src/client/locales-de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,21 @@ export const de: Record<string, string> = {
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',
Expand Down
15 changes: 15 additions & 0 deletions src/client/locales-fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ export const fr: Record<string, string> = {
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',
Expand Down
15 changes: 15 additions & 0 deletions src/client/locales-hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ export const hi: Record<string, string> = {
unsaved: 'असहेजित',
saveFailed: 'सहेजना विफल',
truncation: 'फ़ाइल बहुत बड़ी — पहले 512KB दिखाए जा रहे',
searchFind: 'खोजें',
searchReplace: 'बदलें',
searchNext: 'अगला',
searchPrevious: 'पिछला',
searchAll: 'सभी चुनें',
searchMatchCase: 'केस का मिलान करें',
searchWholeWord: 'पूरा शब्द',
searchRegexp: 'रेगेक्स',
searchReplaceAll: 'सभी बदलें',
searchCurrentMatch: 'वर्तमान मिलान',
searchOnLine: 'पंक्ति में',
searchReplacedMatches: '$ मिलान बदले गए',
searchReplacedMatchOnLine: 'पंक्ति $ में मिलान बदला गया',
searchGoToLine: 'पंक्ति पर जाएँ',
searchGo: 'जाएँ',
binary: 'बाइनरी फ़ाइल, पूर्वावलोकन अनुपलब्ध',
loading: 'लोड हो रहा…',
error: 'लोड विफल',
Expand Down
15 changes: 15 additions & 0 deletions src/client/locales-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ export const id: Record<string, string> = {
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',
Expand Down
15 changes: 15 additions & 0 deletions src/client/locales-it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ export const it: Record<string, string> = {
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',
Expand Down
Loading