Localization string actions delegating to bandlab-localizer#215
Conversation
The plugin never edits string resource files directly — every add/remove delegates to the
bandlab-localizer CLI, which owns merge, validation, key-sorting and multi-locale output. The
actions read the manifest and shell out, so they work even when Gradle sync fails.
- "Localizer" submenu (Tools menu, Project View popup, editor popup), shown whenever the project's
bandlab-localizer-config.toml resolves:
- Update Strings -> `update-strings` (full sync).
- Add Strings -> dialog (target-file picker + multi-key paste) -> `update-strings --add-keys
--add-keys-to-file`. Targeted; pre-selects the clicked file's `[[file]]` group.
- Delete Strings -> dialog -> `update-strings --delete-keys`.
- Alt+Enter "Delete String" intention on a `<string>`/`<plurals>` element (any caret position) ->
`update-strings --delete-keys` for that one string.
- Runs the CLI as a tracked process in a Console tab (using the IDE's captured shell env so the
wrapper finds java), then refreshes the manifest's files on completion so open editors reload.
- Reads the manifest via the published `com.bandlab.localizer.config:config` (3.2.0-SNAPSHOT for now).
Related to bandlab#214.
…ocalizerOps - Editor banner (EditorNotificationProvider) on manifest string files, with Update / Add / Delete Strings quick links. - Alt+Enter "Localizer: Add string" intention on an `R.string.X` / `R.plurals.X` Kotlin reference that isn't defined locally yet -> `update-strings --add-keys X`. PSI-only (no Gradle sync). - Extract LocalizerOps so the menu actions, the panel, and the intentions share one CLI-invocation + file-refresh path instead of each building it.
Alt+Enter on a `<string>`/`<plurals>` element in a manifest string file re-fetches that one string fresh from Tolgee and overwrites it in place (base + every translation) via `update-strings --update-keys`. Mirrors the Delete String intention; routes through LocalizerOps.
- "Update Strings" (menu/panel) now opens a dialog: All strings (full sync) or Selected strings (`update-strings --update-keys <keys>`), so the scope is explicit instead of always full-sync. - The "Update String" intention also fires on an `R.string.X` / `R.plurals.X` reference whose key is already defined in a managed base file (re-fetch), mirroring the Add-string intention which fires when the key is not yet local.
- Editor: remove the Localizer submenu from the editor (text) popup — IntelliJ surfaces editor-popup actions in the Alt+Enter list, so it leaked "Update Strings" into the intention bulb. The warning banner + the intentions cover the editor; the submenu stays in Tools menu + Project View. - New: a non-blocking warning when hand-editing a managed string file (TypedHandlerDelegate, once per file per session) nudging toward the Localizer actions. Configurable under Settings > Tools > Localizer (LocalizerSettings + LocalizerSettingsConfigurable), on by default. - Disable buildSearchableOptions (collides with a running IDE; settings stay searchable without it).
- Implement Iconable on the Update/Delete/Add string intentions - Update ↻ (Actions.Refresh), Delete − (General.Remove), Add + (General.Add) so the bulb-list entries are visually distinguishable
- Recognize R.string/R.plurals references reached via an import alias (import com.app.R as appR) or a top-level typealias, not just bare R.string.X / com.app.R.string.X. bandlab-android disambiguates its several per-module R classes with import aliases, so the intentions previously didn't fire on the common audiostretchCommonStringsR.string.X form. - Resolve the receiver head (alias-aware) and check it stands for an R class; keep the textual receiver check as a fallback for unsynced/unindexed code. The key itself need not resolve, so the Add (unresolved-key) case still works. - Code references only; the XML <string>/<plurals> path is untouched. - Tests: import-alias, typealias, and typealias-to-non-R-class cases.
…lete intentions - Add AddStringUnresolvedQuickFixRegistrar: contributes "Localizer: Add string" to the red error-fix section on an unresolved R.string/R.plurals reference (key not defined yet), via the Kotlin K2 UNRESOLVED_REFERENCE diagnostic (org.jetbrains.kotlin.codeinsight.quickfix.registrar EP). Sits beside Android's "Create string value resource" instead of in the yellow intention list. Handles bare / FQN / import-aliased / typealiased receivers via shared resStringKey. - The always-on AddStringFromReferenceIntention stays as a fallback until the red-section path is confirmed in the IDE (transient duplicate in the unresolved case). - Mark Update/Delete intentions HighPriorityAction so they float to the top of the yellow list (they act on a known-existing string). - Declare K2 plugin-mode support (the diagnostic API is FIR/K2-only) + depend on the Kotlin plugin to register the EP.
Match its Update Strings / Delete Strings siblings (all open multi-key dialogs).
The red-section unresolved-reference quick fix now owns the Add case, so the always-on yellow AddStringFromReferenceIntention is redundant. Move the shared R.string detection helpers (resStringKeyAt / resStringKey / localBaseKeys) to ResStringReference.kt; drop the intention class + its plugin.xml registration.
Both fire on a <string> element in a managed string file; bump Update to PriorityAction.Priority.TOP (Delete stays HIGH) so Update lists first.
|
Reviewer guide Suggested reading order:
Worth a careful look:
Don't merge yet: pinned to Test: |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new “Localizer” feature set in the IntelliJ plugin that delegates all add/update/delete localization-key operations to the bandlab-localizer CLI (instead of editing string resource files directly), including menu actions, Alt+Enter intentions/quick-fixes, and editor warnings/banners. It also adds manifest-driven configuration resolution so these workflows can function even when Gradle sync is unavailable.
Changes:
- Adds Localizer infrastructure (manifest resolution, CLI runner, shared ops) and wires new “Localizer” menu actions for Update/Add/Delete.
- Adds PSI-based intentions (Update/Delete on XML + Update on references) and a K2 diagnostic-based quick fix to “Add string” for unresolved
R.string/R.pluralsreferences. - Adds UI/UX nudges for managed files (editor notification banner + one-time typing warning) and updates tests accordingly.
Reviewed changes
Copilot reviewed 28 out of 29 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/kotlin/com/bandlab/intellij/plugin/strings/UpdateStringsActionTest.kt | Removes legacy tests tied to the old module/file-based visibility logic. |
| src/test/kotlin/com/bandlab/intellij/plugin/strings/StringKeyAtTest.kt | Adds tests for extracting XML <string>/<plurals> keys at caret offsets. |
| src/test/kotlin/com/bandlab/intellij/plugin/strings/ResStringKeyAtTest.kt | Adds tests for detecting keys from R.string / R.plurals Kotlin references (incl. alias/typealias). |
| src/test/kotlin/com/bandlab/intellij/plugin/strings/LocalizerActionsTest.kt | Adds tests verifying Localizer actions show/hide based on manifest configuration. |
| src/test/kotlin/com/bandlab/intellij/plugin/localizer/LocalizerConfigServiceTest.kt | Adds tests for manifest parsing, target ordering, and file-to-target mapping. |
| src/test/kotlin/com/bandlab/intellij/plugin/localizer/KeyListTest.kt | Adds tests for parsing pasted key lists into normalized key arrays. |
| src/main/resources/META-INF/plugin.xml | Registers new intentions, notifications, typed handler, settings UI, K2 quickfix registrar, and a “Localizer” action group. |
| src/main/resources/intentionDescriptions/UpdateStringIntention/description.html | Adds IDE intention description for “Update String”. |
| src/main/resources/intentionDescriptions/DeleteStringIntention/description.html | Adds IDE intention description for “Delete String”. |
| src/main/resources/intentionDescriptions/AddStringFromReferenceIntention/description.html | Adds IDE intention description for “Add string” from unresolved reference. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/UpdateStringsDialog.kt | Introduces Update scope dialog (“All strings” vs “Selected strings”). |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/UpdateStringsAction.kt | Refactors Update action to delegate to shared Localizer ops. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/UpdateStringIntention.kt | Adds “Update String” intention (XML + existing-key R.string references). |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/ResStringReference.kt | Implements Kotlin R.string/R.plurals key detection and base-key scanning. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/LocalizerStringFileNotificationProvider.kt | Adds an editor banner for localizer-managed string files with action links. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/LocalizerOps.kt | Centralizes CLI argument construction + refresh list for Update/Add/Delete flows. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/DeleteStringsAction.kt | Adds menu action for deleting keys via the CLI. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/DeleteStringIntention.kt | Adds caret-level “Delete String” intention for <string>/<plurals> elements. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/AddStringUnresolvedQuickFix.kt | Adds K2 diagnostic-based quick fix to “Add string” on unresolved R.string references. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/AddStringsDialog.kt | Adds dialog for picking a target [[file]] group + pasting multiple keys. |
| src/main/kotlin/com/bandlab/intellij/plugin/strings/AddStringsAction.kt | Adds menu action for adding keys with optional context-target preselection. |
| src/main/kotlin/com/bandlab/intellij/plugin/localizer/LocalizerSettingsConfigurable.kt | Adds Settings UI under Tools > Localizer for warning toggles. |
| src/main/kotlin/com/bandlab/intellij/plugin/localizer/LocalizerSettings.kt | Persists Localizer settings at application level. |
| src/main/kotlin/com/bandlab/intellij/plugin/localizer/LocalizerRunner.kt | Runs the wrapper as a tracked process and refreshes affected files on completion. |
| src/main/kotlin/com/bandlab/intellij/plugin/localizer/LocalizerEditWarningTypedHandler.kt | Adds one-time-per-file typing warning notification for managed files. |
| src/main/kotlin/com/bandlab/intellij/plugin/localizer/LocalizerConfigService.kt | Loads/caches localizer manifest and maps managed base/translation files to targets. |
| src/main/kotlin/com/bandlab/intellij/plugin/localizer/LocalizerAction.kt | Introduces shared visibility gating for Localizer actions (global vs popup). |
| src/main/kotlin/com/bandlab/intellij/plugin/localizer/KeyList.kt | Adds key-list parsing utility used by dialogs and delete flow. |
| build.gradle.kts | Adds snapshot repo + dependency on com.bandlab.localizer.config:config and disables buildSearchableOptions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| internal fun resStringKeyAt(psiFile: PsiFile, offset: Int): String? { | ||
| val element = psiFile.findElementAt(offset) ?: psiFile.findElementAt(offset - 1) ?: return null | ||
| val ref = PsiTreeUtil.getParentOfType(element, KtNameReferenceExpression::class.java, false) ?: return null | ||
| return resStringKey(ref) | ||
| } |
| internal fun stringKeyAt(psiFile: PsiFile, offset: Int): String? { | ||
| val element = psiFile.findElementAt(offset) ?: psiFile.findElementAt(offset - 1) ?: return null | ||
| val tag = generateSequence(PsiTreeUtil.getParentOfType(element, XmlTag::class.java, false)) { it.parentTag } | ||
| .firstOrNull { it.name == "string" || it.name == "plurals" } ?: return null | ||
| return tag.getAttributeValue("name")?.takeIf { it.isNotBlank() } | ||
| } |
| internal fun localBaseKeys(project: Project): Set<String> { | ||
| val regex = Regex("<(?:string|plurals)\\s+name=\"([^\"]+)\"") | ||
| return project.service<LocalizerConfigService>().targets() | ||
| .flatMap { target -> | ||
| runCatching { target.baseFile.readText() }.getOrDefault("") | ||
| .let { text -> regex.findAll(text).map { it.groupValues[1] }.toList() } | ||
| } | ||
| .toSet() |
| internal fun managedKeyAt(project: Project, file: PsiFile, offset: Int): String? { | ||
| val service = project.service<LocalizerConfigService>() | ||
| val virtualFile = file.virtualFile | ||
| if (virtualFile != null && service.isManagedStringFile(virtualFile)) { | ||
| return stringKeyAt(file, offset) | ||
| } | ||
| return resStringKeyAt(file, offset)?.takeIf { it in localBaseKeys(project) } | ||
| } |
| override fun processTerminated(event: ProcessEvent) { | ||
| ApplicationManager.getApplication().invokeLater { | ||
| val lfs = LocalFileSystem.getInstance() | ||
| val files = refresh.mapNotNull { runCatching { lfs.refreshAndFindFileByNioFile(it) }.getOrNull() } | ||
| if (files.isEmpty()) return@invokeLater | ||
| // Re-stat + re-read from disk, then force open editors to reload the new | ||
| // content (a plain VFS refresh updates the cache but doesn't reload the | ||
| // editor's Document — reloadFiles does). | ||
| VfsUtil.markDirtyAndRefresh(false, false, false, *files.toTypedArray()) | ||
| FileDocumentManager.getInstance().reloadFiles(*files.toTypedArray()) | ||
| } | ||
| } |
…file - Map R class FQN -> module base path; an unresolved R.string/R.plurals quick fix adds straight to the single mapped target, or prompts when ambiguous/unmapped - Force an explicit target pick in the Add dialog (no silent default), pre-filling the key - Mark the "Add string" quick fix HighPriorityAction so it tops the Alt+Enter list
- First keystroke in a managed file is consumed and opens a blocking dialog (edit on this branch / Update / Add / Delete / cancel) instead of a passive balloon - Remember "edit on this branch" in the project workspace keyed by git branch (.git/HEAD, worktree-aware); unknown branch falls back to ask-once-ever - Drop the now-unused balloon notification group
Override EditorNotificationPanel.getIntentionAction() to null so the banner Update/Add/Delete labels stay on the banner but no longer surface as suggested intentions.
- Bundle config.jar (built from localizer 3.2 code) under libs/, depended on via files() - Declare config's runtime deps explicitly (app.gildor:ktoml-file, kotlinx-serialization-json) since file() deps carry no transitive metadata
- Drop the per-command buttons; the banner and Alt+Enter intentions already expose Update/Add/Delete, so the dialog just gates hand-editing - No default-highlighted button; the message points to the context actions and editor toolbar
- The Add-string quick fix and the Update/Delete intentions use PriorityAction.TOP so they head their groups - Register Update before Delete so they sort in that order
- Guard findElementAt(offset - 1) when the caret is at file start (resStringKeyAt, stringKeyAt) - localBaseKeys: match name= anywhere in the opening tag (not only as the first attribute), and cache the scan (invalidated on PSI change) so a moving caret no longer re-reads every base file - LocalizerRunner: skip the post-process VFS refresh when the project is already disposed
Replace the old "Update Localized Strings" section (module-level full re-sync) with a concise Localization Strings section covering the new surfaces: Add on unresolved references, per-key Update/Delete in string files, global Add/Update/Delete, and the managed-file edit reminder.
Both intentions were PriorityAction.TOP, which ties and tie-breaks alphabetically — putting Delete above Update. Restore Delete to HIGH so Update (TOP) sorts first while both still lead the intentions group.
The dominant bandlab-android form is `Strings.key` (typealias `Strings = R.string`, `Plurals = R.plurals`), where the alias lives beside `R` in the same strings module — previously undetected, so "Add string" never appeared. - Recognize a plain-name receiver `Strings`/`Plurals` as a string/plurals reference; map `<pkg>.Strings` → `<pkg>.R` by reading the import (text only, no type resolution — works without a Gradle sync). A matching import is required, so locals named `Strings` do not false-positive. - Add tests for the member-alias forms and a guard that Update outranks Delete in the ⌥⏎ popup.
A bare-name receiver like `Strings` is ambiguous, so recognizing it as a string reference unconditionally would offer "Add string" on any unrelated class named `Strings`. Gate it on the known strings-module packages — only `<pkg>.Strings`/`<pkg>.Plurals` whose `<pkg>.R` is in the hardcoded map count. - Lift R_CLASS_TO_MODULE to a shared internal constant + isKnownRClass(), reused by both target resolution and reference detection (single source of truth). - Explicit `R.string.X` forms stay ungated (syntactically unambiguous); only the bare-name form is gated.
Remove the application settings + Tools > Localizer configurable and the warn-on-edit toggle. The per-branch "edit on this branch" memory is the real control; reminding on every fresh branch is intentional — it builds the habit of going through the actions. A toggle can come back if anyone actually asks.
Both intentions are PriorityAction.TOP so they sit together at the top rather than being split by another TOP-priority action (Android's "Open editor"). IntelliJ only orders by priority tier then alphabetically — no custom ordinal — so the visible order is Delete then Update, accepted as the trade for keeping them adjacent.
Replace the stale terminal screenshot with shots of the main areas: add/update from a reference, the managed-file banner + context actions, the global actions, and the edit-warning dialog. Committed under docs/images and referenced by relative path (renders on GitHub; swap to absolute URLs if the in-IDE/Marketplace description needs them).
| private companion object { | ||
| // R class FQN -> module base_path prefix (matches the addKeysToFile path prefix). | ||
| // Hardcoded — devs are not expected to customize it. | ||
| val R_CLASS_TO_MODULE = mapOf( |
There was a problem hiding this comment.
@kevinguitar just put config here, so you can set prefix for string file
- Bump app.gildor:ktoml-file 0.8.0 → 0.8.1 (matches localizer 3.2.0) - Vendored libs/config.jar verified byte-identical to the published config 3.2.0 release artifact (libs-release), so no jar change needed
Move app.gildor:ktoml-file and kotlinx-serialization-json from inline coordinates into gradle/libs.versions.toml, per review.
Adds plugin actions for localization strings that never edit string resource files directly — every add/update/delete delegates to the
bandlab-localizerCLI, which owns merge, validation, key-sorting and multi-locale output (#214). They read the manifest and shell out, so they work even when Gradle sync fails.Manifest parsing uses the localizer
configmodel, vendored as a prebuilt jar underlibs/(built from localizer 3.2 code) — the plugin resolves it from there rather than a Maven repository, so the build needs no extra repo or credentials. Swap the jar when a releasedconfiglands.Surfaces
bandlab-localizer-config.tomlresolves:update-strings. The dialog offers All strings (full sync) or Selected strings →--update-keys.update-strings --add-keys --add-keys-to-file. The target is resolved from the reference'sRclass via a built-in package→module map; when it resolves to a single file the add runs straight through, and when it can't be resolved (or the action is invoked globally) you pick the target explicitly — no silent default.update-strings --delete-keys.R.string.X, package-qualifiedcom.app.R.string.X, import-aliasedaudiostretchCommonStringsR.string.X, andtypealias-ed (the bandlab-android convention is per-moduleRimport aliases):R.string.X/R.plurals.Xis unresolved (key doesn't exist yet), surfaced in the error-fix section via the Kotlin K2UNRESOLVED_REFERENCEdiagnostic →--add-keys. Sorts to the top of the fixes, above Android's "Create string value resource".<string>/<plurals>element, or on anR.string.Xreference whose key is already defined →--update-keys.<string>/<plurals>element (any caret position inside it) →--delete-keys.PriorityAction.TOPso they lead their group in the ⌥⏎ list (Update before Delete).getIntentionAction() = null) so the precise per-string intentions own that surface. Typing into a managed file raises a blocking-but-skippable dialog (Edit on this branch / Cancel) that gates hand-editing.Behavior
java), then refreshes the manifest's files on completion so open editors reload the CLI's changes.<string>/<plurals>path is pure PSI and needs no resolution or sync. Bare/qualified forms are matched textually (work unsynced); aliased forms resolve the alias to itsRclass..git/HEAD, worktree-aware; an undeterminable branch is prompted at most once), and the whole guard can be turned off in Settings → Tools → Localizer.Note: K2 plugin mode
Surfacing "Add string" on the unresolved-reference diagnostic uses the FIR/K2-only Analysis API, so the plugin declares
<supportsKotlinPluginMode supportsK2="true"/>and depends on the Kotlin plugin. It will be disabled in legacy K1 mode — fine for a 2025.3+ codebase, but a deliberate compatibility choice.Manually verified against bandlab-android: add / update / delete; editor reload; alias-form detection; the quick fix tops the unresolved-ref fixes; the edit-gating dialog and its per-branch memory.
Related to #214.