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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .junie/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"github-JetBrains-junie-extensions": {
"url": "https://github.com/JetBrains/junie-extensions",
"extensions": [
"context7",
"kotlin-engineer"
],
"source": {
"type": "git",
"url": "https://github.com/JetBrains/junie-extensions"
}
}
}
89 changes: 89 additions & 0 deletions .junie/plans/custom-application.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Requirements

### Behavior
- The **IDE icon** selector gains a **Custom** option in both settings pages: the main project page ("Cat Activity") and **Override Defaults**.
- Selecting **Custom** reveals: **Application ID** field; a hint link below it (create an application in the Discord Developer Portal, `https://discord.com/developers/applications`); required **Application title** field; **Icon variant** selector with the same entries as `IDEIcon.entries` (New / Old / JetBrains / Cat Activity / Custom); when variant = Custom, a **Custom icon URL** field.
- **Override Defaults**: Application ID, title, and (for Custom variant) URL are mandatory; Apply is blocked with inline errors otherwise.
- **Project page**: fields are optional; blank values and `Default` variant inherit from Override Defaults. Non-blank values must be valid; effective (resolved) values must satisfy the Custom requirements, otherwise Apply is blocked.
- **Application ID validation (strict)**: digits only, 17-20 characters, must fit into `Long` (Discord snowflake).
- **Custom icon URL validation**: absolute HTTP(S) URL with a host; credentials and fragments rejected; query parameters allowed.
- **Runtime**: resolved Application ID drives the Discord SDK connection (project reinit on change); resolved title is the large-image tooltip (`largeImageText`); resolved variant/URL is used for the large icon and for the small icon in File mode.
- **Apply in Override Defaults re-renders all open non-default projects** so a new Application ID reconnects immediately.
- Invalid values persisted manually in XML never break rendering: fall back to the current IDE's Application ID/title and the standard New icon.

### Scope
- Only the existing bundles are updated (`ConfigBundle.properties` and `ConfigBundle_en.properties`).
- Out of scope: checking that the Application ID exists on Discord, managing app assets on the portal, README/docs updates, new locales.

# Technical Design

### Existing Pattern
- `IDEIcon` / `IDEIconDefaultable` enums pair an icon with an `IDEType` (app ID + title); `UnitedState` is a fresh merge snapshot of project + app defaults per render; `TimeService.render()` reinits `ActivityRender` when `clientID` changes; the custom-icon-server feature (commit `490acf3`) is the reference for Custom UI + `validationOnApply` + conditional rows.
- UI: Kotlin UI DSL v2; conditions from `com.intellij.ui.layout` (`visibleIf`, `selectedValueMatches`, combinable with `and`). Persistence: `XmlSerializerUtil.copyBean`, so new fields with defaults are backward/forward compatible.

### Changes and Contracts
1. **Enums** (`setting/DefaultsState.kt`, `setting/ProjectState.kt`):
- `IDEIcon.app: IDEType` -> `IDEType?`; add `Custom("Custom", null)`.
- `IDEIconDefaultable`: add `Custom("Custom", null)`.
- These same enums are reused for the icon variant selector (project variant additionally has `Default` = inherit).
2. **State fields** (defaults keep old behavior):
- `DefaultsState`: `customApplicationId = ""`, `customApplicationTitle = ""`, `customIconVariant: IDEIcon = IDEIcon.New`, `customIconUrl = ""`.
- `ProjectState`: same strings; `customIconVariant: IDEIconDefaultable = IDEIconDefaultable.Default`.
3. **Validation helpers** (top-level `internal` in `DefaultsState.kt`, next to `normalizeUrl`):
- `parseApplicationId(String): Long?` / `isValidApplicationId(String): Boolean` - trim, `^\d{17,20}$`, `toLongOrNull()`.
- `normalizeIconUrl(String): String?` - trim; absolute HTTP(S), host required, no userinfo/fragment; **query allowed**. `normalizeUrl` (server) stays unchanged.
4. **`UnitedState`** - replace `ideIcon: IDEIcon` with resolved fields: `applicationId: Long`, `applicationTitle: String`, `iconVariant: IDEIcon`, `customIconUrl: String?`:
- Not Custom -> ID/title from `icon.app` (fallback `currentIDEType`), variant = icon, URL = null.
- Custom -> project value `ifBlank` -> default value; variant `Default` -> defaults' variant; URL only when effective variant = Custom; invalid/missing values fall back to `currentIDEType` ID/title and New icon.
- `resolveIDEIcon` exhaustive `when` gains `Custom`.
5. **`TimeService`**:
- `buildIDEIconUrl(iconVariant, customIconUrl, theme, iconsUrl)`; `IDEIcon.Custom` -> custom URL (New URL as a safety fallback).
- `applyIDEInfo`/`applyFileInfo` accept `UnitedState` (title = `applicationTitle`); all four call sites updated.
- Reinit check uses `configState.applicationId`; `ActivityRender` keeps its default `clientID = currentIDEType.applicationId` and switches on first render.
6. **UI**:
- `DefaultsConfigurable`: rows after IDE icon, `visibleIf(ideIcon == Custom)` - Application ID (validationOnApply), link row (`BrowserUtil.browse`), Application title (validationOnApply non-blank), Icon variant combo; URL row visibility = combined condition (`selectedValueMatches` on both combos joined with `and`), validationOnApply only when effective variant Custom. `apply()` additionally renders open non-default projects via `ProjectManager`.
- `ProjectConfigurable`: same rows using `IDEIconDefaultable`; `emptyText = leaveBlankDefaultValue` on text fields; validation resolves effective values from `CatActivitySettingAppState` (project `ifBlank` -> defaults); variant `Default` inherits.
7. **Bundles** (both files): `customApplicationId`, `customApplicationTitle`, `customIconVariant`, `customIconUrl`, `createApplicationLink`, `invalidApplicationId`, `emptyApplicationTitle`, `invalidCustomIconUrl`, plus a short tooltip help for title.

### File Structure
- Modify: `src/main/kotlin/cat/wavy/catactivity/setting/{DefaultsState,ProjectState,UnitedState,DefaultsConfigurable,ProjectConfigurable}.kt`, `service/TimeService.kt`, `src/main/resources/messages/ConfigBundle.properties`, `ConfigBundle_en.properties`.
- Add: `src/test/kotlin/cat/wavy/catactivity/setting/CustomApplicationValidationTest.kt`.
- Unchanged: `render/ActivityRender.kt` (only its default clientID is used as a fallback), `CatSettingState.kt`, `types/IDEType.kt`, `plugin.xml`.

### Settings Flow
```mermaid
graph LR
UI[Configurables] --> States[DefaultsState / ProjectState]
States --> United[UnitedState resolution]
United --> Time[TimeService: URL, title, clientID]
Time --> Render[ActivityRender reinit / updateActivity]
```

# Testing

### Automated Checks
- Plain JUnit 4 tests (no platform deps) for the pure helpers: snowflake acceptance (17-20 digits) and rejection (blank, non-digit, too short/long, Long overflow); `normalizeIconUrl` acceptance (HTTP/HTTPS, port, base path, query) and rejection (relative, ftp, userinfo, fragment, blank); `normalizeUrl` regression unchanged. `UnitedState`/`IDEIcon` tests are intentionally not added - enum init touches `currentIDEType`, which requires the platform runtime.
- `.\gradlew.bat check` (compile + tests) and `.\gradlew.bat verifyPlugin`.
- Sandbox smoke test via `.\gradlew.bat runIde`: open both settings pages, exercise validation, Apply re-render, inheritance, and File-mode icon swap.

# Delivery Steps

### Step 1: Settings model and validators
Enums and both state classes support custom app values; validation helpers exist and are unit-tested.
- Extend `IDEIcon`/`IDEIconDefaultable`; add state fields; add `parseApplicationId`/`isValidApplicationId`/`normalizeIconUrl`; add `CustomApplicationValidationTest`.

### Step 2: Resolution and runtime wiring
Custom values reach Discord: app ID/title/icon are resolved and applied per render.
- Extend `UnitedState` resolution (inheritance + fallbacks); rework `buildIDEIconUrl`, `applyIDEInfo`, `applyFileInfo`, and the `clientID` reinit check in `TimeService`.

### Step 3: Override Defaults UI
Custom controls with validation and an immediate effect.
- Add conditional rows + link + validation to `DefaultsConfigurable`; re-render open projects on apply; add bundle keys to both files.

### Step 4: Project settings UI
Overrides with blank/`Default` inheritance.
- Add the same rows to `ProjectConfigurable` using `IDEIconDefaultable`, `leaveBlankDefaultValue`, and effective-value validation.

### Step 5: Verification
Build, tests, and manual smoke checks pass; bundle keys resolve.
- Run `.\gradlew.bat check` and `.\gradlew.bat verifyPlugin`; smoke-test in `runIde`.
31 changes: 16 additions & 15 deletions src/main/kotlin/cat/wavy/catactivity/service/TimeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ class TimeService : Disposable {
state = configState.idleStateFormat.replaceVariables(variables),
details = configState.idleDetailFormat.ifBlank { null }?.replaceVariables(variables),
startTimestamp = startIdleTimestamp
).applyIDEInfo(configState.ideIcon, configState.theme, configState.iconsUrl)
.applyFileInfo(configState.ideIcon, configState.theme, configState.iconsUrl)
).applyIDEInfo(configState)
.applyFileInfo(configState)

activityWrapper.let {
activityRender.updateActivity(it)
Expand Down Expand Up @@ -179,8 +179,8 @@ class TimeService : Disposable {
state = configState.fileStateFormat.replaceVariables(variables),
details = configState.fileDetailFormat.ifBlank { null }?.replaceVariables(variables),
startTimestamp = editingFile?.key?.let { timeTracker.getIfPresent(it) },
).applyIDEInfo(configState.ideIcon, configState.theme, configState.iconsUrl)
.applyFileInfo(configState.ideIcon, configState.theme, configState.iconsUrl)
).applyIDEInfo(configState)
.applyFileInfo(configState)

if (configState.showRepositoryButton && !gitRemotes.isNullOrEmpty()) {
activityWrapper.applyRepoButton(gitRemotes[0])
Expand All @@ -192,7 +192,7 @@ class TimeService : Disposable {
state = configState.projectStateFormat.replaceVariables(variables),
details = configState.projectDetailFormat.ifBlank { null }?.replaceVariables(variables),
startTimestamp = editingProject?.key?.let { timeTracker.getIfPresent(it) },
).applyIDEInfo(configState.ideIcon, configState.theme, configState.iconsUrl)
).applyIDEInfo(configState)

if (configState.showRepositoryButton && !gitRemotes.isNullOrEmpty()) {
activityWrapper.applyRepoButton(gitRemotes[0])
Expand All @@ -203,14 +203,14 @@ class TimeService : Disposable {
activityWrapper = ActivityWrapper(
state = ApplicationInfoEx.getInstanceEx().fullApplicationName,
startTimestamp = startTime,
).applyIDEInfo(configState.ideIcon, configState.theme, configState.iconsUrl)
).applyIDEInfo(configState)
}
}

thisLogger().info("Rendering file: ${activityWrapper.details}")

if (activityRender.clientID != configState.ideIcon.app.applicationId) activityRender.apply {
clientID = configState.ideIcon.app.applicationId
if (activityRender.clientID != configState.applicationId) activityRender.apply {
clientID = configState.applicationId
project.service<ActivityNotificationService>().ignoreReloadAlert.set(false)
reinit(false)
}
Expand Down Expand Up @@ -261,18 +261,18 @@ class TimeService : Disposable {
return this
}

private fun ActivityWrapper.applyIDEInfo(ideIcon: IDEIcon, theme: Theme, iconsUrl: String): ActivityWrapper {
largeImageKey = buildIDEIconUrl(ideIcon, theme, iconsUrl)
largeImageText = ideIcon.app.title
private fun ActivityWrapper.applyIDEInfo(configState: UnitedState): ActivityWrapper {
largeImageKey = buildIDEIconUrl(configState.iconVariant, configState.customIconUrl, configState.theme, configState.iconsUrl)
largeImageText = configState.applicationTitle
return this
}

private fun ActivityWrapper.applyFileInfo(ideIcon: IDEIcon, theme: Theme, iconsUrl: String): ActivityWrapper {
private fun ActivityWrapper.applyFileInfo(configState: UnitedState): ActivityWrapper {
editingFile?.let {
val type = getFileTypeByName(it.type, it.fileName, it.extension)
smallImageKey = buildIDEIconUrl(ideIcon, theme, iconsUrl)
smallImageKey = buildIDEIconUrl(configState.iconVariant, configState.customIconUrl, configState.theme, configState.iconsUrl)
smallImageText = largeImageText // swap
largeImageKey = buildLangIconUrl(type.icon, theme, iconsUrl)
largeImageKey = buildLangIconUrl(type.icon, configState.theme, configState.iconsUrl)
largeImageText = type.typeName
}
return this
Expand All @@ -284,11 +284,12 @@ class TimeService : Disposable {
internal fun buildLangIconUrl(icon: String, theme: Theme, iconsUrl: String): String =
"$iconsUrl/${theme.name}/$icon.webp"

internal fun buildIDEIconUrl(ideIcon: IDEIcon, theme: Theme, iconsUrl: String): String = when (ideIcon) {
internal fun buildIDEIconUrl(iconVariant: IDEIcon, customIconUrl: String?, theme: Theme, iconsUrl: String): String = when (iconVariant) {
IDEIcon.New -> "$iconsUrl/IDE/new/${theme.name}/${currentIDEType.icon}.webp"
IDEIcon.Old -> "$iconsUrl/IDE/${theme.name}/${currentIDEType.icon}.webp"
IDEIcon.JetBrains -> "$iconsUrl/IDE/${theme.name}/jetbrains.webp"
IDEIcon.CatActivity -> "$iconsUrl/IDE/${theme.name}/cat_activity.webp"
IDEIcon.Custom -> customIconUrl ?: "$iconsUrl/IDE/${theme.name}/cat_activity.webp"
}

sealed class TimedItem(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package cat.wavy.catactivity.setting

import cat.wavy.catactivity.bundle.ConfigBundle
import cat.wavy.catactivity.service.TimeService
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.components.service
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.ui.ComboBox
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBTextField
import com.intellij.ui.dsl.builder.*
import com.intellij.ui.layout.and
import com.intellij.ui.layout.enteredTextSatisfies
import com.intellij.ui.layout.selectedValueMatches
import com.intellij.ui.dsl.listCellRenderer.textListCellRenderer
Expand All @@ -17,6 +20,8 @@ class DefaultsConfigurable : Configurable {
private val panel = panel {
val state = service<CatActivitySettingAppState>().state
var customServerSelector: ComboBox<Boolean>? = null
var ideIconSelector: ComboBox<IDEIcon>? = null
var iconVariantSelector: ComboBox<IDEIcon>? = null

group(ConfigBundle.message("display")) {
row(ConfigBundle.message("theme")) {
Expand All @@ -26,11 +31,65 @@ class DefaultsConfigurable : Configurable {
row(ConfigBundle.message("ideIcons")) {
comboBox(items = IDEIcon.entries)
.applyToComponent {
ideIconSelector = this
renderer = textListCellRenderer("") { it.displayName }
}
.bindItem(state::ideIcon.toNullableProperty())
}

val isCustomApplication = ideIconSelector!!.selectedValueMatches { it == IDEIcon.Custom }

row(ConfigBundle.message("customApplicationId")) {
textField().columns(COLUMNS_LARGE)
.bindText(state::customApplicationId)
.validationOnApply {
if (state.ideIcon == IDEIcon.Custom && !isValidApplicationId(state.customApplicationId)) {
error(ConfigBundle.message("invalidApplicationId"))
} else null
}

contextHelp(ConfigBundle.message("customApplicationHelp"))
}.visibleIf(isCustomApplication)

row {
link(ConfigBundle.message("createApplicationLink")) {
BrowserUtil.browse("https://discord.com/developers/applications")
}
}.visibleIf(isCustomApplication)

row(ConfigBundle.message("customApplicationTitle")) {
textField().columns(COLUMNS_LARGE)
.bindText(state::customApplicationTitle)
.validationOnApply {
if (state.ideIcon == IDEIcon.Custom && state.customApplicationTitle.isBlank()) {
error(ConfigBundle.message("emptyApplicationTitle"))
} else null
}

contextHelp(ConfigBundle.message("customApplicationTitleHelp"))
}.visibleIf(isCustomApplication)

row(ConfigBundle.message("customIconVariant")) {
comboBox(items = IDEIcon.entries)
.applyToComponent {
iconVariantSelector = this
renderer = textListCellRenderer("") { it.displayName }
}
.bindItem(state::customIconVariant.toNullableProperty())
}.visibleIf(isCustomApplication)

row(ConfigBundle.message("customIconUrl")) {
textField().columns(COLUMNS_LARGE)
.bindText(state::customIconUrl)
.validationOnApply {
if (state.ideIcon == IDEIcon.Custom && state.customIconVariant == IDEIcon.Custom &&
normalizeIconUrl(state.customIconUrl) == null
) {
error(ConfigBundle.message("invalidCustomIconUrl"))
} else null
}
}.visibleIf(isCustomApplication and iconVariantSelector!!.selectedValueMatches { it == IDEIcon.Custom })

row(ConfigBundle.message("iconsServer")) {
comboBox(items = listOf(false, true))
.applyToComponent {
Expand Down Expand Up @@ -151,7 +210,11 @@ class DefaultsConfigurable : Configurable {

override fun isModified(): Boolean = panel.isModified()

override fun apply() = panel.apply()
override fun apply() = panel.apply().also {
ProjectManager.getInstance().openProjects
.filter { !it.isDefault }
.forEach { project -> project.service<TimeService>().render(project) }
}

override fun reset() = panel.reset()

Expand Down
Loading
Loading