diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendMigrationManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendMigrationManager.kt index ad4ef0a8a6d..7174290f0b5 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendMigrationManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendMigrationManager.kt @@ -9,6 +9,7 @@ import ai.kilocode.backend.migration.LegacyMigrationHttpBackend import ai.kilocode.backend.migration.LegacyMigrationReport import ai.kilocode.backend.migration.LegacyMigrationSelections import ai.kilocode.backend.migration.LegacyMigrationSink +import ai.kilocode.backend.migration.LegacyMigrationStatus import ai.kilocode.backend.migration.LegacyMigrationStore import okhttp3.OkHttpClient @@ -26,6 +27,12 @@ class KiloBackendMigrationManager( private fun base() = "http://127.0.0.1:$port" private fun httpBackend(): LegacyMigrationBackend = LegacyMigrationHttpBackend(client, base()) + fun status(store: LegacyMigrationStore): LegacyMigrationStatus? = + LegacyMigrationEngine(store, httpBackend()).status() + + fun mark(store: LegacyMigrationStore, status: LegacyMigrationStatus) = + LegacyMigrationEngine(store, httpBackend()).mark(status) + fun detect(store: LegacyMigrationStore): LegacyMigrationDetection = LegacyMigrationEngine(store, httpBackend()).detect() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt new file mode 100644 index 00000000000..fb7e536662e --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt @@ -0,0 +1,50 @@ +package ai.kilocode.backend.migration + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service + +/** + * Provides the production [LegacyMigrationStore] for use by the migration RPC implementation. + * + * Status persistence uses [PropertiesComponent] (app-level JetBrains persistent store). + * Raw legacy source acquisition is not yet implemented; the store returns null for all + * data accessors, so [LegacyMigrationEngine.detect] will report hasData=false and the + * migration wizard will remain hidden until a real source adapter is plugged in. + */ +@Service(Service.Level.APP) +class KiloBackendLegacyMigrationStoreService { + + companion object { + private const val STATUS_KEY = "kilo.legacyMigrationStatus" + + fun getInstance(): KiloBackendLegacyMigrationStoreService = service() + } + + fun store(): LegacyMigrationStore = PersistentStatusStore() + + private inner class PersistentStatusStore : LegacyMigrationStore { + override fun status(): LegacyMigrationStatus? { + val raw = PropertiesComponent.getInstance().getValue(STATUS_KEY) ?: return null + return runCatching { LegacyMigrationStatus.valueOf(raw) }.getOrNull() + } + + override fun mark(status: LegacyMigrationStatus) { + PropertiesComponent.getInstance().setValue(STATUS_KEY, status.name) + } + + // Legacy source adapters — not yet implemented; return null to suppress migration UI. + override fun providerProfilesRaw(): String? = null + override fun oauthRaw(key: String): String? = null + override fun mcpSettingsRaw(): String? = null + override fun customModesRaw(): String? = null + override fun customModePromptsRaw(): String? = null + override fun autocompleteRaw(): String? = null + override fun globalStateValue(key: String) = null + override fun taskHistoryRaw(): String? = null + override fun taskConversationRaw(id: String): String? = null + + override fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport = + LegacyCleanupReport(cleaned = emptyList(), errors = emptyList()) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt new file mode 100644 index 00000000000..ab682ac6229 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt @@ -0,0 +1,104 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService +import ai.kilocode.backend.migration.LegacyMigrationResultItem +import ai.kilocode.backend.migration.LegacyMigrationSink +import ai.kilocode.backend.migration.LegacyMigrationStatus +import ai.kilocode.backend.migration.MigrationItemCategory +import ai.kilocode.backend.migration.MigrationItemStatus +import ai.kilocode.rpc.KiloMigrationRpcApi +import ai.kilocode.rpc.dto.LegacyCleanupReportDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import ai.kilocode.backend.app.KiloBackendMigrationManager +import com.intellij.openapi.components.service +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.withContext + +class KiloMigrationRpcApiImpl : KiloMigrationRpcApi { + + private val app: KiloBackendAppService get() = service() + private val storeService: KiloBackendLegacyMigrationStoreService get() = service() + + private fun manager(): KiloBackendMigrationManager { + val http = app.http ?: throw IllegalStateException("Not connected") + val port = app.port + return KiloBackendMigrationManager(http, port) + } + + override suspend fun status(): LegacyMigrationStatusDto? { + val mgr = manager() + val store = storeService.store() + val status = mgr.status(store) ?: return null + return MigrationRpcMapper.toDto(status) + } + + override suspend fun detect(): LegacyMigrationDetectionDto { + val mgr = manager() + val store = storeService.store() + val detection = withContext(Dispatchers.IO) { mgr.detect(store) } + return MigrationRpcMapper.toDto(detection) + } + + override suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow { + val mgr = manager() + val domainSelections = MigrationRpcMapper.fromDto(selections) + val store = storeService.store() + return channelFlow { + withContext(Dispatchers.IO) { + val sink = object : LegacyMigrationSink { + override fun item(progress: ai.kilocode.backend.migration.LegacyMigrationItemProgress) { + trySendBlocking(LegacyMigrationEventDto.Item(MigrationRpcMapper.toDto(progress))) + } + override fun session(progress: ai.kilocode.backend.migration.LegacyMigrationSessionProgress) { + trySendBlocking(LegacyMigrationEventDto.Session(MigrationRpcMapper.toDto(progress))) + } + } + val report = runCatching { + mgr.migrate(store, domainSelections, sink) + }.getOrElse { e -> + val msg = e.message ?: "Migration failed" + val errItem = LegacyMigrationResultItem( + item = "Migration", + category = MigrationItemCategory.settings, + status = MigrationItemStatus.error, + message = msg, + ) + trySendBlocking(LegacyMigrationEventDto.Complete(listOf(MigrationRpcMapper.toDto(errItem)))) + return@withContext + } + trySendBlocking(LegacyMigrationEventDto.Complete(report.items.map(MigrationRpcMapper::toDto))) + } + } + } + + override suspend fun skip() { + val mgr = manager() + val store = storeService.store() + mgr.mark(store, LegacyMigrationStatus.Skipped) + } + + override suspend fun finalize(status: LegacyMigrationStatusDto) { + val mgr = manager() + val store = storeService.store() + val domain = MigrationRpcMapper.fromDto(status) + if (domain == LegacyMigrationStatus.Skipped) return + mgr.mark(store, domain) + } + + override suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto { + val mgr = manager() + val store = storeService.store() + val report = withContext(Dispatchers.IO) { mgr.cleanup(store, MigrationRpcMapper.fromDto(targets)) } + return MigrationRpcMapper.toDto(report) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiProvider.kt new file mode 100644 index 00000000000..eb6e67fb8c7 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiProvider.kt @@ -0,0 +1,15 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.KiloMigrationRpcApi +import com.intellij.platform.rpc.backend.RemoteApiProvider +import fleet.rpc.remoteApiDescriptor + +internal class KiloMigrationRpcApiProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + KiloMigrationRpcApiImpl() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/MigrationRpcMapper.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/MigrationRpcMapper.kt new file mode 100644 index 00000000000..e9ab75a564f --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/MigrationRpcMapper.kt @@ -0,0 +1,230 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.migration.LegacyAutocompleteSettings +import ai.kilocode.backend.migration.LegacyCleanupReport +import ai.kilocode.backend.migration.LegacyCleanupTargets +import ai.kilocode.backend.migration.LegacyMigrationDetection +import ai.kilocode.backend.migration.LegacyMigrationItemProgress +import ai.kilocode.backend.migration.LegacyMigrationResultItem +import ai.kilocode.backend.migration.LegacyMigrationSelections +import ai.kilocode.backend.migration.LegacyMigrationSessionProgress +import ai.kilocode.backend.migration.LegacyMigrationStatus +import ai.kilocode.backend.migration.LegacySettings +import ai.kilocode.backend.migration.MigrationAutoApprovalSelections +import ai.kilocode.backend.migration.MigrationCustomModeInfo +import ai.kilocode.backend.migration.MigrationDefaultModelInfo +import ai.kilocode.backend.migration.MigrationItemCategory +import ai.kilocode.backend.migration.MigrationItemProgressStatus +import ai.kilocode.backend.migration.MigrationItemStatus +import ai.kilocode.backend.migration.MigrationMcpServerInfo +import ai.kilocode.backend.migration.MigrationProviderInfo +import ai.kilocode.backend.migration.MigrationSessionInfo +import ai.kilocode.backend.migration.MigrationSessionPhase +import ai.kilocode.backend.migration.MigrationSettingsSelections +import ai.kilocode.backend.migration.MigrationSessionSelection +import ai.kilocode.rpc.dto.LegacyAutocompleteSettingsDto +import ai.kilocode.rpc.dto.LegacyCleanupReportDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationItemProgressDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import ai.kilocode.rpc.dto.LegacySettingsDto +import ai.kilocode.rpc.dto.MigrationAutoApprovalSelectionsDto +import ai.kilocode.rpc.dto.MigrationCustomModeInfoDto +import ai.kilocode.rpc.dto.MigrationDefaultModelInfoDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationMcpServerInfoDto +import ai.kilocode.rpc.dto.MigrationProviderInfoDto +import ai.kilocode.rpc.dto.MigrationSessionInfoDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto +import ai.kilocode.rpc.dto.MigrationSessionSelectionDto +import ai.kilocode.rpc.dto.MigrationSettingsSelectionsDto + +internal object MigrationRpcMapper { + + // ----------------------------------------------------------------------- + // Status + // ----------------------------------------------------------------------- + + fun toDto(status: LegacyMigrationStatus): LegacyMigrationStatusDto = when (status) { + LegacyMigrationStatus.Completed -> LegacyMigrationStatusDto.completed + LegacyMigrationStatus.CompletedWithErrors -> LegacyMigrationStatusDto.completed_with_errors + LegacyMigrationStatus.Skipped -> LegacyMigrationStatusDto.skipped + } + + fun fromDto(dto: LegacyMigrationStatusDto): LegacyMigrationStatus = when (dto) { + LegacyMigrationStatusDto.completed -> LegacyMigrationStatus.Completed + LegacyMigrationStatusDto.completed_with_errors -> LegacyMigrationStatus.CompletedWithErrors + LegacyMigrationStatusDto.skipped -> LegacyMigrationStatus.Skipped + } + + // ----------------------------------------------------------------------- + // Detection + // ----------------------------------------------------------------------- + + fun toDto(detection: LegacyMigrationDetection): LegacyMigrationDetectionDto = + LegacyMigrationDetectionDto( + providers = detection.providers.map(::toDto), + mcpServers = detection.mcpServers.map(::toDto), + customModes = detection.customModes.map(::toDto), + sessions = detection.sessions.map(::toDto), + defaultModel = detection.defaultModel?.let(::toDto), + settings = detection.settings?.let(::toDto), + hasData = detection.hasData, + ) + + private fun toDto(p: MigrationProviderInfo): MigrationProviderInfoDto = + MigrationProviderInfoDto( + profileName = p.profileName, + provider = p.provider, + model = p.model, + hasApiKey = p.hasApiKey, + supported = p.supported, + newProviderName = p.newProviderName, + ) + + private fun toDto(m: MigrationMcpServerInfo): MigrationMcpServerInfoDto = + MigrationMcpServerInfoDto(name = m.name, type = m.type, disabled = m.disabled) + + private fun toDto(c: MigrationCustomModeInfo): MigrationCustomModeInfoDto = + MigrationCustomModeInfoDto(name = c.name, slug = c.slug, nativeSlug = c.nativeSlug) + + fun toDto(s: MigrationSessionInfo): MigrationSessionInfoDto = + MigrationSessionInfoDto(id = s.id, title = s.title, directory = s.directory, time = s.time) + + private fun toDto(d: MigrationDefaultModelInfo): MigrationDefaultModelInfoDto = + MigrationDefaultModelInfoDto(provider = d.provider, model = d.model) + + private fun toDto(s: LegacySettings): LegacySettingsDto = + LegacySettingsDto( + autoApprovalEnabled = s.autoApprovalEnabled, + allowedCommands = s.allowedCommands, + deniedCommands = s.deniedCommands, + alwaysAllowReadOnly = s.alwaysAllowReadOnly, + alwaysAllowReadOnlyOutsideWorkspace = s.alwaysAllowReadOnlyOutsideWorkspace, + alwaysAllowWrite = s.alwaysAllowWrite, + alwaysAllowExecute = s.alwaysAllowExecute, + alwaysAllowMcp = s.alwaysAllowMcp, + alwaysAllowModeSwitch = s.alwaysAllowModeSwitch, + alwaysAllowSubtasks = s.alwaysAllowSubtasks, + language = s.language, + autocomplete = s.autocomplete?.let(::toDto), + ) + + private fun toDto(a: LegacyAutocompleteSettings): LegacyAutocompleteSettingsDto = + LegacyAutocompleteSettingsDto( + enableAutoTrigger = a.enableAutoTrigger, + enableSmartInlineTaskKeybinding = a.enableSmartInlineTaskKeybinding, + enableChatAutocomplete = a.enableChatAutocomplete, + ) + + // ----------------------------------------------------------------------- + // Selections (DTO → domain) + // ----------------------------------------------------------------------- + + fun fromDto(dto: LegacyMigrationSelectionsDto): LegacyMigrationSelections = + LegacyMigrationSelections( + providers = dto.providers, + mcpServers = dto.mcpServers, + customModes = dto.customModes, + sessions = dto.sessions.map(::fromDto), + defaultModel = dto.defaultModel, + settings = fromDto(dto.settings), + ) + + private fun fromDto(dto: MigrationSessionSelectionDto): MigrationSessionSelection = + MigrationSessionSelection(id = dto.id, force = dto.force) + + private fun fromDto(dto: MigrationSettingsSelectionsDto): MigrationSettingsSelections = + MigrationSettingsSelections( + autoApproval = fromDto(dto.autoApproval), + language = dto.language, + autocomplete = dto.autocomplete, + ) + + private fun fromDto(dto: MigrationAutoApprovalSelectionsDto): MigrationAutoApprovalSelections = + MigrationAutoApprovalSelections( + commandRules = dto.commandRules, + readPermission = dto.readPermission, + writePermission = dto.writePermission, + executePermission = dto.executePermission, + mcpPermission = dto.mcpPermission, + taskPermission = dto.taskPermission, + ) + + // ----------------------------------------------------------------------- + // Progress / result + // ----------------------------------------------------------------------- + + fun toDto(p: LegacyMigrationItemProgress): LegacyMigrationItemProgressDto = + LegacyMigrationItemProgressDto(item = p.item, status = toDto(p.status), message = p.message) + + fun toDto(p: LegacyMigrationSessionProgress): LegacyMigrationSessionProgressDto = + LegacyMigrationSessionProgressDto( + session = p.session?.let(::toDto), + index = p.index, + total = p.total, + phase = toDto(p.phase), + error = p.error, + ) + + fun toDto(r: LegacyMigrationResultItem): LegacyMigrationResultItemDto = + LegacyMigrationResultItemDto( + item = r.item, + category = toDto(r.category), + status = toDto(r.status), + message = r.message, + ) + + private fun toDto(s: MigrationItemProgressStatus): MigrationItemProgressStatusDto = when (s) { + MigrationItemProgressStatus.migrating -> MigrationItemProgressStatusDto.migrating + MigrationItemProgressStatus.success -> MigrationItemProgressStatusDto.success + MigrationItemProgressStatus.warning -> MigrationItemProgressStatusDto.warning + MigrationItemProgressStatus.error -> MigrationItemProgressStatusDto.error + } + + private fun toDto(p: MigrationSessionPhase): MigrationSessionPhaseDto = when (p) { + MigrationSessionPhase.preparing -> MigrationSessionPhaseDto.preparing + MigrationSessionPhase.storing -> MigrationSessionPhaseDto.storing + MigrationSessionPhase.skipped -> MigrationSessionPhaseDto.skipped + MigrationSessionPhase.done -> MigrationSessionPhaseDto.done + MigrationSessionPhase.summary -> MigrationSessionPhaseDto.summary + MigrationSessionPhase.error -> MigrationSessionPhaseDto.error + } + + private fun toDto(c: MigrationItemCategory): MigrationItemCategoryDto = when (c) { + MigrationItemCategory.provider -> MigrationItemCategoryDto.provider + MigrationItemCategory.mcpServer -> MigrationItemCategoryDto.mcpServer + MigrationItemCategory.customMode -> MigrationItemCategoryDto.customMode + MigrationItemCategory.session -> MigrationItemCategoryDto.session + MigrationItemCategory.defaultModel -> MigrationItemCategoryDto.defaultModel + MigrationItemCategory.settings -> MigrationItemCategoryDto.settings + } + + private fun toDto(s: MigrationItemStatus): MigrationItemStatusDto = when (s) { + MigrationItemStatus.success -> MigrationItemStatusDto.success + MigrationItemStatus.warning -> MigrationItemStatusDto.warning + MigrationItemStatus.error -> MigrationItemStatusDto.error + } + + // ----------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------- + + fun fromDto(dto: LegacyCleanupTargetsDto): LegacyCleanupTargets = + LegacyCleanupTargets( + providerProfiles = dto.providerProfiles, + mcpSettings = dto.mcpSettings, + customModes = dto.customModes, + globalState = dto.globalState, + taskHistory = dto.taskHistory, + ) + + fun toDto(r: LegacyCleanupReport): LegacyCleanupReportDto = + LegacyCleanupReportDto(cleaned = r.cleaned, errors = r.errors) +} diff --git a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml index 1a88a88350a..b5e04928d9f 100644 --- a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml +++ b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml @@ -9,5 +9,7 @@ + + diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt new file mode 100644 index 00000000000..ffdc0bdbb18 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt @@ -0,0 +1,331 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client.migration + +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.KiloMigrationRpcApi +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Interface exposed to session UI components. + */ +interface MigrationUiController { + val state: StateFlow + fun check() + fun start(selections: MigrationUiSelections) + fun force(ids: List) + fun skip() + fun finish() +} + +/** + * App-level service that manages migration wizard state shared across all session UIs. + * + * Detects and runs legacy migration via [KiloMigrationRpcApi]. + * All Swing interactions must happen on EDT; service coroutines run off EDT. + */ +@Service(Service.Level.APP) +class KiloMigrationService internal constructor( + private val cs: CoroutineScope, + private val rpc: KiloMigrationRpcApi?, +) : MigrationUiController { + + /** Platform constructor — resolves RPC lazily. */ + constructor(cs: CoroutineScope) : this(cs, null) + + companion object { + private val LOG = KiloLog.create(KiloMigrationService::class.java) + + fun getInstance(): KiloMigrationService = service() + } + + private val _state = MutableStateFlow(MigrationUiState.Hidden) + override val state: StateFlow = _state.asStateFlow() + + private val checking = AtomicBoolean(false) + private val migrating = AtomicBoolean(false) + private val migrateJob = AtomicReference(null) + + // ------ RPC helper ------ + + private suspend fun call(block: suspend KiloMigrationRpcApi.() -> T): T { + val api = rpc + return if (api != null) block(api) else durable { block(KiloMigrationRpcApi.getInstance()) } + } + + // ------ MigrationUiController ------ + + /** + * Check if migration is needed. Idempotent and in-flight guarded. + * Calls status first; if status exists, hides. Then calls detect; if no data, hides. + * Detection failures log and leave state unchanged. + */ + override fun check() { + if (!checking.compareAndSet(false, true)) return + cs.launch { + try { + val status = try { + call { status() } + } catch (e: Exception) { + LOG.warn("migration status check failed", e) + checking.set(false) + return@launch + } + if (status != null) { + _state.value = MigrationUiState.Hidden + checking.set(false) + return@launch + } + val detection = try { + call { detect() } + } catch (e: Exception) { + LOG.warn("migration detect failed", e) + checking.set(false) + return@launch + } + _state.value = if (detection.hasData) MigrationUiState.Needed(detection) else MigrationUiState.Hidden + } finally { + checking.set(false) + } + } + } + + /** + * Start migration for the given user selections. + */ + override fun start(selections: MigrationUiSelections) { + val current = _state.value as? MigrationUiState.Needed ?: return + if (!migrating.compareAndSet(false, true)) return + + val dto = MigrationSelectionBuilder.toDto(selections) + val initialProgress = buildInitialProgress(selections, current.detection) + + _state.value = current.copy( + phase = MigrationUiPhase.migrating, + running = true, + progress = initialProgress, + sessionProgress = null, + sessionSummary = SessionMigrationSummary(), + results = emptyList(), + ) + + val job = cs.launch { + try { + val flow = try { + call { migrate(dto) } + } catch (e: Exception) { + LOG.warn("migration start failed", e) + finishWithError(e.message ?: "Migration failed") + return@launch + } + flow.collect { event -> handleEvent(event) } + } finally { + migrating.set(false) + } + } + migrateJob.set(job) + } + + /** + * Force re-import selected sessions (skipped sessions). + */ + override fun force(ids: List) { + val current = _state.value as? MigrationUiState.Needed ?: return + if (!migrating.compareAndSet(false, true)) return + + val dto = MigrationSelectionBuilder.forceSessionsDto(ids) + val initialProgress = ids.map { + MigrationItemUiProgress(it, MigrationItemCategoryDto.session, MigrationItemProgressStatusDto.migrating) + } + + // Keep non-session results, reset session progress + val nonSession = current.progress.filter { it.category != MigrationItemCategoryDto.session } + _state.value = current.copy( + phase = MigrationUiPhase.migrating, + running = true, + progress = nonSession + initialProgress, + sessionProgress = null, + sessionSummary = SessionMigrationSummary(), + ) + + val job = cs.launch { + try { + val flow = try { + call { migrate(dto) } + } catch (e: Exception) { + LOG.warn("force migration start failed", e) + finishWithError(e.message ?: "Migration failed") + return@launch + } + flow.collect { event -> handleEvent(event) } + } finally { + migrating.set(false) + } + } + migrateJob.set(job) + } + + /** + * Skip migration — marks status and hides for all observers. + */ + override fun skip() { + cs.launch { + try { + call { skip() } + } catch (e: Exception) { + LOG.warn("migration skip failed", e) + } + _state.value = MigrationUiState.Hidden + } + } + + /** + * Finalize migration — marks completed/completed_with_errors and hides. + */ + override fun finish() { + val current = _state.value as? MigrationUiState.Needed ?: run { + _state.value = MigrationUiState.Hidden + return + } + val hasErrors = current.results.any { it.status == MigrationItemStatusDto.error } + val status = if (hasErrors) LegacyMigrationStatusDto.completed_with_errors else LegacyMigrationStatusDto.completed + cs.launch { + try { + call { finalize(status) } + } catch (e: Exception) { + LOG.warn("migration finalize failed", e) + } + _state.value = MigrationUiState.Hidden + } + } + + // ------ Internal event handling ------ + + private fun handleEvent(event: LegacyMigrationEventDto) { + val current = _state.value as? MigrationUiState.Needed ?: return + when (event) { + is LegacyMigrationEventDto.Item -> { + val p = event.progress + val updated = current.progress.map { + if (it.item == p.item) it.copy(status = p.status, message = p.message) else it + } + _state.value = current.copy(progress = updated) + } + is LegacyMigrationEventDto.Session -> { + val sp = event.progress + val phase = sp.phase + + // Update session summary buckets + val summary = when (phase) { + MigrationSessionPhaseDto.done -> { + val item = LegacyMigrationResultItemDto( + item = sp.session?.id ?: "", + category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.session, + status = MigrationItemStatusDto.success, + ) + current.sessionSummary.copy(imported = current.sessionSummary.imported + item) + } + MigrationSessionPhaseDto.skipped -> { + val item = LegacyMigrationResultItemDto( + item = sp.session?.id ?: "", + category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.session, + status = MigrationItemStatusDto.success, + message = "skipped", + ) + current.sessionSummary.copy(skipped = current.sessionSummary.skipped + item) + } + MigrationSessionPhaseDto.error -> { + val item = LegacyMigrationResultItemDto( + item = sp.session?.id ?: "", + category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.session, + status = MigrationItemStatusDto.error, + message = sp.error, + ) + current.sessionSummary.copy(errored = current.sessionSummary.errored + item) + } + else -> current.sessionSummary + } + _state.value = current.copy(sessionProgress = sp, sessionSummary = summary) + } + is LegacyMigrationEventDto.Complete -> { + val items = event.items + val hasErrors = items.any { it.status == MigrationItemStatusDto.error } + val phase = if (hasErrors) MigrationUiPhase.error else MigrationUiPhase.done + _state.value = current.copy( + running = false, + phase = phase, + results = items, + ) + } + is LegacyMigrationEventDto.Error -> { + finishWithError(event.message) + } + } + } + + private fun finishWithError(msg: String) { + val current = _state.value as? MigrationUiState.Needed ?: return + val errItem = LegacyMigrationResultItemDto( + item = "Migration", + category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.settings, + status = MigrationItemStatusDto.error, + message = msg, + ) + _state.value = current.copy( + running = false, + phase = MigrationUiPhase.error, + results = listOf(errItem), + ) + } + + private fun buildInitialProgress( + selections: MigrationUiSelections, + detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto, + ): List { + val list = mutableListOf() + for (id in selections.providers) { + list.add(MigrationItemUiProgress(id, MigrationItemCategoryDto.provider)) + } + for (name in selections.mcpServers) { + list.add(MigrationItemUiProgress(name, MigrationItemCategoryDto.mcpServer)) + } + for (slug in selections.customModes) { + val info = detection.customModes.find { it.slug == slug } + list.add(MigrationItemUiProgress(info?.name ?: slug, MigrationItemCategoryDto.customMode)) + } + for (id in selections.sessions) { + list.add(MigrationItemUiProgress(id, MigrationItemCategoryDto.session)) + } + if (selections.defaultModel) { + list.add(MigrationItemUiProgress("Default model", MigrationItemCategoryDto.defaultModel)) + } + // Settings sub-items + val ap = selections.settings.autoApproval + if (ap.commandRules) list.add(MigrationItemUiProgress("Command rules", MigrationItemCategoryDto.settings)) + if (ap.readPermission) list.add(MigrationItemUiProgress("Read permission", MigrationItemCategoryDto.settings)) + if (ap.writePermission) list.add(MigrationItemUiProgress("Write permission", MigrationItemCategoryDto.settings)) + if (ap.executePermission) list.add(MigrationItemUiProgress("Execute permission", MigrationItemCategoryDto.settings)) + if (ap.mcpPermission) list.add(MigrationItemUiProgress("MCP permission", MigrationItemCategoryDto.settings)) + if (ap.taskPermission) list.add(MigrationItemUiProgress("Task permission", MigrationItemCategoryDto.settings)) + if (selections.settings.language) list.add(MigrationItemUiProgress("Language preference", MigrationItemCategoryDto.settings)) + if (selections.settings.autocomplete) list.add(MigrationItemUiProgress("Autocomplete settings", MigrationItemCategoryDto.settings)) + return list + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationSelectionBuilder.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationSelectionBuilder.kt new file mode 100644 index 00000000000..15c96b276a9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationSelectionBuilder.kt @@ -0,0 +1,110 @@ +package ai.kilocode.client.migration + +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.MigrationAutoApprovalSelectionsDto +import ai.kilocode.rpc.dto.MigrationSessionSelectionDto +import ai.kilocode.rpc.dto.MigrationSettingsSelectionsDto + +/** + * Builds default preselections from detection data, mirroring VS Code behavior. + * Also converts UI selections to wire DTOs for RPC. + */ +object MigrationSelectionBuilder { + + /** + * Build default selections mirroring VS Code preselection logic: + * - Providers: supported providers with API keys + * - MCP: all if any servers exist + * - Modes: all if any custom modes exist + * - Sessions: all if any sessions exist + * - Default model: if present + * - Auto-approval: subfields if corresponding data exists + * - Language: if present + * - Autocomplete: if present + */ + fun defaults(detection: LegacyMigrationDetectionDto): MigrationUiSelections { + val providers = detection.providers + .filter { it.supported && it.hasApiKey } + .map { it.profileName } + val mcpServers = if (detection.mcpServers.isNotEmpty()) detection.mcpServers.map { it.name } else emptyList() + val customModes = if (detection.customModes.isNotEmpty()) detection.customModes.map { it.slug } else emptyList() + val sessions = if (detection.sessions.isNotEmpty()) detection.sessions.map { it.id } else emptyList() + val defaultModel = detection.defaultModel != null + + val settings = detection.settings + val ap = MigrationAutoApprovalUiSelections( + commandRules = settings?.let { + !it.allowedCommands.isNullOrEmpty() || !it.deniedCommands.isNullOrEmpty() + } ?: false, + readPermission = settings?.alwaysAllowReadOnly != null || settings?.alwaysAllowReadOnlyOutsideWorkspace != null, + writePermission = settings?.alwaysAllowWrite != null, + executePermission = settings?.alwaysAllowExecute != null, + mcpPermission = settings?.alwaysAllowMcp != null, + taskPermission = settings?.alwaysAllowModeSwitch != null || settings?.alwaysAllowSubtasks != null, + ) + val settingsSel = MigrationSettingsUiSelections( + autoApproval = ap, + language = !settings?.language.isNullOrEmpty(), + autocomplete = settings?.autocomplete != null, + ) + + return MigrationUiSelections( + providers = providers, + mcpServers = mcpServers, + customModes = customModes, + sessions = sessions, + defaultModel = defaultModel, + settings = settingsSel, + ) + } + + /** + * Convert UI selections into the wire DTO, taking only supported+apiKey providers. + */ + fun toDto( + selections: MigrationUiSelections, + sessionForce: Boolean = false, + ): LegacyMigrationSelectionsDto = LegacyMigrationSelectionsDto( + providers = selections.providers, + mcpServers = selections.mcpServers, + customModes = selections.customModes, + sessions = selections.sessions.map { MigrationSessionSelectionDto(it, force = sessionForce) }, + defaultModel = selections.defaultModel, + settings = MigrationSettingsSelectionsDto( + autoApproval = MigrationAutoApprovalSelectionsDto( + commandRules = selections.settings.autoApproval.commandRules, + readPermission = selections.settings.autoApproval.readPermission, + writePermission = selections.settings.autoApproval.writePermission, + executePermission = selections.settings.autoApproval.executePermission, + mcpPermission = selections.settings.autoApproval.mcpPermission, + taskPermission = selections.settings.autoApproval.taskPermission, + ), + language = selections.settings.language, + autocomplete = selections.settings.autocomplete, + ), + ) + + /** + * Build a session-only selection with specific IDs and force=true. + */ + fun forceSessionsDto(ids: List): LegacyMigrationSelectionsDto = LegacyMigrationSelectionsDto( + providers = emptyList(), + mcpServers = emptyList(), + customModes = emptyList(), + sessions = ids.map { MigrationSessionSelectionDto(it, force = true) }, + defaultModel = false, + settings = MigrationSettingsSelectionsDto( + autoApproval = MigrationAutoApprovalSelectionsDto( + commandRules = false, + readPermission = false, + writePermission = false, + executePermission = false, + mcpPermission = false, + taskPermission = false, + ), + language = false, + autocomplete = false, + ), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationUiState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationUiState.kt new file mode 100644 index 00000000000..44d2ae9fff5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationUiState.kt @@ -0,0 +1,117 @@ +package ai.kilocode.client.migration + +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto + +// --------------------------------------------------------------------------- +// User selections for UI +// --------------------------------------------------------------------------- + +data class MigrationAutoApprovalUiSelections( + val commandRules: Boolean = false, + val readPermission: Boolean = false, + val writePermission: Boolean = false, + val executePermission: Boolean = false, + val mcpPermission: Boolean = false, + val taskPermission: Boolean = false, +) + +data class MigrationSettingsUiSelections( + val autoApproval: MigrationAutoApprovalUiSelections = MigrationAutoApprovalUiSelections(), + val language: Boolean = false, + val autocomplete: Boolean = false, +) + +data class MigrationUiSelections( + val providers: List = emptyList(), + val mcpServers: List = emptyList(), + val customModes: List = emptyList(), + val sessions: List = emptyList(), + val defaultModel: Boolean = false, + val settings: MigrationSettingsUiSelections = MigrationSettingsUiSelections(), +) + +// --------------------------------------------------------------------------- +// Progress tracking per item +// --------------------------------------------------------------------------- + +data class MigrationItemUiProgress( + val item: String, + val category: MigrationItemCategoryDto, + val status: MigrationItemProgressStatusDto = MigrationItemProgressStatusDto.migrating, + val message: String? = null, +) + +// --------------------------------------------------------------------------- +// Session summary buckets +// --------------------------------------------------------------------------- + +data class SessionMigrationSummary( + val imported: List = emptyList(), + val skipped: List = emptyList(), + val errored: List = emptyList(), +) + +// --------------------------------------------------------------------------- +// Migration phase for the overall UI +// --------------------------------------------------------------------------- + +enum class MigrationUiPhase { + /** Wizard showing selection checkboxes. */ + selecting, + /** Migration is running. */ + migrating, + /** Migration finished with no errors. */ + done, + /** Migration finished with errors. */ + error, +} + +// --------------------------------------------------------------------------- +// Top-level shared state emitted by KiloMigrationService +// --------------------------------------------------------------------------- + +sealed class MigrationUiState { + /** Migration overlay should not be shown. */ + object Hidden : MigrationUiState() + + /** Migration data was detected; show the wizard. */ + data class Needed( + val detection: LegacyMigrationDetectionDto, + val phase: MigrationUiPhase = MigrationUiPhase.selecting, + val running: Boolean = false, + val progress: List = emptyList(), + val sessionProgress: LegacyMigrationSessionProgressDto? = null, + val sessionSummary: SessionMigrationSummary = SessionMigrationSummary(), + val results: List = emptyList(), + ) : MigrationUiState() +} + +// --------------------------------------------------------------------------- +// Derived helpers on state +// --------------------------------------------------------------------------- + +fun MigrationItemProgressStatusDto.toResultStatus(): MigrationItemStatusDto? = when (this) { + MigrationItemProgressStatusDto.success -> MigrationItemStatusDto.success + MigrationItemProgressStatusDto.warning -> MigrationItemStatusDto.warning + MigrationItemProgressStatusDto.error -> MigrationItemStatusDto.error + MigrationItemProgressStatusDto.migrating -> null +} + +/** Derive group-level status from item progress entries in a category. */ +fun groupStatus(items: List): MigrationItemProgressStatusDto { + if (items.any { it.status == MigrationItemProgressStatusDto.error }) return MigrationItemProgressStatusDto.error + if (items.any { it.status == MigrationItemProgressStatusDto.warning }) return MigrationItemProgressStatusDto.warning + if (items.all { it.status == MigrationItemProgressStatusDto.success }) return MigrationItemProgressStatusDto.success + if (items.any { it.status == MigrationItemProgressStatusDto.migrating }) return MigrationItemProgressStatusDto.migrating + return MigrationItemProgressStatusDto.migrating +} + +/** True if the session summary phase is currently showing. */ +fun LegacyMigrationSessionProgressDto.isSummary(): Boolean = + phase == MigrationSessionPhaseDto.summary diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationItemRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationItemRow.kt new file mode 100644 index 00000000000..ceeacb1f1df --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationItemRow.kt @@ -0,0 +1,76 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.client.migration.MigrationItemUiProgress +import ai.kilocode.client.migration.MigrationUiPhase +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.FlowLayout +import javax.swing.JPanel + +/** + * A single row in the migration item list. + * Shows a checkbox in [MigrationUiPhase.selecting] or a status icon otherwise. + */ +class MigrationItemRow( + private val label: String, + private val category: MigrationItemCategoryDto, +) : BorderLayoutPanel() { + + private val check = JBCheckBox(label) + private val statusIcon = MigrationStatusIcon() + private val nameLabel = JBLabel(label) + private val messageLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + border = JBUI.Borders.emptyLeft(UiStyle.Gap.sm()) + } + + private val selectRow = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + add(check) + } + private val progressRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(statusIcon) + add(nameLabel) + add(messageLabel) + } + + var selected: Boolean + get() = check.isSelected + set(v) { check.isSelected = v } + + var onSelectionChanged: ((Boolean) -> Unit)? = null + + init { + isOpaque = false + border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs()) + + check.isOpaque = false + check.addActionListener { onSelectionChanged?.invoke(check.isSelected) } + + addToCenter(selectRow) + progressRow.isVisible = false + add(progressRow, java.awt.BorderLayout.SOUTH) + } + + fun updatePhase(phase: MigrationUiPhase) { + val selecting = phase == MigrationUiPhase.selecting + selectRow.isVisible = selecting + progressRow.isVisible = !selecting + } + + fun updateProgress(progress: MigrationItemUiProgress?) { + if (progress == null) { + statusIcon.update(MigrationItemProgressStatusDto.migrating) + messageLabel.text = null + return + } + statusIcon.update(progress.status) + messageLabel.text = progress.message + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt new file mode 100644 index 00000000000..4c441c2a317 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt @@ -0,0 +1,52 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.client.migration.MigrationUiSelections +import ai.kilocode.client.migration.MigrationUiState +import ai.kilocode.client.ui.UiStyle +import com.intellij.ui.components.JBPanel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import javax.swing.JComponent + +/** + * Outer container for the migration wizard rendered inside the blocker layer. + * + * Wraps [MigrationWizardPanel] in a bordered overlay with a panel background. + * Build once; call [update] on every state change. + */ +class MigrationOverlayPanel : JBPanel(BorderLayout()) { + + private val wizard = MigrationWizardPanel() + + var onSkip: (() -> Unit)? + get() = wizard.onSkip + set(v) { wizard.onSkip = v } + + var onStart: ((MigrationUiSelections) -> Unit)? + get() = wizard.onStart + set(v) { wizard.onStart = v } + + var onForce: ((List) -> Unit)? + get() = wizard.onForce + set(v) { wizard.onForce = v } + + var onDone: (() -> Unit)? + get() = wizard.onDone + set(v) { wizard.onDone = v } + + var onContinueFromError: (() -> Unit)? + get() = wizard.onContinueFromError + set(v) { wizard.onContinueFromError = v } + + init { + withBackground(UiStyle.Colors.bg()) + border = JBUI.Borders.customLine(com.intellij.ui.JBColor.border(), 1) + add(wizard, BorderLayout.CENTER) + } + + fun update(state: MigrationUiState.Needed) { + wizard.update(state) + } + + fun preferredFocusComponent(): JComponent = wizard.preferredFocusComponent() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationStatusIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationStatusIcon.kt new file mode 100644 index 00000000000..ad7c5bd4993 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationStatusIcon.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import com.intellij.icons.AllIcons +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.JBLabel +import javax.swing.Icon + +/** Shows an animated spinner (migrating), success, warning, or error icon. */ +class MigrationStatusIcon : JBLabel() { + + fun update(status: MigrationItemProgressStatusDto) { + icon = iconFor(status) + } + + private fun iconFor(status: MigrationItemProgressStatusDto): Icon = when (status) { + MigrationItemProgressStatusDto.migrating -> AnimatedIcon.Default() + MigrationItemProgressStatusDto.success -> AllIcons.General.InspectionsOK + MigrationItemProgressStatusDto.warning -> AllIcons.General.Warning + MigrationItemProgressStatusDto.error -> AllIcons.General.Error + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt new file mode 100644 index 00000000000..6b2e77eef8e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt @@ -0,0 +1,344 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.client.migration.MigrationItemUiProgress +import ai.kilocode.client.migration.MigrationSelectionBuilder +import ai.kilocode.client.migration.MigrationSettingsUiSelections +import ai.kilocode.client.migration.MigrationUiPhase +import ai.kilocode.client.migration.MigrationUiSelections +import ai.kilocode.client.migration.MigrationUiState +import ai.kilocode.client.migration.SessionMigrationSummary +import ai.kilocode.client.migration.groupStatus +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.FlowLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.JButton as JBtn + +private const val CARD_WHATS_NEW = "whats-new" +private const val CARD_MIGRATE = "migrate" + +/** + * Two-screen migration wizard: "What's New" → "Migrate Your Settings". + * + * Build once; call [update] for every state change. Does not rebuild the component tree. + */ +class MigrationWizardPanel : JPanel(BorderLayout()) { + + // ------ Callbacks ------ + var onSkip: (() -> Unit)? = null + var onStart: ((MigrationUiSelections) -> Unit)? = null + var onForce: ((List) -> Unit)? = null + var onDone: (() -> Unit)? = null + var onContinueFromError: (() -> Unit)? = null + + // ------ Card layout ------ + private val cards = CardLayout() + private val cardPanel = JPanel(cards) + + // ------ What's New screen ------ + private val whatsNewPanel = buildWhatsNewPanel() + + // ------ Migrate screen row state ------ + private val rows = mutableMapOf() + private val settingsRow = MigrationItemRow(KiloBundle.message("migration.row.settings"), MigrationItemCategoryDto.settings) + private val providerRow = MigrationItemRow(KiloBundle.message("migration.row.providers"), MigrationItemCategoryDto.provider) + private val mcpRow = MigrationItemRow(KiloBundle.message("migration.row.mcp"), MigrationItemCategoryDto.mcpServer) + private val modesRow = MigrationItemRow(KiloBundle.message("migration.row.modes"), MigrationItemCategoryDto.customMode) + private val sessionsRow = MigrationItemRow(KiloBundle.message("migration.row.sessions"), MigrationItemCategoryDto.session) + private val modelRow = MigrationItemRow(KiloBundle.message("migration.row.model"), MigrationItemCategoryDto.defaultModel) + + private val sessionProgress = SessionMigrationProgressPanel() + private val sessionSummary = SessionMigrationSummaryPanel() + + private val migrateBtn = JButton(KiloBundle.message("migration.button.migrate")) + private val backBtn = JButton(KiloBundle.message("migration.button.back")) + private val skipBtn = JButton(KiloBundle.message("migration.button.skip")) + private val doneBtn = JButton(KiloBundle.message("migration.button.done")) + private val continueBtn = JButton(KiloBundle.message("migration.button.continue")) + + private val emptyLabel = JBLabel(KiloBundle.message("migration.empty")).apply { + foreground = UiStyle.Colors.weak() + } + + private var detection: LegacyMigrationDetectionDto? = null + private var selections = MigrationUiSelections() + + init { + isOpaque = false + + rows[MigrationItemCategoryDto.provider] = providerRow + rows[MigrationItemCategoryDto.mcpServer] = mcpRow + rows[MigrationItemCategoryDto.customMode] = modesRow + rows[MigrationItemCategoryDto.session] = sessionsRow + rows[MigrationItemCategoryDto.defaultModel] = modelRow + rows[MigrationItemCategoryDto.settings] = settingsRow + + for (row in rows.values) { + row.onSelectionChanged = { _ -> updateMigrateButtonEnabled() } + } + + migrateBtn.addActionListener { onStart?.invoke(currentSelections()) } + backBtn.addActionListener { cards.show(cardPanel, CARD_WHATS_NEW) } + skipBtn.addActionListener { onSkip?.invoke() } + doneBtn.addActionListener { onDone?.invoke() } + continueBtn.addActionListener { onContinueFromError?.invoke() } + + sessionSummary.onForceReimport = { ids -> onForce?.invoke(ids) } + + cardPanel.isOpaque = false + cardPanel.add(whatsNewPanel, CARD_WHATS_NEW) + cardPanel.add(buildMigratePanel(), CARD_MIGRATE) + + add(cardPanel, BorderLayout.CENTER) + + cards.show(cardPanel, CARD_WHATS_NEW) + } + + // ------ Public update ------ + + fun update(state: MigrationUiState.Needed) { + val det = state.detection + // Detection and default selections are set once on first update and are stable for the + // lifetime of a single wizard session. The service never re-detects mid-session; + // force re-import uses existing session IDs without changing detection data. + if (detection == null || detection != det) { + detection = det + selections = MigrationSelectionBuilder.defaults(det) + applyDefaults(det) + } + + val phase = state.phase + + // Update row visibility based on what data exists + providerRow.isVisible = det.providers.any { it.supported } + mcpRow.isVisible = det.mcpServers.isNotEmpty() + modesRow.isVisible = det.customModes.isNotEmpty() + sessionsRow.isVisible = det.sessions.isNotEmpty() + modelRow.isVisible = det.defaultModel != null + settingsRow.isVisible = det.settings != null + emptyLabel.isVisible = !det.hasData + + // Update phase for all rows + for (row in rows.values) { + row.updatePhase(phase) + } + + // Update progress for each row category + updateRowProgress(MigrationItemCategoryDto.provider, state.progress) + updateRowProgress(MigrationItemCategoryDto.mcpServer, state.progress) + updateRowProgress(MigrationItemCategoryDto.customMode, state.progress) + updateRowProgress(MigrationItemCategoryDto.session, state.progress) + updateRowProgress(MigrationItemCategoryDto.defaultModel, state.progress) + updateRowProgress(MigrationItemCategoryDto.settings, state.progress) + + // Session progress/summary + val sp = state.sessionProgress + if (sp != null && sp.phase != MigrationSessionPhaseDto.summary) { + sessionProgress.update(sp) + sessionProgress.isVisible = true + sessionSummary.isVisible = false + } else if (sp != null && sp.phase == MigrationSessionPhaseDto.summary) { + sessionSummary.update(state.sessionSummary) + sessionProgress.isVisible = false + sessionSummary.isVisible = true + } else { + sessionProgress.isVisible = false + sessionSummary.isVisible = false + } + + updateButtons(phase, state.running) + updateMigrateButtonEnabled() + } + + fun preferredFocusComponent() = migrateBtn + + // ------ Internal helpers ------ + + private fun applyDefaults(det: LegacyMigrationDetectionDto) { + val defaults = MigrationSelectionBuilder.defaults(det) + providerRow.selected = defaults.providers.isNotEmpty() + mcpRow.selected = defaults.mcpServers.isNotEmpty() + modesRow.selected = defaults.customModes.isNotEmpty() + sessionsRow.selected = defaults.sessions.isNotEmpty() + modelRow.selected = defaults.defaultModel + settingsRow.selected = defaults.settings.autoApproval.commandRules || + defaults.settings.autoApproval.readPermission || + defaults.settings.autoApproval.writePermission || + defaults.settings.autoApproval.executePermission || + defaults.settings.autoApproval.mcpPermission || + defaults.settings.autoApproval.taskPermission || + defaults.settings.language || + defaults.settings.autocomplete + } + + private fun updateRowProgress(category: MigrationItemCategoryDto, items: List) { + val row = rows[category] ?: return + val categoryItems = items.filter { it.category == category } + if (categoryItems.isEmpty()) { + row.updateProgress(null) + return + } + val status = groupStatus(categoryItems) + row.updateProgress(MigrationItemUiProgress(category.name, category, status)) + } + + private fun updateButtons(phase: MigrationUiPhase, running: Boolean) { + backBtn.isVisible = phase == MigrationUiPhase.selecting + skipBtn.isVisible = phase == MigrationUiPhase.selecting + migrateBtn.isVisible = phase == MigrationUiPhase.selecting || phase == MigrationUiPhase.migrating + migrateBtn.isEnabled = !running && phase == MigrationUiPhase.selecting + migrateBtn.text = if (running) KiloBundle.message("migration.button.migrating") else KiloBundle.message("migration.button.migrate") + doneBtn.isVisible = phase == MigrationUiPhase.done + continueBtn.isVisible = phase == MigrationUiPhase.error + } + + private fun updateMigrateButtonEnabled() { + val any = rows.values.any { it.isVisible && it.selected } + migrateBtn.isEnabled = any && migrateBtn.text == KiloBundle.message("migration.button.migrate") + } + + private fun currentSelections(): MigrationUiSelections { + val det = detection ?: return MigrationUiSelections() + val providers = if (providerRow.selected) det.providers.filter { it.supported && it.hasApiKey }.map { it.profileName } else emptyList() + val mcpServers = if (mcpRow.selected) det.mcpServers.map { it.name } else emptyList() + val modes = if (modesRow.selected) det.customModes.map { it.slug } else emptyList() + val sessions = if (sessionsRow.selected) det.sessions.map { it.id } else emptyList() + val defaults = MigrationSelectionBuilder.defaults(det) + return MigrationUiSelections( + providers = providers, + mcpServers = mcpServers, + customModes = modes, + sessions = sessions, + defaultModel = modelRow.selected, + settings = if (settingsRow.selected) defaults.settings else MigrationSettingsUiSelections(), + ) + } + + private fun buildWhatsNewPanel(): JPanel { + val panel = JPanel(BorderLayout()).apply { isOpaque = false } + + val title = JBLabel(KiloBundle.message("migration.whats_new.title")).apply { + font = JBFont.h2().asBold() + border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + } + val subtitle = JBLabel(KiloBundle.message("migration.whats_new.subtitle")).apply { + foreground = UiStyle.Colors.weak() + border = JBUI.Borders.emptyBottom(UiStyle.Gap.md()) + } + + val features = listOf( + KiloBundle.message("migration.whats_new.feature.performance"), + KiloBundle.message("migration.whats_new.feature.interface"), + KiloBundle.message("migration.whats_new.feature.agent_manager"), + KiloBundle.message("migration.whats_new.feature.foundation"), + ) + + val featurePanel = JPanel(GridBagLayout()).apply { isOpaque = false } + val gc = GridBagConstraints().apply { + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + gridx = 0 + } + for (f in features) { + val row = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.xs(), 0)).apply { + isOpaque = false + add(JBLabel(AllIcons.General.InspectionsOK)) + add(JBLabel(f)) + } + featurePanel.add(row, gc) + } + + val content = JPanel(GridBagLayout()).apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + val c = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 } + add(title, c) + add(subtitle, c) + add(featurePanel, c) + } + + val continueWnBtn = JButton(KiloBundle.message("migration.button.continue_to_migrate")).apply { + addActionListener { cards.show(cardPanel, CARD_MIGRATE) } + } + val footer = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(continueWnBtn) + } + + panel.add(JBScrollPane(content).apply { border = JBUI.Borders.empty() }, BorderLayout.CENTER) + panel.add(footer, BorderLayout.SOUTH) + return panel + } + + private fun buildMigratePanel(): JPanel { + val panel = JPanel(BorderLayout()).apply { isOpaque = false } + + val title = JBLabel(KiloBundle.message("migration.migrate.title")).apply { + font = JBFont.h2().asBold() + border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + } + val subtitle = JBLabel(KiloBundle.message("migration.migrate.subtitle")).apply { + foreground = UiStyle.Colors.weak() + border = JBUI.Borders.emptyBottom(UiStyle.Gap.md()) + } + val sectionLabel = JBLabel(KiloBundle.message("migration.migrate.section")).apply { + font = JBFont.medium() + border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs()) + } + + val rowsPanel = JPanel(GridBagLayout()).apply { + isOpaque = false + val gc = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 } + add(emptyLabel, gc) + add(providerRow, gc) + add(mcpRow, gc) + add(modesRow, gc) + add(sessionsRow, gc) + add(modelRow, gc) + add(settingsRow, gc) + add(sessionProgress, gc) + add(sessionSummary, gc) + } + + val content = JPanel(GridBagLayout()).apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + val gc = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 } + add(title, gc) + add(subtitle, gc) + add(sectionLabel, gc) + add(rowsPanel, gc) + } + + val footer = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(backBtn) + add(skipBtn) + add(migrateBtn) + add(doneBtn) + add(continueBtn) + } + + panel.add(JBScrollPane(content).apply { border = JBUI.Borders.empty() }, BorderLayout.CENTER) + panel.add(footer, BorderLayout.SOUTH) + return panel + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/SessionMigrationProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/SessionMigrationProgressPanel.kt new file mode 100644 index 00000000000..d30183a3fc6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/SessionMigrationProgressPanel.kt @@ -0,0 +1,101 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.text.SimpleDateFormat +import java.util.Date +import javax.swing.JPanel + +/** + * Shows live session migration progress (current/total, metadata, step labels). + * Summary phase delegates rendering to [SessionMigrationSummaryPanel]. + */ +class SessionMigrationProgressPanel : JPanel(GridBagLayout()) { + + private val header = JBLabel().apply { font = font.deriveFont(java.awt.Font.BOLD) } + private val directory = JBLabel().apply { foreground = UiStyle.Colors.weak() } + private val title = JBLabel() + private val date = JBLabel().apply { foreground = UiStyle.Colors.weak() } + private val preparingIcon = MigrationStatusIcon() + private val storingIcon = MigrationStatusIcon() + private val preparingLabel = JBLabel(KiloBundle.message("migration.session.step.preparing")) + private val storingLabel = JBLabel(KiloBundle.message("migration.session.step.storing")) + private val dateFormat = SimpleDateFormat("HH:mm MM/dd/yyyy") + + init { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.sm()) + + val gc = GridBagConstraints().apply { + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + gridx = 0 + } + + add(header, gc) + add(directory, gc) + add(title, gc) + add(date, gc) + + val stepPanel = JPanel(java.awt.FlowLayout(java.awt.FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + } + val stepPanel2 = JPanel(java.awt.FlowLayout(java.awt.FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + } + stepPanel.add(preparingIcon) + stepPanel.add(preparingLabel) + stepPanel2.add(storingIcon) + stepPanel2.add(storingLabel) + + val stepsPanel = BorderLayoutPanel().apply { + isOpaque = false + addToTop(stepPanel) + addToCenter(stepPanel2) + } + add(stepsPanel, gc) + } + + fun update(progress: LegacyMigrationSessionProgressDto) { + header.text = KiloBundle.message("migration.session.header", progress.index + 1, progress.total) + val info = progress.session + directory.text = info?.directory?.takeIf { it.isNotEmpty() } ?: KiloBundle.message("migration.session.unknown.path") + title.text = info?.title?.takeIf { it.isNotEmpty() } ?: KiloBundle.message("migration.session.unknown.title") + date.text = if ((info?.time ?: 0L) > 0L) dateFormat.format(Date(info!!.time)) else KiloBundle.message("migration.session.unknown.date") + + val phase = progress.phase + when (phase) { + MigrationSessionPhaseDto.preparing -> { + preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.migrating) + storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.migrating) + storingLabel.foreground = UiStyle.Colors.weak() + } + MigrationSessionPhaseDto.storing -> { + preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.success) + storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.migrating) + storingLabel.foreground = UiStyle.Colors.fg() + } + MigrationSessionPhaseDto.skipped -> { + preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.success) + storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.warning) + storingLabel.text = KiloBundle.message("migration.session.step.skipped") + } + MigrationSessionPhaseDto.done -> { + preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.success) + storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.success) + } + MigrationSessionPhaseDto.error -> { + preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.error) + storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.error) + } + MigrationSessionPhaseDto.summary -> { /* handled by summary panel */ } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/SessionMigrationSummaryPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/SessionMigrationSummaryPanel.kt new file mode 100644 index 00000000000..150087078c4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/SessionMigrationSummaryPanel.kt @@ -0,0 +1,140 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.client.migration.SessionMigrationSummary +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.FlowLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.datatransfer.StringSelection +import javax.swing.JButton +import javax.swing.JPanel + +/** + * Renders session summary after migration completes. + * Shows imported/skipped/errored buckets, re-import checkbox, and copy report button. + */ +class SessionMigrationSummaryPanel : JPanel(BorderLayout()) { + + private val imported = JBLabel() + private val skipped = JBLabel() + private val errored = JBLabel() + private val reimportAll = JBCheckBox(KiloBundle.message("migration.session.summary.reimport.all")) + private val copyBtn = JButton(KiloBundle.message("migration.session.summary.copy.report"), AllIcons.Actions.Copy) + + private var skippedItems: List = emptyList() + private var selectedForReimport: MutableSet = mutableSetOf() + private val reimportCheckboxes = mutableListOf>() + private val skippedPanel = JPanel(GridBagLayout()).apply { isOpaque = false } + private val feedbackLabel = JBLabel(KiloBundle.message("migration.session.summary.copied")).apply { + isVisible = false + foreground = UiStyle.Colors.weak() + } + + var onForceReimport: ((List) -> Unit)? = null + + private val forceBtn = JButton(KiloBundle.message("migration.session.summary.force.reimport")).apply { + isEnabled = false + } + + init { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.sm()) + + val statsPanel = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(imported) + add(skipped) + add(errored) + } + + reimportAll.isOpaque = false + reimportAll.addActionListener { toggleAll(reimportAll.isSelected) } + + forceBtn.addActionListener { onForceReimport?.invoke(selectedForReimport.toList()) } + + val btnRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(forceBtn) + add(copyBtn) + add(feedbackLabel) + } + + copyBtn.addActionListener { + CopyPasteManager.getInstance().setContents(StringSelection(buildReport())) + feedbackLabel.isVisible = true + } + + val center = JPanel(GridBagLayout()).apply { + isOpaque = false + val gc = GridBagConstraints().apply { + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + gridx = 0 + } + add(statsPanel, gc) + add(skippedPanel, gc) + add(reimportAll, gc) + add(btnRow, gc) + } + add(center, BorderLayout.CENTER) + } + + fun update(summary: SessionMigrationSummary) { + imported.text = KiloBundle.message("migration.session.summary.imported", summary.imported.size) + skipped.text = KiloBundle.message("migration.session.summary.skipped", summary.skipped.size) + errored.text = KiloBundle.message("migration.session.summary.errored", summary.errored.size) + + skippedItems = summary.skipped + selectedForReimport.clear() + skippedPanel.removeAll() + reimportCheckboxes.clear() + + val gc = GridBagConstraints().apply { + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + gridx = 0 + } + for (item in summary.skipped) { + val cb = JBCheckBox(item.item) + cb.isOpaque = false + cb.addActionListener { + if (cb.isSelected) selectedForReimport.add(item.item) else selectedForReimport.remove(item.item) + forceBtn.isEnabled = selectedForReimport.isNotEmpty() + reimportAll.isSelected = selectedForReimport.size == skippedItems.size + } + reimportCheckboxes.add(cb to item.item) + skippedPanel.add(cb, gc) + } + + reimportAll.isVisible = summary.skipped.isNotEmpty() + forceBtn.isEnabled = false + feedbackLabel.isVisible = false + skippedPanel.revalidate() + skippedPanel.repaint() + } + + private fun toggleAll(checked: Boolean) { + for ((cb, id) in reimportCheckboxes) { + cb.isSelected = checked + if (checked) selectedForReimport.add(id) else selectedForReimport.remove(id) + } + forceBtn.isEnabled = checked && reimportCheckboxes.isNotEmpty() + } + + private fun buildReport(): String { + val sb = StringBuilder() + sb.appendLine(KiloBundle.message("migration.session.summary.imported", skippedItems.size)) + for (item in skippedItems) { + sb.appendLine(" - ${item.item}: ${item.message ?: "skipped"}") + } + return sb.toString() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 612032ca90d..9bf6aebd42c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -3,6 +3,10 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.migration.KiloMigrationService +import ai.kilocode.client.migration.MigrationUiController +import ai.kilocode.client.migration.MigrationUiState +import ai.kilocode.client.migration.ui.MigrationOverlayPanel import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.scroll.SessionScroll @@ -25,14 +29,19 @@ import ai.kilocode.client.session.views.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.awt.BorderLayout import javax.swing.BoxLayout import javax.swing.JComponent @@ -53,6 +62,7 @@ class SessionUi( ref: SessionRef? = null, displayMs: Long = SessionController.DISPLAY_DELAY_MS, private val manager: SessionManager? = null, + private val migration: MigrationUiController = service(), ) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget { companion object { @@ -61,6 +71,9 @@ class SessionUi( private val project = project private val app = app + private val cs = cs + private val sessions = sessions + private val workspace = workspace private var opening = ref != null private var pending = false private var loaded: Boolean? = null @@ -102,6 +115,7 @@ class SessionUi( private lateinit var prompt: PromptPanel private lateinit var load: LoadingPanel + private lateinit var migrationOverlay: MigrationOverlayPanel private var style = SessionEditorStyle.current() init { @@ -109,6 +123,7 @@ class SessionUi( scroll.show(body(controller.model.state)) bindUi() bindStyle() + bindMigration() applyStyle(style) onStateChanged(controller.model.state) loaded?.let(::finishOpen) @@ -116,6 +131,7 @@ class SessionUi( override fun addNotify() { super.addNotify() + migration.check() resumeOpen() } @@ -132,11 +148,26 @@ class SessionUi( internal fun currentStyle() = style - val defaultFocusedComponent: JComponent get() = prompt.defaultFocusedComponent + val defaultFocusedComponent: JComponent get() { + val state = migration.state.value + if (state !is MigrationUiState.Hidden && root.blocker.isVisible) { + return migrationOverlay.preferredFocusComponent() + } + return prompt.defaultFocusedComponent + } private fun buildUi() { root = SessionRootPanel() + migrationOverlay = MigrationOverlayPanel().apply { + onSkip = { migration.skip() } + onDone = { migration.finish(); sessions.refresh(workspace.directory) } + onContinueFromError = { migration.finish(); sessions.refresh(workspace.directory) } + onStart = { sel -> migration.start(sel) } + onForce = { ids -> migration.force(ids) } + } + root.setBlocker(migrationOverlay) + sessionContent = JPanel(BorderLayout()) blankBody = JPanel(BorderLayout()).apply { @@ -228,7 +259,13 @@ class SessionUi( scroll.show(messageBody) } - is SessionControllerEvent.AppChanged, + is SessionControllerEvent.AppChanged -> { + prompt.setReady(controller.model.isReady()) + if (app.state.value.status == KiloAppStatusDto.READY) { + migration.check() + } + } + is SessionControllerEvent.WorkspaceChanged -> { prompt.setReady(controller.model.isReady()) } @@ -262,6 +299,28 @@ class SessionUi( } } + private fun bindMigration() { + cs.launch { + migration.state.collect { state -> + withContext(Dispatchers.Main) { + applyMigrationState(state) + } + } + } + } + + private fun applyMigrationState(state: MigrationUiState) { + when (state) { + is MigrationUiState.Hidden -> { + root.setBlocked(false) + } + is MigrationUiState.Needed -> { + migrationOverlay.update(state) + root.setBlocked(true) + } + } + } + private fun bindStyle() { val bus = ApplicationManager.getApplication().messageBus.connect(this) bus.subscribe(EditorColorsManager.TOPIC, EditorColorsListener { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt index 5b1f9f63974..6ad7d29af3b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -14,18 +14,37 @@ class SessionRootPanel : JLayeredPane() { val overlay = Overlay() + val blocker = Blocker() + init { layout = null add(content) setLayer(content, DEFAULT_LAYER) add(overlay) setLayer(overlay, PALETTE_LAYER) + add(blocker) + setLayer(blocker, MODAL_LAYER) + blocker.isVisible = false } fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { overlay.addOverlay(child, bounds) } + fun setBlocker(child: JComponent) { + blocker.removeAll() + blocker.add(child) + blocker.revalidate() + blocker.repaint() + } + + fun setBlocked(value: Boolean) { + blocker.isVisible = value + if (value) blocker.requestFocusInWindow() + revalidate() + repaint() + } + override fun doLayout() { components .sortedBy { getLayer(it) } @@ -36,8 +55,9 @@ class SessionRootPanel : JLayeredPane() { } override fun getPreferredSize(): Dimension { - val w = components.maxOfOrNull { it.preferredSize.width } ?: 0 - val h = components.maxOfOrNull { it.preferredSize.height } ?: 0 + // Only content and overlay contribute to preferred size; blocker is invisible by default. + val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0 + val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0 return JBDimension(w, h) } @@ -77,4 +97,22 @@ class SessionRootPanel : JLayeredPane() { return JBDimension(w, h) } } + + /** + * Full-area blocking overlay rendered above the scroll overlay at MODAL_LAYER. + * When visible: consumes all mouse events across the full panel area. + * When hidden: passes all mouse events through (isVisible=false). + */ + class Blocker : JPanel() { + init { + layout = java.awt.BorderLayout() + isOpaque = false + isFocusable = true + } + + override fun contains(x: Int, y: Int): Boolean { + if (!isVisible) return false + return super.contains(x, y) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index dedf3ee3045..ce147ef1f54 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -7,6 +7,8 @@ messages.KiloBundle + + (MigrationUiState.Hidden) + override val state: StateFlow = _state + + val checks = mutableListOf() + val starts = mutableListOf() + val forces = mutableListOf>() + val skips = mutableListOf() + val finishes = mutableListOf() + + override fun check() { + checks.add(Unit) + } + + override fun start(selections: MigrationUiSelections) { + starts.add(selections) + } + + override fun force(ids: List) { + forces.add(ids) + } + + override fun skip() { + skips.add(Unit) + } + + override fun finish() { + finishes.add(Unit) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt new file mode 100644 index 00000000000..a750ba78f08 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt @@ -0,0 +1,211 @@ +package ai.kilocode.client.migration + +import ai.kilocode.client.testing.FakeMigrationRpcApi +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationProviderInfoDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +@Suppress("UnstableApiUsage") +class KiloMigrationServiceTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeMigrationRpcApi + private lateinit var service: KiloMigrationService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeMigrationRpcApi() + service = KiloMigrationService(scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + private fun settle() = runBlocking { + repeat(3) { + delay(50) + UIUtil.dispatchAllInvocationEvents() + } + } + + fun `test check calls status before detect`() { + rpc.statusResult = null + rpc.detectResult = FakeMigrationRpcApi.emptyDetection() + service.check() + settle() + assertEquals(1, rpc.statusCalls.size) + assertEquals(1, rpc.detectCalls.size) + } + + fun `test existing status hides state and does not call detect`() { + rpc.statusResult = LegacyMigrationStatusDto.completed + service.check() + settle() + assertEquals(1, rpc.statusCalls.size) + assertEquals(0, rpc.detectCalls.size) + assertEquals(MigrationUiState.Hidden, service.state.value) + } + + fun `test no data hides state`() { + rpc.statusResult = null + rpc.detectResult = FakeMigrationRpcApi.emptyDetection() + service.check() + settle() + assertEquals(MigrationUiState.Hidden, service.state.value) + } + + fun `test detected data sets needed state`() { + rpc.statusResult = null + rpc.detectResult = sampleDetection() + service.check() + settle() + assertTrue("state should be Needed", service.state.value is MigrationUiState.Needed) + } + + fun `test duplicate check while in flight makes one rpc call`() { + rpc.statusResult = null + rpc.detectResult = FakeMigrationRpcApi.emptyDetection() + service.check() + service.check() + settle() + // Due to in-flight guard only one pair of calls should happen + assertEquals(1, rpc.statusCalls.size) + } + + fun `test skip marks status and hides`() { + rpc.statusResult = null + rpc.detectResult = sampleDetection() + service.check() + settle() + service.skip() + settle() + assertEquals(1, rpc.skipCalls.size) + assertEquals(MigrationUiState.Hidden, service.state.value) + } + + fun `test finish calls finalize and hides`() { + rpc.statusResult = null + rpc.detectResult = sampleDetection() + service.check() + settle() + service.finish() + settle() + assertEquals(1, rpc.finalizeCalls.size) + assertEquals(LegacyMigrationStatusDto.completed, rpc.finalizeCalls[0]) + assertEquals(MigrationUiState.Hidden, service.state.value) + } + + fun `test start emits migrating state and initial pending progress`() = runBlocking { + rpc.statusResult = null + rpc.detectResult = sampleDetection() + service.check() + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val selections = MigrationUiSelections(providers = listOf("profile1")) + service.start(selections) + delay(50) + UIUtil.dispatchAllInvocationEvents() + + val state = service.state.value + assertTrue("should be Needed after start", state is MigrationUiState.Needed) + val needed = state as MigrationUiState.Needed + assertEquals(MigrationUiPhase.migrating, needed.phase) + assertTrue(needed.running) + assertTrue("should have initial progress entries", needed.progress.isNotEmpty()) + } + + fun `test complete event without errors sets done phase`() = runBlocking { + rpc.statusResult = null + rpc.detectResult = sampleDetection() + service.check() + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val selections = MigrationUiSelections(providers = listOf("profile1")) + service.start(selections) + delay(50) + UIUtil.dispatchAllInvocationEvents() + + val items = listOf(LegacyMigrationResultItemDto("profile1", MigrationItemCategoryDto.provider, MigrationItemStatusDto.success)) + rpc.events.emit(LegacyMigrationEventDto.Complete(items)) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val state = service.state.value as? MigrationUiState.Needed + assertNotNull(state) + assertEquals(MigrationUiPhase.done, state!!.phase) + assertFalse(state.running) + } + + fun `test complete event with errors sets error phase`() = runBlocking { + rpc.statusResult = null + rpc.detectResult = sampleDetection() + service.check() + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val selections = MigrationUiSelections(providers = listOf("profile1")) + service.start(selections) + delay(50) + UIUtil.dispatchAllInvocationEvents() + + val items = listOf(LegacyMigrationResultItemDto("profile1", MigrationItemCategoryDto.provider, MigrationItemStatusDto.error, "bad key")) + rpc.events.emit(LegacyMigrationEventDto.Complete(items)) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val state = service.state.value as? MigrationUiState.Needed + assertNotNull(state) + assertEquals(MigrationUiPhase.error, state!!.phase) + } + + fun `test force sends only session selections with force true`() = runBlocking { + rpc.statusResult = null + rpc.detectResult = sampleDetection() + service.check() + delay(100) + UIUtil.dispatchAllInvocationEvents() + + service.force(listOf("ses_1", "ses_2")) + delay(50) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(1, rpc.migrateCalls.size) + val dto = rpc.migrateCalls[0] + assertEquals(emptyList(), dto.providers) + assertEquals(2, dto.sessions.size) + assertTrue(dto.sessions.all { it.force }) + assertEquals(listOf("ses_1", "ses_2"), dto.sessions.map { it.id }) + } + + private fun sampleDetection() = LegacyMigrationDetectionDto( + providers = listOf( + MigrationProviderInfoDto("profile1", "anthropic", "claude-3", true, true, "anthropic"), + ), + mcpServers = emptyList(), + customModes = emptyList(), + sessions = emptyList(), + defaultModel = null, + settings = null, + hasData = true, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt new file mode 100644 index 00000000000..08b348464b4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt @@ -0,0 +1,97 @@ +package ai.kilocode.client.migration + +import ai.kilocode.client.session.SessionUiTestBase +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.MigrationProviderInfoDto + +@Suppress("UnstableApiUsage") +class SessionUiMigrationTest : SessionUiTestBase() { + + private lateinit var fakeMigration: FakeMigrationUiController + + override fun setUp() { + super.setUp() + // Replace the default UI with one using our observable fake migration controller. + fakeMigration = FakeMigrationUiController() + ui = newUi(migration = fakeMigration) + layout() + } + + fun `test addNotify calls migration check`() { + // SessionUi.addNotify should call migration.check(). + // Use a fresh UI with the same fakeMigration to track the call. + val fresh = newUi(migration = fakeMigration) + val before = fakeMigration.checks.size + try { + fresh.addNotify() + assertTrue("addNotify should call migration.check()", fakeMigration.checks.size > before) + } finally { + fresh.removeNotify() + com.intellij.openapi.util.Disposer.dispose(fresh) + } + } + + fun `test hidden migration state keeps blocker hidden`() { + val root = find(ui) + fakeMigration._state.value = MigrationUiState.Hidden + settle() + assertFalse(root.blocker.isVisible) + } + + fun `test visible migration state shows root blocker`() { + val root = find(ui) + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + assertTrue("blocker should be visible", root.blocker.isVisible) + } + + fun `test hidden state after visible hides blocker`() { + val root = find(ui) + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + assertTrue(root.blocker.isVisible) + + fakeMigration._state.value = MigrationUiState.Hidden + settle() + assertFalse(root.blocker.isVisible) + } + + fun `test two session UIs sharing one controller both react to state change`() { + val ui2 = newUi(migration = fakeMigration) + ui2.setSize(800, 600) + try { + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + + val root1 = find(ui) + val root2 = find(ui2) + assertTrue("ui1 blocker should be visible", root1.blocker.isVisible) + assertTrue("ui2 blocker should be visible", root2.blocker.isVisible) + } finally { + com.intellij.openapi.util.Disposer.dispose(ui2) + } + } + + fun `test default focused component is migration overlay when blocked`() { + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + val root = find(ui) + assertTrue("blocker should be visible for defaultFocused test", root.blocker.isVisible) + // defaultFocusedComponent should not throw and should not be the prompt editor + val focused = ui.defaultFocusedComponent + assertNotNull(focused) + } + + private fun sampleDetection() = LegacyMigrationDetectionDto( + providers = listOf( + MigrationProviderInfoDto("profile1", "anthropic", "claude-3", true, true, "anthropic"), + ), + mcpServers = emptyList(), + customModes = emptyList(), + sessions = emptyList(), + defaultModel = null, + settings = null, + hasData = true, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 1e8beed18a9..b3256c446d4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -24,14 +24,17 @@ import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") class SessionUiLayoutTest : SessionUiTestBase() { - fun `test root contains content and overlay layers`() { + fun `test root contains content overlay and blocker layers`() { val root = find(ui) - assertEquals(2, root.componentCount) + assertEquals(3, root.componentCount) assertSame(root.content, root.components.first { it === root.content }) assertSame(root.overlay, root.components.first { it === root.overlay }) + assertSame(root.blocker, root.components.first { it === root.blocker }) assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker)) + assertFalse(root.blocker.isVisible) } fun `test bottom stack contains connection and prompt only`() { @@ -61,7 +64,8 @@ class SessionUiLayoutTest : SessionUiTestBase() { fun `test header is docked above shared scroll pane and hidden while empty`() { val root = find(ui) val header = find(ui) - val scroll = find(ui) + // Search from root.content to avoid finding the migration wizard scroll panes + val scroll = find(root.content) assertSame(root.content, header.parent.parent) assertSame(scroll.parent, header.parent) @@ -273,14 +277,13 @@ class SessionUiLayoutTest : SessionUiTestBase() { fun `test existing session history shows header above scroll pane`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) - ui = SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.Local("ses_test"), displayMs = 0).apply { - setSize(800, 600) - } + ui = newUi(id = "ses_test") settle() layout() + val root = find(ui) val header = find(ui) - val scroll = find(ui) + val scroll = find(root.content) assertTrue(header.isVisible) assertTrue(header.y + header.height <= scroll.y) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 13591100a75..59687514476 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -4,6 +4,8 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.migration.FakeMigrationUiController +import ai.kilocode.client.migration.MigrationUiController import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.controller.SessionController @@ -79,6 +81,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { id: String? = null, displayMs: Long = 0, open: ((SessionRef) -> Unit)? = null, + migration: MigrationUiController = FakeMigrationUiController(), ): SessionUi { val manager = open?.let { fn -> object : SessionManager { @@ -87,7 +90,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun openSession(ref: SessionRef) = fn(ref) } } - return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager).apply { + return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager, migration = migration).apply { setSize(800, 600) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt index 8135144a541..d5e14e2b360 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -9,17 +9,24 @@ import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") class SessionRootPanelTest : BasePlatformTestCase() { - fun `test root owns content and overlay layers`() { + fun `test root owns content overlay and blocker layers`() { val root = SessionRootPanel() - assertEquals(2, root.componentCount) + assertEquals(3, root.componentCount) assertSame(root.content, root.components.first { it === root.content }) assertSame(root.overlay, root.components.first { it === root.overlay }) + assertSame(root.blocker, root.components.first { it === root.blocker }) assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker)) } - fun `test root layout fills immediate children`() { + fun `test blocker is hidden by default`() { + val root = SessionRootPanel() + assertFalse(root.blocker.isVisible) + } + + fun `test root layout fills all immediate children`() { val root = SessionRootPanel().apply { setSize(320, 180) } @@ -28,9 +35,10 @@ class SessionRootPanelTest : BasePlatformTestCase() { assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds) assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.blocker.bounds) } - fun `test root preferred size is max of immediate children`() { + fun `test root preferred size is max of content and overlay`() { val root = SessionRootPanel().apply { content.preferredSize = Dimension(300, 120) overlay.preferredSize = Dimension(180, 220) @@ -55,6 +63,35 @@ class SessionRootPanelTest : BasePlatformTestCase() { assertTrue(child.laid) } + fun `test setBlocked makes blocker visible and setBlocked false hides it`() { + val root = SessionRootPanel().apply { setSize(200, 100) } + root.doLayout() + + assertFalse(root.blocker.isVisible) + + root.setBlocked(true) + assertTrue(root.blocker.isVisible) + + root.setBlocked(false) + assertFalse(root.blocker.isVisible) + } + + fun `test blocker contains returns false when hidden`() { + val root = SessionRootPanel().apply { setSize(200, 100) } + root.doLayout() + + root.setBlocked(false) + assertFalse(root.blocker.contains(50, 50)) + } + + fun `test blocker contains returns true when visible`() { + val root = SessionRootPanel().apply { setSize(200, 100) } + root.doLayout() + + root.setBlocked(true) + assertTrue(root.blocker.contains(50, 50)) + } + private class Probe : BorderLayoutPanel() { var laid = false diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMigrationRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMigrationRpcApi.kt new file mode 100644 index 00000000000..27048492eeb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMigrationRpcApi.kt @@ -0,0 +1,78 @@ +package ai.kilocode.client.testing + +import ai.kilocode.rpc.KiloMigrationRpcApi +import ai.kilocode.rpc.dto.LegacyCleanupReportDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * Fake [KiloMigrationRpcApi] for testing. + * + * Supply [statusResult] and [detectResult] before calling check. + * Push events via [events] for migration flows. + * All suspend methods assert they are NOT called on the EDT. + */ +class FakeMigrationRpcApi : KiloMigrationRpcApi { + + var statusResult: LegacyMigrationStatusDto? = null + var detectResult: LegacyMigrationDetectionDto = emptyDetection() + val events = MutableSharedFlow(extraBufferCapacity = 64) + + val statusCalls = mutableListOf() + val detectCalls = mutableListOf() + val migrateCalls = mutableListOf() + val skipCalls = mutableListOf() + val finalizeCalls = mutableListOf() + val cleanupCalls = mutableListOf() + + override suspend fun status(): LegacyMigrationStatusDto? { + assertNotEdt("status") + statusCalls.add(Unit) + return statusResult + } + + override suspend fun detect(): LegacyMigrationDetectionDto { + assertNotEdt("detect") + detectCalls.add(Unit) + return detectResult + } + + override suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow { + assertNotEdt("migrate") + migrateCalls.add(selections) + return events + } + + override suspend fun skip() { + assertNotEdt("skip") + skipCalls.add(Unit) + } + + override suspend fun finalize(status: LegacyMigrationStatusDto) { + assertNotEdt("finalize") + finalizeCalls.add(status) + } + + override suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto { + assertNotEdt("cleanup") + cleanupCalls.add(targets) + return LegacyCleanupReportDto(emptyList(), emptyList()) + } + + companion object { + fun emptyDetection() = LegacyMigrationDetectionDto( + providers = emptyList(), + mcpServers = emptyList(), + customModes = emptyList(), + sessions = emptyList(), + defaultModel = null, + settings = null, + hasData = false, + ) + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMigrationRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMigrationRpcApi.kt new file mode 100644 index 00000000000..f06863ce214 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMigrationRpcApi.kt @@ -0,0 +1,47 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.rpc + +import ai.kilocode.rpc.dto.LegacyCleanupReportDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow + +/** + * App-level RPC API for legacy migration operations. + * + * All operations are app-scoped. The backend implementation delegates to + * [ai.kilocode.backend.app.KiloBackendMigrationManager] using the active CLI connection. + */ +@Rpc +interface KiloMigrationRpcApi : RemoteApi { + companion object { + suspend fun getInstance(): KiloMigrationRpcApi = + RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + + /** Return the persisted migration status, or null if not yet set. */ + suspend fun status(): LegacyMigrationStatusDto? + + /** Detect legacy data and return a summary of what can be migrated. */ + suspend fun detect(): LegacyMigrationDetectionDto + + /** Run migration for the given selections, streaming progress events. */ + suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow + + /** Mark migration as skipped. */ + suspend fun skip() + + /** Mark migration as completed or completed with errors. */ + suspend fun finalize(status: LegacyMigrationStatusDto) + + /** Clean up legacy data after migration. */ + suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MigrationDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MigrationDto.kt new file mode 100644 index 00000000000..10ef707c952 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MigrationDto.kt @@ -0,0 +1,233 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +@Serializable +enum class LegacyMigrationStatusDto { + @SerialName("completed") completed, + @SerialName("completed_with_errors") completed_with_errors, + @SerialName("skipped") skipped, +} + +// --------------------------------------------------------------------------- +// Detection DTOs +// --------------------------------------------------------------------------- + +@Serializable +data class MigrationProviderInfoDto( + val profileName: String, + val provider: String, + val model: String?, + val hasApiKey: Boolean, + val supported: Boolean, + val newProviderName: String?, +) + +@Serializable +data class MigrationMcpServerInfoDto( + val name: String, + val type: String, + val disabled: Boolean?, +) + +@Serializable +data class MigrationCustomModeInfoDto( + val name: String, + val slug: String, + val nativeSlug: String? = null, +) + +@Serializable +data class MigrationSessionInfoDto( + val id: String, + val title: String, + val directory: String, + val time: Long, +) + +@Serializable +data class MigrationDefaultModelInfoDto( + val provider: String, + val model: String, +) + +@Serializable +data class LegacyAutocompleteSettingsDto( + val enableAutoTrigger: Boolean?, + val enableSmartInlineTaskKeybinding: Boolean?, + val enableChatAutocomplete: Boolean?, +) + +@Serializable +data class LegacySettingsDto( + val autoApprovalEnabled: Boolean?, + val allowedCommands: List?, + val deniedCommands: List?, + val alwaysAllowReadOnly: Boolean?, + val alwaysAllowReadOnlyOutsideWorkspace: Boolean?, + val alwaysAllowWrite: Boolean?, + val alwaysAllowExecute: Boolean?, + val alwaysAllowMcp: Boolean?, + val alwaysAllowModeSwitch: Boolean?, + val alwaysAllowSubtasks: Boolean?, + val language: String?, + val autocomplete: LegacyAutocompleteSettingsDto?, +) + +@Serializable +data class LegacyMigrationDetectionDto( + val providers: List, + val mcpServers: List, + val customModes: List, + val sessions: List, + val defaultModel: MigrationDefaultModelInfoDto?, + val settings: LegacySettingsDto?, + val hasData: Boolean, +) + +// --------------------------------------------------------------------------- +// Selection DTOs +// --------------------------------------------------------------------------- + +@Serializable +data class MigrationAutoApprovalSelectionsDto( + val commandRules: Boolean, + val readPermission: Boolean, + val writePermission: Boolean, + val executePermission: Boolean, + val mcpPermission: Boolean, + val taskPermission: Boolean, +) + +@Serializable +data class MigrationSettingsSelectionsDto( + val autoApproval: MigrationAutoApprovalSelectionsDto, + val language: Boolean, + val autocomplete: Boolean, +) + +@Serializable +data class MigrationSessionSelectionDto( + val id: String, + val force: Boolean = false, +) + +@Serializable +data class LegacyMigrationSelectionsDto( + val providers: List, + val mcpServers: List, + val customModes: List, + val sessions: List, + val defaultModel: Boolean, + val settings: MigrationSettingsSelectionsDto, +) + +// --------------------------------------------------------------------------- +// Result / Progress DTOs +// --------------------------------------------------------------------------- + +@Serializable +enum class MigrationItemCategoryDto { + @SerialName("provider") provider, + @SerialName("mcpServer") mcpServer, + @SerialName("customMode") customMode, + @SerialName("session") session, + @SerialName("defaultModel") defaultModel, + @SerialName("settings") settings, +} + +@Serializable +enum class MigrationItemStatusDto { + @SerialName("success") success, + @SerialName("warning") warning, + @SerialName("error") error, +} + +@Serializable +data class LegacyMigrationResultItemDto( + val item: String, + val category: MigrationItemCategoryDto, + val status: MigrationItemStatusDto, + val message: String? = null, +) + +@Serializable +enum class MigrationItemProgressStatusDto { + @SerialName("migrating") migrating, + @SerialName("success") success, + @SerialName("warning") warning, + @SerialName("error") error, +} + +@Serializable +enum class MigrationSessionPhaseDto { + @SerialName("preparing") preparing, + @SerialName("storing") storing, + @SerialName("skipped") skipped, + @SerialName("done") done, + @SerialName("summary") summary, + @SerialName("error") error, +} + +@Serializable +data class LegacyMigrationItemProgressDto( + val item: String, + val status: MigrationItemProgressStatusDto, + val message: String? = null, +) + +@Serializable +data class LegacyMigrationSessionProgressDto( + val session: MigrationSessionInfoDto?, + val index: Int, + val total: Int, + val phase: MigrationSessionPhaseDto, + val error: String? = null, +) + +// --------------------------------------------------------------------------- +// Cleanup DTOs +// --------------------------------------------------------------------------- + +@Serializable +data class LegacyCleanupTargetsDto( + val providerProfiles: Boolean = false, + val mcpSettings: Boolean = false, + val customModes: Boolean = false, + val globalState: Boolean = false, + val taskHistory: Boolean = false, +) + +@Serializable +data class LegacyCleanupReportDto( + val cleaned: List, + val errors: List, +) + +// --------------------------------------------------------------------------- +// Migration event sealed class (streamed from migrate()) +// --------------------------------------------------------------------------- + +@Serializable +sealed class LegacyMigrationEventDto { + @Serializable + @SerialName("item") + data class Item(val progress: LegacyMigrationItemProgressDto) : LegacyMigrationEventDto() + + @Serializable + @SerialName("session") + data class Session(val progress: LegacyMigrationSessionProgressDto) : LegacyMigrationEventDto() + + @Serializable + @SerialName("complete") + data class Complete(val items: List) : LegacyMigrationEventDto() + + @Serializable + @SerialName("error") + data class Error(val message: String) : LegacyMigrationEventDto() +}