Skip to content

Localization string actions delegating to bandlab-localizer#215

Merged
kevinguitar merged 30 commits into
bandlab:mainfrom
gildor:feature/localizer-key-actions
Jun 15, 2026
Merged

Localization string actions delegating to bandlab-localizer#215
kevinguitar merged 30 commits into
bandlab:mainfrom
gildor:feature/localizer-key-actions

Conversation

@gildor

@gildor gildor commented Jun 8, 2026

Copy link
Copy Markdown
Member

Adds plugin actions for localization strings that never edit string resource files directly — every add/update/delete delegates to the bandlab-localizer CLI, 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 config model, vendored as a prebuilt jar under libs/ (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 released config lands.

Surfaces

  • "Localizer" submenu (Tools menu + Project View popup, BandLab icon), shown whenever bandlab-localizer-config.toml resolves:
    • Update Strings (↻) → update-strings. The dialog offers All strings (full sync) or Selected strings--update-keys.
    • Add Strings (+) → dialog (target-file picker + multi-key paste) → update-strings --add-keys --add-keys-to-file. The target is resolved from the reference's R class 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.
    • Delete Strings (−) → dialog → update-strings --delete-keys.
  • ⌥⏎ intentions / quick fixes on Android resource references and string elements — recognized in every form: bare R.string.X, package-qualified com.app.R.string.X, import-aliased audiostretchCommonStringsR.string.X, and typealias-ed (the bandlab-android convention is per-module R import aliases):
    • Add string (+) — when R.string.X / R.plurals.X is unresolved (key doesn't exist yet), surfaced in the error-fix section via the Kotlin K2 UNRESOLVED_REFERENCE diagnostic → --add-keys. Sorts to the top of the fixes, above Android's "Create string value resource".
    • Update (↻) — on a <string>/<plurals> element, or on an R.string.X reference whose key is already defined → --update-keys.
    • Delete (−) — on a <string>/<plurals> element (any caret position inside it) → --delete-keys.
    • All three use PriorityAction.TOP so they lead their group in the ⌥⏎ list (Update before Delete).
  • Editor banner on managed string files — the ambient "this file is localizer-managed" indicator, with Update / Add / Delete quick links. Its own actions are kept off the ⌥⏎ popup (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

  • 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 the CLI's changes.
  • Reference detection is code-only; the XML <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 its R class.
  • The edit-gating dialog only fires when you actually type in a managed file. It remembers the "Edit on this branch" choice per git branch (read from .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.

gildor added 13 commits June 8, 2026 15:09
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.
@gildor

gildor commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Reviewer guide

Suggested reading order:

  1. LocalizerConfigService — manifest parse (published :config) + the single isConfigured() gate (targets() / isManagedStringFile()).
  2. LocalizerRunner + LocalizerOps — command building (unit-tested) and the console-run-then-refresh.
  3. ResStringReference.ktR.string/R.plurals detection (textual + alias resolution).
  4. AddStringUnresolvedQuickFix.kt — the one part worth extra eyes (below).

Worth a careful look:

  • The "Add string" quick fix uses the FIR/K2-only Analysis API (KaFirDiagnostic.UnresolvedReference) via the codeinsight.quickfix.registrar EP — internal API, and it forces supportsKotlinPluginMode supportsK2="true" for the whole plugin (disabled in legacy K1). Intentional, but a compatibility call worth your sign-off.
  • Targeted ops stay targeted: Add → --add-keys, Delete → --delete-keys; only Update runs a full sync.

Don't merge yet: pinned to config:3.2.0-SNAPSHOT until localizer releases :config; will bump to the release coordinate + un-draft then.

Test: ./gradlew check (unit tests, no creds). Manual against bandlab-android — open a file with <moduleR>.string.<key>: unknown key → "Add string" in the error-fix section, known key → ⌥⏎ Update.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.plurals references.
  • 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.

Comment on lines +35 to +39
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)
}
Comment on lines +56 to +61
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() }
}
Comment on lines +84 to +91
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()
Comment on lines +58 to +65
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) }
}
Comment on lines +59 to +70
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())
}
}
gildor added 6 commits June 9, 2026 13:54
…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
@gildor gildor marked this pull request as ready for review June 9, 2026 10:56
gildor and others added 6 commits June 9, 2026 19:06
- 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.
gildor added 3 commits June 10, 2026 11:23
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(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevinguitar just put config here, so you can set prefix for string file

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

- 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
Comment thread build.gradle.kts Outdated
Move app.gildor:ktoml-file and kotlinx-serialization-json from inline coordinates into gradle/libs.versions.toml, per review.
@kevinguitar kevinguitar merged commit 9ffe618 into bandlab:main Jun 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants