mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat(jetbrains): add legacy migration wizard UI
Add the JetBrains frontend migration wizard for the legacy migration core. The wizard renders as a MODAL_LAYER blocking overlay inside every visible SessionUi and is driven by a shared KiloMigrationService app service. - Shared: MigrationDto.kt with full DTO set, KiloMigrationRpcApi with status/detect/migrate/skip/finalize/cleanup - Backend: KiloBackendLegacyMigrationStoreService (PropertiesComponent status persistence), MigrationRpcMapper (pure bidirectional mapper), KiloMigrationRpcApiImpl (channelFlow streaming off Dispatchers.IO), KiloMigrationRpcApiProvider; extend KiloBackendMigrationManager with status/mark - Frontend service: KiloMigrationService with idempotent check, in-flight guard, progress collection from streamed events, skip/finish/force re-import; MigrationSelectionBuilder mirrors VS Code preselection rules - SessionRootPanel: new Blocker class at MODAL_LAYER with setBlocker/setBlocked helpers; existing scroll overlay at PALETTE_LAYER unchanged - Swing wizard: MigrationWizardPanel (What's New + Migrate screens, retained component tree), MigrationItemRow, MigrationStatusIcon, SessionMigrationProgressPanel, SessionMigrationSummaryPanel, MigrationOverlayPanel - SessionUi: wired to MigrationUiController via coroutine state collection on Dispatchers.Main; check() called from addNotify() and AppChanged READY - Tests: SessionRootPanelTest, SessionUiLayoutTest, KiloMigrationServiceTest, SessionUiMigrationTest, FakeMigrationRpcApi, FakeMigrationUiController
This commit is contained in:
+7
@@ -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()
|
||||
|
||||
|
||||
+50
@@ -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())
|
||||
}
|
||||
}
|
||||
+104
@@ -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<LegacyMigrationEventDto> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+15
@@ -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<KiloMigrationRpcApi>()) {
|
||||
KiloMigrationRpcApiImpl()
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
@@ -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)
|
||||
}
|
||||
@@ -9,5 +9,7 @@
|
||||
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloAppRpcApiProvider"/>
|
||||
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloProjectRpcApiProvider"/>
|
||||
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloSessionRpcApiProvider"/>
|
||||
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloMigrationRpcApiProvider"/>
|
||||
<applicationService serviceImplementation="ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
||||
+331
@@ -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<MigrationUiState>
|
||||
fun check()
|
||||
fun start(selections: MigrationUiSelections)
|
||||
fun force(ids: List<String>)
|
||||
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>(MigrationUiState.Hidden)
|
||||
override val state: StateFlow<MigrationUiState> = _state.asStateFlow()
|
||||
|
||||
private val checking = AtomicBoolean(false)
|
||||
private val migrating = AtomicBoolean(false)
|
||||
private val migrateJob = AtomicReference<Job?>(null)
|
||||
|
||||
// ------ RPC helper ------
|
||||
|
||||
private suspend fun <T> 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<String>) {
|
||||
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<MigrationItemUiProgress> {
|
||||
val list = mutableListOf<MigrationItemUiProgress>()
|
||||
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
|
||||
}
|
||||
}
|
||||
+110
@@ -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<String>): 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+117
@@ -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<String> = emptyList(),
|
||||
val mcpServers: List<String> = emptyList(),
|
||||
val customModes: List<String> = emptyList(),
|
||||
val sessions: List<String> = 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<LegacyMigrationResultItemDto> = emptyList(),
|
||||
val skipped: List<LegacyMigrationResultItemDto> = emptyList(),
|
||||
val errored: List<LegacyMigrationResultItemDto> = 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<MigrationItemUiProgress> = emptyList(),
|
||||
val sessionProgress: LegacyMigrationSessionProgressDto? = null,
|
||||
val sessionSummary: SessionMigrationSummary = SessionMigrationSummary(),
|
||||
val results: List<LegacyMigrationResultItemDto> = 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<MigrationItemUiProgress>): 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
|
||||
+76
@@ -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
|
||||
}
|
||||
}
|
||||
+52
@@ -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<MigrationOverlayPanel>(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<String>) -> 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()
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
+344
@@ -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<String>) -> 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<MigrationItemCategoryDto, MigrationItemRow>()
|
||||
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<MigrationItemUiProgress>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
+101
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -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<LegacyMigrationResultItemDto> = emptyList()
|
||||
private var selectedForReimport: MutableSet<String> = mutableSetOf()
|
||||
private val reimportCheckboxes = mutableListOf<Pair<JBCheckBox, String>>()
|
||||
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<String>) -> 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()
|
||||
}
|
||||
}
|
||||
+61
-2
@@ -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<KiloMigrationService>(),
|
||||
) : 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 {
|
||||
|
||||
+40
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<resource-bundle>messages.KiloBundle</resource-bundle>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<applicationService serviceImplementation="ai.kilocode.client.migration.KiloMigrationService"/>
|
||||
|
||||
<toolWindow id="Kilo Code"
|
||||
anchor="left"
|
||||
icon="/icons/kilo.svg"
|
||||
|
||||
@@ -148,3 +148,47 @@ action.Kilo.Session.Rename.description=Rename the selected session
|
||||
action.Kilo.Session.Delete.text=Delete
|
||||
action.Kilo.Session.Delete.description=Delete the selected session(s)
|
||||
action.Kilo.History.ContextMenu.text=History Actions
|
||||
|
||||
# Migration wizard
|
||||
migration.whats_new.title=What''s New in Kilo Code
|
||||
migration.whats_new.subtitle=We''ve rebuilt the extension on a faster, more efficient foundation.
|
||||
migration.whats_new.feature.performance=Faster Agent Performance
|
||||
migration.whats_new.feature.interface=Streamlined Interface
|
||||
migration.whats_new.feature.agent_manager=Agent Manager
|
||||
migration.whats_new.feature.foundation=Shared Foundation
|
||||
|
||||
migration.migrate.title=Migrate Your Settings
|
||||
migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over.
|
||||
migration.migrate.section=Select what to migrate
|
||||
migration.empty=Nothing to migrate was found in the legacy settings.
|
||||
|
||||
migration.row.providers=Provider API Keys
|
||||
migration.row.mcp=MCP Servers
|
||||
migration.row.modes=Custom Modes / Agents
|
||||
migration.row.sessions=Chat Sessions & History
|
||||
migration.row.model=Default Model
|
||||
migration.row.settings=Auto-Approval, Language & Autocomplete
|
||||
|
||||
migration.button.back=Back
|
||||
migration.button.skip=Skip
|
||||
migration.button.migrate=Migrate Settings
|
||||
migration.button.migrating=Migrating...
|
||||
migration.button.done=Done
|
||||
migration.button.continue=Continue
|
||||
migration.button.continue_to_migrate=Continue
|
||||
|
||||
migration.session.header=Migrating {0} of {1}
|
||||
migration.session.step.preparing=Preparing session
|
||||
migration.session.step.storing=Storing session
|
||||
migration.session.step.skipped=Session skipped
|
||||
migration.session.unknown.path=(unknown path)
|
||||
migration.session.unknown.title=(untitled)
|
||||
migration.session.unknown.date=(unknown date)
|
||||
|
||||
migration.session.summary.imported={0} imported
|
||||
migration.session.summary.skipped={0} skipped
|
||||
migration.session.summary.errored={0} errored
|
||||
migration.session.summary.reimport.all=Re-import all
|
||||
migration.session.summary.force.reimport=Force Re-Import
|
||||
migration.session.summary.copy.report=Copy Report
|
||||
migration.session.summary.copied=Copied report
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package ai.kilocode.client.migration
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Fake [MigrationUiController] for UI tests.
|
||||
*
|
||||
* Push state changes by setting [_state].
|
||||
* Track calls via [checks], [starts], [forces], [skips], [finishes].
|
||||
*/
|
||||
class FakeMigrationUiController : MigrationUiController {
|
||||
|
||||
val _state = MutableStateFlow<MigrationUiState>(MigrationUiState.Hidden)
|
||||
override val state: StateFlow<MigrationUiState> = _state
|
||||
|
||||
val checks = mutableListOf<Unit>()
|
||||
val starts = mutableListOf<MigrationUiSelections>()
|
||||
val forces = mutableListOf<List<String>>()
|
||||
val skips = mutableListOf<Unit>()
|
||||
val finishes = mutableListOf<Unit>()
|
||||
|
||||
override fun check() {
|
||||
checks.add(Unit)
|
||||
}
|
||||
|
||||
override fun start(selections: MigrationUiSelections) {
|
||||
starts.add(selections)
|
||||
}
|
||||
|
||||
override fun force(ids: List<String>) {
|
||||
forces.add(ids)
|
||||
}
|
||||
|
||||
override fun skip() {
|
||||
skips.add(Unit)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
finishes.add(Unit)
|
||||
}
|
||||
}
|
||||
+211
@@ -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<String>(), 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,
|
||||
)
|
||||
}
|
||||
+97
@@ -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<SessionRootPanel>(ui)
|
||||
fakeMigration._state.value = MigrationUiState.Hidden
|
||||
settle()
|
||||
assertFalse(root.blocker.isVisible)
|
||||
}
|
||||
|
||||
fun `test visible migration state shows root blocker`() {
|
||||
val root = find<SessionRootPanel>(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<SessionRootPanel>(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<SessionRootPanel>(ui)
|
||||
val root2 = find<SessionRootPanel>(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<SessionRootPanel>(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,
|
||||
)
|
||||
}
|
||||
+10
-7
@@ -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<SessionRootPanel>(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<SessionRootPanel>(ui)
|
||||
val header = find<SessionHeaderPanel>(ui)
|
||||
val scroll = find<JBScrollPane>(ui)
|
||||
// Search from root.content to avoid finding the migration wizard scroll panes
|
||||
val scroll = find<JBScrollPane>(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<SessionRootPanel>(ui)
|
||||
val header = find<SessionHeaderPanel>(ui)
|
||||
val scroll = find<JBScrollPane>(ui)
|
||||
val scroll = find<JBScrollPane>(root.content)
|
||||
assertTrue(header.isVisible)
|
||||
assertTrue(header.y + header.height <= scroll.y)
|
||||
}
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-4
@@ -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
|
||||
|
||||
|
||||
+78
@@ -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<LegacyMigrationEventDto>(extraBufferCapacity = 64)
|
||||
|
||||
val statusCalls = mutableListOf<Unit>()
|
||||
val detectCalls = mutableListOf<Unit>()
|
||||
val migrateCalls = mutableListOf<LegacyMigrationSelectionsDto>()
|
||||
val skipCalls = mutableListOf<Unit>()
|
||||
val finalizeCalls = mutableListOf<LegacyMigrationStatusDto>()
|
||||
val cleanupCalls = mutableListOf<LegacyCleanupTargetsDto>()
|
||||
|
||||
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<LegacyMigrationEventDto> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Unit> {
|
||||
companion object {
|
||||
suspend fun getInstance(): KiloMigrationRpcApi =
|
||||
RemoteApiProviderService.resolve(remoteApiDescriptor<KiloMigrationRpcApi>())
|
||||
}
|
||||
|
||||
/** 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<LegacyMigrationEventDto>
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -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<String>?,
|
||||
val deniedCommands: List<String>?,
|
||||
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<MigrationProviderInfoDto>,
|
||||
val mcpServers: List<MigrationMcpServerInfoDto>,
|
||||
val customModes: List<MigrationCustomModeInfoDto>,
|
||||
val sessions: List<MigrationSessionInfoDto>,
|
||||
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<String>,
|
||||
val mcpServers: List<String>,
|
||||
val customModes: List<String>,
|
||||
val sessions: List<MigrationSessionSelectionDto>,
|
||||
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<String>,
|
||||
val errors: List<String>,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<LegacyMigrationResultItemDto>) : LegacyMigrationEventDto()
|
||||
|
||||
@Serializable
|
||||
@SerialName("error")
|
||||
data class Error(val message: String) : LegacyMigrationEventDto()
|
||||
}
|
||||
Reference in New Issue
Block a user