diff --git a/.claude/skills/lich-changelog-i18n/SKILL.md b/.claude/skills/lich-changelog-i18n/SKILL.md new file mode 100644 index 00000000..9541140e --- /dev/null +++ b/.claude/skills/lich-changelog-i18n/SKILL.md @@ -0,0 +1,71 @@ +--- +name: lich-changelog-i18n +description: Translate the latest release's CHANGELOG.md section into every interface language lich ships, writing changelog/.md, the files the What's new popup reads. Use when cutting a release (right after its section moves out of [Unreleased]), when TestTranslations in internal/patchnotes fails, or when a language is added. +--- + +# lich changelog translations + +The What's new popup shows the running build's `CHANGELOG.md` section (`internal/patchnotes`). A user whose +interface language is not English reads it from `changelog/.md` instead, embedded in the binary +next to `CHANGELOG.md`. Each file holds **only the latest release**: a build only ever reads its own version, +and git keeps the old translations. `CHANGELOG.md` stays English and canonical; the release page reads it. + +## When + +Cutting a release (`CLAUDE.md` › Releases): once the section is under its `## [X.Y.Z] - YYYY-MM-DD` heading and +its text is final, run this before tagging, in the same commit. A fix to the released English text afterwards +means running it again. + +## Steps + +1. **Slice the source.** The release is the first `## [` heading in `CHANGELOG.md` below `## [Unreleased]`. + Its section runs from that heading to the next `## ` heading. +2. **One file per locale, except English.** The locales are `prompt.Langs` (`internal/prompt/prompt.go`), + file names are their tags as written there: `pt-BR.md`, `es.md`, `zh-CN.md`, `ja.md`, `ko.md`, `ru.md`. + A language added to lich gets its file in the same PR. +3. **Overwrite each file** with the translated section and nothing else: no `# Changelog` title, no + `[Unreleased]`, no older release, no compare links. +4. **Translate from the English**, never from another translation. The locales are independent, so each + one can go to its own subagent in parallel, on a cheap model (Sonnet); hand each the English section and + the rules below. +5. **Run the check** and finish only when it is green (needs nothing built): + `go test -run TestTranslations ./internal/patchnotes` + +## What stays in English, byte for byte + +The parser and both windows read these, so `TestTranslations` fails when one is touched: + +- The version heading, date included: `## [0.69.0] - 2026-10-16`. +- The group headings: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, + `### Security`. The windows translate the label on screen and color it by the English word. +- The alert openers: `> [!IMPORTANT]`, `> [!WARNING]`, `> [!NOTE]`. The text under them is translated, each + line still starting with `> `. +- Every `code` span, exactly as written. Their order inside an item may follow the language's grammar. + +## How to translate + +- **Same structure.** Same groups in the same order, same number of `- ` items in each, same alert blocks. + An item may wrap over as many lines as it needs; a blank line between items is fine; a blank line inside + an item ends it. +- **The bold lead-in stays bold.** `**The native window opens on Windows.**` becomes one `**…**` in the + translation, and the rest of the item follows it as plain text. No bold, italics or links the English + does not have. +- **Names stay names:** lich, the providers (Claude Code, Codex, Antigravity, opencode, oh-my-pi, Crush, + Cursor CLI, Kiro CLI), OS and app names (Linux, Windows, macOS, Wayland, Terminal.app), commands and key + names, plus the rest of `lich-i18n`'s literal list. +- **Interface words match the interface.** A setting, menu or button named in the English (Settings › + Appearance › Window, Remove worktree, Undo) is written the way that language's catalog shows it on + screen: look it up in `native/i18n/locales//` or `frontend/src/lib/i18n/locales//` instead of + translating it afresh. `›` stays as the path separator. +- **Plain release-note register** for that language, written for a developer: what changed for them, in + the same tone and length as the English. Do not add, drop or explain. + +## If the check fails + +- `version headings = …`: the file holds another release, or more than one. Rewrite it from step 1. +- `N groups` / `N items`: an item was merged, split or lost, often a blank line inside an item. +- `group N is "…"`: a `### ` heading was translated. +- `code spans …` or `bold markers …`: a code span was changed or a `**` was lost; the message prints the + item. + +The check is the contract. Fix the translation, never the test. diff --git a/.claude/skills/lich-i18n/SKILL.md b/.claude/skills/lich-i18n/SKILL.md index f48be0d6..f277b55e 100644 --- a/.claude/skills/lich-i18n/SKILL.md +++ b/.claude/skills/lich-i18n/SKILL.md @@ -31,7 +31,8 @@ Classify every string first. Who reads it? | An agent, typed by the frontend into a PTY | `locales//prompts.ts`, read with `prompt()` from `lib/i18n/prompt.ts` | prompt | | An agent, typed by the native window into a PTY | `native/i18n/locales//prompts.json`, read with `i18n.Prompt()` | prompt | | An agent, added by a lich-plugin mod | `hooks/prompt-text.js` in `lichdotdev/lich-plugin` | prompt | -| Nobody localizes it | error text through `errorText()`, CHANGELOG, MCP tool descriptions, CLI `--help`, identifiers | none | +| The user, in the What's new dialog | `changelog/.md`, one release, written by the `lich-changelog-i18n` skill when a release is cut | interface | +| Nobody localizes it | error text through `errorText()`, `CHANGELOG.md`, MCP tool descriptions, CLI `--help`, identifiers | none | Then, in the same PR: @@ -112,6 +113,8 @@ language at the **end** of every list so parallel additions merge as a union. **Docs** - `docs/hooks/README.md`: the accepted `LICH_PROMPT_LANG` values. - `CHANGELOG.md` `[Unreleased]` › Added. +- `changelog/.md`: the latest release translated with the `lich-changelog-i18n` skill; + `TestTranslations` (`internal/patchnotes`) fails until it exists. - README.md and each README..md only if they enumerate languages. **Companion repo** `lichdotdev/lich-plugin` (separate PR, after lich's) diff --git a/.gitignore b/.gitignore index 6d2ca642..33283f5a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ frontend/coverage .claude/skills/* !.claude/skills/lich-i18n/ !.claude/skills/lich-build-dev/ +!.claude/skills/lich-changelog-i18n/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8e64d0..d8d0c021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **What's new speaks your language.** The dialog that opens after an update + shows the release notes, and their group names, in the interface language + you picked, in both windows. A build whose notes have no translation yet + shows them in English. - **Fork a session in the native window.** Right-click a card and pick Fork › Here to carry its conversation on in a new card right under it, in the same checkout, or Fork › Worktree… to take it to a new worktree along diff --git a/CLAUDE.md b/CLAUDE.md index bb6ab0e2..f06fefe2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,8 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here. (`internal/terminal`). - No literal user-visible or agent-facing text: a string a person reads on a lich screen, or that lich types into a session, goes through the catalogs and lands in **every** locale in the same PR (`lich-i18n` skill). - English is the source; tests stay English. Error text, CHANGELOG, MCP tool descriptions and CLI help stay - English by design. + English is the source; tests stay English. Error text, `CHANGELOG.md`, MCP tool descriptions and CLI help + stay English by design; the What's new dialog reads a release's translations from `changelog/.md`. - Service shapes are hand-owned in `frontend/src/lib/api-types.ts`: touch a Go struct's JSON tags and that mirror moves in the same change. There is no codegen. - A fact more than one place shows (git status, a pull request, plan quota, a session's profile or status) has @@ -119,6 +119,9 @@ The version comes from the git tag (`git describe` in the Taskfile, env `VERSION greps `^## \[X.Y.Z\]` and ships empty notes if it misses. Keep the sections in their canonical order (Added, Changed, Deprecated, Removed, Fixed, Security), and refresh the compare links, `[Unreleased]` included. +- [ ] Translate the cut section for the What's new dialog with the `lich-changelog-i18n` skill, alert blocks + included, in the same commit: `TestTranslations` (`internal/patchnotes`) fails until every + `changelog/.md` holds this release. - [ ] Want the release to announce itself? Write GitHub alert blocks right under its heading, before the first `###`: `> [!IMPORTANT]` is the release's headline and opens the What's new dialog (one per release — a second one is demoted to a callout, which is the signal to fold it into the groups); `> [!WARNING]` is diff --git a/changelog/es.md b/changelog/es.md new file mode 100644 index 00000000..980cda4a --- /dev/null +++ b/changelog/es.md @@ -0,0 +1,51 @@ +## [0.69.0] - 2026-10-16 + +### Added + +- **La ventana nativa en macOS, experimental.** Lich.app la incluye junto a lich + en Apple Silicon e Intel, y Ajustes › Apariencia › Ventana la ofrece igual + que en Linux y Windows. Option escribe caracteres en su terminal, como en + Terminal.app, así que los atajos Option+letra de un agente no llegan hasta + ella. Por ahora solo se ha ejecutado en las máquinas macOS de GitHub: las + notificaciones son simples y no se puede hacer clic en ellas, y no hay + elemento en la barra de menús. +- **El árbol de archivos reconoce muchos más tipos de archivo.** Java, Gradle, + C y C++, C#, PHP, Ruby, Swift, SQL, Vue, Svelte, Terraform, Dockerfile, + .gitignore y unos treinta más ahora muestran su propio icono en el árbol de + archivos y en los resultados de búsqueda, donde antes mostraban un glifo de + archivo genérico. + +### Fixed + +- **`lich quit` cierra la ventana nativa.** Se quedaba abierta mostrando lich + como perdido una vez que el backend había terminado; Salir de lich en la + bandeja y una señal de parada también la cierran, mientras que un reinicio + tras una actualización sigue conservándola. +- **La ventana nativa ofrece eliminar una worktree cuando se cierra su última sesión.** El + aviso Se cerró gana Eliminar worktree junto a Deshacer; una worktree que lich no creó, + o una con cambios sin confirmar, se nombra en un diálogo antes de eliminarla. +- **El terminal de la ventana nativa toma la celda bajo el puntero.** Un clic, + una selección con arrastre, un enlace y un programa que sigue el ratón leían + todos el puntero desplazado por el relleno del terminal, así que la celda + que tomaban quedaba por debajo y a la derecha de la señalada, por casi una + celda entera en una pantalla escalada. +- **El terminal de la ventana nativa dibuja emoji.** Una fuente de emoji a + color como Noto Color Emoji guarda cada emoji como una imagen y no como un + contorno, y el terminal solo dibujaba contornos, así que cada emoji aparecía + como un hueco en blanco; ahora se dibujan a color, ocupando dos celdas. +- **El árbol de archivos de la ventana nativa muestra el icono de lenguaje de cada archivo.** + Todos los archivos dibujaban el mismo glifo de archivo genérico; ahora cada + uno lleva el icono que le da la ventana del navegador. +- **Un pull request que abre un agente aparece en su tarjeta al terminar el turno.** + La insignia solo volvía a consultarse cuando cambiaba la rama o su último + commit, y abrir un pull request no cambia ninguno de los dos, así que + esperaba a que la ventana perdiera y recuperara el foco. +- **La ventana nativa se abre en Windows.** Buscaba las fuentes mediante + fontconfig, que Windows no incluye, así que se cerraba al iniciar y lich + recurría a la ventana web. Ahora, en Windows, encuentra por sí misma las + fuentes instaladas, con Cascadia Mono o Consolas para el terminal por + defecto, y los selectores de fuente de Ajustes las listan. +- **Cambiar entre un tema claro y uno oscuro ya no congela la ventana + nativa.** A una sesión que sigue el esquema de color del terminal, como hace + Claude Code, se le avisaba del cambio mientras la ventana todavía conservaba + sus terminales, así que la ventana se colgaba y se cerraba. diff --git a/changelog/ja.md b/changelog/ja.md new file mode 100644 index 00000000..5dc2a985 --- /dev/null +++ b/changelog/ja.md @@ -0,0 +1,18 @@ +## [0.69.0] - 2026-10-16 + +### Added + +- **macOS でネイティブウィンドウを利用可能に(実験的)。** Lich.app は Apple Silicon と Intel の両方で lich と並べてネイティブウィンドウを同梱しており、Linux や Windows と同じく設定 › 外観 › ウィンドウから選べます。ターミナルでは Option キーが文字入力に使われます(Terminal.app と同じ動作)。そのため、エージェントの Option+文字 のショートカットは届きません。これまでのところ GitHub の macOS マシンでしか動作しておらず、通知はクリックできない通常の通知で、メニューバー項目もありません。 +- **ファイルツリーが対応するファイル形式が大幅に増えました。** Java、Gradle、C と C++、C#、PHP、Ruby、Swift、SQL、Vue、Svelte、Terraform、Dockerfile、.gitignore のほか約 30 種類が、これまでの無地のファイルアイコンに代わって、ファイルツリーと検索結果で専用のアイコンで表示されます。 + +### Fixed + +- **`lich quit` がネイティブウィンドウを閉じるようになりました。** バックエンドが終了したあとも、ウィンドウは lich を接続切れとして表示したまま開いたままでした。トレイの終了や停止シグナルでも閉じます。一方、アップデート後の再起動ではウィンドウは維持されます。 +- **ネイティブウィンドウで、最後のセッションを閉じたときにワークツリーを削除できるようになりました。** 「閉じました」トーストに「元に戻す」と並んで「ワークツリーを削除」が加わります。lich が作成していないワークツリーや未コミットの変更があるワークツリーは、削除する前にダイアログで知らされます。 + +- **ネイティブウィンドウのターミナルが、ポインターの下のセルを正しく扱うようになりました。** クリック、ドラッグ選択、リンク、マウスを追跡するプログラムのいずれも、ターミナルのパディング分ずれた位置のポインターを読み取っていたため、実際に指しているセルの右下のセルが対象になっていました。スケーリングされたディスプレイでは、そのずれはほぼセル 1 つ分でした。 +- **ネイティブウィンドウのターミナルが絵文字を描画するようになりました。** Noto Color Emoji などのカラー絵文字フォントは、各絵文字をアウトラインではなく画像として保持しています。ターミナルはアウトラインしか描画しなかったため、すべての絵文字が空白になっていました。現在は 2 セル幅のカラーで描画されます。 +- **ネイティブウィンドウのファイルツリーが、ファイルごとの言語アイコンを表示するようになりました。** これまではどのファイルも同じ無地のファイルアイコンでしたが、ブラウザウィンドウと同じアイコンが付きます。 +- **エージェントが開いたプルリクエストが、ターンの終了時にカードへ表示されるようになりました。** バッジはブランチまたは最後のコミットが動いたときにしか再確認されず、プルリクエストを開いてもどちらも動かないため、ウィンドウのフォーカスが外れて戻るまで待たされていました。 +- **Windows でネイティブウィンドウが開くようになりました。** フォントの検索に Windows には含まれない fontconfig を使っていたため、起動時に閉じてしまい、lich はウェブウィンドウに切り替わっていました。Windows ではインストール済みのフォントを自前で見つけるようになり、ターミナルの既定は Cascadia Mono または Consolas、設定のフォントピッカーにも一覧表示されます。 +- **ライトテーマとダークテーマを切り替えてもネイティブウィンドウがフリーズしなくなりました。** Claude Code のように、ターミナルの配色に追従するセッションへ、ウィンドウがまだターミナルを保持している間に変更が通知されていたため、ウィンドウがハングして閉じていました。 diff --git a/changelog/ko.md b/changelog/ko.md new file mode 100644 index 00000000..5c8a3d14 --- /dev/null +++ b/changelog/ko.md @@ -0,0 +1,39 @@ +## [0.69.0] - 2026-10-16 + +### Added + +- **macOS에서 네이티브 창을 사용할 수 있습니다(실험).** Lich.app이 Apple Silicon과 Intel 모두에서 + lich와 함께 네이티브 창을 제공하며, 설정 › 모양 › 창에서 Linux, Windows와 같이 선택할 수 + 있습니다. 터미널에서는 Option 키가 Terminal.app처럼 문자를 입력하므로, 에이전트의 Option+글자 + 단축키는 터미널에 전달되지 않습니다. 지금까지는 GitHub의 macOS 머신에서만 실행해 보았습니다. + 알림은 클릭할 수 없는 일반 알림이며, 메뉴 막대 항목은 없습니다. +- **파일 트리가 훨씬 더 많은 파일 형식을 인식합니다.** Java, Gradle, C와 C++, C#, PHP, Ruby, + Swift, SQL, Vue, Svelte, Terraform, Dockerfile, .gitignore를 비롯해 약 서른 가지가 더 이제 + 파일 트리와 검색 결과에서 일반 파일 아이콘 대신 각자의 아이콘으로 표시됩니다. + +### Fixed + +- **`lich quit`이 네이티브 창을 닫습니다.** 백엔드가 사라진 뒤에도 창이 열린 채 lich를 연결이 + 끊긴 것으로 표시했습니다. 이제 트레이의 종료와 중지 신호도 창을 닫으며, 업데이트 후 재시작할 + 때는 창이 그대로 유지됩니다. +- **네이티브 창이 마지막 세션을 닫을 때 worktree 삭제를 제안합니다.** 닫힘 토스트에 실행 취소 + 옆으로 worktree 삭제 버튼이 생겼으며, lich가 만들지 않은 worktree나 커밋하지 않은 변경 사항이 + 있는 worktree는 삭제하기 전에 대화상자에서 알려 줍니다. + +- **네이티브 창의 터미널이 포인터 아래의 셀을 선택합니다.** 클릭, 드래그 선택, 링크, 마우스를 + 추적하는 프로그램이 모두 터미널 패딩만큼 어긋난 포인터 위치를 읽었기 때문에, 가리킨 셀이 아니라 + 오른쪽 아래의 셀이 선택되었습니다. 배율이 적용된 디스플레이에서는 어긋남이 셀 하나에 가까웠습니다. +- **네이티브 창의 터미널이 이모지를 그립니다.** Noto Color Emoji 같은 컬러 이모지 글꼴은 각 + 이모지를 윤곽선이 아니라 그림으로 저장하는데, 터미널은 윤곽선만 그릴 수 있어서 모든 이모지가 + 빈 칸으로 보였습니다. 이제 컬러로, 두 셀 너비로 그려집니다. +- **네이티브 창의 파일 트리가 파일마다 언어 아이콘을 표시합니다.** 모든 파일이 똑같은 일반 파일 + 아이콘으로 그려졌는데, 이제 웹 창이 붙여 주는 것과 같은 아이콘이 붙습니다. +- **에이전트가 연 풀 리퀘스트가 턴이 끝나면 카드에 표시됩니다.** 배지는 브랜치나 마지막 커밋이 + 바뀔 때만 다시 확인했는데, 풀 리퀘스트를 열어도 둘 다 바뀌지 않아서 창이 포커스를 잃었다가 + 되찾을 때까지 기다려야 했습니다. +- **Windows에서 네이티브 창이 열립니다.** 글꼴을 Windows에 없는 fontconfig로 찾으려다 시작할 때 + 창이 닫혔고, lich는 웹 창으로 대체되었습니다. 이제 Windows에서는 설치된 글꼴을 직접 찾으며, + 터미널 기본 글꼴은 Cascadia Mono 또는 Consolas이고 설정의 글꼴 선택기에도 목록이 표시됩니다. +- **밝은 테마와 어두운 테마를 전환해도 네이티브 창이 더 이상 멈추지 않습니다.** Claude Code처럼 + 터미널의 색 구성표를 따르는 세션이, 창이 아직 터미널을 붙잡고 있는 동안 변경 사실을 전달받아 + 창이 멈춘 뒤 닫혔습니다. diff --git a/changelog/pt-BR.md b/changelog/pt-BR.md new file mode 100644 index 00000000..457864c8 --- /dev/null +++ b/changelog/pt-BR.md @@ -0,0 +1,49 @@ +## [0.69.0] - 2026-10-16 + +### Added + +- **A janela nativa no macOS, experimental.** O Lich.app a traz junto com o lich + no Apple Silicon e no Intel, e Configurações › Aparência › Janela a oferece como + no Linux e no Windows. A tecla Option digita caracteres no terminal dela, como + no Terminal.app, então atalhos Option+letra de um agente não chegam a ele. Até + agora ela só rodou nas máquinas macOS do GitHub: as notificações são simples e + não dá para clicar nelas, e não há item na barra de menus. +- **A árvore de arquivos reconhece muito mais tipos de arquivo.** Java, Gradle, C e C++, + C#, PHP, Ruby, Swift, SQL, Vue, Svelte, Terraform, Dockerfile, .gitignore e + mais umas trinta extensões agora mostram o próprio ícone na árvore de arquivos e + nos resultados de busca, onde antes mostravam um glifo de arquivo genérico. + +### Fixed + +- **`lich quit` fecha a janela nativa.** Ela continuava aberta mostrando o lich como + perdido depois que o backend se fora; o Encerrar o lich da bandeja e um sinal de parada + também a fecham, enquanto um reinício depois de uma atualização ainda a mantém. +- **A janela nativa oferece remover o worktree quando a última sessão dele fecha.** O + toast de sessão fechada ganha Remover worktree ao lado de Desfazer; um worktree que o lich não criou, ou + um com alterações não commitadas, é citado em um diálogo antes de ser removido. + +- **O terminal da janela nativa pega a célula sob o ponteiro.** Um clique, + uma seleção por arrasto, um link e um programa que rastreia o mouse liam o + ponteiro deslocado pelo padding do terminal, então a célula captada ficava abaixo + e à direita da apontada, por quase uma célula inteira em uma tela + escalada. +- **O terminal da janela nativa desenha emoji.** Uma fonte de emoji colorido como a + Noto Color Emoji guarda cada emoji como imagem em vez de contorno, e o + terminal só desenhava contornos, então todo emoji aparecia como um espaço vazio; agora + eles são desenhados em cores, ocupando duas células. +- **A árvore de arquivos da janela nativa mostra o ícone de linguagem de cada arquivo.** Todo + arquivo desenhava o mesmo glifo de arquivo genérico; cada um agora leva o ícone que a janela + do navegador lhe dá. +- **Um pull request aberto por um agente aparece no card quando o turno termina.** O + badge só consultava de novo quando a branch ou o último commit dela mudava, e abrir + um pull request não muda nenhum dos dois, então ele esperava a janela perder e recuperar o + foco. +- **A janela nativa abre no Windows.** Ela buscava as fontes pelo + fontconfig, que o Windows não traz, então fechava na inicialização e o lich + recorria à janela web. No Windows ela agora encontra as fontes instaladas + por conta própria, Cascadia Mono ou Consolas para o terminal por padrão, e os + seletores de fonte em Configurações as listam. +- **Alternar entre um tema claro e um escuro não trava mais a janela nativa.** Uma sessão que segue o esquema de cores do terminal, como o Claude Code + faz, era avisada da mudança enquanto a janela ainda segurava seus terminais, então + a janela travava e fechava. + diff --git a/changelog/ru.md b/changelog/ru.md new file mode 100644 index 00000000..84683b5b --- /dev/null +++ b/changelog/ru.md @@ -0,0 +1,49 @@ +## [0.69.0] - 2026-10-16 + +### Added + +- **Нативное окно на macOS, экспериментально.** Lich.app поставляет его вместе + с lich на Apple Silicon и Intel, а Настройки › Оформление › Окно предлагает его + так же, как на Linux и Windows. Клавиша Option вводит символы в его терминале, + как в Terminal.app, поэтому сочетания Option+буква агента до него не доходят. + Пока оно запускалось только на macOS-машинах GitHub: уведомления простые, по + ним нельзя щёлкнуть, а пункта в строке меню нет. +- **Дерево файлов знает гораздо больше типов файлов.** Java, Gradle, C и C++, + C#, PHP, Ruby, Swift, SQL, Vue, Svelte, Terraform, Dockerfile, .gitignore и + ещё около тридцати типов теперь показывают собственную иконку в дереве файлов + и в результатах поиска, где раньше был обычный значок файла. + +### Fixed + +- **`lich quit` закрывает нативное окно.** Оно оставалось открытым и показывало + lich как потерянный после остановки бэкенда; Quit в трее и сигнал остановки + тоже его закрывают, а перезапуск после обновления по-прежнему его сохраняет. +- **Нативное окно предлагает удалить worktree, когда закрывается его последняя сессия.** В + уведомлении «Закрыто» рядом с «Отменить» появляется «Удалить worktree»; worktree, созданный + не lich, или с незакоммиченными изменениями, называется в диалоге до удаления. + +- **Терминал нативного окна берёт ячейку под указателем.** Щелчок, выделение + перетаскиванием, ссылка и программа, отслеживающая мышь, считывали положение + указателя со сдвигом на отступ терминала, поэтому выбранная ячейка оказывалась + ниже и правее той, на которую указывали, почти на целую ячейку на дисплее с + масштабированием. +- **Терминал нативного окна рисует эмодзи.** Шрифт цветных эмодзи, например + Noto Color Emoji, хранит каждое эмодзи как картинку, а не как контур, а + терминал рисовал только контуры, поэтому каждое эмодзи выглядело пустым + местом; теперь они рисуются цветными, шириной в две ячейки. +- **Дерево файлов нативного окна показывает иконку языка каждого файла.** Каждый + файл рисовался одним и тем же обычным значком; теперь у каждого та иконка, + которую ему даёт веб-окно. +- **Pull request, который открывает агент, появляется на его карточке в конце хода.** + Значок перепроверялся только при смене ветки или её последнего коммита, а + открытие pull request не меняет ни то, ни другое, поэтому он ждал, пока окно + потеряет и вернёт фокус. +- **Нативное окно открывается на Windows.** Оно искало шрифты через fontconfig, + которого в Windows нет, поэтому закрывалось при запуске, и lich переходил на + веб-окно. Теперь на Windows оно само находит установленные шрифты, по умолчанию + Cascadia Mono или Consolas для терминала, и списки шрифтов в Настройках их + показывают. +- **Переключение между светлой и тёмной темой больше не зависает в нативном + окне.** Сессию, которая следует цветовой схеме терминала, как Claude Code, + уведомляли об изменении, пока окно ещё держало свои терминалы, поэтому окно + зависало и закрывалось. diff --git a/changelog/zh-CN.md b/changelog/zh-CN.md new file mode 100644 index 00000000..08db663c --- /dev/null +++ b/changelog/zh-CN.md @@ -0,0 +1,17 @@ +## [0.69.0] - 2026-10-16 + +### Added + +- **macOS 上的原生窗口(实验性)。** Lich.app 在 Apple Silicon 和 Intel 上随 lich 一同提供它,设置 › 外观 › 窗口与 Linux 和 Windows 上一样可以选择它。Option 键在其终端中用于输入字符,和 Terminal.app 一样,因此智能体的 Option+字母快捷键无法到达终端。目前它只在 GitHub 的 macOS 机器上运行过:通知是无法点击的普通通知,也没有菜单栏图标。 +- **文件树认识更多的文件类型。** Java、Gradle、C 和 C++、C#、PHP、Ruby、Swift、SQL、Vue、Svelte、Terraform、Dockerfile、.gitignore 以及约三十种其他类型,现在在文件树和搜索结果中都有各自的图标,此前它们显示的是普通文件图标。 + +### Fixed + +- **`lich quit` 会关闭原生窗口。** 后端退出后,窗口原本仍然开着,并显示 lich 已丢失;托盘中的退出和停止信号现在也会关闭它,而更新后的重启仍会保留它。 +- **最后一个会话关闭时,原生窗口会提示删除工作树。** 已关闭提示中,撤销旁边新增了删除工作树;对于不是 lich 创建的工作树,或带有未提交更改的工作树,删除前会在对话框中点明。 +- **原生窗口的终端会选中指针下方的单元格。** 点击、拖动选择、链接以及跟踪鼠标的程序,读取到的指针位置都偏移了终端的内边距,导致它们选中的单元格位于所指单元格的右下方,在缩放显示器上偏差接近一个单元格。 +- **原生窗口的终端会绘制 emoji。** Noto Color Emoji 等彩色 emoji 字体把每个 emoji 存为图片而非轮廓,而终端只会绘制轮廓,所以每个 emoji 都显示为空白;现在它们以彩色绘制,占两个单元格宽。 +- **原生窗口的文件树显示每个文件的语言图标。** 此前每个文件都画成同一个普通文件图标;现在每个文件都带有网页窗口为它提供的图标。 +- **智能体打开的拉取请求会在这一轮结束时显示在它的卡片上。** 徽章原本只在分支或其最新提交变化时才会重新检查,而打开拉取请求两者都不会变,所以它要等到窗口失去焦点再重新获得焦点后才出现。 +- **原生窗口可以在 Windows 上打开。** 它原本通过 fontconfig 查找字体,而 Windows 并不提供,因此启动时就关闭了,lich 随后回退到网页窗口。在 Windows 上,它现在自己查找已安装的字体,终端默认使用 Cascadia Mono 或 Consolas,设置中的字体选择器也会列出它们。 +- **在浅色和深色主题之间切换不再冻结原生窗口。** 像 Claude Code 那样跟随终端配色方案的会话,会在窗口仍持有其终端时收到变化通知,导致窗口卡死并关闭。 diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 0e71aa35..32f80c74 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -143,7 +143,8 @@ derive one from the other. No i18n library: `lib/i18n/` is the whole mechanism. `import { t } from "@/lib/i18n/i18n"`, read at call time, never at import time. A key built at runtime must still be a typed template: `` t(`sidebar.cardColor.color.${name}`) `` with `name` a union. - **Not translated:** backend error text shown through `errorText()` (only the sentence around it is), - `CHANGELOG.md` and the What's new dialog, identifiers and names (providers, branches, commands, file + `CHANGELOG.md` (the What's new dialog reads its translations from `changelog/.md`, written by the + `lich-changelog-i18n` skill at release time), identifiers and names (providers, branches, commands, file paths, `lich`, `Claude Code`), keyboard shortcuts, and a test's fixture data. - **Tests stay English.** The node suites and jsdom both resolve to `en`; a test asserting English text keeps passing untouched. Assert another locale by calling `setLocale` in the test, never by editing an English diff --git a/frontend/src/components/PatchNotesDialog.test.tsx b/frontend/src/components/PatchNotesDialog.test.tsx new file mode 100644 index 00000000..972cb95d --- /dev/null +++ b/frontend/src/components/PatchNotesDialog.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +// +// The harness is imported first for the reason render-budget.test.tsx names. +import { mountBudget } from "@/test/render-budget" +import { createElement } from "react" +import { afterEach, expect, test, vi } from "vitest" +import type { PatchNotes } from "@/lib/api-types" +import { resetLocale, setLocale } from "@/lib/i18n/i18n" +import { PatchNotesDialog } from "./PatchNotesDialog" + +vi.mock("@/lib/rpc", () => ({ System: { OpenExternal: () => Promise.resolve(null) } })) + +afterEach(() => resetLocale()) + +const notes: PatchNotes = { + version: "0.69.0", + highlights: null, + groups: [ + { label: "Fixed", items: ["**Uma correção.**", "**Outra.**"] }, + { label: "Highlights", items: ["**Fora das seis.**"] }, + ], +} + +test("names Keep a Changelog's groups in the interface language", async () => { + setLocale("pt-BR") + const mounted = await mountBudget(createElement(PatchNotesDialog, { notes, onClose: () => {} })) + const text = document.body.textContent + expect(text).toContain("2 correções") + expect(text).toContain("Correções") + expect(text).toContain("1 highlights") + await mounted.unmount() +}) diff --git a/frontend/src/components/PatchNotesDialog.tsx b/frontend/src/components/PatchNotesDialog.tsx index e30fbd80..92158f2b 100644 --- a/frontend/src/components/PatchNotesDialog.tsx +++ b/frontend/src/components/PatchNotesDialog.tsx @@ -25,6 +25,31 @@ function dotColor(label: string): string { } } +// Keep a Changelog's six headings, which every translation of the changelog +// keeps in English so they can be named in the interface language here. +const GROUP_KEYS = ["added", "changed", "deprecated", "removed", "fixed", "security"] as const +type GroupKey = (typeof GROUP_KEYS)[number] + +function groupKey(label: string): GroupKey | null { + const key = label.toLowerCase() + return GROUP_KEYS.find((known) => known === key) ?? null +} + +type Translate = ReturnType + +// groupLabel shows any other heading as written. +function groupLabel(t: Translate, label: string): string { + const key = groupKey(label) + return key ? t(`shell.patchNotesDialog.groups.${key}`) : label +} + +function groupCount(t: Translate, label: string, count: number): string { + const key = groupKey(label) + return key + ? t(`shell.patchNotesDialog.counts.${key}`, { count }) + : `${count} ${label.toLowerCase()}` +} + // renderInline renders a changelog item's markdown: **bold** lead-ins and // `code` spans. No full markdown parser — those two are all the CHANGELOG uses. // The split is positional and static for a given item, so the index is the key. @@ -149,7 +174,7 @@ export function PatchNotesDialog({ notes, onClose }: PatchNotesDialogProps) { {groups.map((group) => ( - {group.items.length} {group.label.toLowerCase()} + {groupCount(t, group.label, group.items.length)} ))} @@ -170,7 +195,7 @@ export function PatchNotesDialog({ notes, onClose }: PatchNotesDialogProps) {
- {group.label} + {groupLabel(t, group.label)}
{group.items.map((item, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: a release's notes are read-only and never reordered. diff --git a/frontend/src/components/PatchNotesGate.tsx b/frontend/src/components/PatchNotesGate.tsx index 0b7b550d..4a664326 100644 --- a/frontend/src/components/PatchNotesGate.tsx +++ b/frontend/src/components/PatchNotesGate.tsx @@ -7,6 +7,7 @@ import { } from "@/lib/update/patch-notes-gate" import { PatchNotes, Store } from "@/lib/rpc" import { readPref } from "@/lib/prefs" +import { getLocale } from "@/lib/i18n/i18n" import type { PatchNotes as PatchNotesData } from "@/lib/api-types" // The setting is the app's, not a project's — it answers for this install. @@ -21,7 +22,10 @@ export function PatchNotesGate() { useEffect(() => { let alive = true - void Promise.all([PatchNotes.Current(), Store.GetSetting(PATCH_NOTES_SEEN_KEY, GLOBAL_SCOPE)]) + void Promise.all([ + PatchNotes.Current(getLocale()), + Store.GetSetting(PATCH_NOTES_SEEN_KEY, GLOBAL_SCOPE), + ]) .then(([current, seen]) => { if (!alive) return // A setting that was never written reads as "": the gate wants null for diff --git a/frontend/src/components/settings/UpdatesSettings.tsx b/frontend/src/components/settings/UpdatesSettings.tsx index 65a9f615..fb355c2a 100644 --- a/frontend/src/components/settings/UpdatesSettings.tsx +++ b/frontend/src/components/settings/UpdatesSettings.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button" import { SettingBlock } from "./SettingBlock" import { PatchNotesDialog } from "@/components/PatchNotesDialog" import { PluginSetting } from "./PluginSetting" -import { useT } from "@/lib/i18n/i18n" +import { useLocale, useT } from "@/lib/i18n/i18n" import { PatchNotes } from "@/lib/rpc" import { runUpdateCheck } from "@/lib/update/update-check" import { useRemoteResource } from "@/lib/use-remote-resource" @@ -12,13 +12,14 @@ import type { PatchNotes as PatchNotesData } from "@/lib/api-types" export function UpdatesSettings() { const t = useT() + const locale = useLocale() const [notesOpen, setNotesOpen] = useState(false) const [checking, setChecking] = useState(false) const [checkResult, setCheckResult] = useState("") const { data: notes } = useRemoteResource( - "patch-notes", - () => PatchNotes.Current(), - { empty: null, cache: "settings.patchNotes" }, + `patch-notes:${locale}`, + () => PatchNotes.Current(locale), + { empty: null, cache: `settings.patchNotes.${locale}` }, ) const checkApp = async () => { setChecking(true) diff --git a/frontend/src/lib/i18n/locales/en/shell.ts b/frontend/src/lib/i18n/locales/en/shell.ts index ec3bc0f8..adde2605 100644 --- a/frontend/src/lib/i18n/locales/en/shell.ts +++ b/frontend/src/lib/i18n/locales/en/shell.ts @@ -94,6 +94,40 @@ export const shell = { title: "What's new in lich {version}", viewChangelog: "View full changelog", gotIt: "Got it", + groups: { + added: "Added", + changed: "Changed", + deprecated: "Deprecated", + removed: "Removed", + fixed: "Fixed", + security: "Security", + }, + counts: { + added: { + one: "{count} added", + other: "{count} added", + }, + changed: { + one: "{count} changed", + other: "{count} changed", + }, + deprecated: { + one: "{count} deprecated", + other: "{count} deprecated", + }, + removed: { + one: "{count} removed", + other: "{count} removed", + }, + fixed: { + one: "{count} fixed", + other: "{count} fixed", + }, + security: { + one: "{count} security", + other: "{count} security", + }, + }, }, planQuota: { planUsage: "Plan usage", diff --git a/frontend/src/lib/i18n/locales/es/shell.ts b/frontend/src/lib/i18n/locales/es/shell.ts index 9a1b6d4e..30556b34 100644 --- a/frontend/src/lib/i18n/locales/es/shell.ts +++ b/frontend/src/lib/i18n/locales/es/shell.ts @@ -98,6 +98,40 @@ export const shell = { title: "Novedades de lich {version}", viewChangelog: "Ver el changelog completo", gotIt: "Entendido", + groups: { + added: "Novedades", + changed: "Cambios", + deprecated: "Obsoleto", + removed: "Eliminado", + fixed: "Correcciones", + security: "Seguridad", + }, + counts: { + added: { + one: "{count} novedad", + other: "{count} novedades", + }, + changed: { + one: "{count} cambio", + other: "{count} cambios", + }, + deprecated: { + one: "{count} obsoleto", + other: "{count} obsoletos", + }, + removed: { + one: "{count} eliminado", + other: "{count} eliminados", + }, + fixed: { + one: "{count} corrección", + other: "{count} correcciones", + }, + security: { + one: "{count} de seguridad", + other: "{count} de seguridad", + }, + }, }, planQuota: { planUsage: "Uso del plan", diff --git a/frontend/src/lib/i18n/locales/ja/shell.ts b/frontend/src/lib/i18n/locales/ja/shell.ts index 70366212..f5ac5945 100644 --- a/frontend/src/lib/i18n/locales/ja/shell.ts +++ b/frontend/src/lib/i18n/locales/ja/shell.ts @@ -98,6 +98,40 @@ export const shell = { title: "lich {version} の新機能", viewChangelog: "変更履歴をすべて見る", gotIt: "了解", + groups: { + added: "追加", + changed: "変更", + deprecated: "非推奨", + removed: "削除", + fixed: "修正", + security: "セキュリティ", + }, + counts: { + added: { + one: "{count} 件の追加", + other: "{count} 件の追加", + }, + changed: { + one: "{count} 件の変更", + other: "{count} 件の変更", + }, + deprecated: { + one: "{count} 件の非推奨", + other: "{count} 件の非推奨", + }, + removed: { + one: "{count} 件の削除", + other: "{count} 件の削除", + }, + fixed: { + one: "{count} 件の修正", + other: "{count} 件の修正", + }, + security: { + one: "{count} 件のセキュリティ", + other: "{count} 件のセキュリティ", + }, + }, }, planQuota: { planUsage: "プランの使用量", diff --git a/frontend/src/lib/i18n/locales/ko/shell.ts b/frontend/src/lib/i18n/locales/ko/shell.ts index 5814a67c..08486d56 100644 --- a/frontend/src/lib/i18n/locales/ko/shell.ts +++ b/frontend/src/lib/i18n/locales/ko/shell.ts @@ -95,6 +95,40 @@ export const shell = { title: "lich {version}의 새로운 기능", viewChangelog: "전체 변경 내역 보기", gotIt: "확인", + groups: { + added: "추가", + changed: "변경", + deprecated: "지원 중단", + removed: "제거", + fixed: "수정", + security: "보안", + }, + counts: { + added: { + one: "{count}개 추가", + other: "{count}개 추가", + }, + changed: { + one: "{count}개 변경", + other: "{count}개 변경", + }, + deprecated: { + one: "{count}개 지원 중단", + other: "{count}개 지원 중단", + }, + removed: { + one: "{count}개 제거", + other: "{count}개 제거", + }, + fixed: { + one: "{count}개 수정", + other: "{count}개 수정", + }, + security: { + one: "{count}개 보안", + other: "{count}개 보안", + }, + }, }, planQuota: { planUsage: "플랜 사용량", diff --git a/frontend/src/lib/i18n/locales/pt-br/shell.ts b/frontend/src/lib/i18n/locales/pt-br/shell.ts index 4c3eea08..4027eada 100644 --- a/frontend/src/lib/i18n/locales/pt-br/shell.ts +++ b/frontend/src/lib/i18n/locales/pt-br/shell.ts @@ -97,6 +97,40 @@ export const shell = { title: "Novidades do lich {version}", viewChangelog: "Ver o changelog completo", gotIt: "Entendi", + groups: { + added: "Novidades", + changed: "Mudanças", + deprecated: "Obsoleto", + removed: "Removido", + fixed: "Correções", + security: "Segurança", + }, + counts: { + added: { + one: "{count} novidade", + other: "{count} novidades", + }, + changed: { + one: "{count} mudança", + other: "{count} mudanças", + }, + deprecated: { + one: "{count} obsoleto", + other: "{count} obsoletos", + }, + removed: { + one: "{count} removido", + other: "{count} removidos", + }, + fixed: { + one: "{count} correção", + other: "{count} correções", + }, + security: { + one: "{count} de segurança", + other: "{count} de segurança", + }, + }, }, planQuota: { planUsage: "Uso do plano", diff --git a/frontend/src/lib/i18n/locales/ru/shell.ts b/frontend/src/lib/i18n/locales/ru/shell.ts index 99598375..6e838975 100644 --- a/frontend/src/lib/i18n/locales/ru/shell.ts +++ b/frontend/src/lib/i18n/locales/ru/shell.ts @@ -97,6 +97,52 @@ export const shell = { title: "Что нового в lich {version}", viewChangelog: "Открыть полный журнал изменений", gotIt: "Понятно", + groups: { + added: "Добавлено", + changed: "Изменено", + deprecated: "Устарело", + removed: "Удалено", + fixed: "Исправлено", + security: "Безопасность", + }, + counts: { + added: { + one: "{count} новинка", + few: "{count} новинки", + many: "{count} новинок", + other: "{count} новинки", + }, + changed: { + one: "{count} изменение", + few: "{count} изменения", + many: "{count} изменений", + other: "{count} изменения", + }, + deprecated: { + one: "{count} устаревшее", + few: "{count} устаревших", + many: "{count} устаревших", + other: "{count} устаревших", + }, + removed: { + one: "{count} удаление", + few: "{count} удаления", + many: "{count} удалений", + other: "{count} удаления", + }, + fixed: { + one: "{count} исправление", + few: "{count} исправления", + many: "{count} исправлений", + other: "{count} исправления", + }, + security: { + one: "{count} по безопасности", + few: "{count} по безопасности", + many: "{count} по безопасности", + other: "{count} по безопасности", + }, + }, }, planQuota: { planUsage: "Использование тарифа", diff --git a/frontend/src/lib/i18n/locales/zh-cn/shell.ts b/frontend/src/lib/i18n/locales/zh-cn/shell.ts index 57329bcb..ed9c637e 100644 --- a/frontend/src/lib/i18n/locales/zh-cn/shell.ts +++ b/frontend/src/lib/i18n/locales/zh-cn/shell.ts @@ -95,6 +95,40 @@ export const shell = { title: "lich {version} 的新功能", viewChangelog: "查看完整更新日志", gotIt: "知道了", + groups: { + added: "新增", + changed: "变更", + deprecated: "弃用", + removed: "移除", + fixed: "修复", + security: "安全", + }, + counts: { + added: { + one: "{count} 项新增", + other: "{count} 项新增", + }, + changed: { + one: "{count} 项变更", + other: "{count} 项变更", + }, + deprecated: { + one: "{count} 项弃用", + other: "{count} 项弃用", + }, + removed: { + one: "{count} 项移除", + other: "{count} 项移除", + }, + fixed: { + one: "{count} 项修复", + other: "{count} 项修复", + }, + security: { + one: "{count} 项安全", + other: "{count} 项安全", + }, + }, }, planQuota: { planUsage: "套餐用量", diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts index 054f69f5..30eaa03d 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -6,6 +6,7 @@ // Each facade mirrors its Go service's method names and signatures; shapes // live in lib/api-types.ts. +import type { Locale } from "@/lib/i18n/i18n" import type { AppUpdateStatus, BaseStatus, @@ -579,8 +580,9 @@ export const AppUpdate = { } export const PatchNotes = { - /** The running build's changelog section, for the "what's new" popup. */ - Current: () => call("patchnotes.Current", []), + /** The running build's changelog section in the interface language, for the + * "what's new" popup; English when the release has no translation into it. */ + Current: (locale: Locale) => call("patchnotes.Current", [locale]), } /** The system tray's menu, which the backend draws in the page's words. */ diff --git a/internal/patchnotes/patchnotes.go b/internal/patchnotes/patchnotes.go index b8797131..75dc7e8c 100644 --- a/internal/patchnotes/patchnotes.go +++ b/internal/patchnotes/patchnotes.go @@ -19,9 +19,18 @@ // volume: "important" is the release's headline and opens the popup as its // title, "warning" is something the reader must do or avoid, "note" a detail // worth a line. The frontend decides how each kind looks. +// +// A translation of the latest release lives in changelog/.md, written +// by the lich-changelog-i18n skill when a release is cut: the same section, +// headings, alert openers and code spans left in English. TestTranslations +// holds each file to the release it translates. package patchnotes -import "strings" +import ( + "errors" + "io/fs" + "strings" +) // Group is one "### Added / Changed / Fixed" block of a release's notes. type Group struct { @@ -47,24 +56,46 @@ type Notes struct { // Service serves the notes for one fixed version — the running build. type Service struct { - version string - changelog string + version string + changelog string + translations fs.FS } // New returns a service that parses changelog for version's section on demand. -func New(version, changelog string) *Service { - return &Service{version: version, changelog: changelog} +// translations holds one changelog/.md per translated language. +func New(version, changelog string, translations fs.FS) *Service { + return &Service{version: version, changelog: changelog, translations: translations} } -// Current returns the running build's changelog section. Groups is empty when -// no section matches (a dev build, or a version not yet in the changelog), and -// the frontend then shows nothing. -func (s *Service) Current() Notes { +// Current returns the running build's changelog section in locale, the +// interface language's BCP 47 tag. A locale with no translation of this +// version (English, a dev build, a release cut without the skill) gets the +// English section. Groups is empty when no section matches (a dev build, or a +// version not yet in the changelog), and the frontend then shows nothing. +func (s *Service) Current(locale string) (Notes, error) { + changelog, err := s.changelogIn(locale) + if err != nil { + return Notes{}, err + } return Notes{ Version: normalize(s.version), - Highlights: Highlights(s.changelog, s.version), - Groups: Section(s.changelog, s.version), + Highlights: Highlights(changelog, s.version), + Groups: Section(changelog, s.version), + }, nil +} + +func (s *Service) changelogIn(locale string) (string, error) { + translated, err := fs.ReadFile(s.translations, "changelog/"+locale+".md") + if errors.Is(err, fs.ErrNotExist) { + return s.changelog, nil + } + if err != nil { + return "", err + } + if _, ok := sliceSection(string(translated), normalize(s.version)); !ok { + return s.changelog, nil } + return string(translated), nil } // Highlights returns the alert blocks under the "## []" heading, in diff --git a/internal/patchnotes/patchnotes_test.go b/internal/patchnotes/patchnotes_test.go index add59a2d..d602b425 100644 --- a/internal/patchnotes/patchnotes_test.go +++ b/internal/patchnotes/patchnotes_test.go @@ -3,6 +3,7 @@ package patchnotes import ( "reflect" "testing" + "testing/fstest" ) const sample = `# Changelog @@ -80,8 +81,11 @@ func TestSectionMissingVersionIsNil(t *testing.T) { } func TestCurrentReportsNormalizedVersion(t *testing.T) { - svc := New("v0.11.0", sample) - got := svc.Current() + svc := New("v0.11.0", sample, fstest.MapFS{}) + got, err := svc.Current("en") + if err != nil { + t.Fatal(err) + } if got.Version != "0.11.0" { t.Fatalf("Version = %q, want 0.11.0", got.Version) } @@ -183,8 +187,72 @@ func TestHighlightsAreNilWithoutBlocksOrSection(t *testing.T) { } func TestCurrentCarriesHighlights(t *testing.T) { - got := New("v0.45.0", withHighlights).Current() + got, err := New("v0.45.0", withHighlights, fstest.MapFS{}).Current("en") + if err != nil { + t.Fatal(err) + } if len(got.Highlights) != 2 || got.Highlights[0].Kind != "important" { t.Fatalf("Highlights = %#v, want the two blocks of 0.45.0", got.Highlights) } } + +const ptBR = `## [0.11.0] - 2026-07-21 + +> [!IMPORTANT] +> **Um destaque traduzido.** + +### Added + +- **Provider padrão para novas sessões.** Escolha qual harness abre. +- **Paleta de comandos.** Vá para qualquer sessão. + +### Fixed + +- **Reabrir uma worktree não cria outra** a partir da branch. +` + +func TestCurrentReadsTheLocalesTranslation(t *testing.T) { + svc := New("v0.11.0", sample, fstest.MapFS{"changelog/pt-BR.md": {Data: []byte(ptBR)}}) + got, err := svc.Current("pt-BR") + if err != nil { + t.Fatal(err) + } + want := Notes{ + Version: "0.11.0", + Highlights: []Highlight{{Kind: "important", Text: "**Um destaque traduzido.**"}}, + Groups: []Group{ + {Label: "Added", Items: []string{ + "**Provider padrão para novas sessões.** Escolha qual harness abre.", + "**Paleta de comandos.** Vá para qualquer sessão.", + }}, + {Label: "Fixed", Items: []string{"**Reabrir uma worktree não cria outra** a partir da branch."}}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Current(pt-BR) mismatch:\n got %#v\nwant %#v", got, want) + } +} + +func TestCurrentWithoutATranslationIsEnglish(t *testing.T) { + svc := New("v0.11.0", sample, fstest.MapFS{"changelog/pt-BR.md": {Data: []byte(ptBR)}}) + got, err := svc.Current("ja") + if err != nil { + t.Fatal(err) + } + if want := Section(sample, "0.11.0"); !reflect.DeepEqual(got.Groups, want) { + t.Fatalf("groups = %#v, want the English %#v", got.Groups, want) + } +} + +// A translation is of one release; a build of another reads English rather +// than a section that is not about it. +func TestCurrentIgnoresATranslationOfAnotherRelease(t *testing.T) { + svc := New("v0.10.0", sample, fstest.MapFS{"changelog/pt-BR.md": {Data: []byte(ptBR)}}) + got, err := svc.Current("pt-BR") + if err != nil { + t.Fatal(err) + } + if want := Section(sample, "0.10.0"); !reflect.DeepEqual(got.Groups, want) { + t.Fatalf("groups = %#v, want the English %#v", got.Groups, want) + } +} diff --git a/internal/patchnotes/translations_test.go b/internal/patchnotes/translations_test.go new file mode 100644 index 00000000..76cba4f3 --- /dev/null +++ b/internal/patchnotes/translations_test.go @@ -0,0 +1,123 @@ +package patchnotes + +import ( + "os" + "path/filepath" + "reflect" + "regexp" + "slices" + "strings" + "testing" + + "github.com/omartelo/lich/internal/prompt" +) + +// The translations live beside CHANGELOG.md at the module root, where main.go +// embeds them. +const repoRoot = "../.." + +var codeSpan = regexp.MustCompile("`[^`]+`") + +// TestTranslations holds every changelog/.md to the latest release in +// CHANGELOG.md: the one section, under the same heading, with the same groups, +// item counts, alert kinds, code spans and bold lead-ins. A release cut without +// running the lich-changelog-i18n skill fails here. +func TestTranslations(t *testing.T) { + english := readRepoFile(t, "CHANGELOG.md") + heading := latestReleaseHeading(t, english) + version := heading[len("## ["):strings.Index(heading, "]")] + for _, lang := range prompt.Langs { + if lang == prompt.English { + continue + } + t.Run(string(lang), func(t *testing.T) { + translated := readRepoFile(t, filepath.Join("changelog", string(lang)+".md")) + if got := versionHeadings(translated); !reflect.DeepEqual(got, []string{heading}) { + t.Fatalf("version headings = %q, want only %q", got, heading) + } + assertSameShape(t, Section(english, version), Section(translated, version)) + assertSameHighlights(t, Highlights(english, version), Highlights(translated, version)) + }) + } +} + +func readRepoFile(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot, name)) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func versionHeadings(changelog string) []string { + var out []string + for _, line := range strings.Split(changelog, "\n") { + if strings.HasPrefix(line, "## [") { + out = append(out, strings.TrimSpace(line)) + } + } + return out +} + +func latestReleaseHeading(t *testing.T, changelog string) string { + t.Helper() + for _, heading := range versionHeadings(changelog) { + if !strings.HasPrefix(heading, "## [Unreleased]") { + return heading + } + } + t.Fatal("CHANGELOG.md has no released version") + return "" +} + +func assertSameShape(t *testing.T, english, translated []Group) { + t.Helper() + if len(english) != len(translated) { + t.Fatalf("%d groups, want %d", len(translated), len(english)) + } + for i, group := range english { + got := translated[i] + if got.Label != group.Label { + t.Fatalf("group %d is %q, want %q left in English", i, got.Label, group.Label) + } + if len(got.Items) != len(group.Items) { + t.Fatalf("%s has %d items, want %d", group.Label, len(got.Items), len(group.Items)) + } + for j, item := range group.Items { + assertSameMarkup(t, item, got.Items[j]) + } + } +} + +func assertSameHighlights(t *testing.T, english, translated []Highlight) { + t.Helper() + if len(english) != len(translated) { + t.Fatalf("%d highlights, want %d", len(translated), len(english)) + } + for i, highlight := range english { + if translated[i].Kind != highlight.Kind { + t.Fatalf("highlight %d is %q, want %q", i, translated[i].Kind, highlight.Kind) + } + assertSameMarkup(t, highlight.Text, translated[i].Text) + } +} + +// assertSameMarkup checks what a translation must not touch: code spans stay +// literal, in whatever order the language's grammar puts them, and the bold +// lead-in keeps its markers. +func assertSameMarkup(t *testing.T, english, translated string) { + t.Helper() + if got, want := sortedCodeSpans(translated), sortedCodeSpans(english); !reflect.DeepEqual(got, want) { + t.Fatalf("code spans %q, want %q in:\n%s", got, want, translated) + } + if got, want := strings.Count(translated, "**"), strings.Count(english, "**"); got != want { + t.Fatalf("%d bold markers, want %d in:\n%s", got, want, translated) + } +} + +func sortedCodeSpans(text string) []string { + spans := codeSpan.FindAllString(text, -1) + slices.Sort(spans) + return spans +} diff --git a/main.go b/main.go index c4e700c1..23f3cf1f 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,12 @@ var assets embed.FS //go:embed CHANGELOG.md var changelog string +// changelogTranslations are the latest release's notes in each interface +// language (internal/patchnotes). +// +//go:embed changelog/*.md +var changelogTranslations embed.FS + // The tray icon, in the encoding each platform's tray takes (internal/tray). var ( //go:embed build/appicon-256.png @@ -349,7 +355,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub dispatcher.Register(provider.ID+"profile", profiles) } dispatcher.Register("appupdate", appupdate.New(version, coord.Install, hub.Emit)) - dispatcher.Register("patchnotes", patchnotes.New(version, changelog)) + dispatcher.Register("patchnotes", patchnotes.New(version, changelog, changelogTranslations)) dispatcher.Register("store", db) // Read before runChromium writes this run's runtime file over the previous // run's: a file still there is the only trace a bad exit leaves, and the diff --git a/native/cmd/lich-native/i18n_shell_test.go b/native/cmd/lich-native/i18n_shell_test.go index 47542890..c54c8de0 100644 --- a/native/cmd/lich-native/i18n_shell_test.go +++ b/native/cmd/lich-native/i18n_shell_test.go @@ -148,3 +148,20 @@ func TestShellMessagesWithPlaceholdersAndPluralsInPortuguese(t *testing.T) { t.Errorf("unknown cwd: %q, hint %q", text, hint) } } + +func TestPatchNotesGroupsAreNamedInTheInterfaceLanguage(t *testing.T) { + groups := []lichclient.PatchNotesGroup{{Label: "Added", Items: []string{"a"}}, {Label: "Fixed", Items: []string{"a", "b"}}} + if got := notesCounts(groups); got != "1 added"+notesCountSep+"2 fixed" { + t.Errorf("English counts %q", got) + } + inPortuguese(t) + if got := notesCounts(groups); got != "1 novidade"+notesCountSep+"2 correções" { + t.Errorf("pt-BR counts %q", got) + } + if got := notesGroupLabel("Fixed"); got != "Correções" { + t.Errorf("pt-BR label %q", got) + } + if got := notesGroupLabel("Highlights"); got != "Highlights" { + t.Errorf("a heading outside Keep a Changelog's six is shown as written, got %q", got) + } +} diff --git a/native/cmd/lich-native/patch_notes_gate.go b/native/cmd/lich-native/patch_notes_gate.go index ae8894a1..e948fc63 100644 --- a/native/cmd/lich-native/patch_notes_gate.go +++ b/native/cmd/lich-native/patch_notes_gate.go @@ -3,6 +3,7 @@ package main import ( "log" + "github.com/omartelo/lich/native/i18n" "github.com/omartelo/lich/native/lichclient" ) @@ -41,7 +42,7 @@ func (s *settingsScreen) startupPatchNotes() { log.Printf("patch notes: last seen: %v", err) return } - notes, err := s.client.PatchNotes(s.ctx) + notes, err := s.client.PatchNotes(s.ctx, string(i18n.Current())) if err != nil { log.Printf("patch notes: %v", err) return diff --git a/native/cmd/lich-native/settings_updates.go b/native/cmd/lich-native/settings_updates.go index e7582231..ab66bead 100644 --- a/native/cmd/lich-native/settings_updates.go +++ b/native/cmd/lich-native/settings_updates.go @@ -64,7 +64,7 @@ func (u *updatesSettings) ensure(s *settingsScreen) { } func (u *updatesSettings) readNotes(s *settingsScreen) { - n, err := s.client.PatchNotes(s.ctx) + n, err := s.client.PatchNotes(s.ctx, string(i18n.Current())) if err != nil { log.Printf("settings: patch notes: %v", err) return diff --git a/native/cmd/lich-native/settings_updates_notes.go b/native/cmd/lich-native/settings_updates_notes.go index 67881602..2841c394 100644 --- a/native/cmd/lich-native/settings_updates_notes.go +++ b/native/cmd/lich-native/settings_updates_notes.go @@ -325,7 +325,7 @@ func (v *patchNotesView) body(gtx C, th *ui.Theme, notes *lichclient.PatchNotes) func notesCounts(groups []lichclient.PatchNotesGroup) string { parts := make([]string, len(groups)) for i, g := range groups { - parts[i] = fmt.Sprintf("%d %s", len(g.Items), strings.ToLower(g.Label)) + parts[i] = notesGroupCount(g.Label, len(g.Items)) } return strings.Join(parts, notesCountSep) } @@ -390,6 +390,47 @@ func notesCallout(th *ui.Theme, h lichclient.PatchNotesHighlight) layout.Widget } } +// notesGroupLabel is a group's heading in the interface language. The +// changelog keeps Keep a Changelog's six headings in English in every +// translation; any other heading is shown as written. +func notesGroupLabel(label string) string { + switch strings.ToLower(label) { + case "added": + return i18n.T("shell.patchNotesDialog.groups.added") + case "changed": + return i18n.T("shell.patchNotesDialog.groups.changed") + case "deprecated": + return i18n.T("shell.patchNotesDialog.groups.deprecated") + case "removed": + return i18n.T("shell.patchNotesDialog.groups.removed") + case "fixed": + return i18n.T("shell.patchNotesDialog.groups.fixed") + case "security": + return i18n.T("shell.patchNotesDialog.groups.security") + } + return label +} + +// notesGroupCount is notesGroupLabel's "3 added", for the header. +func notesGroupCount(label string, items int) string { + count := i18n.Args{"count": items} + switch strings.ToLower(label) { + case "added": + return i18n.T("shell.patchNotesDialog.counts.added", count) + case "changed": + return i18n.T("shell.patchNotesDialog.counts.changed", count) + case "deprecated": + return i18n.T("shell.patchNotesDialog.counts.deprecated", count) + case "removed": + return i18n.T("shell.patchNotesDialog.counts.removed", count) + case "fixed": + return i18n.T("shell.patchNotesDialog.counts.fixed", count) + case "security": + return i18n.T("shell.patchNotesDialog.counts.security", count) + } + return fmt.Sprintf("%d %s", items, strings.ToLower(label)) +} + // notesGroupHeading is a group's divider and its dot and label. func notesGroupHeading(th *ui.Theme, label string, divider bool) layout.Widget { return func(gtx C) D { @@ -401,7 +442,7 @@ func notesGroupHeading(th *ui.Theme, label string, divider bool) layout.Widget { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return notesDotOf(gtx, noteDot(th, label), notesGroupDot) }), ui.Gap(1.5), - layout.Rigid(th.Text(ui.TextXS, strings.ToUpper(label)).Muted().Weight(font.SemiBold).Layout), + layout.Rigid(th.Text(ui.TextXS, strings.ToUpper(notesGroupLabel(label))).Muted().Weight(font.SemiBold).Layout), ) }), ui.VGap(1.5)) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) diff --git a/native/i18n/locales/en/shell.json b/native/i18n/locales/en/shell.json index 83c1fa55..2ed07903 100644 --- a/native/i18n/locales/en/shell.json +++ b/native/i18n/locales/en/shell.json @@ -70,7 +70,41 @@ "patchNotesDialog": { "title": "What's new in lich {version}", "viewChangelog": "View full changelog", - "gotIt": "Got it" + "gotIt": "Got it", + "groups": { + "added": "Added", + "changed": "Changed", + "deprecated": "Deprecated", + "removed": "Removed", + "fixed": "Fixed", + "security": "Security" + }, + "counts": { + "added": { + "one": "{count} added", + "other": "{count} added" + }, + "changed": { + "one": "{count} changed", + "other": "{count} changed" + }, + "deprecated": { + "one": "{count} deprecated", + "other": "{count} deprecated" + }, + "removed": { + "one": "{count} removed", + "other": "{count} removed" + }, + "fixed": { + "one": "{count} fixed", + "other": "{count} fixed" + }, + "security": { + "one": "{count} security", + "other": "{count} security" + } + } }, "planQuota": { "planUsage": "Plan usage", diff --git a/native/i18n/locales/es/shell.json b/native/i18n/locales/es/shell.json index 1691f552..6d6b03b2 100644 --- a/native/i18n/locales/es/shell.json +++ b/native/i18n/locales/es/shell.json @@ -70,7 +70,41 @@ "patchNotesDialog": { "title": "Novedades de lich {version}", "viewChangelog": "Ver el changelog completo", - "gotIt": "Entendido" + "gotIt": "Entendido", + "groups": { + "added": "Novedades", + "changed": "Cambios", + "deprecated": "Obsoleto", + "removed": "Eliminado", + "fixed": "Correcciones", + "security": "Seguridad" + }, + "counts": { + "added": { + "one": "{count} novedad", + "other": "{count} novedades" + }, + "changed": { + "one": "{count} cambio", + "other": "{count} cambios" + }, + "deprecated": { + "one": "{count} obsoleto", + "other": "{count} obsoletos" + }, + "removed": { + "one": "{count} eliminado", + "other": "{count} eliminados" + }, + "fixed": { + "one": "{count} corrección", + "other": "{count} correcciones" + }, + "security": { + "one": "{count} de seguridad", + "other": "{count} de seguridad" + } + } }, "planQuota": { "planUsage": "Uso del plan", diff --git a/native/i18n/locales/ja/shell.json b/native/i18n/locales/ja/shell.json index a66a9381..affa3209 100644 --- a/native/i18n/locales/ja/shell.json +++ b/native/i18n/locales/ja/shell.json @@ -70,7 +70,41 @@ "patchNotesDialog": { "title": "lich {version} の新機能", "viewChangelog": "変更履歴をすべて見る", - "gotIt": "了解" + "gotIt": "了解", + "groups": { + "added": "追加", + "changed": "変更", + "deprecated": "非推奨", + "removed": "削除", + "fixed": "修正", + "security": "セキュリティ" + }, + "counts": { + "added": { + "one": "{count} 件の追加", + "other": "{count} 件の追加" + }, + "changed": { + "one": "{count} 件の変更", + "other": "{count} 件の変更" + }, + "deprecated": { + "one": "{count} 件の非推奨", + "other": "{count} 件の非推奨" + }, + "removed": { + "one": "{count} 件の削除", + "other": "{count} 件の削除" + }, + "fixed": { + "one": "{count} 件の修正", + "other": "{count} 件の修正" + }, + "security": { + "one": "{count} 件のセキュリティ", + "other": "{count} 件のセキュリティ" + } + } }, "planQuota": { "planUsage": "プランの使用量", diff --git a/native/i18n/locales/ko/shell.json b/native/i18n/locales/ko/shell.json index 2b1a0aa3..df304827 100644 --- a/native/i18n/locales/ko/shell.json +++ b/native/i18n/locales/ko/shell.json @@ -70,7 +70,41 @@ "patchNotesDialog": { "title": "lich {version}의 새로운 기능", "viewChangelog": "전체 변경 내역 보기", - "gotIt": "확인" + "gotIt": "확인", + "groups": { + "added": "추가", + "changed": "변경", + "deprecated": "지원 중단", + "removed": "제거", + "fixed": "수정", + "security": "보안" + }, + "counts": { + "added": { + "one": "{count}개 추가", + "other": "{count}개 추가" + }, + "changed": { + "one": "{count}개 변경", + "other": "{count}개 변경" + }, + "deprecated": { + "one": "{count}개 지원 중단", + "other": "{count}개 지원 중단" + }, + "removed": { + "one": "{count}개 제거", + "other": "{count}개 제거" + }, + "fixed": { + "one": "{count}개 수정", + "other": "{count}개 수정" + }, + "security": { + "one": "{count}개 보안", + "other": "{count}개 보안" + } + } }, "planQuota": { "planUsage": "플랜 사용량", diff --git a/native/i18n/locales/pt-br/shell.json b/native/i18n/locales/pt-br/shell.json index 84ec7bb0..aae8ae48 100644 --- a/native/i18n/locales/pt-br/shell.json +++ b/native/i18n/locales/pt-br/shell.json @@ -70,7 +70,41 @@ "patchNotesDialog": { "title": "Novidades do lich {version}", "viewChangelog": "Ver o changelog completo", - "gotIt": "Entendi" + "gotIt": "Entendi", + "groups": { + "added": "Novidades", + "changed": "Mudanças", + "deprecated": "Obsoleto", + "removed": "Removido", + "fixed": "Correções", + "security": "Segurança" + }, + "counts": { + "added": { + "one": "{count} novidade", + "other": "{count} novidades" + }, + "changed": { + "one": "{count} mudança", + "other": "{count} mudanças" + }, + "deprecated": { + "one": "{count} obsoleto", + "other": "{count} obsoletos" + }, + "removed": { + "one": "{count} removido", + "other": "{count} removidos" + }, + "fixed": { + "one": "{count} correção", + "other": "{count} correções" + }, + "security": { + "one": "{count} de segurança", + "other": "{count} de segurança" + } + } }, "planQuota": { "planUsage": "Uso do plano", diff --git a/native/i18n/locales/ru/shell.json b/native/i18n/locales/ru/shell.json index c1a95120..67a53b64 100644 --- a/native/i18n/locales/ru/shell.json +++ b/native/i18n/locales/ru/shell.json @@ -70,7 +70,53 @@ "patchNotesDialog": { "title": "Что нового в lich {version}", "viewChangelog": "Открыть полный журнал изменений", - "gotIt": "Понятно" + "gotIt": "Понятно", + "groups": { + "added": "Добавлено", + "changed": "Изменено", + "deprecated": "Устарело", + "removed": "Удалено", + "fixed": "Исправлено", + "security": "Безопасность" + }, + "counts": { + "added": { + "one": "{count} новинка", + "few": "{count} новинки", + "many": "{count} новинок", + "other": "{count} новинки" + }, + "changed": { + "one": "{count} изменение", + "few": "{count} изменения", + "many": "{count} изменений", + "other": "{count} изменения" + }, + "deprecated": { + "one": "{count} устаревшее", + "few": "{count} устаревших", + "many": "{count} устаревших", + "other": "{count} устаревших" + }, + "removed": { + "one": "{count} удаление", + "few": "{count} удаления", + "many": "{count} удалений", + "other": "{count} удаления" + }, + "fixed": { + "one": "{count} исправление", + "few": "{count} исправления", + "many": "{count} исправлений", + "other": "{count} исправления" + }, + "security": { + "one": "{count} по безопасности", + "few": "{count} по безопасности", + "many": "{count} по безопасности", + "other": "{count} по безопасности" + } + } }, "planQuota": { "planUsage": "Использование тарифа", diff --git a/native/i18n/locales/zh-cn/shell.json b/native/i18n/locales/zh-cn/shell.json index 8552c93b..4b2015c6 100644 --- a/native/i18n/locales/zh-cn/shell.json +++ b/native/i18n/locales/zh-cn/shell.json @@ -70,7 +70,41 @@ "patchNotesDialog": { "title": "lich {version} 的新功能", "viewChangelog": "查看完整更新日志", - "gotIt": "知道了" + "gotIt": "知道了", + "groups": { + "added": "新增", + "changed": "变更", + "deprecated": "弃用", + "removed": "移除", + "fixed": "修复", + "security": "安全" + }, + "counts": { + "added": { + "one": "{count} 项新增", + "other": "{count} 项新增" + }, + "changed": { + "one": "{count} 项变更", + "other": "{count} 项变更" + }, + "deprecated": { + "one": "{count} 项弃用", + "other": "{count} 项弃用" + }, + "removed": { + "one": "{count} 项移除", + "other": "{count} 项移除" + }, + "fixed": { + "one": "{count} 项修复", + "other": "{count} 项修复" + }, + "security": { + "one": "{count} 项安全", + "other": "{count} 项安全" + } + } }, "planQuota": { "planUsage": "套餐用量", diff --git a/native/lichclient/update.go b/native/lichclient/update.go index 29052b1f..4658cbd1 100644 --- a/native/lichclient/update.go +++ b/native/lichclient/update.go @@ -78,10 +78,12 @@ func (c *Client) AppUpdateStatus(ctx context.Context) (AppUpdateStatus, error) { return s, c.Call(ctx, "appupdate.Status", &s) } -// PatchNotes returns the running build's changelog section. -func (c *Client) PatchNotes(ctx context.Context) (PatchNotes, error) { +// PatchNotes returns the running build's changelog section in locale, the +// interface language's tag; the backend falls back to English when the release +// has no translation into it. +func (c *Client) PatchNotes(ctx context.Context, locale string) (PatchNotes, error) { var n PatchNotes - return n, c.Call(ctx, "patchnotes.Current", &n) + return n, c.Call(ctx, "patchnotes.Current", &n, locale) } // PluginStatuses lists the lich plugin's state in every provider that can run diff --git a/native/lichclient/update_test.go b/native/lichclient/update_test.go index 059b5688..61f68db2 100644 --- a/native/lichclient/update_test.go +++ b/native/lichclient/update_test.go @@ -15,13 +15,13 @@ func TestPatchNotesKeepsNoSectionApartFromAnEmptyOne(t *testing.T) { "no section": {`{"version":"1.0.0","highlights":null,"groups":null}`, false}, "empty section": {`{"version":"1.0.0","highlights":null,"groups":[]}`, true}, } { - c := fakeBackend(t, func(method string, _ []json.RawMessage) (any, int) { - if method != "patchnotes.Current" { - t.Errorf("method %s", method) + c := fakeBackend(t, func(method string, args []json.RawMessage) (any, int) { + if method != "patchnotes.Current" || len(args) != 1 || string(args[0]) != `"pt-BR"` { + t.Errorf("call %s %s, want patchnotes.Current with the locale", method, args) } return json.RawMessage(tc.body), http.StatusOK }) - n, err := c.PatchNotes(context.Background()) + n, err := c.PatchNotes(context.Background(), "pt-BR") if err != nil || (n.Groups != nil) != tc.wantGroup { t.Errorf("%s: groups %#v, %v; want non-nil %v", name, n.Groups, err, tc.wantGroup) }