diff --git a/.gitignore b/.gitignore index 526e5048..b1b4dc26 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build .kotlin local.properties +.intellijPlatform diff --git a/build.gradle.kts b/build.gradle.kts index a9c11588..54613965 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,10 +10,14 @@ plugins { alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.buildconfig) apply false alias(libs.plugins.maven.publish) apply false + alias(libs.plugins.jetbrainsCompose) apply false + alias(libs.plugins.sqldelight) apply false + alias(libs.plugins.intelliJPlatform) apply false // convention plugin alias(libs.plugins.backintimeLint) apply false alias(libs.plugins.backintimePublication) apply false alias(libs.plugins.backintimeCompilerModule) apply false + alias(libs.plugins.intelliJComposeFeature) apply false } allprojects { diff --git a/compiler-test/build.gradle.kts b/compiler-test/build.gradle.kts index a7b602aa..c3e86cb9 100644 --- a/compiler-test/build.gradle.kts +++ b/compiler-test/build.gradle.kts @@ -16,7 +16,7 @@ kotlin { implementation(projects.core.annotations) implementation(projects.core.websocket.server) implementation(projects.core.websocket.event) - implementation(projects.tooling.model) + implementation(projects.tooling.core.model) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) } diff --git a/compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterInstanceEventTest.kt b/compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterInstanceTest.kt similarity index 92% rename from compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterInstanceEventTest.kt rename to compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterInstanceTest.kt index c637e9c5..9d7eeec5 100644 --- a/compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterInstanceEventTest.kt +++ b/compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterInstanceTest.kt @@ -9,7 +9,7 @@ import org.junit.Assert.assertEquals import kotlin.test.Test import kotlin.test.assertIs -class RegisterInstanceEventTest : BackInTimeDebugServiceTest() { +class RegisterInstanceTest : BackInTimeDebugServiceTest() { @BackInTime private class TestStateHolder diff --git a/compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterRelationShipTest.kt b/compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterNewDependencyTest.kt similarity index 97% rename from compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterRelationShipTest.kt rename to compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterNewDependencyTest.kt index 9d039b4b..b57456f2 100644 --- a/compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterRelationShipTest.kt +++ b/compiler-test/src/commonTest/kotlin/com/kitakkun/backintime/test/basic/RegisterNewDependencyTest.kt @@ -12,7 +12,7 @@ import kotlin.test.assertIs /** * Checks if relationship between state holders are captured as expected. */ -class RegisterRelationShipTest : BackInTimeDebugServiceTest() { +class RegisterNewDependencyTest : BackInTimeDebugServiceTest() { @BackInTime private class ParentTestStateHolderWithNormalChild { val child = ChildTestStateHolder() diff --git a/core/runtime/build.gradle.kts b/core/runtime/build.gradle.kts index bd012260..9aeade78 100644 --- a/core/runtime/build.gradle.kts +++ b/core/runtime/build.gradle.kts @@ -19,7 +19,7 @@ kotlin { commonMain.dependencies { implementation(projects.core.websocket.client) implementation(projects.core.websocket.event) - implementation(projects.tooling.model) + implementation(projects.tooling.core.model) implementation(libs.ktor.client.core) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) diff --git a/core/websocket/server/src/commonMain/kotlin/com/kitakkun/backintime/core/websocket/server/BackInTimeWebSocketServer.kt b/core/websocket/server/src/commonMain/kotlin/com/kitakkun/backintime/core/websocket/server/BackInTimeWebSocketServer.kt index 08000145..4962b914 100644 --- a/core/websocket/server/src/commonMain/kotlin/com/kitakkun/backintime/core/websocket/server/BackInTimeWebSocketServer.kt +++ b/core/websocket/server/src/commonMain/kotlin/com/kitakkun/backintime/core/websocket/server/BackInTimeWebSocketServer.kt @@ -15,6 +15,7 @@ import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.receiveDeserialized import io.ktor.server.websocket.sendSerialized import io.ktor.server.websocket.webSocket +import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.filter @@ -26,6 +27,8 @@ class BackInTimeWebSocketServer { private var server: ApplicationEngine? = null val isRunning: Boolean get() = server?.application?.isActive == true + val runningPort: Int? get() = server?.environment?.connectors?.firstOrNull()?.port + private val mutableSessionInfoList = mutableSetOf() val sessionInfoList: List get() = mutableSessionInfoList.toList() @@ -78,9 +81,14 @@ class BackInTimeWebSocketServer { } val receiveEventJob = launch { - while (true) { - val event = receiveDeserialized() - mutableEventFromClientFlow.emit(EventFromClient(sessionId, event)) + try { + while (true) { + val event = receiveDeserialized() + mutableEventFromClientFlow.emit(EventFromClient(sessionId, event)) + } + } catch (e: ClosedReceiveChannelException) { + // Prevent the parent coroutine scope from terminating + // and ensure that the code following closeReason.await() gets executed. } } @@ -91,21 +99,19 @@ class BackInTimeWebSocketServer { address = this.call.request.origin.remoteAddress, ) - closeReason.invokeOnCompletion { - mutableSessionInfoList.remove(sessionInfo) - - sendEventJob.cancel() - receiveEventJob.cancel() - - launch { - mutableSessionClosedFlow.emit(sessionInfo) - } - } - mutableSessionInfoList += sessionInfo mutableNewSessionFlow.emit(sessionInfo) closeReason.await() + + mutableSessionInfoList.remove(sessionInfo) + + sendEventJob.cancel() + receiveEventJob.cancel() + + launch { + mutableSessionClosedFlow.emit(sessionInfo) + } } } } diff --git a/demo/app/build.gradle.kts b/demo/app/build.gradle.kts index 4311e480..3473d11c 100644 --- a/demo/app/build.gradle.kts +++ b/demo/app/build.gradle.kts @@ -61,7 +61,7 @@ dependencies { implementation(projects.core.runtime) implementation(projects.core.annotations) implementation(projects.core.websocket.event) - implementation(projects.tooling.model) + implementation(projects.tooling.core.model) implementation(libs.core.ktx) implementation(libs.lifecycle.runtime.ktx) implementation(libs.activity.compose) diff --git a/demo/app/src/main/kotlin/com/kitakkun/backintime/evaluation/MyApplication.kt b/demo/app/src/main/kotlin/com/kitakkun/backintime/evaluation/MyApplication.kt index 5dd18c72..860cb7d6 100644 --- a/demo/app/src/main/kotlin/com/kitakkun/backintime/evaluation/MyApplication.kt +++ b/demo/app/src/main/kotlin/com/kitakkun/backintime/evaluation/MyApplication.kt @@ -1,7 +1,10 @@ package com.kitakkun.backintime.evaluation import android.app.Application -import com.kitakkun.backintime.demo.flipper.FlipperInitializer +import com.kitakkun.backintime.core.runtime.BackInTimeDebugService +import com.kitakkun.backintime.core.runtime.connector.BackInTimeKtorWebSocketConnector +import com.kitakkun.backintime.core.runtime.getBackInTimeDebugService +import com.kitakkun.backintime.core.runtime.internal.BackInTimeCompilerInternalApi import com.kitakkun.backintime.evaluation.di.appModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger @@ -11,7 +14,11 @@ class MyApplication : Application() { override fun onCreate() { super.onCreate() - FlipperInitializer().init(this) + @OptIn(BackInTimeCompilerInternalApi::class) + val service: BackInTimeDebugService = getBackInTimeDebugService() + + service.setConnector(BackInTimeKtorWebSocketConnector(host = "10.0.2.2", port = 50023)) + service.startService() startKoin { androidLogger() diff --git a/flipper-plugin/package.json b/flipper-plugin/package.json index fc00efc5..80ffa384 100644 --- a/flipper-plugin/package.json +++ b/flipper-plugin/package.json @@ -67,7 +67,7 @@ "@mui/material": "^5.14.18", "@textea/json-viewer": "^3.2.3", "backintime-websocket-event": "file:../core/websocket/event/build/dist/js/productionLibrary", - "backintime-tooling-model": "file:../tooling/model/build/dist/js/productionLibrary", + "backintime-tooling-model": "file:../tooling/core/model/build/dist/js/productionLibrary", "backintime-flipper-lib": "file:../tooling/flipper-lib/build/dist/js/productionLibrary", "react-icons": "^5.0.1" } diff --git a/flipper-plugin/yarn.lock b/flipper-plugin/yarn.lock index cec45280..2e161515 100644 --- a/flipper-plugin/yarn.lock +++ b/flipper-plugin/yarn.lock @@ -3300,8 +3300,11 @@ babel-preset-jest@^29.6.3: format-util "^1.0.5" react "^19.0.0" -"backintime-tooling-model@file:../tooling/model/build/dist/js/productionLibrary": +"backintime-tooling-model@file:../tooling/core/model/build/dist/js/productionLibrary": version "0.0.1-alpha01" + dependencies: + "@js-joda/core" "3.2.0" + format-util "^1.0.5" "backintime-websocket-event@file:../core/websocket/event/build/dist/js/productionLibrary": version "0.0.1-alpha01" diff --git a/gradle-conventions-settings/build.gradle.kts b/gradle-conventions-settings/build.gradle.kts index 46f9c3f7..87fd2388 100644 --- a/gradle-conventions-settings/build.gradle.kts +++ b/gradle-conventions-settings/build.gradle.kts @@ -1,3 +1,7 @@ plugins { `kotlin-dsl` -} \ No newline at end of file +} + +dependencies { + compileOnly(libs.jetbrains.compose.gradle.plugin) +} diff --git a/gradle-conventions-settings/src/main/kotlin/util/Project.kt b/gradle-conventions-settings/src/main/kotlin/util/Project.kt index 82919921..2b1b2a96 100644 --- a/gradle-conventions-settings/src/main/kotlin/util/Project.kt +++ b/gradle-conventions-settings/src/main/kotlin/util/Project.kt @@ -3,5 +3,8 @@ package util import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.getByType +import org.jetbrains.compose.ComposeExtension val Project.libs get() = extensions.getByType().named("libs") + +val Project.compose get() = extensions.getByType().dependencies diff --git a/gradle-conventions/build.gradle.kts b/gradle-conventions/build.gradle.kts index d9743ecd..adbb93f0 100644 --- a/gradle-conventions/build.gradle.kts +++ b/gradle-conventions/build.gradle.kts @@ -7,4 +7,6 @@ dependencies { implementation(libs.kotlin.gradle.plugin) compileOnly(libs.ktlint.gradle) compileOnly(libs.maven.publish) + implementation(libs.jetbrains.compose.gradle.plugin) + implementation(libs.compose.compiler.gradle.plugin) } diff --git a/gradle-conventions/src/main/kotlin/intellij-compose-feature.gradle.kts b/gradle-conventions/src/main/kotlin/intellij-compose-feature.gradle.kts new file mode 100644 index 00000000..e9ca30b4 --- /dev/null +++ b/gradle-conventions/src/main/kotlin/intellij-compose-feature.gradle.kts @@ -0,0 +1,28 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import util.libs + +plugins { + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") +} + +configure { + jvmToolchain(17) +} + +repositories { + mavenCentral() + google() + maven("https://packages.jetbrains.team/maven/p/kpm/public/") +} + +dependencies { + implementation(project(":tooling:core:ui")) + implementation(project(":tooling:core:model")) + implementation(project(":tooling:core:usecase")) + implementation(libs.findLibrary("jewel").get()) + implementation(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e4bd65d9..19840915 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,15 @@ include( ":core:websocket:server", ":core:websocket:client", ":core:websocket:event", - ":tooling:model", ":tooling:flipper-lib", + ":tooling:idea-plugin", + ":tooling:core:model", + ":tooling:core:database", + ":tooling:core:ui", + ":tooling:core:usecase", + ":tooling:core:shared", + ":tooling:app", + ":tooling:feature:inspector", + ":tooling:feature:settings", + ":tooling:feature:log", ) diff --git a/tooling/app/build.gradle.kts b/tooling/app/build.gradle.kts new file mode 100644 index 00000000..976ff41f --- /dev/null +++ b/tooling/app/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.intelliJComposeFeature) +} + +dependencies { + implementation(projects.tooling.core.shared) + implementation(projects.tooling.core.database) + implementation(projects.tooling.feature.log) + implementation(projects.tooling.feature.inspector) + implementation(projects.tooling.feature.settings) +} diff --git a/tooling/app/src/main/kotlin/com/kitakkun/backintime/tooling/app/BackInTimeDebuggerApp.kt b/tooling/app/src/main/kotlin/com/kitakkun/backintime/tooling/app/BackInTimeDebuggerApp.kt new file mode 100644 index 00000000..9ddee53d --- /dev/null +++ b/tooling/app/src/main/kotlin/com/kitakkun/backintime/tooling/app/BackInTimeDebuggerApp.kt @@ -0,0 +1,89 @@ +package com.kitakkun.backintime.tooling.app + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import com.kitakkun.backintime.feature.settings.SettingsScreen +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDebuggerService +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDebuggerSettings +import com.kitakkun.backintime.tooling.core.shared.IDENavigator +import com.kitakkun.backintime.tooling.core.shared.PluginStateService +import com.kitakkun.backintime.tooling.core.ui.component.HorizontalDivider +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalIDENavigator +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalPluginStateService +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalServer +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalSettings +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkun.backintime.tooling.feature.log.LogScreen +import com.kitakkun.backintime.tooling.model.Tab +import com.kitakkunl.backintime.feature.inspector.InspectorScreen +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter + +@Composable +fun BackInTimeDebuggerApp() { + val server = LocalServer.current + val serverState = server.state + val settings = LocalSettings.current + + val pluginStateService = LocalPluginStateService.current + val pluginState by pluginStateService.stateFlow.collectAsState() + + LaunchedEffect(Unit) { + if (!serverState.serverIsRunning) { + server.restartServer(settings.getState().serverPort) + } + } + + // automatically select the new session if no session is selected. + LaunchedEffect(server, pluginState) { + snapshotFlow { server.state.connections } + .distinctUntilChanged() + .filter { it.isNotEmpty() } + .collect { + if (pluginState.globalState.selectedSessionId == null) { + pluginStateService.updateSessionId(it.first().id) + } + } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + HorizontalDivider() + BackInTimeTopBar( + currentTab = pluginState.globalState.activeTab, + onClickInstances = { pluginStateService.updateTab(Tab.Inspector) }, + onClickLog = { pluginStateService.updateTab(Tab.Log) }, + onClickSettings = { pluginStateService.updateTab(Tab.Settings) }, + ) + HorizontalDivider() + when (pluginState.globalState.activeTab) { + Tab.Inspector -> InspectorScreen() + Tab.Log -> LogScreen() + Tab.Settings -> SettingsScreen() + } + } +} + +@Preview +@Composable +private fun BackInTimeDebuggerAppPreview() { + PreviewContainer { + CompositionLocalProvider( + LocalSettings provides BackInTimeDebuggerSettings.Dummy, + LocalServer provides BackInTimeDebuggerService.Dummy, + LocalPluginStateService provides PluginStateService.Dummy, + LocalIDENavigator provides IDENavigator.Noop, + ) { + BackInTimeDebuggerApp() + } + } +} diff --git a/tooling/app/src/main/kotlin/com/kitakkun/backintime/tooling/app/BackInTimeTopBar.kt b/tooling/app/src/main/kotlin/com/kitakkun/backintime/tooling/app/BackInTimeTopBar.kt new file mode 100644 index 00000000..8520bceb --- /dev/null +++ b/tooling/app/src/main/kotlin/com/kitakkun/backintime/tooling/app/BackInTimeTopBar.kt @@ -0,0 +1,55 @@ +package com.kitakkun.backintime.tooling.app + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkun.backintime.tooling.model.Tab +import org.jetbrains.jewel.ui.component.SelectableIconActionButton +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +@Composable +fun BackInTimeTopBar( + currentTab: Tab, + onClickSettings: () -> Unit, + onClickInstances: () -> Unit, + onClickLog: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + SelectableIconActionButton( + selected = currentTab == Tab.Inspector, + onClick = onClickInstances, + key = AllIconsKeys.Toolwindows.ToolWindowHierarchy, + contentDescription = null, + ) + SelectableIconActionButton( + selected = currentTab == Tab.Log, + onClick = onClickLog, + key = AllIconsKeys.Nodes.DataSchema, + contentDescription = null, + ) + Spacer(Modifier.weight(1f)) + SelectableIconActionButton( + selected = currentTab == Tab.Settings, + onClick = onClickSettings, + key = AllIconsKeys.General.Settings, + contentDescription = null, + ) + } +} + +@Preview +@Composable +private fun BackInTimeTopBarPreview() { + PreviewContainer { + BackInTimeTopBar( + currentTab = Tab.Inspector, + onClickInstances = {}, + onClickLog = {}, + onClickSettings = {}, + ) + } +} diff --git a/tooling/core/database/build.gradle.kts b/tooling/core/database/build.gradle.kts new file mode 100644 index 00000000..38093bb3 --- /dev/null +++ b/tooling/core/database/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.sqldelight) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvmToolchain(17) +} + +sqldelight { + databases { + create("Database") { + packageName.set("com.kitakkun.backintime.tooling.core.database") + } + } +} + +dependencies { + implementation(projects.core.websocket.event) + implementation(projects.tooling.core.model) + implementation(projects.tooling.core.shared) + implementation(libs.sqldelight.sqlite.driver) + implementation(libs.sqldelight.coroutines.extensions) + implementation(libs.kotlinx.serialization.json) + testImplementation(libs.kotlin.test) +} diff --git a/tooling/core/database/src/main/kotlin/com/kitakkun/backintime/tooling/core/database/BackInTimeDatabaseImpl.kt b/tooling/core/database/src/main/kotlin/com/kitakkun/backintime/tooling/core/database/BackInTimeDatabaseImpl.kt new file mode 100644 index 00000000..a9c8bbe4 --- /dev/null +++ b/tooling/core/database/src/main/kotlin/com/kitakkun/backintime/tooling/core/database/BackInTimeDatabaseImpl.kt @@ -0,0 +1,105 @@ +package com.kitakkun.backintime.tooling.core.database + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDatabase +import com.kitakkun.backintime.tooling.model.EventEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import java.io.File + +class BackInTimeDatabaseImpl private constructor() : BackInTimeDatabase { + companion object { + val instance by lazy { BackInTimeDatabaseImpl() } + } + + private val mutableStateFlow: MutableStateFlow = MutableStateFlow(BackInTimeDatabase.State.RunningInMemory) + override val stateFlow: StateFlow = mutableStateFlow + + private var database = createDatabase() + private val queries get() = database.eventQueries + private val ioDispatcher = Dispatchers.IO + + override fun restartDatabaseAsFile(filePath: String, migrate: Boolean) { + val dbFile = File(filePath) + if (!dbFile.exists()) dbFile.createNewFile() + val newDatabase = createDatabase("jdbc:sqlite:$filePath") + if (migrate) { + val prevDatabase = database + prevDatabase.eventQueries.selectAll().executeAsList().forEach { + newDatabase.eventQueries.insert( + id = it.id, + sessionId = it.sessionId, + instanceId = it.instanceId, + time = it.time, + event = it.event, + ) + } + } + database = newDatabase + mutableStateFlow.update { BackInTimeDatabase.State.RunningWithFile(filePath) } + } + + override fun restartDatabaseInMemory(migrate: Boolean) { + val newDatabase = createDatabase(JdbcSqliteDriver.IN_MEMORY) + if (migrate) { + val prevDatabase = database + prevDatabase.eventQueries.selectAll().executeAsList().forEach { + newDatabase.eventQueries.insert( + id = it.id, + sessionId = it.sessionId, + instanceId = it.instanceId, + time = it.time, + event = it.event, + ) + } + } + database = newDatabase + mutableStateFlow.update { BackInTimeDatabase.State.RunningInMemory } + } + + override fun insert(eventEntity: EventEntity) { + queries.insert( + id = eventEntity.eventId, + sessionId = eventEntity.sessionId, + instanceId = eventEntity.instanceId, + time = eventEntity.time, + event = eventEntity, + ) + } + + override fun selectForSession(sessionId: String): Flow> { + return queries.selectBySessionId(sessionId) + .asFlow() + .mapToList(ioDispatcher) + .map { events -> events.map { it.event } } + } + + override fun selectForInstance(sessionId: String, instanceId: String): Flow> { + return queries + .selectByInstanceId( + instanceId = instanceId, + sessionId = sessionId, + ) + .asFlow() + .mapToList(ioDispatcher) + .map { events -> + events.map { it.event } + } + } + + override fun selectInstanceIds(sessionId: String): Flow> { + return queries + .selectAllInstanceId(sessionId) + .asFlow() + .mapToList(ioDispatcher) + .map { instanceIds -> + instanceIds.mapNotNull { it.instanceId } + } + } +} \ No newline at end of file diff --git a/tooling/core/database/src/main/kotlin/com/kitakkun/backintime/tooling/core/database/SqlDelightDatabase.kt b/tooling/core/database/src/main/kotlin/com/kitakkun/backintime/tooling/core/database/SqlDelightDatabase.kt new file mode 100644 index 00000000..e6923f9a --- /dev/null +++ b/tooling/core/database/src/main/kotlin/com/kitakkun/backintime/tooling/core/database/SqlDelightDatabase.kt @@ -0,0 +1,29 @@ +package com.kitakkun.backintime.tooling.core.database + +import app.cash.sqldelight.ColumnAdapter +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.kitakkun.backintime.tooling.model.EventEntity +import kotlinx.serialization.json.Json + +private val eventEntityAdapter = object : ColumnAdapter { + override fun encode(value: EventEntity): String { + return Json.encodeToString(value) + } + + override fun decode(databaseValue: String): EventEntity { + return Json.decodeFromString(databaseValue) + } +} + +fun createDatabase(url: String = JdbcSqliteDriver.IN_MEMORY): Database { + // Fix no suitable driver found for jdbc:sqlite: + // FYI: https://stackoverflow.com/questions/16725377/unable-to-connect-to-database-no-suitable-driver-found + Class.forName("org.sqlite.JDBC") + + val driver = JdbcSqliteDriver(url) + Database.Schema.create(driver) + return Database( + driver = driver, + eventAdapter = Event.Adapter(eventAdapter = eventEntityAdapter) + ) +} diff --git a/tooling/core/database/src/main/sqldelight/com/kitakkun/backintime/tooling/core/database/Event.sq b/tooling/core/database/src/main/sqldelight/com/kitakkun/backintime/tooling/core/database/Event.sq new file mode 100644 index 00000000..57244221 --- /dev/null +++ b/tooling/core/database/src/main/sqldelight/com/kitakkun/backintime/tooling/core/database/Event.sq @@ -0,0 +1,24 @@ +import com.kitakkun.backintime.tooling.model.EventEntity; + +CREATE TABLE IF NOT EXISTS event ( + id TEXT PRIMARY KEY NOT NULL, + sessionId TEXT NOT NULL, + instanceId TEXT, + time INTEGER NOT NULL, + event TEXT AS EventEntity NOT NULL +); + +insert: +INSERT OR IGNORE INTO event(id, sessionId, instanceId, time, event) VALUES (?, ?, ?, ?, ?); + +selectByInstanceId: +SELECT * FROM event WHERE instanceId == ? AND sessionId == ?; + +selectAll: +SELECT * FROM event; + +selectBySessionId: +SELECT * FROM event WHERE sessionId == ?; + +selectAllInstanceId: +SELECT DISTINCT instanceId FROM event WHERE sessionId == ?; diff --git a/tooling/core/database/src/test/kotlin/com/kitakkun/backintime/tooling/core/database/BackInTimeDatabaseImplTest.kt b/tooling/core/database/src/test/kotlin/com/kitakkun/backintime/tooling/core/database/BackInTimeDatabaseImplTest.kt new file mode 100644 index 00000000..bfa16b13 --- /dev/null +++ b/tooling/core/database/src/test/kotlin/com/kitakkun/backintime/tooling/core/database/BackInTimeDatabaseImplTest.kt @@ -0,0 +1,12 @@ +package com.kitakkun.backintime.tooling.core.database + +import com.kitakkun.backintime.tooling.model.EventEntity +import kotlin.test.Test + +class BackInTimeDatabaseImplTest { + @Test + fun test() { + val database = BackInTimeDatabaseImpl.instance + database.insert(EventEntity.Instance.Unregister(sessionId = "sessionId", instanceId = "uuid", time = 0L)) + } +} diff --git a/tooling/model/build.gradle.kts b/tooling/core/model/build.gradle.kts similarity index 84% rename from tooling/model/build.gradle.kts rename to tooling/core/model/build.gradle.kts index 8682ef48..dd30a076 100644 --- a/tooling/model/build.gradle.kts +++ b/tooling/core/model/build.gradle.kts @@ -21,7 +21,10 @@ kotlin { iosSimulatorArm64() sourceSets.commonMain.dependencies { + implementation(projects.core.websocket.event) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + implementation(libs.uuid) } compilerOptions { diff --git a/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/BackInTimeEventData.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/BackInTimeEventData.kt similarity index 93% rename from tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/BackInTimeEventData.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/BackInTimeEventData.kt index ce15e5bc..8a508496 100644 --- a/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/BackInTimeEventData.kt +++ b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/BackInTimeEventData.kt @@ -1,13 +1,15 @@ -package com.kitakkun.backintime.tooling.flipper +package com.kitakkun.backintime.tooling.model import com.benasher44.uuid.uuid4 import com.kitakkun.backintime.core.websocket.event.BackInTimeDebugServiceEvent import com.kitakkun.backintime.core.websocket.event.BackInTimeDebuggerEvent import com.kitakkun.backintime.core.websocket.event.BackInTimeWebSocketEvent import kotlinx.datetime.Clock +import kotlin.js.JsExport @JsExport data class BackInTimeEventData( + val sessionId: String, val uuid: String = uuid4().toString(), val time: Int = Clock.System.now().epochSeconds.toInt(), val payload: BackInTimeWebSocketEvent, diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ClassInfo.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ClassInfo.kt similarity index 79% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ClassInfo.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ClassInfo.kt index aa3aa6b8..8f123cf3 100644 --- a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ClassInfo.kt +++ b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ClassInfo.kt @@ -1,8 +1,10 @@ package com.kitakkun.backintime.tooling.model +import kotlinx.serialization.Serializable import kotlin.js.JsExport @JsExport +@Serializable data class ClassInfo( val classSignature: String, val superClassSignature: String, diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/DependencyInfo.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/DependencyInfo.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/DependencyInfo.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/DependencyInfo.kt diff --git a/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/EventEntity.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/EventEntity.kt new file mode 100644 index 00000000..1f498dbb --- /dev/null +++ b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/EventEntity.kt @@ -0,0 +1,102 @@ +package com.kitakkun.backintime.tooling.model + +import com.benasher44.uuid.uuid4 +import kotlinx.serialization.Serializable + +/** + * Database entity types corresponding to [com.kitakkun.backintime.core.websocket.event.BackInTimeWebSocketEvent] + */ +@Serializable +sealed class EventEntity { + val eventId: String = uuid4().toString() + + abstract val sessionId: String + abstract val instanceId: String? + abstract val time: Long + + @Serializable + sealed class System : EventEntity() { + override val instanceId: String? get() = null + + @Serializable + data class CheckInstanceAlive( + override val sessionId: String, + override val time: Long, + ) : System() + + @Serializable + data class CheckInstanceAliveResult( + override val sessionId: String, + override val time: Long, + val isAlive: Map, + ) : System() + + @Serializable + data class DebuggerError( + override val sessionId: String, + override val time: Long, + val message: String, + ) : System() + + @Serializable + data class AppError( + override val sessionId: String, + override val time: Long, + val message: String, + ) : System() + } + + @Serializable + sealed class Instance : EventEntity() { + @Serializable + data class Register( + override val sessionId: String, + override val instanceId: String, + override val time: Long, + val classInfo: ClassInfo, + ) : Instance() + + @Serializable + data class MethodInvocation( + override val sessionId: String, + override val instanceId: String, + override val time: Long, + val callId: String, + val methodSignature: String, + ) : Instance() + + @Serializable + data class StateChange( + override val sessionId: String, + override val instanceId: String, + override val time: Long, + val callId: String, + val propertySignature: String, + val newValueAsJson: String, + ) : Instance() + + @Serializable + data class Unregister( + override val sessionId: String, + override val instanceId: String, + override val time: Long, + ) : Instance() + + @Serializable + data class NewDependency( + override val sessionId: String, + override val instanceId: String, + override val time: Long, + val dependencyInstanceId: String, + ) : Instance() + + @Serializable + data class BackInTime( + override val sessionId: String, + override val instanceId: String, + override val time: Long, + val jsonValues: Map, + val destinationPointEventId: String?, + ) : Instance() + } +} \ No newline at end of file diff --git a/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Instance.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Instance.kt new file mode 100644 index 00000000..874aca7f --- /dev/null +++ b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Instance.kt @@ -0,0 +1,11 @@ +package com.kitakkun.backintime.tooling.model + +data class Instance( + val id: String, + val className: String, + val superClassName: String, + val properties: List, + val events: List, +) { + val totalEvents: Int get() = events.size +} diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/InstanceInfo.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/InstanceInfo.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/InstanceInfo.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/InstanceInfo.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/MethodCallInfo.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/MethodCallInfo.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/MethodCallInfo.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/MethodCallInfo.kt diff --git a/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PluginState.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PluginState.kt new file mode 100644 index 00000000..260b9697 --- /dev/null +++ b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PluginState.kt @@ -0,0 +1,75 @@ +package com.kitakkun.backintime.tooling.model + +data class PluginState( + val globalState: GlobalState, + val settingsState: SettingsState, + val inspectorState: InspectorState, + val logState: LogState, +) { + companion object { + val Default = PluginState( + globalState = GlobalState.Default, + settingsState = SettingsState.Default, + inspectorState = InspectorState.Default, + logState = LogState.Default, + ) + } +} + +data class GlobalState( + val activeTab: Tab, + val selectedSessionId: String?, +) { + companion object { + val Default: GlobalState = GlobalState( + activeTab = Tab.Inspector, + selectedSessionId = null, + ) + } +} + +data class InspectorState( + val selectedInstanceId: String?, + val selectedPropertyKey: String?, + val expandedInstanceIds: Set, + val horizontalSplitPanePosition: Float, + val verticalSplitPanePosition: Float, + val selectedEventId: String?, +) { + companion object { + val Default: InspectorState = InspectorState( + selectedInstanceId = null, + selectedPropertyKey = null, + expandedInstanceIds = emptySet(), + horizontalSplitPanePosition = 0.5f, + verticalSplitPanePosition = 0.5f, + selectedEventId = null, + ) + } +} + +data class LogState( + val selectedEventId: String?, + val verticalSplitPanePosition: Float, +) { + companion object { + val Default: LogState = LogState( + selectedEventId = null, + verticalSplitPanePosition = 0.5f, + ) + } +} + +data class SettingsState( + val serverPort: Int, +) { + companion object { + val Default: SettingsState = SettingsState(50020) + } +} + +enum class Tab { + Inspector, + Log, + Settings, +} diff --git a/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Property.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Property.kt new file mode 100644 index 00000000..ac0652e5 --- /dev/null +++ b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Property.kt @@ -0,0 +1,11 @@ +package com.kitakkun.backintime.tooling.model + +import kotlin.js.JsExport + +@JsExport +data class Property( + val name: String, + val type: String, + val totalEvents: Int, + val debuggable: Boolean, +) diff --git a/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PropertyInfo.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PropertyInfo.kt new file mode 100644 index 00000000..d773d32d --- /dev/null +++ b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PropertyInfo.kt @@ -0,0 +1,32 @@ +package com.kitakkun.backintime.tooling.model + +import kotlinx.serialization.Serializable +import kotlin.js.JsExport + +@JsExport +@Serializable +data class PropertyInfo( + val signature: String, + val debuggable: Boolean, + val isDebuggableStateHolder: Boolean, + val propertyType: String, + val valueType: String, +) { + val name: String get() = signature.split(".").last() + + companion object { + fun fromString(rawValue: String): PropertyInfo { + /** + * see [com.kitakkun.backintime.compiler.backend.transformer.capture.BackInTimeDebuggableConstructorTransformer] for details. + */ + val info = rawValue.split(",") + return PropertyInfo( + signature = info[0], + debuggable = info[1].toBoolean(), + isDebuggableStateHolder = info[2].toBoolean(), + propertyType = info[3], + valueType = info[4], + ) + } + } +} diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/RawEventLog.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/RawEventLog.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/RawEventLog.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/RawEventLog.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/BackInTimeState.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/BackInTimeState.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/BackInTimeState.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/BackInTimeState.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/HistoryInfo.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/HistoryInfo.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/HistoryInfo.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/HistoryInfo.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/InstanceItem.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/InstanceItem.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/InstanceItem.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/InstanceItem.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/InstanceListState.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/InstanceListState.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/InstanceListState.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/InstanceListState.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PersistentState.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PersistentState.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PersistentState.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PersistentState.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PropertyInspectorState.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PropertyInspectorState.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PropertyInspectorState.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PropertyInspectorState.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PropertyItem.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PropertyItem.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PropertyItem.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/PropertyItem.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/RawLogState.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/RawLogState.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/RawLogState.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/RawLogState.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/ValueChangeInfo.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/ValueChangeInfo.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/ValueChangeInfo.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/ValueChangeInfo.kt diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/ValueEmitModalPageState.kt b/tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/ValueEmitModalPageState.kt similarity index 100% rename from tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/ValueEmitModalPageState.kt rename to tooling/core/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/ui/ValueEmitModalPageState.kt diff --git a/tooling/core/shared/build.gradle.kts b/tooling/core/shared/build.gradle.kts new file mode 100644 index 00000000..1fdad62c --- /dev/null +++ b/tooling/core/shared/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.kotlinJvm) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(projects.tooling.core.model) + implementation(projects.core.websocket.event) + implementation(libs.kotlinx.coroutines.core) +} diff --git a/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDatabase.kt b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDatabase.kt new file mode 100644 index 00000000..7326cd85 --- /dev/null +++ b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDatabase.kt @@ -0,0 +1,22 @@ +package com.kitakkun.backintime.tooling.core.shared + +import com.kitakkun.backintime.tooling.model.EventEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface BackInTimeDatabase { + val stateFlow: StateFlow + + fun restartDatabaseAsFile(filePath: String, migrate: Boolean) + fun restartDatabaseInMemory(migrate: Boolean) + fun insert(eventEntity: EventEntity) + fun selectForSession(sessionId: String): Flow> + fun selectForInstance(sessionId: String, instanceId: String): Flow> + fun selectInstanceIds(sessionId: String): Flow> + + sealed interface State { + data object RunningInMemory : State + data class RunningWithFile(val filePath: String) : State + data object Stopped : State + } +} \ No newline at end of file diff --git a/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDebuggerService.kt b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDebuggerService.kt new file mode 100644 index 00000000..dae8354c --- /dev/null +++ b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDebuggerService.kt @@ -0,0 +1,44 @@ +package com.kitakkun.backintime.tooling.core.shared + +import com.kitakkun.backintime.core.websocket.event.BackInTimeDebuggerEvent + +interface BackInTimeDebuggerService { + data class State( + val serverIsRunning: Boolean, + val port: Int?, + val connections: List, + ) { + data class Connection( + val id: String, + val isActive: Boolean, + val port: Int, + val address: String, + ) + } + + val state: State + + fun restartServer(port: Int) + + fun sendEvent(sessionId: String, event: BackInTimeDebuggerEvent) + + fun backInTime(sessionId: String, instanceId: String, values: Map) { + values.forEach { (signature, jsonValue) -> + sendEvent( + sessionId, BackInTimeDebuggerEvent.ForceSetPropertyValue( + targetInstanceId = instanceId, + propertySignature = signature, + jsonValue = jsonValue, + ) + ) + } + } + + companion object { + val Dummy = object : BackInTimeDebuggerService { + override val state: State get() = State(true, null, emptyList()) + override fun restartServer(port: Int) {} + override fun sendEvent(sessionId: String, event: BackInTimeDebuggerEvent) {} + } + } +} diff --git a/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDebuggerSettings.kt b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDebuggerSettings.kt new file mode 100644 index 00000000..e1be93ac --- /dev/null +++ b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/BackInTimeDebuggerSettings.kt @@ -0,0 +1,27 @@ +package com.kitakkun.backintime.tooling.core.shared + +interface BackInTimeDebuggerSettings { + data class State( + val serverPort: Int = 50023, + val showNonDebuggableProperties: Boolean = true, + val persistSessionData: Boolean = false, + val databasePath: String? = null, + ) + + fun getState(): State + fun loadState(state: State) + + fun update(block: (prevState: State) -> State) { + loadState(block(getState())) + } + + companion object { + val Dummy = object : BackInTimeDebuggerSettings { + override fun loadState(state: State) {} + + override fun getState(): State { + return State() + } + } + } +} diff --git a/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/IDENavigator.kt b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/IDENavigator.kt new file mode 100644 index 00000000..05e8de61 --- /dev/null +++ b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/IDENavigator.kt @@ -0,0 +1,29 @@ +package com.kitakkun.backintime.tooling.core.shared + +/** + * responsible for navigating to specific declarations in the IntelliJ IDEA. + */ +interface IDENavigator { + /** + * @param classSignature kotlin-based class signature. ex) com/example/MyClass, com/example/MyClass.Nested + */ + fun navigateToClass(classSignature: String) + + /** + * @param propertySignature kotlin-based member property signature. ex) com/example/MyClass.prop + */ + fun navigateToMemberProperty(propertySignature: String) + + /** + * @param functionSignature kotlin-based function signature. ex) com/example/MyExtensionReceiver com/example/MyClass.myFunction(kotlin/Int):kotlin/Unit + */ + fun navigateToMemberFunction(functionSignature: String) + + companion object { + val Noop = object : IDENavigator { + override fun navigateToMemberFunction(functionSignature: String) {} + override fun navigateToMemberProperty(propertySignature: String) {} + override fun navigateToClass(classSignature: String) {} + } + } +} diff --git a/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/PluginStateService.kt b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/PluginStateService.kt new file mode 100644 index 00000000..8df5869b --- /dev/null +++ b/tooling/core/shared/src/main/kotlin/com/kitakkun/backintime/tooling/core/shared/PluginStateService.kt @@ -0,0 +1,73 @@ +package com.kitakkun.backintime.tooling.core.shared + +import com.kitakkun.backintime.tooling.model.GlobalState +import com.kitakkun.backintime.tooling.model.InspectorState +import com.kitakkun.backintime.tooling.model.LogState +import com.kitakkun.backintime.tooling.model.PluginState +import com.kitakkun.backintime.tooling.model.SettingsState +import com.kitakkun.backintime.tooling.model.Tab +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +interface PluginStateService { + fun getState(): PluginState + fun loadState(state: PluginState) + + fun updateSessionId(sessionId: String) { + val prevState = getState() + loadState( + prevState.copy( + globalState = prevState.globalState.copy(selectedSessionId = sessionId), + inspectorState = if (prevState.globalState.selectedSessionId != sessionId) InspectorState.Default else prevState.inspectorState + ) + ) + } + + fun updateTab(tab: Tab) { + val prevState = getState() + loadState(prevState.copy(globalState = prevState.globalState.copy(activeTab = tab))) + } + + fun updateGlobalState(function: (GlobalState) -> GlobalState) { + val prevState = getState() + val newState = prevState.copy(globalState = function(prevState.globalState)) + loadState(newState) + } + + fun updateInspectorState(function: (InspectorState) -> InspectorState) { + val prevState = getState() + val newState = prevState.copy(inspectorState = function(prevState.inspectorState)) + loadState(newState) + } + + fun updateLogState(function: (LogState) -> LogState) { + val prevState = getState() + val newState = prevState.copy(logState = function(prevState.logState)) + loadState(newState) + } + + fun updateSettingsState(function: (SettingsState) -> SettingsState) { + val prevState = getState() + val newState = prevState.copy(settingsState = function(prevState.settingsState)) + loadState(newState) + } + + val stateFlow: StateFlow + + companion object { + val Dummy = object : PluginStateService { + private val mutableStateFlow = MutableStateFlow(PluginState.Default) + override val stateFlow: StateFlow = mutableStateFlow.asStateFlow() + + override fun loadState(state: PluginState) { + mutableStateFlow.update { state } + } + + override fun getState(): PluginState { + return stateFlow.value + } + } + } +} diff --git a/tooling/core/ui/build.gradle.kts b/tooling/core/ui/build.gradle.kts new file mode 100644 index 00000000..489d537c --- /dev/null +++ b/tooling/core/ui/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvmToolchain(17) +} + +repositories { + mavenCentral() + google() + maven("https://packages.jetbrains.team/maven/p/kpm/public/") +} + +dependencies { + implementation(projects.tooling.core.shared) + implementation(projects.tooling.core.model) + implementation(projects.tooling.core.database) + implementation(projects.tooling.core.usecase) + + implementation(libs.jewel) + implementation(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } + implementation(libs.kotlinx.serialization.json) +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/Badge.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/Badge.kt new file mode 100644 index 00000000..a5ae718b --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/Badge.kt @@ -0,0 +1,37 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun Badge( + containerColor: Color, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit = {}, +) { + Box( + modifier = modifier.background(containerColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + content() + } +} + +@Preview +@Composable +private fun BadgePreview() { + PreviewContainer { + Badge(containerColor = Color.Red) { + Text("10") + } + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/BalloonView.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/BalloonView.kt new file mode 100644 index 00000000..780aa073 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/BalloonView.kt @@ -0,0 +1,197 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.theme.popupContainerStyle + +enum class TrianglePosition { + Top, + Left, + Right, + Bottom, +} + +class BalloonShape( + val position: TrianglePosition, + val triangleSizeDp: Dp, + val radius: Dp, + val triangleOffset: Density.(size: Size) -> Float, +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val diameterPx = with(density) { radius.toPx() } + val radiusPx = diameterPx / 2 + val triangleSizePx = with(density) { triangleSizeDp.toPx() } + val offset = with(density) { triangleOffset(size) } + + val path = Path().apply { + // Start at top-left corner + moveTo(radiusPx, 0f) + + // Top line + if (position == TrianglePosition.Top) { + lineTo(offset - triangleSizePx / 2, 0f) + lineTo(offset, -triangleSizePx) + lineTo(offset + triangleSizePx / 2, 0f) + } + lineTo(size.width - radiusPx, 0f) + + // Top-right arc + arcTo( + rect = Rect( + offset = Offset(size.width - diameterPx, 0f), + size = Size(diameterPx, diameterPx) + ), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Right line + if (position == TrianglePosition.Right) { + lineTo(size.width, offset - triangleSizePx / 2) + lineTo(size.width + triangleSizePx, offset) + lineTo(size.width, offset + triangleSizePx / 2) + } + lineTo(size.width, size.height - radiusPx) + + // Bottom-right arc + arcTo( + rect = Rect( + offset = Offset(size.width - diameterPx, size.height - diameterPx), + size = Size(diameterPx, diameterPx) + ), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Bottom line + if (position == TrianglePosition.Bottom) { + lineTo(size.width - offset - triangleSizePx / 2, size.height) + lineTo(size.width - offset, size.height + triangleSizePx) + lineTo(size.width - offset + triangleSizePx / 2, size.height) + } + lineTo(radiusPx, size.height) + + // Bottom-left arc + arcTo( + rect = Rect( + offset = Offset(0f, size.height - diameterPx), + size = Size(diameterPx, diameterPx) + ), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Left line + if (position == TrianglePosition.Left) { + lineTo(0f, size.height - offset + triangleSizePx / 2) + lineTo(-triangleSizePx, size.height - offset) + lineTo(0f, size.height - offset - triangleSizePx / 2) + } + lineTo(0f, radiusPx) + + // Top-left arc + arcTo( + rect = Rect( + offset = Offset(0f, 0f), + size = Size(diameterPx, diameterPx) + ), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + } + return Outline.Generic(path = path) + } +} + +@Composable +fun BalloonView( + position: TrianglePosition, + triangleSizeDp: Dp, + radius: Dp, + elevation: Dp = 1.dp, + borderWidth: Dp = 1.dp, + containerColor: Color = JewelTheme.popupContainerStyle.colors.background, + borderColor: Color = JewelTheme.popupContainerStyle.colors.border, + modifier: Modifier = Modifier, + triangleOffset: Density.(size: Size) -> Float = { size -> + when (position) { + TrianglePosition.Top, + TrianglePosition.Bottom, + -> (size.width - triangleSizeDp.toPx()) / 2 + + TrianglePosition.Left, + TrianglePosition.Right + -> (size.height - triangleSizeDp.toPx()) / 2 + } + }, + contentPadding: PaddingValues = PaddingValues(16.dp), + content: @Composable ColumnScope.() -> Unit, +) { + val triangleShape = remember(position, radius, triangleSizeDp, triangleOffset) { + BalloonShape( + position = position, + triangleSizeDp = triangleSizeDp, + radius = radius, + triangleOffset = triangleOffset, + ) + } + + Column( + modifier = modifier + .background(color = containerColor, shape = triangleShape) + .border(width = borderWidth, color = borderColor, shape = triangleShape) + .shadow(elevation = elevation, shape = triangleShape) + .padding(contentPadding), + ) { + content() + } +} + +@Preview +@Composable +fun Preview() { + PreviewContainer { + BalloonView( + elevation = 1.dp, + position = TrianglePosition.Bottom, + triangleSizeDp = 10.dp, + radius = 10.dp, + containerColor = Color.Red, + modifier = Modifier.size(100.dp), + ) { + Text("Balloon") + } + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/CommonConfirmationDialog.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/CommonConfirmationDialog.kt new file mode 100644 index 00000000..b3e73cb4 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/CommonConfirmationDialog.kt @@ -0,0 +1,71 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.ActionButton +import org.jetbrains.jewel.ui.component.DefaultButton +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun CommonConfirmationDialog( + onDismissRequest: () -> Unit, + onClickOk: () -> Unit, + onClickCancel: () -> Unit, + modifier: Modifier = Modifier, + contentSpacing: Dp = 16.dp, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + Column( + modifier = modifier + .background( + shape = RoundedCornerShape(32.dp), + color = JewelTheme.globalColors.panelBackground, + ) + .padding(32.dp), + verticalArrangement = Arrangement.spacedBy(contentSpacing), + ) { + content() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.align(Alignment.End), + ) { + ActionButton(onClickCancel) { + Text("Cancel") + } + DefaultButton(onClickOk) { + Text("OK") + } + } + } + } +} + +@Preview +@Composable +private fun CommonConfirmationDialogPreview() { + PreviewContainer { + CommonConfirmationDialog( + onDismissRequest = {}, + onClickOk = {}, + onClickCancel = {}, + ) { + Text("Description") + } + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/HorizontalDivider.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/HorizontalDivider.kt new file mode 100644 index 00000000..d51f0115 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/HorizontalDivider.kt @@ -0,0 +1,46 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * [org.jetbrains.jewel.ui.component.Divider] seems to be broken. + * This is our own implementation for HorizontalDivider + */ +@Composable +fun HorizontalDivider( + color: Color = JewelTheme.globalColors.borders.normal, + thickness: Dp = 1.dp, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .background(color) + .fillMaxWidth() + .height(thickness) + ) +} + +@Preview +@Composable +private fun HorizontalDividerPreview() { + PreviewContainer { + Column { + Text("Top of divider") + HorizontalDivider() + Text("Bottom of divider") + } + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/JsonEditorView.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/JsonEditorView.kt new file mode 100644 index 00000000..0c6d1453 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/JsonEditorView.kt @@ -0,0 +1,136 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.insert +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.jetbrains.jewel.foundation.theme.LocalTextStyle +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField + +@Composable +fun JsonEditorView( + initialJsonString: String, + modifier: Modifier = Modifier, +) { + val textFieldState = rememberTextFieldState(initialText = initialJsonString) + val jsonElement = remember(initialJsonString) { Json.parseToJsonElement(initialJsonString) } + + val currentJsonElement by remember { + derivedStateOf { + try { + Json.parseToJsonElement(textFieldState.text.toString()) + } catch (e: Throwable) { + null + } + } + } + + Column { + TextField( + state = rememberTextFieldState(initialText = initialJsonString), + ) + if (currentJsonElement == null) { + Text(text = "Invalid JSON Format") + } + } +} + +@Composable +fun EditableView( + jsonElement: JsonElement, + key: String? = null, + indentLevel: Int = 0, +) { + val letterDp = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() } + val indentDp = (letterDp * 4) * indentLevel + + Column( + modifier = Modifier.padding(start = indentDp) + ) { + Row { + key?.let { KeyTextField(it) } + Text(text = ":") + when (jsonElement) { + is JsonArray -> Text(text = "{") + is JsonObject -> TODO() + is JsonPrimitive -> TODO() + JsonNull -> TODO() + } + } + when (jsonElement) { + is JsonObject -> { + Column { + Text("{") + + Text("}") + } + } + + is JsonArray -> { + + } + + is JsonPrimitive -> { + + } + + JsonNull -> { + UnsafeValueTextField( + initialValue = "null", + ) + } + } + } +} + +@Composable +private fun KeyTextField( + key: String, +) { + val textFieldState = rememberTextFieldState(initialText = key) + TextField( + state = textFieldState, + outputTransformation = { + this.insert(this.asCharSequence().length, "\"") + this.insert(0, "\"") + } + ) +} + +@Composable +private fun UnsafeValueTextField( + initialValue: String, + modifier: Modifier = Modifier, +) { + val textFieldState = rememberTextFieldState(initialValue) + TextField( + state = textFieldState, + modifier = modifier, + ) +} + +@Preview +@Composable +private fun KeyTextFieldPreview() { + PreviewContainer { + KeyTextField( + key = "key" + ) + } +} \ No newline at end of file diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/JsonView.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/JsonView.kt new file mode 100644 index 00000000..b3329b23 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/JsonView.kt @@ -0,0 +1,197 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.SystemTheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkun.backintime.tooling.core.ui.theme.isIDEInDarkTheme +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import org.jetbrains.jewel.foundation.theme.LocalThemeName +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun JsonView( + jsonString: String, + colorStyle: JsonColorStyle = if (isIDEInDarkTheme()) JsonColorStyle.Dark else JsonColorStyle.Light, + modifier: Modifier = Modifier, +) { + val jsonElement = remember(jsonString) { Json.parseToJsonElement(jsonString) } + + Text( + text = remember(colorStyle, jsonElement) { + buildAnnotatedString { + appendJsonString( + rootJsonElement = jsonElement, + printTrailingComma = false, + colorStyle = colorStyle, + ) + } + }, + modifier = modifier, + ) +} + +sealed interface JsonColorStyle { + val keySpanStyle: SpanStyle + val stringSpanStyle: SpanStyle + val numberSpanStyle: SpanStyle + val booleanSpanStyle: SpanStyle + val nullSpanStyle: SpanStyle + + data object Light : JsonColorStyle { + override val keySpanStyle = SpanStyle(color = Color(0xFF1565C0)) + override val stringSpanStyle = SpanStyle(color = Color(0xFF2E7D32)) + override val numberSpanStyle = SpanStyle(color = Color(0xFFEF6C00)) + override val booleanSpanStyle = SpanStyle(color = Color(0xFFD84315)) + override val nullSpanStyle = SpanStyle(color = Color(0xFF6A1B9A)) + } + + data object Dark : JsonColorStyle { + override val keySpanStyle = SpanStyle(color = Color(0xFFBC77B1)) + override val stringSpanStyle = SpanStyle(color = Color(0xFF6AAB73)) + override val numberSpanStyle = SpanStyle(color = Color(0xFF2CABB8)) + override val booleanSpanStyle = SpanStyle(color = Color(0xFFCE8E6D)) + override val nullSpanStyle = SpanStyle(color = Color(0xFFCE8E6D)) + } +} + +fun AnnotatedString.Builder.appendJsonString( + rootJsonElement: JsonElement, + key: String? = null, + currentIndentLevel: Int = 0, + printTrailingComma: Boolean = false, + colorStyle: JsonColorStyle = JsonColorStyle.Light, +) { + append(" ".repeat(4 * currentIndentLevel)) + + key?.let { + withStyle(colorStyle.keySpanStyle) { + append(text = "\"$key\": ") + } + } + + when (rootJsonElement) { + is JsonObject -> { + appendLine("{") + rootJsonElement.entries.forEachIndexed { index, (key, jsonElement) -> + appendJsonString( + rootJsonElement = jsonElement, + key = key, + currentIndentLevel = currentIndentLevel + 1, + printTrailingComma = index != rootJsonElement.entries.size - 1, + colorStyle = colorStyle, + ) + } + appendWithIndent(indentLevel = currentIndentLevel, text = "}") + } + + is JsonArray -> { + appendLine("[") + rootJsonElement.forEachIndexed { index, jsonElement -> + appendJsonString( + rootJsonElement = jsonElement, + key = null, + currentIndentLevel = currentIndentLevel + 1, + printTrailingComma = index != rootJsonElement.size - 1, + colorStyle = colorStyle, + ) + } + appendWithIndent(indentLevel = currentIndentLevel, text = "]") + } + + is JsonPrimitive -> { + val style = when { + rootJsonElement.isString -> colorStyle.stringSpanStyle + rootJsonElement.booleanOrNull != null -> colorStyle.booleanSpanStyle + else -> colorStyle.numberSpanStyle + } + withStyle(style) { + if (rootJsonElement.isString) append("\"") + append(rootJsonElement.content) + if (rootJsonElement.isString) append("\"") + } + } + + JsonNull -> { + withStyle(colorStyle.nullSpanStyle) { + append("null") + } + } + } + + if (printTrailingComma) { + appendLine(",") + } else { + appendLine() + } +} + +private fun AnnotatedString.Builder.appendWithIndent( + indentLevel: Int, + text: String, + indentSpaces: Int = 4, +) { + append(" ".repeat(indentSpaces * indentLevel)) + append(text) +} + +private fun AnnotatedString.Builder.appendLineWithIndent( + indentLevel: Int, + text: String, + indentSpaces: Int = 4, +) { + appendWithIndent( + indentLevel = indentLevel, + text = text + "\n", + indentSpaces = indentSpaces, + ) +} + +@Preview +@Composable +private fun JsonViewPreview() { + CompositionLocalProvider(LocalThemeName provides SystemTheme.Light.name) { + PreviewContainer { + JsonView( + jsonString = + """ + { + "key1": "String", + "key2": 0, + "key3": { + "nested_key1": "String", + "nested_key2": 0, + "nested_key3": 1.0, + "nested_key4": [ + "string1", + "string2", + "string3", + "string4" + ] + }, + "key4": [ + "string1", + "string2", + "string3", + "string4" + ] + } + """.trimIndent() + ) + } + } +} \ No newline at end of file diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/SessionSelectorView.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/SessionSelectorView.kt new file mode 100644 index 00000000..6b66aac9 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/SessionSelectorView.kt @@ -0,0 +1,41 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ui.component.Dropdown +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.items + +@Composable +fun SessionSelectorView( + sessionIdCandidates: List, + selectedSessionId: String?, + onSelectItem: (sessionId: String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("session:") + Dropdown( + menuContent = { + items( + items = sessionIdCandidates, + isSelected = { it == selectedSessionId }, + onItemClick = { onSelectItem(it) }, + ) { + Text(text = it) + } + }, + content = { + Text(selectedSessionId ?: "no session selected") + }, + ) + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/Switch.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/Switch.kt new file mode 100644 index 00000000..73c7e301 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/Switch.kt @@ -0,0 +1,82 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.LocalTextStyle +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun Switch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val fontSizeDp = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() } + + Box( + modifier = modifier + .background(Color.LightGray) + .width(fontSizeDp * 4) + .toggleable( + value = checked, + onValueChange = onCheckedChange, + ), + ) { + if (checked) { + Text( + text = "ON", + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterStart) + .background(Color(83, 104, 79)) + .fillMaxWidth(0.6f) + .padding(2.dp), + ) + } else { + Text( + text = "OFF", + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterEnd) + .background(Color.DarkGray) + .fillMaxWidth(0.6f) + .padding(2.dp), + ) + } + } +} + +@Preview +@Composable +private fun SwitchPreview_Checked() { + PreviewContainer { + Switch( + checked = true, + onCheckedChange = {}, + ) + } +} + +@Preview +@Composable +private fun SwitchPreview_Unchecked() { + PreviewContainer { + Switch( + checked = false, + onCheckedChange = {}, + ) + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/VerticalDivider.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/VerticalDivider.kt new file mode 100644 index 00000000..bc712ffd --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/component/VerticalDivider.kt @@ -0,0 +1,50 @@ +package com.kitakkun.backintime.tooling.core.ui.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * [org.jetbrains.jewel.ui.component.Divider] seems to be broken. + * This is our own implementation for HorizontalDivider + */ +@Composable +fun VerticalDivider( + modifier: Modifier = Modifier, + color: Color = JewelTheme.globalColors.borders.normal, + thickness: Dp = 1.dp, +) { + Box( + modifier = modifier + .background(color) + .fillMaxHeight() + .width(thickness) + ) +} + +@Preview +@Composable +private fun VerticalDividerPreview() { + PreviewContainer { + Row { + Text("Left") + VerticalDivider( + Modifier.height(20.dp), + color = Color.Red, + ) + Text("Right") + } + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/compositionlocal/CompositionLocals.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/compositionlocal/CompositionLocals.kt new file mode 100644 index 00000000..e7504968 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/compositionlocal/CompositionLocals.kt @@ -0,0 +1,24 @@ +package com.kitakkun.backintime.tooling.core.ui.compositionlocal + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDebuggerService +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDebuggerSettings +import com.kitakkun.backintime.tooling.core.shared.IDENavigator +import com.kitakkun.backintime.tooling.core.shared.PluginStateService + +val LocalPluginStateService = staticCompositionLocalOf { + error("No PluginStateProvider specified via composition local!") +} + +val LocalSettings = compositionLocalOf { + error("No BackInTimeDebuggerSettings provided!") +} + +val LocalServer = compositionLocalOf { + error("No BackInTimeDebuggerService provided!") +} + +val LocalIDENavigator = compositionLocalOf { + error("No IDENavigator provided!") +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/logic/EventEmitter.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/logic/EventEmitter.kt new file mode 100644 index 00000000..a2feca01 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/logic/EventEmitter.kt @@ -0,0 +1,35 @@ +package com.kitakkun.backintime.tooling.core.ui.logic + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +typealias EventEmitter = MutableSharedFlow + +@Composable +fun rememberEventEmitter(): EventEmitter { + return remember { + MutableSharedFlow(extraBufferCapacity = 20) + } +} + +@Composable +fun EventEffect( + eventEmitter: EventEmitter, + block: suspend CoroutineScope.(event: T) -> Unit, +) { + LaunchedEffect(eventEmitter) { + eventEmitter.collect { + launch { + try { + block(it) + } catch (e: Throwable) { + e.printStackTrace() + } + } + } + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/preview/PreviewContainer.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/preview/PreviewContainer.kt new file mode 100644 index 00000000..18d82371 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/preview/PreviewContainer.kt @@ -0,0 +1,23 @@ +package com.kitakkun.backintime.tooling.core.ui.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import com.kitakkun.backintime.tooling.core.ui.theme.BackInTimeTheme +import com.kitakkun.backintime.tooling.core.ui.theme.LocalIsIDEInDarkTheme +import org.jetbrains.jewel.foundation.theme.JewelTheme + +@Composable +fun PreviewContainer( + content: @Composable () -> Unit, +) { + CompositionLocalProvider(LocalIsIDEInDarkTheme provides true) { + BackInTimeTheme { + Box(Modifier.background(JewelTheme.globalColors.panelBackground)) { + content() + } + } + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/theme/BackInTimeTheme.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/theme/BackInTimeTheme.kt new file mode 100644 index 00000000..0bf7bae5 --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/theme/BackInTimeTheme.kt @@ -0,0 +1,14 @@ +package com.kitakkun.backintime.tooling.core.ui.theme + +import androidx.compose.runtime.Composable +import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme + +@Composable +fun BackInTimeTheme( + isDark: Boolean = isIDEInDarkTheme(), + content: @Composable () -> Unit, +) { + IntUiTheme(isDark = isDark) { + content() + } +} diff --git a/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/theme/IsIDEDarkTheme.kt b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/theme/IsIDEDarkTheme.kt new file mode 100644 index 00000000..dc1df9dd --- /dev/null +++ b/tooling/core/ui/src/main/kotlin/com/kitakkun/backintime/tooling/core/ui/theme/IsIDEDarkTheme.kt @@ -0,0 +1,11 @@ +package com.kitakkun.backintime.tooling.core.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf + +val LocalIsIDEInDarkTheme = compositionLocalOf { error("No LocalIsIDEInDarkTheme is provided!") } + +@Composable +fun isIDEInDarkTheme(): Boolean { + return LocalIsIDEInDarkTheme.current +} diff --git a/tooling/core/usecase/build.gradle.kts b/tooling/core/usecase/build.gradle.kts new file mode 100644 index 00000000..739e167b --- /dev/null +++ b/tooling/core/usecase/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.jetbrainsCompose) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(projects.tooling.core.shared) + implementation(projects.tooling.core.database) + implementation(projects.tooling.core.model) + implementation(compose.runtime) +} diff --git a/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/InstanceMapper.kt b/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/InstanceMapper.kt new file mode 100644 index 00000000..d4836280 --- /dev/null +++ b/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/InstanceMapper.kt @@ -0,0 +1,52 @@ +package com.kitakkun.backintime.tooling.core.usecase + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import com.kitakkun.backintime.tooling.model.EventEntity +import com.kitakkun.backintime.tooling.model.Instance +import com.kitakkun.backintime.tooling.model.Property +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun rememberInstances(sessionId: String?): State> { + val database = LocalDatabase.current + + val allInstanceIdsFlow = remember(sessionId) { + database.selectInstanceIds(sessionId ?: "") + } + val eachInstanceEventsFlow = remember(allInstanceIdsFlow) { + allInstanceIdsFlow.flatMapLatest { instanceIds -> + combine(instanceIds.map { database.selectForInstance(sessionId = sessionId ?: "", instanceId = it) }) { + it.toList() + } + } + } + + return remember(eachInstanceEventsFlow) { + eachInstanceEventsFlow.map { + it.mapNotNull { events -> + val registerEvent = events.filterIsInstance().firstOrNull() ?: return@mapNotNull null + Instance( + id = registerEvent.instanceId, + className = registerEvent.classInfo.classSignature, + superClassName = registerEvent.classInfo.superClassSignature, + properties = registerEvent.classInfo.properties.map { property -> + Property( + name = property.name, + type = property.propertyType, + totalEvents = events.count { event -> event is EventEntity.Instance.StateChange && event.propertySignature == property.signature }, + debuggable = property.debuggable, + ) + }, + events = events, + ) + } + } + }.collectAsState(emptyList()) +} \ No newline at end of file diff --git a/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/LocalDatabase.kt b/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/LocalDatabase.kt new file mode 100644 index 00000000..3c06b3b0 --- /dev/null +++ b/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/LocalDatabase.kt @@ -0,0 +1,7 @@ +package com.kitakkun.backintime.tooling.core.usecase + +import androidx.compose.runtime.staticCompositionLocalOf +import com.kitakkun.backintime.tooling.core.database.BackInTimeDatabaseImpl +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDatabase + +val LocalDatabase = staticCompositionLocalOf { BackInTimeDatabaseImpl.instance } diff --git a/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/Mapper.kt b/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/Mapper.kt new file mode 100644 index 00000000..87355f06 --- /dev/null +++ b/tooling/core/usecase/src/main/kotlin/com/kitakkun/backintime/tooling/core/usecase/Mapper.kt @@ -0,0 +1,11 @@ +package com.kitakkun.backintime.tooling.core.usecase + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import com.kitakkun.backintime.tooling.model.EventEntity + +@Composable +fun allEvents(sessionId: String?): List { + val database = LocalDatabase.current + return database.selectForSession(sessionId = sessionId ?: "").collectAsState(emptyList()).value +} diff --git a/tooling/feature/inspector/build.gradle.kts b/tooling/feature/inspector/build.gradle.kts new file mode 100644 index 00000000..2e265985 --- /dev/null +++ b/tooling/feature/inspector/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.intelliJComposeFeature) + alias(libs.plugins.kotlinSerialization) +} + +dependencies { + implementation(projects.tooling.core.shared) + implementation(projects.tooling.core.database) + implementation(libs.kotlinx.serialization.json) +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/InspectorScreen.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/InspectorScreen.kt new file mode 100644 index 00000000..64b683f5 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/InspectorScreen.kt @@ -0,0 +1,188 @@ +package com.kitakkunl.backintime.feature.inspector + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.shared.IDENavigator +import com.kitakkun.backintime.tooling.core.ui.component.SessionSelectorView +import com.kitakkun.backintime.tooling.core.ui.component.Switch +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalIDENavigator +import com.kitakkun.backintime.tooling.core.ui.logic.EventEmitter +import com.kitakkun.backintime.tooling.core.ui.logic.rememberEventEmitter +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkunl.backintime.feature.inspector.components.EventItemUiState +import com.kitakkunl.backintime.feature.inspector.components.InstanceItemUiState +import com.kitakkunl.backintime.feature.inspector.components.PropertyItemUiState +import com.kitakkunl.backintime.feature.inspector.section.HistorySection +import com.kitakkunl.backintime.feature.inspector.section.HistorySectionUiState +import com.kitakkunl.backintime.feature.inspector.section.InstanceListSection +import com.kitakkunl.backintime.feature.inspector.section.PropertyInspectorSection +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.jewel.ui.component.HorizontalSplitLayout +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.VerticalSplitLayout +import org.jetbrains.jewel.ui.component.rememberSplitLayoutState + +@Composable +fun InspectorScreen( + eventEmitter: EventEmitter = rememberEventEmitter(), + uiState: InspectorScreenUiState = inspectorScreenPresenter(eventEmitter), +) { + InspectorScreen( + uiState = uiState, + onClickProperty = { instance, property -> eventEmitter.tryEmit(InspectorScreenEvent.SelectProperty(instance.uuid, property.name)) }, + onSelectSessionId = { eventEmitter.tryEmit(InspectorScreenEvent.SelectSession(it)) }, + onClickItem = { eventEmitter.tryEmit(InspectorScreenEvent.SelectInstance(it.uuid)) }, + onTogglePropertyVisibility = { eventEmitter.tryEmit(InspectorScreenEvent.TogglePropertyVisibility(it.uuid)) }, + onUpdateVerticalSplitDividerPosition = { eventEmitter.tryEmit(InspectorScreenEvent.UpdateVerticalDividerPosition(it)) }, + onUpdateHorizontalSplitDividerPosition = { eventEmitter.tryEmit(InspectorScreenEvent.UpdateHorizontalDividerPosition(it)) }, + onClickEvent = { eventEmitter.tryEmit(InspectorScreenEvent.SelectEvent(it)) }, + onPerformBackInTime = { sessionId, instanceId, eventId -> eventEmitter.tryEmit(InspectorScreenEvent.BackInTime(sessionId, instanceId, eventId)) }, + onToggleShowNonDebuggableProperties = { eventEmitter.tryEmit(InspectorScreenEvent.UpdateNonDebuggablePropertiesVisibility(it)) } + ) +} + +data class InspectorScreenUiState( + val selectedSessionId: String?, + val selectedInstanceId: String?, + val selectedPropertyName: String?, + val availableSessionIds: List, + val instances: List, + val horizontalDividerPosition: Float, + val verticalDividerPosition: Float, + val history: HistorySectionUiState?, + val showNonDebuggableProperties: Boolean, +) { + val selectedInstance: InstanceItemUiState? get() = instances.find { it.uuid == selectedInstanceId } +} + +@Composable +fun InspectorScreen( + uiState: InspectorScreenUiState, + onSelectSessionId: (String) -> Unit, + onClickItem: (InstanceItemUiState) -> Unit, + onClickProperty: (InstanceItemUiState, PropertyItemUiState) -> Unit, + onTogglePropertyVisibility: (InstanceItemUiState) -> Unit, + onUpdateVerticalSplitDividerPosition: (Float) -> Unit, + onUpdateHorizontalSplitDividerPosition: (Float) -> Unit, + onClickEvent: (event: EventItemUiState) -> Unit, + onPerformBackInTime: (sessionId: String, instanceId: String, eventId: String) -> Unit, + onToggleShowNonDebuggableProperties: (Boolean) -> Unit, +) { + val verticalSplitLayoutState = rememberSplitLayoutState(uiState.verticalDividerPosition) + val horizontalSplitLayoutState = rememberSplitLayoutState(uiState.horizontalDividerPosition) + + LaunchedEffect(verticalSplitLayoutState) { + snapshotFlow { verticalSplitLayoutState.dividerPosition } + .distinctUntilChanged() + .collect(onUpdateVerticalSplitDividerPosition) + } + + LaunchedEffect(horizontalSplitLayoutState) { + snapshotFlow { horizontalSplitLayoutState.dividerPosition } + .distinctUntilChanged() + .collect(onUpdateHorizontalSplitDividerPosition) + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SessionSelectorView( + sessionIdCandidates = uiState.availableSessionIds, + selectedSessionId = uiState.selectedSessionId, + onSelectItem = onSelectSessionId, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Show non-debuggable properties: " + ) + Switch( + checked = uiState.showNonDebuggableProperties, + onCheckedChange = onToggleShowNonDebuggableProperties, + ) + } + } + VerticalSplitLayout( + first = { + HorizontalSplitLayout( + first = { + InstanceListSection( + instances = uiState.instances, + onClickItem = onClickItem, + onClickProperty = onClickProperty, + onTogglePropertyVisibility = onTogglePropertyVisibility, + modifier = Modifier.padding(8.dp), + ) + }, + second = { + PropertyInspectorSection( + uiState = uiState.selectedInstance, + propertyName = uiState.selectedPropertyName, + modifier = Modifier.padding(8.dp), + ) + }, + firstPaneMinWidth = 200.dp, + secondPaneMinWidth = 200.dp, + state = horizontalSplitLayoutState, + ) + }, + second = { + HistorySection( + uiState = uiState.history, + onClickEvent = onClickEvent, + onPerformBackInTime = { onPerformBackInTime(uiState.selectedSessionId!!, uiState.selectedInstanceId!!, it.id) } + ) + }, + firstPaneMinWidth = 200.dp, + secondPaneMinWidth = 200.dp, + state = verticalSplitLayoutState, + ) + } +} + +@Preview +@Composable +private fun InspectorScreenPreview() { + PreviewContainer { + CompositionLocalProvider(LocalIDENavigator provides IDENavigator.Noop) { + InspectorScreen( + uiState = InspectorScreenUiState( + selectedSessionId = null, + selectedInstanceId = null, + selectedPropertyName = null, + availableSessionIds = listOf(), + instances = listOf(), + horizontalDividerPosition = 0.5f, + verticalDividerPosition = 0.5f, + history = null, + showNonDebuggableProperties = true, + ), + onClickEvent = {}, + onSelectSessionId = {}, + onClickItem = {}, + onClickProperty = { _, _ -> }, + onUpdateVerticalSplitDividerPosition = {}, + onUpdateHorizontalSplitDividerPosition = {}, + onTogglePropertyVisibility = {}, + onPerformBackInTime = { _, _, _ -> }, + onToggleShowNonDebuggableProperties = {}, + ) + } + } +} \ No newline at end of file diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/InspectorScreenPresenter.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/InspectorScreenPresenter.kt new file mode 100644 index 00000000..6d4965c1 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/InspectorScreenPresenter.kt @@ -0,0 +1,214 @@ +package com.kitakkunl.backintime.feature.inspector + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalPluginStateService +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalServer +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalSettings +import com.kitakkun.backintime.tooling.core.ui.logic.EventEffect +import com.kitakkun.backintime.tooling.core.ui.logic.EventEmitter +import com.kitakkun.backintime.tooling.core.usecase.rememberInstances +import com.kitakkun.backintime.tooling.model.EventEntity +import com.kitakkunl.backintime.feature.inspector.components.EventItemUiState +import com.kitakkunl.backintime.feature.inspector.components.InstanceItemUiState +import com.kitakkunl.backintime.feature.inspector.components.PropertyItemUiState +import com.kitakkunl.backintime.feature.inspector.section.HistorySectionUiState + +sealed interface InspectorScreenEvent { + data class TogglePropertyVisibility(val instanceId: String) : InspectorScreenEvent + data class SelectSession(val id: String) : InspectorScreenEvent + data class SelectInstance(val id: String) : InspectorScreenEvent + data class SelectProperty(val instanceId: String, val name: String) : InspectorScreenEvent + data class UpdateVerticalDividerPosition(val position: Float) : InspectorScreenEvent + data class UpdateHorizontalDividerPosition(val position: Float) : InspectorScreenEvent + data class SelectEvent(val event: EventItemUiState) : InspectorScreenEvent + data class BackInTime(val sessionId: String, val instanceId: String, val eventId: String) : InspectorScreenEvent + data class UpdateNonDebuggablePropertiesVisibility(val visible: Boolean) : InspectorScreenEvent +} + +@Composable +fun inspectorScreenPresenter(eventEmitter: EventEmitter): InspectorScreenUiState { + val pluginStateService = LocalPluginStateService.current + val pluginState by pluginStateService.stateFlow.collectAsState() + + val server = LocalServer.current + val serverState by rememberUpdatedState(server.state) + + val settings = LocalSettings.current + val settingsState by rememberUpdatedState(settings.getState()) + + val instances by rememberInstances(pluginState.globalState.selectedSessionId) + + val instanceUiStates by remember { + derivedStateOf { + instances.map { instance -> + InstanceItemUiState( + uuid = instance.id, + className = instance.className, + properties = instance.properties + .filter { settingsState.showNonDebuggableProperties || it.debuggable } + .map { property -> + PropertyItemUiState( + name = property.name, + type = property.type, + eventCount = property.totalEvents, + isSelected = pluginState.inspectorState.selectedInstanceId == instance.id && pluginState.inspectorState.selectedPropertyKey == property.name, + ) + }, + propertiesExpanded = instance.id in pluginState.inspectorState.expandedInstanceIds, + totalEventsCount = instance.totalEvents, + ) + } + } + } + + val history by remember { + derivedStateOf { + instances.find { it.id == pluginState.inspectorState.selectedInstanceId }?.let { instance -> + val events = instance.events.mapNotNull { event -> + val eventIsSelected = event.eventId == pluginState.inspectorState.selectedEventId + when (event) { + is EventEntity.Instance.MethodInvocation -> EventItemUiState.MethodInvocation( + expandedDetails = true, + stateChanges = instance.events.filterIsInstance() + .filter { it.callId == event.callId } + .groupBy { it.propertySignature } + .map { (propertyFqName, stateChanges) -> + EventItemUiState.MethodInvocation.UpdatedProperty( + name = propertyFqName, + stateUpdates = stateChanges.map { it.newValueAsJson }, + ) + }, + invokedFunctionName = event.methodSignature, + id = event.eventId, + selected = eventIsSelected, + time = event.time, + ) + + is EventEntity.Instance.Register -> EventItemUiState.Register( + id = event.eventId, + selected = eventIsSelected, + expandedDetails = false, + time = event.time, + ) + + is EventEntity.Instance.StateChange -> null // collected as a part of MethodInvocation + is EventEntity.Instance.Unregister -> EventItemUiState.Unregister( + id = event.eventId, + selected = event.eventId == pluginState.inspectorState.selectedEventId, + expandedDetails = false, + time = event.time, + ) + + is EventEntity.Instance.BackInTime -> TODO() + is EventEntity.Instance.NewDependency -> TODO() + is EventEntity.System.AppError -> TODO() + is EventEntity.System.CheckInstanceAlive -> TODO() + is EventEntity.System.CheckInstanceAliveResult -> TODO() + is EventEntity.System.DebuggerError -> TODO() + } + } + + HistorySectionUiState( + events = events, + selectedEventData = events.find { it.id == pluginState.inspectorState.selectedEventId }, + ) + } + } + } + + EventEffect(eventEmitter) { event -> + when (event) { + is InspectorScreenEvent.TogglePropertyVisibility -> { + pluginStateService.updateInspectorState { + it.copy( + expandedInstanceIds = if (event.instanceId in it.expandedInstanceIds) { + it.expandedInstanceIds - event.instanceId + } else { + it.expandedInstanceIds + event.instanceId + }, + ) + } + } + + is InspectorScreenEvent.SelectSession -> { + pluginStateService.updateSessionId(event.id) + } + + is InspectorScreenEvent.SelectInstance -> { + if (pluginState.inspectorState.selectedInstanceId != event.id) { + pluginStateService.loadState( + pluginState.copy(inspectorState = pluginState.inspectorState.copy(selectedInstanceId = event.id, selectedPropertyKey = null)) + ) + } else { + pluginStateService.loadState( + pluginState.copy(inspectorState = pluginState.inspectorState.copy(selectedInstanceId = event.id)) + ) + } + } + + is InspectorScreenEvent.SelectProperty -> { + pluginStateService.updateInspectorState { + it.copy(selectedInstanceId = event.instanceId, selectedPropertyKey = event.name) + } + } + + is InspectorScreenEvent.UpdateHorizontalDividerPosition -> { + pluginStateService.updateInspectorState { + it.copy(horizontalSplitPanePosition = event.position) + } + } + + is InspectorScreenEvent.UpdateVerticalDividerPosition -> { + pluginStateService.updateInspectorState { + it.copy(verticalSplitPanePosition = event.position) + } + } + + is InspectorScreenEvent.SelectEvent -> { + pluginStateService.updateInspectorState { + it.copy(selectedEventId = event.event.id) + } + } + + is InspectorScreenEvent.BackInTime -> { + val instance = instances.find { it.id == event.instanceId } ?: return@EventEffect + val allEventsBeforeBackInTimePoint = (instance.events.takeWhile { it.eventId != event.eventId } + instance.events.find { it.eventId == event.eventId }).filterNotNull() + val values = allEventsBeforeBackInTimePoint + .filterIsInstance() + .reversed() + .map { it.propertySignature to it.newValueAsJson } + .distinctBy { it.first } + .toMap() + println(values) + server.backInTime( + sessionId = event.sessionId, + instanceId = event.instanceId, + values = values, + ) + } + + is InspectorScreenEvent.UpdateNonDebuggablePropertiesVisibility -> { + settings.update { + it.copy(showNonDebuggableProperties = event.visible) + } + } + } + } + + return InspectorScreenUiState( + selectedInstanceId = pluginState.inspectorState.selectedInstanceId, + selectedPropertyName = pluginState.inspectorState.selectedPropertyKey, + selectedSessionId = pluginState.globalState.selectedSessionId, + availableSessionIds = serverState.connections.map { it.id }, + instances = instanceUiStates, + horizontalDividerPosition = pluginState.inspectorState.horizontalSplitPanePosition, + verticalDividerPosition = pluginState.inspectorState.verticalSplitPanePosition, + history = history, + showNonDebuggableProperties = settingsState.showNonDebuggableProperties, + ) +} \ No newline at end of file diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/BackInTimeOperationConfirmationDialog.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/BackInTimeOperationConfirmationDialog.kt new file mode 100644 index 00000000..a7a16dc0 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/BackInTimeOperationConfirmationDialog.kt @@ -0,0 +1,34 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.Composable +import com.kitakkun.backintime.tooling.core.ui.component.CommonConfirmationDialog +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun BackInTimeOperationConfirmationDialog( + onDismissRequest: () -> Unit, + onClickCancel: () -> Unit, + onClickOk: () -> Unit, +) { + CommonConfirmationDialog( + onDismissRequest = onDismissRequest, + onClickCancel = onClickCancel, + onClickOk = onClickOk, + ) { + Text(text = "Are you sure to back-in-time to this point?") + } +} + +@Preview +@Composable +private fun BackInTimeOperationConfirmationDialogPreview() { + PreviewContainer { + BackInTimeOperationConfirmationDialog( + onDismissRequest = {}, + onClickCancel = {}, + onClickOk = {}, + ) + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EmptyInstanceView.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EmptyInstanceView.kt new file mode 100644 index 00000000..ea9beb07 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EmptyInstanceView.kt @@ -0,0 +1,30 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun EmptyInstanceView( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text(text = "No instance is registered.") + } +} + +@Preview +@Composable +private fun EmptyInstanceViewPreview() { + PreviewContainer { + EmptyInstanceView() + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventDetailView.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventDetailView.kt new file mode 100644 index 00000000..eeef6083 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventDetailView.kt @@ -0,0 +1,107 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.component.BalloonView +import com.kitakkun.backintime.tooling.core.ui.component.TrianglePosition +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.foundation.theme.LocalContentColor +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun EventDetailView( + uiState: EventItemUiState, +) { + BalloonView( + position = TrianglePosition.Top, + radius = 10.dp, + triangleSizeDp = 10.dp, + borderWidth = 4.dp, + modifier = Modifier.widthIn(max = 200.dp), + ) { + when (uiState) { + is EventItemUiState.MethodInvocation -> { + MethodInvocationDetailView(uiState) + } + + is EventItemUiState.Register -> { + // NONE + } + + is EventItemUiState.Unregister -> { + // NONE + } + } + } +} + +@Composable +private fun MethodInvocationDetailView( + uiState: EventItemUiState.MethodInvocation, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "${uiState.invokedFunctionName}(...)", + ) + CompositionLocalProvider(LocalContentColor provides JewelTheme.contentColor.copy(alpha = 0.7f)) { + if (uiState.stateChanges.isEmpty()) { + Text("No state changes.") + } else { + uiState.stateChanges.forEach { + Row { + Text(text = it.name) + Spacer( + Modifier + .widthIn(min = 20.dp) + .weight(1f) + ) + Text( + text = it.stateUpdates.joinToString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun MethodInvocationDetailViewPreview() { + PreviewContainer { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + MethodInvocationDetailView( + uiState = EventItemUiState.MethodInvocation( + invokedFunctionName = "reload", + stateChanges = emptyList(), + expandedDetails = true, + id = "", + selected = false, + time = 0, + ) + ) + } + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventItemView.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventItemView.kt new file mode 100644 index 00000000..083ef930 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventItemView.kt @@ -0,0 +1,130 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Text + +sealed interface EventItemUiState { + val id: String + val selected: Boolean + val expandedDetails: Boolean + val time: Long + + data class Register( + override val id: String, + override val selected: Boolean, + override val expandedDetails: Boolean, + override val time: Long, + ) : EventItemUiState + + data class Unregister( + override val id: String, + override val selected: Boolean, + override val expandedDetails: Boolean, + override val time: Long, + ) : EventItemUiState + + data class MethodInvocation( + override val id: String, + override val selected: Boolean, + override val expandedDetails: Boolean, + override val time: Long, + val invokedFunctionName: String, + val stateChanges: List, + ) : EventItemUiState { + data class UpdatedProperty( + val name: String, + val stateUpdates: List, + ) + } + + val color: Color + get() = when (this) { + is MethodInvocation -> if (stateChanges.isEmpty()) Color.Gray else Color.Red + is Register -> Color.White + is Unregister -> Color.Gray + } + + val label: String + get() = when (this) { + is MethodInvocation -> "Method Call" + is Register -> "Register" + is Unregister -> "Unregister" + } +} + +val EventCircleIndicatorSize = 8.dp + +@Composable +fun EventItemView( + uiState: EventItemUiState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .animateContentSize() + .clickable(onClick = onClick) + .then( + if (uiState.selected) { + Modifier.background( + color = Color.White.copy(alpha = 0.2f), + ) + } else { + Modifier + } + ) + .padding(horizontal = 2.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .background( + color = uiState.color, + shape = CircleShape, + ) + .size(EventCircleIndicatorSize), + ) + Text( + text = uiState.label, + modifier = Modifier.wrapContentWidth(unbounded = true) + ) + if (uiState.expandedDetails) { + EventDetailView(uiState) + } + } +} + +@Preview +@Composable +fun EventItemViewPreview() { + PreviewContainer { + EventItemView( + uiState = EventItemUiState.Register( + id = "", + selected = false, + expandedDetails = false, + time = 0, + ), + modifier = Modifier.height(100.dp), + onClick = {}, + ) + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventSequenceView.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventSequenceView.kt new file mode 100644 index 00000000..1115a516 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/EventSequenceView.kt @@ -0,0 +1,106 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.HorizontalScrollbar +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer + +@Composable +fun EventSequenceView( + items: List, + onClickEvent: (event: EventItemUiState) -> Unit, + modifier: Modifier = Modifier, +) { + val lazyListState = rememberLazyListState() + + Box( + modifier = modifier.fillMaxSize(), + ) { + LazyRow( + state = lazyListState, + modifier = modifier.matchParentSize(), + ) { + itemsIndexed( + items = items, + ) { index, item -> + EventItemView( + uiState = item, + onClick = { onClickEvent(item) }, + modifier = Modifier + .fillParentMaxHeight() + .drawBehind { + if (index != 0) { + this.drawLine( + color = Color.Red, + start = Offset(0f, EventCircleIndicatorSize.toPx() / 2), + end = Offset(size.width / 2, EventCircleIndicatorSize.toPx() / 2), + ) + } + if (index != items.size - 1) { + this.drawLine( + color = Color.Red, + start = Offset(size.width / 2, EventCircleIndicatorSize.toPx() / 2), + end = Offset(size.width, EventCircleIndicatorSize.toPx() / 2), + ) + } + } + ) + } + } + HorizontalScrollbar( + adapter = rememberScrollbarAdapter(lazyListState), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) + } +} + +@Preview +@Composable +private fun EventSequenceViewPreview() { + PreviewContainer { + EventSequenceView( + items = mutableListOf().apply { + add( + EventItemUiState.Register( + id = "-1", + selected = true, + expandedDetails = false, + time = 0, + ) + ) + addAll( + List(10) { + EventItemUiState.MethodInvocation( + id = it.toString(), + expandedDetails = it % 5 == 0, + stateChanges = listOf( + EventItemUiState.MethodInvocation.UpdatedProperty( + name = "prop1", + stateUpdates = listOf("new Value") + ) + ), + invokedFunctionName = "updateValues", + selected = false, + time = 0, + ) + } + ) + }, + onClickEvent = {}, + ) + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/InstanceItemView.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/InstanceItemView.kt new file mode 100644 index 00000000..8f18a289 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/InstanceItemView.kt @@ -0,0 +1,102 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.component.Badge +import com.kitakkun.backintime.tooling.core.ui.component.HorizontalDivider +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +data class InstanceItemUiState( + val uuid: String, + val className: String, + val properties: List, + val propertiesExpanded: Boolean, + val totalEventsCount: Int, +) + +@Composable +fun InstanceItemView( + uiState: InstanceItemUiState, + onClick: () -> Unit, + onClickProperty: (PropertyItemUiState) -> Unit, + onTogglePropertyVisibility: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(8.dp), + ) { + IconButton(onClick = onTogglePropertyVisibility) { + Icon( + key = if (uiState.propertiesExpanded) AllIconsKeys.General.ArrowDown else AllIconsKeys.General.ArrowRight, + contentDescription = null, + ) + } + Text(text = uiState.uuid.take(5)) + Text(text = uiState.className, modifier = Modifier.weight(1f)) + Badge(containerColor = Color.Red) { + Text(text = uiState.totalEventsCount.toString()) + } + } + AnimatedVisibility( + visible = uiState.propertiesExpanded, + ) { + Column { + uiState.properties.forEach { property -> + HorizontalDivider() + PropertyItemView( + uiState = property, + onClick = { onClickProperty(property) }, + ) + HorizontalDivider() + } + } + } + } +} + +@Preview +@Composable +private fun InstanceItemViewPreview() { + PreviewContainer { + InstanceItemView( + uiState = InstanceItemUiState( + uuid = "c9ed94d9-1c1f-493d-b982-db34db076ffe", + className = "com.example.MyStateHolder", + propertiesExpanded = true, + properties = List(10) { + PropertyItemUiState( + name = "prop$it", + type = "kotlin/String", + eventCount = it, + isSelected = false, + ) + }, + totalEventsCount = 10, + ), + onClick = {}, + onTogglePropertyVisibility = {}, + onClickProperty = {}, + ) + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/KeyValueRow.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/KeyValueRow.kt new file mode 100644 index 00000000..6ba07606 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/KeyValueRow.kt @@ -0,0 +1,31 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun KeyValueRow( + key: String, + value: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = key) + Spacer(Modifier.width(20.dp)) + Text( + text = value, + textAlign = TextAlign.End, + ) + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/PropertyItemView.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/PropertyItemView.kt new file mode 100644 index 00000000..82761791 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/PropertyItemView.kt @@ -0,0 +1,100 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.component.Badge +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +data class PropertyItemUiState( + val name: String, + val type: String, + val eventCount: Int, + val isSelected: Boolean, +) + +@Composable +fun PropertyItemView( + uiState: PropertyItemUiState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .then( + if (uiState.isSelected) { + Modifier.background(Color.White.copy(alpha = 0.2f)) + } else { + Modifier + } + ) + .clickable(onClick = onClick) + .padding(8.dp), + ) { + Text( + text = uiState.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = uiState.type, + maxLines = 1, + textAlign = TextAlign.End, + overflow = TextOverflow.Ellipsis, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f), + modifier = Modifier + .padding(start = 20.dp) + .weight(1f) + ) + Box( + modifier = Modifier + .width(with(LocalDensity.current) { JewelTheme.defaultTextStyle.fontSize.toDp() * 2 }) + .height(with(LocalDensity.current) { JewelTheme.defaultTextStyle.fontSize.toDp() }) + ) { + if (uiState.eventCount != 0) { + Badge( + containerColor = Color.Red, + modifier = Modifier.matchParentSize() + ) { + Text( + text = if (uiState.eventCount >= 100) "99+" else uiState.eventCount.toString(), + ) + } + } + } + } +} + +@Preview +@Composable +private fun PropertyItemViewPreview() { + PreviewContainer { + PropertyItemView( + uiState = PropertyItemUiState( + name = "prop1", + type = "kotlin/Int", + eventCount = 10, + isSelected = false, + ), + onClick = {}, + ) + } +} \ No newline at end of file diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/SelectedEventDetailView.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/SelectedEventDetailView.kt new file mode 100644 index 00000000..bfdff64e --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/components/SelectedEventDetailView.kt @@ -0,0 +1,107 @@ +package com.kitakkunl.backintime.feature.inspector.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.ActionButton +import org.jetbrains.jewel.ui.component.DefaultButton +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun SelectedEventDetailView( + selectedEvent: EventItemUiState, + onPerformBackInTime: () -> Unit, +) { + var showConfirmationDialog by remember { mutableStateOf(false) } + + if (showConfirmationDialog) { + BackInTimeOperationConfirmationDialog( + onDismissRequest = { showConfirmationDialog = false }, + onClickCancel = { showConfirmationDialog = false }, + onClickOk = { + onPerformBackInTime() + showConfirmationDialog = false + }, + ) + } + + Column { + Column( + modifier = Modifier.weight(1f), + ) { + KeyValueRow( + key = "eventId:", + value = selectedEvent.id, + ) + KeyValueRow( + key = "time:", + value = selectedEvent.time.toString(), + ) + when (selectedEvent) { + is EventItemUiState.MethodInvocation -> MethodInvocationDetailsView(selectedEvent) + is EventItemUiState.Register -> RegisterDetailsView(selectedEvent) + is EventItemUiState.Unregister -> { + /* Show nothing */ + } + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DefaultButton( + onClick = { showConfirmationDialog = true }, + ) { + Text(text = "Back-in-time to this point") + } + ActionButton(onClick = {}) { + Text(text = "Edit and emit") + } + } + } +} + +@Composable +private fun MethodInvocationDetailsView( + event: EventItemUiState.MethodInvocation, +) { + Column { + Text("Updated Values") + event.stateChanges.forEach { + KeyValueRow( + key = it.name, + value = it.stateUpdates.joinToString(", "), + ) + } + } +} + +@Composable +private fun RegisterDetailsView(event: EventItemUiState.Register) { +} + +@Preview +@Composable +private fun SelectedEventDetailViewPreview() { + PreviewContainer { + SelectedEventDetailView( + selectedEvent = EventItemUiState.Register( + id = "", + selected = false, + expandedDetails = false, + time = 0, + ), + onPerformBackInTime = {}, + ) + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/HistorySection.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/HistorySection.kt new file mode 100644 index 00000000..8db7090a --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/HistorySection.kt @@ -0,0 +1,68 @@ +package com.kitakkunl.backintime.feature.inspector.section + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkunl.backintime.feature.inspector.components.EventItemUiState +import com.kitakkunl.backintime.feature.inspector.components.EventSequenceView +import com.kitakkunl.backintime.feature.inspector.components.SelectedEventDetailView +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.VerticalSplitLayout + +data class HistorySectionUiState( + val events: List, + val selectedEventData: EventItemUiState?, +) + +@Composable +fun HistorySection( + uiState: HistorySectionUiState?, + onClickEvent: (event: EventItemUiState) -> Unit, + onPerformBackInTime: (event: EventItemUiState) -> Unit, +) { + VerticalSplitLayout( + first = { + uiState?.let { + EventSequenceView( + items = it.events, + onClickEvent = onClickEvent, + ) + } ?: Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text(text = "No instance is selected. No histories to show.") + } + }, + second = { + uiState?.selectedEventData?.let { + SelectedEventDetailView( + selectedEvent = it, + onPerformBackInTime = { onPerformBackInTime(it) } + ) + } + }, + firstPaneMinWidth = 200.dp, + secondPaneMinWidth = 50.dp, + ) +} + +@Preview +@Composable +private fun HistorySectionPreview() { + PreviewContainer { + HistorySection( + uiState = HistorySectionUiState( + events = emptyList(), + selectedEventData = null, + ), + onClickEvent = {}, + onPerformBackInTime = {}, + ) + } +} diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/InstanceListSection.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/InstanceListSection.kt new file mode 100644 index 00000000..152b394f --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/InstanceListSection.kt @@ -0,0 +1,76 @@ +package com.kitakkunl.backintime.feature.inspector.section + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkunl.backintime.feature.inspector.components.EmptyInstanceView +import com.kitakkunl.backintime.feature.inspector.components.InstanceItemUiState +import com.kitakkunl.backintime.feature.inspector.components.InstanceItemView +import com.kitakkunl.backintime.feature.inspector.components.PropertyItemUiState + +@Composable +fun InstanceListSection( + instances: List, + onClickItem: (InstanceItemUiState) -> Unit, + onClickProperty: (InstanceItemUiState, PropertyItemUiState) -> Unit, + onTogglePropertyVisibility: (InstanceItemUiState) -> Unit, + modifier: Modifier = Modifier, +) { + if (instances.isEmpty()) { + EmptyInstanceView(modifier) + } else { + LazyColumn( + modifier = modifier.fillMaxSize() + ) { + items( + items = instances, + key = { it.uuid }, + ) { instance -> + InstanceItemView( + uiState = instance, + onClick = { onClickItem(instance) }, + onClickProperty = { onClickProperty(instance, it) }, + onTogglePropertyVisibility = { onTogglePropertyVisibility(instance) }, + ) + } + } + } +} + +@Preview +@Composable +private fun InstanceListSectionPreview_Empty() { + PreviewContainer { + InstanceListSection( + instances = emptyList(), + onClickProperty = { _, _ -> }, + onClickItem = {}, + onTogglePropertyVisibility = {}, + ) + } +} + +@Preview +@Composable +private fun InstanceListSectionPreview() { + PreviewContainer { + InstanceListSection( + instances = List(10) { + InstanceItemUiState( + uuid = "$it", + className = "com/example/A$it", + properties = listOf(), + propertiesExpanded = it == 0, + totalEventsCount = it, + ) + }, + onClickProperty = { _, _ -> }, + onClickItem = {}, + onTogglePropertyVisibility = {}, + ) + } +} \ No newline at end of file diff --git a/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/PropertyInspectorSection.kt b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/PropertyInspectorSection.kt new file mode 100644 index 00000000..41b697c3 --- /dev/null +++ b/tooling/feature/inspector/src/main/kotlin/com/kitakkunl/backintime/feature/inspector/section/PropertyInspectorSection.kt @@ -0,0 +1,89 @@ +package com.kitakkunl.backintime.feature.inspector.section + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.shared.IDENavigator +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalIDENavigator +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkunl.backintime.feature.inspector.components.InstanceItemUiState +import com.kitakkunl.backintime.feature.inspector.components.KeyValueRow +import org.jetbrains.jewel.ui.component.IconActionButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +@Composable +fun PropertyInspectorSection( + uiState: InstanceItemUiState?, + propertyName: String?, + modifier: Modifier = Modifier, +) { + val localNavigator = LocalIDENavigator.current + + if (uiState != null) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Text(text = "Instance") + KeyValueRow( + "uuid", + uiState.uuid, + ) + Row { + KeyValueRow( + "class", + uiState.className, + modifier = Modifier.weight(1f), + ) + IconActionButton( + key = AllIconsKeys.Actions.EditSource, + contentDescription = "Go to source", + onClick = { + localNavigator.navigateToClass(uiState.className) + }, + ) + } + uiState.properties.find { it.name == propertyName }?.let { + Text(text = "Property") + KeyValueRow( + "name", + it.name, + ) + KeyValueRow( + "type", + it.type, + ) + } + } + } else { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text(text = "No instance is selected") + } + } +} + + +@Preview +@Composable +private fun PropertyInspectorSectionPreview() { + PreviewContainer { + CompositionLocalProvider(LocalIDENavigator provides IDENavigator.Noop) { + PropertyInspectorSection( + uiState = null, + propertyName = null, + ) + } + } +} diff --git a/tooling/feature/log/build.gradle.kts b/tooling/feature/log/build.gradle.kts new file mode 100644 index 00000000..2e265985 --- /dev/null +++ b/tooling/feature/log/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.intelliJComposeFeature) + alias(libs.plugins.kotlinSerialization) +} + +dependencies { + implementation(projects.tooling.core.shared) + implementation(projects.tooling.core.database) + implementation(libs.kotlinx.serialization.json) +} diff --git a/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/LogScreen.kt b/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/LogScreen.kt new file mode 100644 index 00000000..deb03764 --- /dev/null +++ b/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/LogScreen.kt @@ -0,0 +1,124 @@ +package com.kitakkun.backintime.tooling.feature.log + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.component.JsonView +import com.kitakkun.backintime.tooling.core.ui.component.SessionSelectorView +import com.kitakkun.backintime.tooling.core.ui.logic.EventEmitter +import com.kitakkun.backintime.tooling.core.ui.logic.rememberEventEmitter +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkun.backintime.tooling.feature.log.component.LogTableView +import com.kitakkun.backintime.tooling.model.ClassInfo +import com.kitakkun.backintime.tooling.model.EventEntity +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.serialization.json.Json +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.VerticalSplitLayout +import org.jetbrains.jewel.ui.component.rememberSplitLayoutState + +@Composable +fun LogScreen( + eventEmitter: EventEmitter = rememberEventEmitter(), + uiState: LogScreenUiState = logScreenPresenter(eventEmitter), +) { + LogScreen( + uiState = uiState, + onSelectSessionId = { eventEmitter.tryEmit(LogScreenEvent.SelectSession(it)) }, + onSelectEvent = { eventEmitter.tryEmit(LogScreenEvent.SelectEvent(it.eventId)) }, + onUpdateVerticalSplitDividerPosition = { eventEmitter.tryEmit(LogScreenEvent.UpdateVerticalSplitDividerPosition(it)) } + ) +} + +data class LogScreenUiState( + val selectedSessionId: String?, + val sessionIdCandidates: List, + val selectedEventId: String?, + val events: List, + val verticalSplitDividerPosition: Float, +) { + val selectedEvent: EventEntity? get() = events.firstOrNull { it.eventId == selectedEventId } +} + +@Composable +fun LogScreen( + uiState: LogScreenUiState, + onSelectSessionId: (String) -> Unit, + onSelectEvent: (EventEntity) -> Unit, + onUpdateVerticalSplitDividerPosition: (Float) -> Unit, +) { + val verticalSplitLayoutState = rememberSplitLayoutState(uiState.verticalSplitDividerPosition) + + LaunchedEffect(verticalSplitLayoutState) { + snapshotFlow { verticalSplitLayoutState.dividerPosition } + .distinctUntilChanged() + .collect(onUpdateVerticalSplitDividerPosition) + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + SessionSelectorView( + selectedSessionId = uiState.selectedSessionId, + sessionIdCandidates = uiState.sessionIdCandidates, + onSelectItem = onSelectSessionId, + ) + VerticalSplitLayout( + state = verticalSplitLayoutState, + first = { + LogTableView( + events = uiState.events, + selectedEventId = uiState.selectedEventId, + onSelectEvent = { onSelectEvent(it) } + ) + }, + second = { + uiState.selectedEvent?.let { + JsonView( + jsonString = Json.encodeToString(it), + modifier = Modifier.verticalScroll(rememberScrollState()), + ) + } ?: Text("No event selected.") + }, + firstPaneMinWidth = 200.dp, + secondPaneMinWidth = 200.dp, + ) + } +} + +@Preview +@Composable +private fun LogScreenPreview() { + PreviewContainer { + LogScreen( + uiState = LogScreenUiState( + selectedSessionId = "session1", + sessionIdCandidates = List(10) { "session$it" }, + events = List(10) { + EventEntity.Instance.Register( + sessionId = "sessionId", + instanceId = it.toString(), + classInfo = ClassInfo( + classSignature = "com/example/A", + superClassSignature = "com/example/B", + properties = emptyList(), + ), + time = 0, + ) + }, + verticalSplitDividerPosition = 0.5f, + selectedEventId = null, + ), + onSelectEvent = {}, + onSelectSessionId = {}, + onUpdateVerticalSplitDividerPosition = {}, + ) + } +} diff --git a/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/LogScreenPresenter.kt b/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/LogScreenPresenter.kt new file mode 100644 index 00000000..ebb63737 --- /dev/null +++ b/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/LogScreenPresenter.kt @@ -0,0 +1,47 @@ +package com.kitakkun.backintime.tooling.feature.log + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalPluginStateService +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalServer +import com.kitakkun.backintime.tooling.core.ui.logic.EventEffect +import com.kitakkun.backintime.tooling.core.ui.logic.EventEmitter +import com.kitakkun.backintime.tooling.core.usecase.allEvents + +sealed interface LogScreenEvent { + data class UpdateVerticalSplitDividerPosition(val position: Float) : LogScreenEvent + data class SelectSession(val sessionId: String) : LogScreenEvent + data class SelectEvent(val eventId: String) : LogScreenEvent +} + +@Composable +fun logScreenPresenter(eventEmitter: EventEmitter): LogScreenUiState { + val pluginStateService = LocalPluginStateService.current + val pluginState by pluginStateService.stateFlow.collectAsState() + val server = LocalServer.current + + EventEffect(eventEmitter) { event -> + when (event) { + is LogScreenEvent.UpdateVerticalSplitDividerPosition -> pluginStateService.loadState( + pluginState.copy(logState = pluginState.logState.copy(verticalSplitPanePosition = event.position)) + ) + + is LogScreenEvent.SelectSession -> pluginStateService.loadState( + pluginState.copy(globalState = pluginState.globalState.copy(selectedSessionId = event.sessionId)) + ) + + is LogScreenEvent.SelectEvent -> pluginStateService.loadState( + pluginState.copy(logState = pluginState.logState.copy(selectedEventId = event.eventId)) + ) + } + } + + return LogScreenUiState( + events = allEvents(pluginState.globalState.selectedSessionId), + selectedSessionId = pluginStateService.getState().globalState.selectedSessionId, + sessionIdCandidates = server.state.connections.map { it.id }, + selectedEventId = pluginState.logState.selectedEventId, + verticalSplitDividerPosition = pluginState.logState.verticalSplitPanePosition, + ) +} diff --git a/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/component/LogTableView.kt b/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/component/LogTableView.kt new file mode 100644 index 00000000..0cf39226 --- /dev/null +++ b/tooling/feature/log/src/main/kotlin/com/kitakkun/backintime/tooling/feature/log/component/LogTableView.kt @@ -0,0 +1,116 @@ +package com.kitakkun.backintime.tooling.feature.log.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import com.kitakkun.backintime.tooling.model.ClassInfo +import com.kitakkun.backintime.tooling.model.EventEntity +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.VerticalScrollbar + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LogTableView( + selectedEventId: String?, + events: List, + onSelectEvent: (EventEntity) -> Unit, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + Box(modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = modifier.matchParentSize(), + ) { + stickyHeader { + ItemRow( + timeText = "Time", + payloadText = "Payload", + modifier = Modifier.background(JewelTheme.globalColors.panelBackground) + ) + } + items( + items = events, + key = { it.eventId }, + ) { + ItemRow( + timeText = it.time.toString(), + payloadText = it.toString(), + modifier = Modifier + .clickable(onClick = { onSelectEvent(it) }) + .then( + if (selectedEventId == it.eventId) { + Modifier.background(Color.White.copy(alpha = 0.2f)) + } else { + Modifier + } + ) + ) + } + } + VerticalScrollbar(listState, Modifier.align(Alignment.CenterEnd)) + } +} + +@Composable +private fun ItemRow( + timeText: String, + payloadText: String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .fillMaxWidth() + .padding(4.dp), + ) { + Text(timeText, modifier = Modifier.width(100.dp)) + Text(payloadText) + } +} + +@Preview +@Composable +private fun LogTableViewPreview() { + PreviewContainer { + LogTableView( + events = listOf( + EventEntity.Instance.Register( + sessionId = "sessionId", + instanceId = "hogehoge", + classInfo = ClassInfo(classSignature = "com/example/A", superClassSignature = "com/example/B", properties = emptyList()), + time = 0, + ), + EventEntity.Instance.MethodInvocation( + sessionId = "sessionId", + instanceId = "hogehoge", + callId = "hoghoeg", + methodSignature = "com/example/A.hoge():kotlin/Unit", + time = 0, + ), + ), + selectedEventId = null, + onSelectEvent = {}, + modifier = Modifier.fillMaxSize() + ) + } +} diff --git a/tooling/feature/settings/build.gradle.kts b/tooling/feature/settings/build.gradle.kts new file mode 100644 index 00000000..2e265985 --- /dev/null +++ b/tooling/feature/settings/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.intelliJComposeFeature) + alias(libs.plugins.kotlinSerialization) +} + +dependencies { + implementation(projects.tooling.core.shared) + implementation(projects.tooling.core.database) + implementation(libs.kotlinx.serialization.json) +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/FileChooserResultLauncher.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/FileChooserResultLauncher.kt new file mode 100644 index 00000000..66215f01 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/FileChooserResultLauncher.kt @@ -0,0 +1,31 @@ +package com.kitakkun.backintime.feature.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import java.io.File +import javax.swing.JFileChooser + +@Composable +fun rememberFileChooserResultLauncher( + resultHandler: (File?) -> Unit, +): FileChooserResultLauncher { + return remember { + object : FileChooserResultLauncher { + override fun launch(chooserConfiguration: JFileChooser.() -> Unit) { + val fileChooser = JFileChooser().apply { + chooserConfiguration() + } + val result = fileChooser.showSaveDialog(null) + if (result == JFileChooser.APPROVE_OPTION) { + resultHandler(fileChooser.selectedFile.absoluteFile) + } else { + resultHandler(null) + } + } + } + } +} + +interface FileChooserResultLauncher { + fun launch(chooserConfiguration: JFileChooser.() -> Unit) +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/SettingsScreen.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/SettingsScreen.kt new file mode 100644 index 00000000..8d671382 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/SettingsScreen.kt @@ -0,0 +1,183 @@ +package com.kitakkun.backintime.feature.settings + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.feature.settings.component.RestartDatabaseInMemoryConfirmationDialog +import com.kitakkun.backintime.feature.settings.component.RestartDatabaseWithFileConfirmationDialog +import com.kitakkun.backintime.feature.settings.component.ServerRestartConfirmationDialog +import com.kitakkun.backintime.feature.settings.section.DataBaseSettingsSection +import com.kitakkun.backintime.feature.settings.section.InspectorSettingsSection +import com.kitakkun.backintime.feature.settings.section.ServerSettingsSection +import com.kitakkun.backintime.tooling.core.ui.component.HorizontalDivider +import com.kitakkun.backintime.tooling.core.ui.logic.EventEmitter +import com.kitakkun.backintime.tooling.core.ui.logic.rememberEventEmitter +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer + +@Composable +fun SettingsScreen( + eventEmitter: EventEmitter = rememberEventEmitter(), + uiState: SettingsScreenUiState = settingsScreenPresenter(eventEmitter), +) { + var showServerRestartConfirmationDialog by remember { mutableStateOf(false) } + + var showRestartDatabaseInMemoryConfirmationDialog by remember { mutableStateOf(false) } + var showRestartDatabaseWithFileConfirmationDialog by remember { mutableStateOf(false) } + + if (showServerRestartConfirmationDialog) { + ServerRestartConfirmationDialog( + onDismissRequest = { showServerRestartConfirmationDialog = false }, + onClickOk = { + eventEmitter.tryEmit(SettingsScreenEvent.RestartServer) + showServerRestartConfirmationDialog = false + } + ) + } + + if (showRestartDatabaseInMemoryConfirmationDialog) { + RestartDatabaseInMemoryConfirmationDialog( + databaseFilePath = uiState.databasePath!!, + onDismissRequest = { showRestartDatabaseInMemoryConfirmationDialog = false }, + onClickCancel = { showRestartDatabaseInMemoryConfirmationDialog = false }, + onClickOk = { + eventEmitter.tryEmit(SettingsScreenEvent.RestartDatabaseInMemory(it)) + showRestartDatabaseInMemoryConfirmationDialog = false + }, + ) + } + + if (showRestartDatabaseWithFileConfirmationDialog) { + RestartDatabaseWithFileConfirmationDialog( + initialDatabasePath = uiState.databasePath, + onDismissRequest = { showRestartDatabaseWithFileConfirmationDialog = false }, + onClickOk = { databaseFilePath, migrate -> + eventEmitter.tryEmit(SettingsScreenEvent.RestartDatabaseWithFile(databaseFilePath, migrate)) + showRestartDatabaseWithFileConfirmationDialog = false + }, + ) + } + + SettingsScreen( + uiState = uiState, + onUpdatePortNumber = { eventEmitter.tryEmit(SettingsScreenEvent.UpdatePortNumber(it)) }, + onToggleShowNonDebuggableProperties = { eventEmitter.tryEmit(SettingsScreenEvent.UpdateNonDebuggablePropertyVisibility(it)) }, + onClickShowInspectorForSession = { eventEmitter.tryEmit(SettingsScreenEvent.ShowInspectorForSession(it)) }, + onClickShowLogForSession = { eventEmitter.tryEmit(SettingsScreenEvent.ShowLogForSession(it)) }, + onTogglePersistSessionData = { persist -> + if (persist) { + showRestartDatabaseWithFileConfirmationDialog = true + } else { + showRestartDatabaseInMemoryConfirmationDialog = true + } + }, + onClickApplyServerConfiguration = { showServerRestartConfirmationDialog = true }, + ) +} + +data class SettingsScreenUiState( + val serverStatus: ServerStatus, + val databaseStatus: DatabaseStatus, + val sessions: List, + val port: Int, + val showNonDebuggableProperties: Boolean, + val persistSessionData: Boolean, + val databasePath: String?, +) { + val needsServerRestart: Boolean get() = serverStatus is ServerStatus.Running && port != serverStatus.port + + sealed interface ServerStatus { + data object Stopped : ServerStatus + data class Running( + val activeConnectionCount: Int, + val port: Int, + ) : ServerStatus + } + + data class SessionStatus( + val id: String, + val isActive: Boolean, + ) + + sealed interface DatabaseStatus { + data object InMemory : DatabaseStatus + + data class File( + val path: String, + ) : DatabaseStatus + } +} + +@Composable +fun SettingsScreen( + uiState: SettingsScreenUiState, + onUpdatePortNumber: (Int) -> Unit, + onToggleShowNonDebuggableProperties: (visible: Boolean) -> Unit, + onTogglePersistSessionData: (persist: Boolean) -> Unit, + onClickApplyServerConfiguration: () -> Unit, + onClickShowInspectorForSession: (sessionId: String) -> Unit, + onClickShowLogForSession: (sessionId: String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ServerSettingsSection( + portNumber = uiState.port, + status = uiState.serverStatus, + sessions = uiState.sessions, + needsServerRestart = uiState.needsServerRestart, + onUpdatePortNumber = onUpdatePortNumber, + onClickApply = onClickApplyServerConfiguration, + onClickShowInspectorForSession = onClickShowInspectorForSession, + onClickShowLogForSession = onClickShowLogForSession, + ) + HorizontalDivider() + InspectorSettingsSection( + showNonDebuggableProperties = uiState.showNonDebuggableProperties, + onToggleShowNonDebuggableProperties = onToggleShowNonDebuggableProperties, + ) + HorizontalDivider() + DataBaseSettingsSection( + status = uiState.databaseStatus, + persistSessionData = uiState.persistSessionData, + onTogglePersistSessionData = onTogglePersistSessionData, + ) + } +} + +@Preview +@Composable +private fun SettingsScreenPreview() { + PreviewContainer { + SettingsScreen( + uiState = SettingsScreenUiState( + serverStatus = SettingsScreenUiState.ServerStatus.Running( + port = 50023, + activeConnectionCount = 10, + ), + sessions = List(10) { + SettingsScreenUiState.SessionStatus( + id = "session$it", + isActive = it == 0, + ) + }, + port = 50023, + showNonDebuggableProperties = true, + persistSessionData = false, + databasePath = null, + databaseStatus = SettingsScreenUiState.DatabaseStatus.InMemory, + ) + ) + } +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/SettingsScreenPresenter.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/SettingsScreenPresenter.kt new file mode 100644 index 00000000..d6f586e9 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/SettingsScreenPresenter.kt @@ -0,0 +1,121 @@ +package com.kitakkun.backintime.feature.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDatabase +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalPluginStateService +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalServer +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalSettings +import com.kitakkun.backintime.tooling.core.ui.logic.EventEffect +import com.kitakkun.backintime.tooling.core.ui.logic.EventEmitter +import com.kitakkun.backintime.tooling.core.usecase.LocalDatabase +import com.kitakkun.backintime.tooling.model.Tab + +sealed interface SettingsScreenEvent { + data class UpdatePortNumber(val portNumber: Int) : SettingsScreenEvent + data class UpdateNonDebuggablePropertyVisibility(val visible: Boolean) : SettingsScreenEvent + data class ShowInspectorForSession(val sessionId: String) : SettingsScreenEvent + data class ShowLogForSession(val sessionId: String) : SettingsScreenEvent + data object RestartServer : SettingsScreenEvent + data class RestartDatabaseWithFile(val databaseFilePath: String, val migrate: Boolean) : SettingsScreenEvent + data class RestartDatabaseInMemory(val migrate: Boolean) : SettingsScreenEvent +} + +@Composable +fun settingsScreenPresenter(eventEmitter: EventEmitter): SettingsScreenUiState { + val pluginStateService = LocalPluginStateService.current + val database = LocalDatabase.current + val server = LocalServer.current + val settings = LocalSettings.current + + val databaseState by database.stateFlow.collectAsState() + val settingsState by rememberUpdatedState(settings.getState()) + val connections = server.state.connections + + EventEffect(eventEmitter) { event -> + when (event) { + is SettingsScreenEvent.UpdatePortNumber -> { + settings.update { + it.copy(serverPort = event.portNumber) + } + } + + is SettingsScreenEvent.UpdateNonDebuggablePropertyVisibility -> { + settings.update { + it.copy(showNonDebuggableProperties = event.visible) + } + } + + is SettingsScreenEvent.RestartServer -> { + server.restartServer(settingsState.serverPort) + } + + is SettingsScreenEvent.ShowInspectorForSession -> { + pluginStateService.loadState( + state = pluginStateService.getState().let { + it.copy( + globalState = it.globalState.copy( + activeTab = Tab.Inspector, + selectedSessionId = event.sessionId, + ) + ) + } + ) + } + + is SettingsScreenEvent.ShowLogForSession -> { + pluginStateService.loadState( + state = pluginStateService.getState().let { + it.copy( + globalState = it.globalState.copy( + activeTab = Tab.Log, + selectedSessionId = event.sessionId, + ) + ) + } + ) + } + + is SettingsScreenEvent.RestartDatabaseWithFile -> { + database.restartDatabaseAsFile( + filePath = event.databaseFilePath, + migrate = event.migrate, + ) + settings.update { it.copy(databasePath = event.databaseFilePath, persistSessionData = true) } + } + + is SettingsScreenEvent.RestartDatabaseInMemory -> { + database.restartDatabaseInMemory(migrate = event.migrate) + settings.update { it.copy(persistSessionData = false) } + } + } + } + + return SettingsScreenUiState( + serverStatus = if (server.state.serverIsRunning && server.state.port != null) { + SettingsScreenUiState.ServerStatus.Running( + connections.size, + server.state.port!!, + ) + } else { + SettingsScreenUiState.ServerStatus.Stopped + }, + port = settingsState.serverPort, + showNonDebuggableProperties = settingsState.showNonDebuggableProperties, + sessions = connections.map { + SettingsScreenUiState.SessionStatus( + id = it.id, + isActive = it.isActive, + ) + }, + persistSessionData = settingsState.persistSessionData, + databasePath = settingsState.databasePath, + databaseStatus = when (val state = databaseState) { + is BackInTimeDatabase.State.RunningInMemory -> SettingsScreenUiState.DatabaseStatus.InMemory + is BackInTimeDatabase.State.RunningWithFile -> SettingsScreenUiState.DatabaseStatus.File(state.filePath) + is BackInTimeDatabase.State.Stopped -> TODO() + }, + ) +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/RestartDatabaseAsFileConfirmationDialog.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/RestartDatabaseAsFileConfirmationDialog.kt new file mode 100644 index 00000000..6753aa8a --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/RestartDatabaseAsFileConfirmationDialog.kt @@ -0,0 +1,94 @@ +package com.kitakkun.backintime.feature.settings.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.feature.settings.rememberFileChooserResultLauncher +import com.kitakkun.backintime.tooling.core.ui.component.CommonConfirmationDialog +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Checkbox +import org.jetbrains.jewel.ui.component.IconActionButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.icons.AllIconsKeys +import java.io.File +import javax.swing.filechooser.FileNameExtensionFilter + +@Composable +fun RestartDatabaseWithFileConfirmationDialog( + initialDatabasePath: String?, + onDismissRequest: () -> Unit, + onClickOk: (databasePath: String, migrate: Boolean) -> Unit, +) { + val databaseTextFieldState = rememberTextFieldState(initialDatabasePath ?: "") + val fileChooserResultLauncher = rememberFileChooserResultLauncher { + it ?: return@rememberFileChooserResultLauncher + databaseTextFieldState.setTextAndPlaceCursorAtEnd(it.absolutePath) + } + var migrateCurrentData by remember { mutableStateOf(false) } + + CommonConfirmationDialog( + onDismissRequest = onDismissRequest, + onClickCancel = onDismissRequest, + onClickOk = { onClickOk(databaseTextFieldState.text.toString(), migrateCurrentData) }, + ) { + Text(text = "DB file location:") + TextField( + state = databaseTextFieldState, + trailingIcon = { + IconActionButton( + key = AllIconsKeys.FileTypes.UiForm, + contentDescription = null, + onClick = { + fileChooserResultLauncher.launch { + selectedFile = File("backintime-database.db") + fileFilter = FileNameExtensionFilter("sqlite database file", "db", "sqlite", "sqlite3") + isAcceptAllFileFilterUsed = false + } + } + ) + }, + modifier = Modifier.widthIn(min = 300.dp), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = migrateCurrentData, + onCheckedChange = { migrateCurrentData = it }, + ) + Text( + text = "Migrate current data to new database file.", + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { migrateCurrentData = !migrateCurrentData }, + ) + ) + } + } +} + +@Preview +@Composable +fun RestartDatabaseWithFileConfirmationDialogPreview() { + PreviewContainer { + RestartDatabaseWithFileConfirmationDialog( + initialDatabasePath = null, + onDismissRequest = {}, + onClickOk = { _, _ -> }, + ) + } +} \ No newline at end of file diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/RestartDatabaseInMemoryConfirmationDialog.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/RestartDatabaseInMemoryConfirmationDialog.kt new file mode 100644 index 00000000..4e6902d8 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/RestartDatabaseInMemoryConfirmationDialog.kt @@ -0,0 +1,74 @@ +package com.kitakkun.backintime.feature.settings.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.component.CommonConfirmationDialog +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Checkbox +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun RestartDatabaseInMemoryConfirmationDialog( + databaseFilePath: String, + onClickOk: (migrate: Boolean) -> Unit, + onClickCancel: () -> Unit, + onDismissRequest: () -> Unit, +) { + var migrateDataToInMemoryDatabase by remember { mutableStateOf(false) } + + CommonConfirmationDialog( + onDismissRequest = onDismissRequest, + onClickOk = { onClickOk(migrateDataToInMemoryDatabase) }, + onClickCancel = onClickCancel, + ) { + Text(text = "The following file is being in use to handle debugger events:") + Text( + text = databaseFilePath, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .border( + width = 1.dp, + color = JewelTheme.globalColors.outlines.focusedWarning, + shape = RoundedCornerShape(8.dp), + ) + .padding(8.dp), + ) + Text(text = "Are you sure to switching to In-Memory database?") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = migrateDataToInMemoryDatabase, + onCheckedChange = { migrateDataToInMemoryDatabase = it }, + ) + Text(text = "Migrate all events data to In-Memory database.(This will not delete current database file)") + } + } +} + +@Preview +@Composable +private fun RestartDatabaseInMemoryConfirmationDialogPreview() { + PreviewContainer { + RestartDatabaseInMemoryConfirmationDialog( + databaseFilePath = "/path/to/backintime-database.db", + onDismissRequest = {}, + onClickOk = {}, + onClickCancel = {}, + ) + } +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/ServerRestartConfirmationDialog.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/ServerRestartConfirmationDialog.kt new file mode 100644 index 00000000..edc1efa7 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/ServerRestartConfirmationDialog.kt @@ -0,0 +1,33 @@ +package com.kitakkun.backintime.feature.settings.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.Composable +import com.kitakkun.backintime.tooling.core.ui.component.CommonConfirmationDialog +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Text + +@Composable +fun ServerRestartConfirmationDialog( + onClickOk: () -> Unit, + onDismissRequest: () -> Unit, +) { + CommonConfirmationDialog( + onDismissRequest = onDismissRequest, + onClickOk = onClickOk, + onClickCancel = onDismissRequest, + ) { + Text(text = "Server will restart.") + Text(text = "All of the active sessions will be terminated.") + } +} + +@Preview +@Composable +private fun ServerRestartConfirmationDialogPreview() { + PreviewContainer { + ServerRestartConfirmationDialog( + onClickOk = {}, + onDismissRequest = {}, + ) + } +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SessionListView.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SessionListView.kt new file mode 100644 index 00000000..8f8c9288 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SessionListView.kt @@ -0,0 +1,86 @@ +package com.kitakkun.backintime.feature.settings.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.feature.settings.SettingsScreenUiState +import com.kitakkun.backintime.tooling.core.ui.component.Badge +import com.kitakkun.backintime.tooling.core.ui.component.HorizontalDivider +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.LocalTextStyle +import org.jetbrains.jewel.ui.component.IconActionButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +@Composable +fun SessionListView( + sessions: List, + onClickShowInspector: (SettingsScreenUiState.SessionStatus) -> Unit, + onClickShowLog: (SettingsScreenUiState.SessionStatus) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + sessions.forEachIndexed { index, session -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = session.id) + Spacer(Modifier.width(4.dp)) + Badge( + containerColor = if (session.isActive) Color.Green else Color.Gray, + modifier = Modifier.size( + with(LocalDensity.current) { + LocalTextStyle.current.fontSize.toDp() + } + ) + ) + Spacer(Modifier.weight(1f)) + IconActionButton( + key = AllIconsKeys.Toolwindows.ToolWindowHierarchy, + contentDescription = null, + onClick = { onClickShowInspector(session) }, + ) + IconActionButton( + key = AllIconsKeys.Nodes.DataSchema, + contentDescription = null, + onClick = { onClickShowLog(session) }, + ) + } + if (index != sessions.size - 1) { + HorizontalDivider() + } + } + } +} + +@Preview +@Composable +private fun SessionListViewPreview() { + PreviewContainer { + SessionListView( + sessions = List(10) { + SettingsScreenUiState.SessionStatus( + id = "session$it", + isActive = true, + ) + }, + onClickShowInspector = {}, + onClickShowLog = {}, + ) + } +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SettingsHeaderItem.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SettingsHeaderItem.kt new file mode 100644 index 00000000..08ee6120 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SettingsHeaderItem.kt @@ -0,0 +1,51 @@ +package com.kitakkun.backintime.feature.settings.component + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.foundation.theme.LocalTextStyle +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icon.IconKey +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +@Composable +fun SettingsHeadingItem( + title: String, + iconKey: IconKey, + modifier: Modifier = Modifier, +) { + val fontSize = LocalTextStyle.current.fontSize * 1.2 + val fontSizeDp = with(LocalDensity.current) { fontSize.toDp() } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Text(text = title, fontSize = fontSize) + Icon( + key = iconKey, + contentDescription = null, + modifier = Modifier.size(fontSizeDp), + ) + } +} + +@Preview +@Composable +private fun SettingsHeaderItemPreview() { + PreviewContainer { + SettingsHeadingItem( + title = "Title", + iconKey = AllIconsKeys.Toolwindows.InfoEvents, + ) + } +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SettingsItemRow.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SettingsItemRow.kt new file mode 100644 index 00000000..6b378e86 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/component/SettingsItemRow.kt @@ -0,0 +1,24 @@ +package com.kitakkun.backintime.feature.settings.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun SettingsItemRow( + label: @Composable () -> Unit, + settingComponent: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + label() + settingComponent() + } +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/DataSettingsSection.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/DataSettingsSection.kt new file mode 100644 index 00000000..d9080549 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/DataSettingsSection.kt @@ -0,0 +1,65 @@ +package com.kitakkun.backintime.feature.settings.section + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.feature.settings.SettingsScreenUiState +import com.kitakkun.backintime.feature.settings.component.SettingsHeadingItem +import com.kitakkun.backintime.feature.settings.component.SettingsItemRow +import com.kitakkun.backintime.tooling.core.ui.component.Switch +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +@Composable +fun DataBaseSettingsSection( + status: SettingsScreenUiState.DatabaseStatus, + persistSessionData: Boolean, + onTogglePersistSessionData: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SettingsHeadingItem( + title = "Database", + iconKey = AllIconsKeys.Nodes.DataSchema, + ) + SettingsItemRow( + label = { Text("Status:") }, + settingComponent = { + Text( + text = when (status) { + is SettingsScreenUiState.DatabaseStatus.File -> "Stored at ${status.path}" + is SettingsScreenUiState.DatabaseStatus.InMemory -> "Serving in memory" + } + ) + } + ) + SettingsItemRow( + label = { Text("Persist session data:") }, + settingComponent = { + Switch( + checked = persistSessionData, + onCheckedChange = onTogglePersistSessionData, + ) + } + ) + } +} + +@Preview +@Composable +private fun DataSettingsSectionPreview() { + PreviewContainer { + DataBaseSettingsSection( + persistSessionData = true, + onTogglePersistSessionData = {}, + status = SettingsScreenUiState.DatabaseStatus.InMemory, + ) + } +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/InspectorSettingsSection.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/InspectorSettingsSection.kt new file mode 100644 index 00000000..32e35276 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/InspectorSettingsSection.kt @@ -0,0 +1,51 @@ +package com.kitakkun.backintime.feature.settings.section + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.feature.settings.component.SettingsHeadingItem +import com.kitakkun.backintime.feature.settings.component.SettingsItemRow +import com.kitakkun.backintime.tooling.core.ui.component.Switch +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +@Composable +fun InspectorSettingsSection( + showNonDebuggableProperties: Boolean, + onToggleShowNonDebuggableProperties: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SettingsHeadingItem( + title = "Inspector", + iconKey = AllIconsKeys.Toolwindows.ToolWindowHierarchy, + ) + SettingsItemRow( + label = { Text(text = "Show non-debuggable properties:") }, + settingComponent = { + Switch( + checked = showNonDebuggableProperties, + onCheckedChange = onToggleShowNonDebuggableProperties, + ) + } + ) + } +} + +@Preview +@Composable +private fun InspectorSettingsSectionPreview() { + PreviewContainer { + InspectorSettingsSection( + showNonDebuggableProperties = true, + onToggleShowNonDebuggableProperties = {}, + ) + } +} diff --git a/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/ServerSettingsSection.kt b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/ServerSettingsSection.kt new file mode 100644 index 00000000..a5fc0649 --- /dev/null +++ b/tooling/feature/settings/src/main/kotlin/com/kitakkun/backintime/feature/settings/section/ServerSettingsSection.kt @@ -0,0 +1,138 @@ +package com.kitakkun.backintime.feature.settings.section + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.feature.settings.SettingsScreenUiState +import com.kitakkun.backintime.feature.settings.component.SessionListView +import com.kitakkun.backintime.feature.settings.component.SettingsHeadingItem +import com.kitakkun.backintime.feature.settings.component.SettingsItemRow +import com.kitakkun.backintime.tooling.core.ui.preview.PreviewContainer +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.foundation.theme.LocalTextStyle +import org.jetbrains.jewel.ui.component.ActionButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +@Composable +fun ServerSettingsSection( + portNumber: Int, + status: SettingsScreenUiState.ServerStatus, + sessions: List, + needsServerRestart: Boolean, + onUpdatePortNumber: (Int) -> Unit, + onClickApply: () -> Unit, + onClickShowInspectorForSession: (sessionId: String) -> Unit, + onClickShowLogForSession: (sessionId: String) -> Unit, + modifier: Modifier = Modifier, +) { + val portNumberTextFieldState = rememberTextFieldState(portNumber.toString()) + val invalidPortRange by rememberUpdatedState(portNumber < 0 || portNumber > 65535) + + LaunchedEffect(portNumber) { + portNumberTextFieldState.setTextAndPlaceCursorAtEnd(portNumber.toString()) + } + + LaunchedEffect(portNumberTextFieldState) { + snapshotFlow { portNumberTextFieldState.text.toString().toIntOrNull() } + .filterNotNull() + .distinctUntilChanged() + .collect(onUpdatePortNumber) + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SettingsHeadingItem( + title = "Server", + iconKey = AllIconsKeys.Webreferences.WebSocket, + ) + SettingsItemRow( + label = { Text(text = "Status:") }, + settingComponent = { + Text( + text = when (status) { + is SettingsScreenUiState.ServerStatus.Running -> "Running on port ${status.port} / ${status.activeConnectionCount} connections" + is SettingsScreenUiState.ServerStatus.Stopped -> "Stopped" + } + ) + }, + ) + if (status is SettingsScreenUiState.ServerStatus.Running && sessions.isNotEmpty()) { + SessionListView( + sessions = sessions, + onClickShowLog = { onClickShowLogForSession(it.id) }, + onClickShowInspector = { onClickShowInspectorForSession(it.id) }, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 8.dp) + ) + } + SettingsItemRow( + label = { Text(text = "Server Port:") }, + settingComponent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (invalidPortRange) { + Text( + text = "Port must be in range 0 to 65535.", + color = JewelTheme.globalColors.text.error, + ) + } + if (needsServerRestart && !invalidPortRange) { + ActionButton( + content = { Text(text = "Apply") }, + onClick = onClickApply, + ) + } + TextField( + state = portNumberTextFieldState, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End), + inputTransformation = { + if (this.asCharSequence().any { !it.isDigit() }) { + revertAllChanges() + } + }, + ) + } + } + ) + } +} + +@Preview +@Composable +private fun ServerSettingsSectionPreview() { + PreviewContainer { + ServerSettingsSection( + portNumber = 50023, + status = SettingsScreenUiState.ServerStatus.Stopped, + sessions = emptyList(), + needsServerRestart = false, + onClickShowLogForSession = {}, + onClickShowInspectorForSession = {}, + onClickApply = {}, + onUpdatePortNumber = {}, + ) + } +} diff --git a/tooling/flipper-lib/build.gradle.kts b/tooling/flipper-lib/build.gradle.kts index 2d393e1e..0b671094 100644 --- a/tooling/flipper-lib/build.gradle.kts +++ b/tooling/flipper-lib/build.gradle.kts @@ -11,7 +11,7 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(projects.tooling.model) + implementation(projects.tooling.core.model) implementation(projects.core.websocket.event) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) diff --git a/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/FlipperAppState.kt b/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/FlipperAppState.kt index 05554ddd..ce1f5231 100644 --- a/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/FlipperAppState.kt +++ b/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/FlipperAppState.kt @@ -1,5 +1,6 @@ package com.kitakkun.backintime.tooling.flipper +import com.kitakkun.backintime.tooling.model.BackInTimeEventData import com.kitakkun.backintime.tooling.model.ClassInfo import com.kitakkun.backintime.tooling.model.DependencyInfo import com.kitakkun.backintime.tooling.model.InstanceInfo diff --git a/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/FlipperAppStateOwner.kt b/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/FlipperAppStateOwner.kt index 5fab6fdd..cb30fb51 100644 --- a/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/FlipperAppStateOwner.kt +++ b/tooling/flipper-lib/src/commonMain/kotlin/com/kitakkun/backintime/tooling/flipper/FlipperAppStateOwner.kt @@ -4,6 +4,7 @@ package com.kitakkun.backintime.tooling.flipper import com.kitakkun.backintime.core.websocket.event.BackInTimeDebugServiceEvent import com.kitakkun.backintime.core.websocket.event.BackInTimeDebuggerEvent +import com.kitakkun.backintime.tooling.model.BackInTimeEventData import com.kitakkun.backintime.tooling.model.ClassInfo import com.kitakkun.backintime.tooling.model.DependencyInfo import com.kitakkun.backintime.tooling.model.InstanceInfo @@ -34,6 +35,7 @@ class FlipperAppStateOwnerImpl( @Suppress("NON_EXPORTABLE_TYPE") val stateFlow = mutableStateFlow.asStateFlow() + private val sessionId: String get() = "${flipperClient.appId}:${flipperClient.appName}" init { mutableStateFlow.update { it.copy(persistentState = it.persistentState.copy(showNonDebuggableProperty = showNonDebuggableProperty.get())) } @@ -46,7 +48,7 @@ class FlipperAppStateOwnerImpl( // Do not pass instances of event generated in the js-side! Type matching in when expressions for Js-generated events is not available. override fun processEvent(jsonAppEvent: String) { val event = BackInTimeDebugServiceEvent.fromJsonString(jsonAppEvent) - mutableStateFlow.update { it.copy(events = it.events + BackInTimeEventData(payload = event)) } + mutableStateFlow.update { it.copy(events = it.events + BackInTimeEventData(sessionId = sessionId, payload = event)) } when (event) { is BackInTimeDebugServiceEvent.RegisterInstance -> { mutableStateFlow.update { appState -> @@ -64,19 +66,7 @@ class FlipperAppStateOwnerImpl( appState.classInfoList + ClassInfo( classSignature = event.classSignature, superClassSignature = event.superClassSignature, - properties = event.properties.map { - /** - * see [com.kitakkun.backintime.compiler.backend.transformer.capture.BackInTimeDebuggableConstructorTransformer] for details. - */ - val info = it.split(",") - PropertyInfo( - signature = info[0], - debuggable = info[1].toBoolean(), - isDebuggableStateHolder = info[2].toBoolean(), - propertyType = info[3], - valueType = info[4], - ) - } + properties = event.properties.map(PropertyInfo::fromString), ) } else { appState.classInfoList @@ -156,7 +146,9 @@ class FlipperAppStateOwnerImpl( // DON'T RENAME THIS VARIABLE!! It is referenced from the following js() call. val payload = BackInTimeDebuggerEvent.toJsonString(event) flipperClient.send("debuggerEvent", js("{payload: payload}")) - mutableStateFlow.update { it.copy(events = it.events + BackInTimeEventData(payload = event)) } + mutableStateFlow.update { + it.copy(events = it.events + BackInTimeEventData(sessionId = sessionId, payload = event)) + } } override fun updateTab(tab: FlipperTab) { diff --git a/tooling/idea-plugin/build.gradle.kts b/tooling/idea-plugin/build.gradle.kts new file mode 100644 index 00000000..33227c7a --- /dev/null +++ b/tooling/idea-plugin/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.intelliJPlatform) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) +} + +kotlin { + jvmToolchain(17) +} + +repositories { + intellijPlatform { + defaultRepositories() + intellijDependencies() + } + mavenCentral() + google() + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/") + maven("https://packages.jetbrains.team/maven/p/kpm/public/") +} + +dependencies { + intellijPlatform { + create("IC", "2024.3.1") + bundledPlugin("com.intellij.java") + } + + implementation(projects.core.websocket.server) + implementation(projects.core.websocket.event) + implementation(projects.tooling.app) + implementation(projects.tooling.core.ui) + implementation(projects.tooling.core.shared) + implementation(projects.tooling.core.model) + implementation(projects.tooling.core.database) + implementation(projects.tooling.core.usecase) + implementation(libs.jewel) + implementation(compose.desktop.currentOs) { + exclude(group = "org.jetbrains.compose.material") + } +} + +// FYI: https://youtrack.jetbrains.com/issue/IJPL-1325/Classpath-clash-when-using-coroutines-in-an-unbundled-IntelliJ-plugin +tasks { + run { + // workaround for https://youtrack.jetbrains.com/issue/IDEA-285839/Classpath-clash-when-using-coroutines-in-an-unbundled-IntelliJ-plugin + buildPlugin { + exclude { "kotlinx.coroutines" in it.name } + } + prepareSandbox { + exclude { "kotlinx.coroutines" in it.name } + } + } +} diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/BackInTimeToolComposePanel.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/BackInTimeToolComposePanel.kt new file mode 100644 index 00000000..55141eab --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/BackInTimeToolComposePanel.kt @@ -0,0 +1,62 @@ +package com.kitakkun.backintime.tooling.idea + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.awt.ComposePanel +import com.intellij.ide.ui.LafManager +import com.intellij.ide.ui.LafManagerListener +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.kitakkun.backintime.tooling.app.BackInTimeDebuggerApp +import com.kitakkun.backintime.tooling.core.database.BackInTimeDatabaseImpl +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalIDENavigator +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalPluginStateService +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalServer +import com.kitakkun.backintime.tooling.core.ui.compositionlocal.LocalSettings +import com.kitakkun.backintime.tooling.core.ui.theme.BackInTimeTheme +import com.kitakkun.backintime.tooling.core.ui.theme.LocalIsIDEInDarkTheme +import com.kitakkun.backintime.tooling.core.usecase.LocalDatabase +import com.kitakkun.backintime.tooling.idea.service.BackInTimeDebuggerServiceImpl +import com.kitakkun.backintime.tooling.idea.service.BackInTimeDebuggerSettingsImpl +import com.kitakkun.backintime.tooling.idea.service.IDENavigatorImpl +import com.kitakkun.backintime.tooling.idea.service.PluginStateServiceImpl + +class BackInTimeToolComposePanel(project: Project) { + val panel = ComposePanel().apply { + setContent { + var isDark by remember { mutableStateOf(LafManager.getInstance().currentUIThemeLookAndFeel.isDark) } + + DisposableEffect(Unit) { + val connection = ApplicationManager.getApplication().messageBus.connect() + + connection.subscribe( + LafManagerListener.TOPIC, LafManagerListener { + isDark = it.currentUIThemeLookAndFeel.isDark + } + ) + + onDispose { + connection.dispose() + } + } + + CompositionLocalProvider( + LocalIsIDEInDarkTheme provides isDark, + LocalIDENavigator provides project.service(), + LocalSettings provides BackInTimeDebuggerSettingsImpl.getInstance(), + LocalServer provides BackInTimeDebuggerServiceImpl.getInstance(), + LocalPluginStateService provides PluginStateServiceImpl.getInstance(), + LocalDatabase provides BackInTimeDatabaseImpl.instance, + ) { + BackInTimeTheme { + BackInTimeDebuggerApp() + } + } + } + } +} diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/BackInTimeToolWindowFactory.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/BackInTimeToolWindowFactory.kt new file mode 100644 index 00000000..f3511001 --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/BackInTimeToolWindowFactory.kt @@ -0,0 +1,15 @@ +package com.kitakkun.backintime.tooling.idea + +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.content.ContentFactory + +class BackInTimeToolWindowFactory : ToolWindowFactory { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val content = ContentFactory.getInstance().createContent( + BackInTimeToolComposePanel(project).panel, null, false + ) + toolWindow.contentManager.addContent(content) + } +} diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/debug/FunctionalityDebuggingToolPanel.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/debug/FunctionalityDebuggingToolPanel.kt new file mode 100644 index 00000000..a1e67b23 --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/debug/FunctionalityDebuggingToolPanel.kt @@ -0,0 +1,35 @@ +package com.kitakkun.backintime.tooling.idea.debug + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.unit.dp +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.kitakkun.backintime.tooling.idea.service.IDENavigatorImpl +import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme + +/** + * Just for debugging purpose. + * Swap [com.kitakkun.backintime.tooling.idea.BackInTimeToolComposePanel] in the [com.kitakkun.backintime.tooling.idea.BackInTimeToolWindowFactory] with this to test. + */ +class FunctionalityDebuggingToolPanel(project: Project) { + private val ideNavigator = project.service() + + val panel = ComposePanel().apply { + setContent { + IntUiTheme(isDark = isSystemInDarkTheme()) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + IDENavigatorDebugSection(ideNavigator) + } + } + } + } +} diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/debug/IDENavigatorDebugSection.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/debug/IDENavigatorDebugSection.kt new file mode 100644 index 00000000..502df03d --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/debug/IDENavigatorDebugSection.kt @@ -0,0 +1,61 @@ +package com.kitakkun.backintime.tooling.idea.debug + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kitakkun.backintime.tooling.core.shared.IDENavigator +import org.jetbrains.jewel.ui.component.DefaultButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField + +@Composable +fun IDENavigatorDebugSection(ideNavigator: IDENavigator) { + Column { + Text("IDENavigator") + Spacer(Modifier.height(8.dp)) + TextFieldDebugActionItem( + actionLabel = "Go", + placeholderText = "ex) com/example/A.B", + onPerformAction = ideNavigator::navigateToClass, + ) + TextFieldDebugActionItem( + actionLabel = "Go", + placeholderText = "ex) com/example/A.prop", + onPerformAction = ideNavigator::navigateToMemberProperty, + ) + TextFieldDebugActionItem( + actionLabel = "Go", + placeholderText = "ex) com/example/Receiver com/example/A.function(kotlin/Int):kotlin/Unit", + onPerformAction = ideNavigator::navigateToMemberFunction, + ) + } +} + +@Composable +private fun TextFieldDebugActionItem( + actionLabel: String, + onPerformAction: (inputText: String) -> Unit, + placeholderText: String? = null, +) { + val textFieldState = rememberTextFieldState() + Row( + modifier = Modifier.fillMaxWidth() + ) { + TextField( + state = textFieldState, + placeholder = { placeholderText?.let { Text(it) } }, + modifier = Modifier.weight(1f), + ) + DefaultButton( + onClick = { onPerformAction(textFieldState.text.toString()) }, + ) { + Text(actionLabel) + } + } +} diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeDebuggerServiceImpl.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeDebuggerServiceImpl.kt new file mode 100644 index 00000000..4c6c4f63 --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeDebuggerServiceImpl.kt @@ -0,0 +1,106 @@ +package com.kitakkun.backintime.tooling.idea.service + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.kitakkun.backintime.core.websocket.event.BackInTimeDebuggerEvent +import com.kitakkun.backintime.core.websocket.server.BackInTimeWebSocketServer +import com.kitakkun.backintime.tooling.core.database.BackInTimeDatabaseImpl +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDatabase +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDebuggerService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Service(Service.Level.APP) +class BackInTimeDebuggerServiceImpl : BackInTimeDebuggerService { + companion object { + fun getInstance(): BackInTimeDebuggerService = ApplicationManager.getApplication().service() + } + + override var state: BackInTimeDebuggerService.State by mutableStateOf( + BackInTimeDebuggerService.State( + serverIsRunning = false, + port = null, + connections = emptyList() + ) + ) + private set + + private val server: BackInTimeWebSocketServer = BackInTimeWebSocketServer() + private val database: BackInTimeDatabase = BackInTimeDatabaseImpl.instance + private val backInTimeEventConverter = BackInTimeEventConverter() + + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + coroutineScope.launch { + while (true) { + delay(5000) + state = state.copy( + serverIsRunning = server.isRunning, + port = server.runningPort, + ) + } + } + coroutineScope.launch { + server.newSessionFlow.collect { sessionInfo -> + state = state.copy( + connections = state.connections.toMutableList().apply { + add( + BackInTimeDebuggerService.State.Connection( + id = sessionInfo.id, + isActive = true, + address = sessionInfo.address, + port = sessionInfo.port, + ) + ) + }.distinctBy { it.id } + ) + thisLogger().warn(sessionInfo.toString()) + } + } + coroutineScope.launch { + server.sessionClosedFlow.collect { sessionInfo -> + thisLogger().warn("disconnected: $sessionInfo") + state = state.copy( + connections = state.connections.toMutableList().apply { + replaceAll { + if (it.id == sessionInfo.id) { + it.copy(isActive = false) + } else { + it + } + } + } + ) + } + } + coroutineScope.launch { + server.eventFromClientFlow.collect { + backInTimeEventConverter.convertToEntity(it.sessionId, it.event)?.let { eventEntity -> + database.insert(eventEntity) + } + } + } + } + + override fun restartServer(port: Int) { + server.stop() + server.start("localhost", port) + thisLogger().warn("Started back-in-time debugger server at localhost:$port") + } + + override fun sendEvent(sessionId: String, event: BackInTimeDebuggerEvent) { + thisLogger().warn("Sending event... sessionId: $sessionId, event: $event") + coroutineScope.launch { + server.send(sessionId, event) + } + } +} diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeDebuggerSettingsImpl.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeDebuggerSettingsImpl.kt new file mode 100644 index 00000000..840c90f2 --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeDebuggerSettingsImpl.kt @@ -0,0 +1,31 @@ +package com.kitakkun.backintime.tooling.idea.service + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.kitakkun.backintime.tooling.core.shared.BackInTimeDebuggerSettings + +@State( + name = "com.kitakkun.backintime.ideaplugin.service.BackInTimeDebuggerSettings", + storages = [Storage("BackInTimeDebuggerPlugin.xml")] +) +class BackInTimeDebuggerSettingsImpl : PersistentStateComponent, BackInTimeDebuggerSettings { + companion object { + fun getInstance(): BackInTimeDebuggerSettings = ApplicationManager.getApplication().service() + } + + private var mutableState by mutableStateOf(BackInTimeDebuggerSettings.State()) + + override fun getState(): BackInTimeDebuggerSettings.State { + return mutableState + } + + override fun loadState(state: BackInTimeDebuggerSettings.State) { + this.mutableState = state + } +} diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeEventConverter.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeEventConverter.kt new file mode 100644 index 00000000..32b60aae --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/BackInTimeEventConverter.kt @@ -0,0 +1,109 @@ +package com.kitakkun.backintime.tooling.idea.service + +import com.kitakkun.backintime.core.websocket.event.BackInTimeDebugServiceEvent +import com.kitakkun.backintime.core.websocket.event.BackInTimeDebuggerEvent +import com.kitakkun.backintime.core.websocket.event.BackInTimeWebSocketEvent +import com.kitakkun.backintime.tooling.model.ClassInfo +import com.kitakkun.backintime.tooling.model.EventEntity +import com.kitakkun.backintime.tooling.model.PropertyInfo + +class BackInTimeEventConverter { + fun convertToEntity( + sessionId: String, + event: BackInTimeWebSocketEvent, + ): EventEntity? { + return when (event) { + is BackInTimeDebugServiceEvent.RegisterInstance -> { + EventEntity.Instance.Register( + sessionId = sessionId, + instanceId = event.instanceUUID, + time = event.time.toLong(), + classInfo = ClassInfo( + classSignature = event.classSignature, + superClassSignature = event.superClassSignature, + properties = event.properties.map(PropertyInfo::fromString), + ) + ) + } + + is BackInTimeDebugServiceEvent.NotifyMethodCall -> { + EventEntity.Instance.MethodInvocation( + sessionId = sessionId, + instanceId = event.instanceUUID, + time = event.time.toLong(), + methodSignature = event.methodSignature, + callId = event.methodCallUUID, + ) + } + + is BackInTimeDebugServiceEvent.NotifyValueChange -> { + EventEntity.Instance.StateChange( + sessionId = sessionId, + instanceId = event.instanceUUID, + time = event.time.toLong(), + propertySignature = event.propertySignature, + newValueAsJson = event.value, + callId = event.methodCallUUID, + ) + } + + is BackInTimeDebugServiceEvent.RegisterRelationship -> { + EventEntity.Instance.NewDependency( + sessionId = sessionId, + instanceId = event.parentUUID, + time = event.time.toLong(), + dependencyInstanceId = event.childUUID, + ) + } + + is BackInTimeDebuggerEvent.ForceSetPropertyValue -> { + EventEntity.Instance.BackInTime( + sessionId = sessionId, + instanceId = event.targetInstanceId, + time = event.time.toLong(), + jsonValues = mapOf(), + destinationPointEventId = null, + ) + } + + is BackInTimeDebugServiceEvent.Error -> { + EventEntity.System.AppError( + sessionId = sessionId, + time = event.time.toLong(), + message = event.message, + ) + } + + is BackInTimeDebuggerEvent.Error -> { + EventEntity.System.DebuggerError( + sessionId = sessionId, + time = event.time.toLong(), + message = event.message, + ) + } + + is BackInTimeDebugServiceEvent.CheckInstanceAliveResult -> { + EventEntity.System.CheckInstanceAliveResult( + sessionId = sessionId, + time = event.time.toLong(), + isAlive = event.isAlive, + ) + } + + is BackInTimeDebuggerEvent.CheckInstanceAlive -> { + EventEntity.System.CheckInstanceAlive( + sessionId = sessionId, + time = event.time.toLong(), + ) + } + + is BackInTimeDebugServiceEvent.Ping -> { + null + } + + is BackInTimeDebuggerEvent.Ping -> { + null + } + } + } +} \ No newline at end of file diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/IDENavigatorImpl.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/IDENavigatorImpl.kt new file mode 100644 index 00000000..c4ea3b7b --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/IDENavigatorImpl.kt @@ -0,0 +1,57 @@ +package com.kitakkun.backintime.tooling.idea.service + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiManager +import com.intellij.psi.util.ClassUtil +import com.intellij.psi.util.PropertyUtil +import com.kitakkun.backintime.tooling.core.shared.IDENavigator + +@Service(Service.Level.PROJECT) +class IDENavigatorImpl(project: Project) : IDENavigator { + private val psiManager = PsiManager.getInstance(project) + + override fun navigateToClass(classSignature: String) { + val jvmBasedClassSignature = classSignature.replace(".", "$").replace("/", ".") + + ApplicationManager.getApplication().executeOnPooledThread { + val psiClass = ReadAction.compute { + ClassUtil.findPsiClass(psiManager, jvmBasedClassSignature) + } + if (psiClass == null) return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater { + ReadAction.run { + thisLogger().debug("Navigating to ${psiClass.nameIdentifier}") + psiClass.navigate(true) + } + } + } + } + + override fun navigateToMemberProperty(propertySignature: String) { + val propertyName = propertySignature.split(".").last() + val classSignature = propertySignature.removeSuffix(".$propertyName") + val jvmBasedClassSignature = classSignature.replace(".", "$").replace("/", ".") + + ApplicationManager.getApplication().executeOnPooledThread { + val psiClass = ReadAction.compute { + ClassUtil.findPsiClass(psiManager, jvmBasedClassSignature) + } ?: return@executeOnPooledThread + val psiProperty = PropertyUtil.findPropertyField(psiClass, propertyName, false) ?: return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater { + ReadAction.run { + thisLogger().debug("Navigating to ${psiProperty.nameIdentifier}") + psiProperty.navigate(true) + } + } + } + } + + override fun navigateToMemberFunction(functionSignature: String) { + TODO("Not implemented yet.") + } +} diff --git a/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/PluginStateServiceImpl.kt b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/PluginStateServiceImpl.kt new file mode 100644 index 00000000..575ae253 --- /dev/null +++ b/tooling/idea-plugin/src/main/kotlin/com/kitakkun/backintime/tooling/idea/service/PluginStateServiceImpl.kt @@ -0,0 +1,29 @@ +package com.kitakkun.backintime.tooling.idea.service + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.kitakkun.backintime.tooling.core.shared.PluginStateService +import com.kitakkun.backintime.tooling.model.PluginState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +@State( + name = "com.kitakkun.backintime.ideaplugin.service.PluginUiStateProvider", + storages = [Storage("BackInTimeUiState.xml")] +) +class PluginStateServiceImpl : PluginStateService, PersistentStateComponent { + companion object { + fun getInstance(): PluginStateService = ApplicationManager.getApplication().service() + } + + private var mutableStateFlow: MutableStateFlow = MutableStateFlow(PluginState.Default) + override val stateFlow: StateFlow = mutableStateFlow.asStateFlow() + + override fun getState(): PluginState = stateFlow.value + override fun loadState(state: PluginState) = mutableStateFlow.update { state } +} diff --git a/tooling/idea-plugin/src/main/resources/META-INF/plugin.xml b/tooling/idea-plugin/src/main/resources/META-INF/plugin.xml new file mode 100644 index 00000000..80bb638a --- /dev/null +++ b/tooling/idea-plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,14 @@ + + com.kitakkun.backintime.tooling.idea + Back-in-Time + kitakkun + + com.intellij.modules.platform + com.intellij.modules.java + + + + + + + diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Property.kt b/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Property.kt deleted file mode 100644 index b4602a43..00000000 --- a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/Property.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.kitakkun.backintime.tooling.model - -import kotlin.js.JsExport - -@JsExport -data class Property( - val signature: String, - val type: String, - val valueType: String, - val isDebuggable: Boolean, - val isBackInTimeDebuggableInstance: Boolean, -) { - val name: String get() = signature.split(".").last() -} diff --git a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PropertyInfo.kt b/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PropertyInfo.kt deleted file mode 100644 index 9bd13013..00000000 --- a/tooling/model/src/commonMain/kotlin/com/kitakkun/backintime/tooling/model/PropertyInfo.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.kitakkun.backintime.tooling.model - -import kotlinx.serialization.Serializable -import kotlin.js.JsExport - -@JsExport -@Serializable -data class PropertyInfo( - val signature: String, - val debuggable: Boolean, - val isDebuggableStateHolder: Boolean, - val propertyType: String, - val valueType: String, -) { - val name: String get() = signature.split(".").last() -} diff --git a/versions-root/libs.versions.toml b/versions-root/libs.versions.toml index 5eaf0cfe..a291e66a 100644 --- a/versions-root/libs.versions.toml +++ b/versions-root/libs.versions.toml @@ -36,9 +36,15 @@ maven-publish = "0.30.0" kaml = "0.67.0" kotlin-react = "2025.1.2-19.0.0" +intelliJPlatform = "2.2.0" +jetbrainsCompose = "1.7.3" +jewel = "0.27.0" +sqldelight = "2.0.2" [libraries] ktlint-gradle = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlint-gradle" } +jetbrains-compose-gradle-plugin = { module = "org.jetbrains.compose:org.jetbrains.compose.gradle.plugin", version.ref = "jetbrainsCompose" } +compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin", version.ref = "kotlin" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } @@ -102,11 +108,16 @@ maven-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" } kotlin-react = { module = "org.jetbrains.kotlin-wrappers:kotlin-react", version.ref = "kotlin-react" } +jewel = { module = "org.jetbrains.jewel:jewel-int-ui-standalone-243", version.ref = "jewel" } +sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } +sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } + [plugins] # convention backintimeLint = { id = "backintime-lint", version = "unspecified" } backintimePublication = { id = "backintime-publication", version = "unspecified" } backintimeCompilerModule = { id = "backintime-compiler-module", version = "unspecified" } +intelliJComposeFeature = { id = "intellij-compose-feature", version = "unspecified" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } @@ -120,3 +131,6 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " javaGradlePlugin = { id = "java-gradle-plugin" } buildconfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildconfig" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } +intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } +jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }