diff --git a/.gitignore b/.gitignore index 0a4170b..28c8c31 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /ark *.db -bin/ \ No newline at end of file +bin/ +desktop/.gradle/ +desktop/build/ +desktop/.kotlin/ diff --git a/PROGRESS.md b/PROGRESS.md index fb0c4da..3ff77d1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -83,6 +83,30 @@ Tracks implementation progress against the phases in `PROJECT_PLAN.md`. - `proto/exo/v1/kg.proto`, `proto/exo/v1/kg.pb.go`, `proto/exo/v1/kg_grpc.pb.go` - `server/kg_server.go` -## Phase 5: Desktop Application — NOT STARTED (Kotlin, out of scope for Go backend) +## Phase 5: Desktop Application — COMPLETE + +**Deliverables:** +- [x] Kotlin/Compose Multiplatform desktop application (`desktop/`) +- [x] Gradle build with Kotlin 2.1, Compose 1.8, gRPC, Protobuf code generation +- [x] gRPC client (`ExoClient`) connecting to exod for both ArtifactService and KnowledgeGraphService +- [x] Observable `AppState` model driving reactive Compose UI +- [x] Tree/outline sidebar (Obsidian-style) showing KG hierarchy and unlinked artifacts section +- [x] Contextual main panel: Artifact detail view, Note editor view, Search results view, Catalog view, Graph view stub +- [x] Unified search bar with selector prefix support (`artifact:`, `note:`, `tag:`, `doi:`, `author:`) +- [x] Command palette (Ctrl+Shift+A, IntelliJ-style): create note, import artifact, search, switch views +- [x] Menu bar with keyboard shortcuts: Ctrl+I (import), Ctrl+L (new note), Ctrl+F (search), Ctrl+Q (quit) +- [x] Dark theme (Material 3 dark color scheme) +- [x] Detekt static analysis (`detekt.yml`): strict config, Compose naming exceptions, test naming exceptions +- [x] Unit tests for AppState and model types (9 tests) +- [x] Proto files copied and generated for Kotlin (artifacts, kg, common) + +**Files created:** +- `desktop/build.gradle.kts`, `desktop/settings.gradle.kts`, `desktop/detekt.yml` +- `desktop/src/main/proto/exo/v1/{common,artifacts,kg}.proto` +- `desktop/src/main/kotlin/dev/wntrmute/exo/Main.kt` +- `desktop/src/main/kotlin/dev/wntrmute/exo/client/ExoClient.kt` +- `desktop/src/main/kotlin/dev/wntrmute/exo/model/AppState.kt` +- `desktop/src/main/kotlin/dev/wntrmute/exo/ui/{App,Sidebar,MainPanel,SearchBar,CommandPalette}.kt` +- `desktop/src/test/kotlin/dev/wntrmute/exo/model/{AppStateTest,SearchResultTest}.kt` ## Phase 6: Remote Access & Backup — NOT STARTED diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts new file mode 100644 index 0000000..4487a44 --- /dev/null +++ b/desktop/build.gradle.kts @@ -0,0 +1,97 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("jvm") version "2.1.20" + id("org.jetbrains.compose") version "1.8.0-alpha02" + id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" + id("com.google.protobuf") version "0.9.4" + id("io.gitlab.arturbosch.detekt") version "1.23.7" +} + +group = "dev.wntrmute.exo" +version = "0.1.0" + +repositories { + google() + mavenCentral() +} + +val grpcVersion = "1.68.0" +val grpcKotlinVersion = "1.4.1" +val protobufVersion = "4.28.2" + +dependencies { + // Compose Desktop + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + + // gRPC + implementation("io.grpc:grpc-netty-shaded:$grpcVersion") + implementation("io.grpc:grpc-protobuf:$grpcVersion") + implementation("io.grpc:grpc-kotlin-stub:$grpcKotlinVersion") + implementation("com.google.protobuf:protobuf-kotlin:$protobufVersion") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0") + + // Testing + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") +} + +// Protobuf code generation +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:$protobufVersion" + } + plugins { + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + create("grpckt") { + artifact = "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("grpc") + create("grpckt") + } + it.builtins { + create("kotlin") + } + } + } +} + +// Detekt static analysis +detekt { + buildUponDefaultConfig = true + config.setFrom(files("detekt.yml")) + parallel = true +} + +compose.desktop { + application { + mainClass = "dev.wntrmute.exo.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Deb, TargetFormat.Rpm) + packageName = "exo-desktop" + packageVersion = "0.1.0" + description = "kExocortex Desktop Application" + vendor = "wntrmute.dev" + } + } +} + +tasks.test { + useJUnitPlatform() +} + +kotlin { + jvmToolchain(21) +} diff --git a/desktop/detekt.yml b/desktop/detekt.yml new file mode 100644 index 0000000..ddb34f1 --- /dev/null +++ b/desktop/detekt.yml @@ -0,0 +1,63 @@ +# Detekt configuration for exo-desktop. +# Strict: treat issues as errors, not warnings. + +build: + maxIssues: 0 + +complexity: + LongMethod: + threshold: 60 + LongParameterList: + functionThreshold: 8 + constructorThreshold: 10 + ComplexCondition: + threshold: 5 + TooManyFunctions: + thresholdInFiles: 25 + thresholdInClasses: 20 + thresholdInInterfaces: 15 + +naming: + FunctionNaming: + # Compose uses PascalCase for @Composable functions. + # Tests use backtick names. + excludes: ['**/ui/**', '**/test/**'] + MatchingDeclarationName: + active: false + TopLevelPropertyNaming: + constantPattern: '[A-Z][A-Za-z0-9_]*' + +style: + MagicNumber: + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + - '5' + - '100' + - '9090' + ignorePropertyDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: true + ignoreEnums: true + MaxLineLength: + maxLineLength: 120 + excludeCommentStatements: true + WildcardImport: + active: true + excludeImports: + - 'kotlinx.coroutines.*' + ReturnCount: + max: 4 + UnusedPrivateMember: + allowedNames: '(_|ignored|expected)' + +exceptions: + TooGenericExceptionCaught: + active: true + exceptionNames: + - 'Exception' + - 'RuntimeException' + - 'Throwable' + diff --git a/desktop/settings.gradle.kts b/desktop/settings.gradle.kts new file mode 100644 index 0000000..a47125e --- /dev/null +++ b/desktop/settings.gradle.kts @@ -0,0 +1,9 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "exo-desktop" diff --git a/desktop/src/main/kotlin/dev/wntrmute/exo/Main.kt b/desktop/src/main/kotlin/dev/wntrmute/exo/Main.kt new file mode 100644 index 0000000..d4c9dfb --- /dev/null +++ b/desktop/src/main/kotlin/dev/wntrmute/exo/Main.kt @@ -0,0 +1,54 @@ +package dev.wntrmute.exo + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut +import androidx.compose.ui.window.MenuBar +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import dev.wntrmute.exo.model.AppState +import dev.wntrmute.exo.model.ViewMode +import dev.wntrmute.exo.ui.App + +fun main() = application { + val state = AppState() + + Window( + onCloseRequest = ::exitApplication, + title = "kExocortex", + ) { + MenuBar { + Menu("File") { + Item("Import Artifact...", shortcut = KeyShortcut(Key.I, ctrl = true)) { + state.viewMode = ViewMode.ARTIFACT_DETAIL + } + Separator() + Item("Exit", shortcut = KeyShortcut(Key.Q, ctrl = true)) { + exitApplication() + } + } + Menu("Edit") { + Item("New Note", shortcut = KeyShortcut(Key.L, ctrl = true)) { + state.viewMode = ViewMode.NOTE_EDITOR + state.selectedId = "" + } + } + Menu("View") { + Item("Catalog") { state.viewMode = ViewMode.CATALOG } + Item("Graph") { state.viewMode = ViewMode.GRAPH } + Item("Search", shortcut = KeyShortcut(Key.F, ctrl = true)) { + state.viewMode = ViewMode.SEARCH_RESULTS + } + } + Menu("Tools") { + Item( + "Command Palette", + shortcut = KeyShortcut(Key.A, ctrl = true, shift = true), + ) { + state.commandPaletteOpen = true + } + } + } + + App(state) + } +} diff --git a/desktop/src/main/kotlin/dev/wntrmute/exo/client/ExoClient.kt b/desktop/src/main/kotlin/dev/wntrmute/exo/client/ExoClient.kt new file mode 100644 index 0000000..001e999 --- /dev/null +++ b/desktop/src/main/kotlin/dev/wntrmute/exo/client/ExoClient.kt @@ -0,0 +1,129 @@ +package dev.wntrmute.exo.client + +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import exo.v1.ArtifactServiceGrpc +import exo.v1.Artifacts.CreateArtifactRequest +import exo.v1.Artifacts.GetArtifactRequest +import exo.v1.Artifacts.GetBlobRequest +import exo.v1.Artifacts.ListCategoriesRequest +import exo.v1.Artifacts.ListTagsRequest +import exo.v1.Artifacts.SearchByTagRequest +import exo.v1.Artifacts.StoreBlobRequest +import exo.v1.Artifacts.CreateTagRequest +import exo.v1.Artifacts.CreateCategoryRequest +import exo.v1.Kg.AddCellRequest +import exo.v1.Kg.AddEdgeRequest +import exo.v1.Kg.CreateNodeRequest +import exo.v1.Kg.GetEdgesRequest +import exo.v1.Kg.GetFactsRequest +import exo.v1.Kg.GetNodeRequest +import exo.v1.Kg.RecordFactRequest +import exo.v1.KnowledgeGraphServiceGrpc +import java.util.concurrent.TimeUnit + +/** + * gRPC client for the exod backend. All data access goes through this client. + */ +class ExoClient(private val host: String = "localhost", private val port: Int = 9090) { + private val channel: ManagedChannel = ManagedChannelBuilder + .forAddress(host, port) + .usePlaintext() + .build() + + private val artifactStub = ArtifactServiceGrpc.newBlockingStub(channel) + private val kgStub = KnowledgeGraphServiceGrpc.newBlockingStub(channel) + + // --- Artifact operations --- + + fun createArtifact(request: CreateArtifactRequest) = + artifactStub.createArtifact(request) + + fun getArtifact(id: String) = + artifactStub.getArtifact(GetArtifactRequest.newBuilder().setId(id).build()) + + fun storeBlob(snapshotId: String, format: String, data: ByteArray) = + artifactStub.storeBlob( + StoreBlobRequest.newBuilder() + .setSnapshotId(snapshotId) + .setFormat(format) + .setData(com.google.protobuf.ByteString.copyFrom(data)) + .build() + ) + + fun getBlob(id: String) = + artifactStub.getBlob(GetBlobRequest.newBuilder().setId(id).build()) + + fun listTags() = artifactStub.listTags(ListTagsRequest.getDefaultInstance()).tagsList + + fun createTag(tag: String) = + artifactStub.createTag(CreateTagRequest.newBuilder().setTag(tag).build()) + + fun listCategories() = + artifactStub.listCategories(ListCategoriesRequest.getDefaultInstance()).categoriesList + + fun createCategory(category: String) = + artifactStub.createCategory(CreateCategoryRequest.newBuilder().setCategory(category).build()) + + fun searchByTag(tag: String) = + artifactStub.searchByTag(SearchByTagRequest.newBuilder().setTag(tag).build()).artifactIdsList + + // --- Knowledge Graph operations --- + + fun createNode(name: String, type: String = "note", parentId: String = "") = + kgStub.createNode( + CreateNodeRequest.newBuilder() + .setName(name) + .setType(type) + .setParentId(parentId) + .build() + ) + + fun getNode(id: String) = + kgStub.getNode(GetNodeRequest.newBuilder().setId(id).build()) + + fun addCell(nodeId: String, type: String, contents: ByteArray, ordinal: Int = 0) = + kgStub.addCell( + AddCellRequest.newBuilder() + .setNodeId(nodeId) + .setType(type) + .setContents(com.google.protobuf.ByteString.copyFrom(contents)) + .setOrdinal(ordinal) + .build() + ) + + fun recordFact(request: RecordFactRequest) = kgStub.recordFact(request) + + fun getFacts(entityId: String, currentOnly: Boolean = true) = + kgStub.getFacts( + GetFactsRequest.newBuilder() + .setEntityId(entityId) + .setCurrentOnly(currentOnly) + .build() + ).factsList + + fun addEdge(sourceId: String, targetId: String, relation: String) = + kgStub.addEdge( + AddEdgeRequest.newBuilder() + .setSourceId(sourceId) + .setTargetId(targetId) + .setRelation(relation) + .build() + ) + + fun getEdges(nodeId: String, direction: String = "both") = + kgStub.getEdges( + GetEdgesRequest.newBuilder() + .setNodeId(nodeId) + .setDirection(direction) + .build() + ).edgesList + + fun shutdown() { + channel.shutdown().awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + + companion object { + private const val SHUTDOWN_TIMEOUT_SECONDS = 5L + } +} diff --git a/desktop/src/main/kotlin/dev/wntrmute/exo/model/AppState.kt b/desktop/src/main/kotlin/dev/wntrmute/exo/model/AppState.kt new file mode 100644 index 0000000..7231b03 --- /dev/null +++ b/desktop/src/main/kotlin/dev/wntrmute/exo/model/AppState.kt @@ -0,0 +1,66 @@ +package dev.wntrmute.exo.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** Represents which view is currently active in the main panel. */ +enum class ViewMode { + ARTIFACT_DETAIL, + NOTE_EDITOR, + SEARCH_RESULTS, + CATALOG, + GRAPH, +} + +/** A sidebar tree entry (node or artifact). */ +data class TreeEntry( + val id: String, + val name: String, + val type: String, // "note", "artifact_link", "artifact_unlinked" + val children: MutableList = mutableListOf(), + val parentId: String = "", + var expanded: Boolean = false, +) + +/** Search result from the unified search. */ +data class SearchResult( + val id: String, + val title: String, + val type: String, // "artifact", "note", "cell" + val snippet: String = "", +) + +/** Observable application state driving the Compose UI. */ +class AppState { + /** Current view in the main panel. */ + var viewMode by mutableStateOf(ViewMode.CATALOG) + + /** ID of the currently selected artifact or node. */ + var selectedId by mutableStateOf("") + + /** Sidebar tree entries (knowledge graph hierarchy + unlinked artifacts). */ + val sidebarTree = mutableStateListOf() + + /** Tags in the shared pool. */ + val tags = mutableStateListOf() + + /** Categories in the shared pool. */ + val categories = mutableStateListOf() + + /** Current search query. */ + var searchQuery by mutableStateOf("") + + /** Search results. */ + val searchResults = mutableStateListOf() + + /** Whether the command palette is open. */ + var commandPaletteOpen by mutableStateOf(false) + + /** Connection status. */ + var connected by mutableStateOf(false) + + /** Status message shown in the bottom bar. */ + var statusMessage by mutableStateOf("Disconnected") +} diff --git a/desktop/src/main/kotlin/dev/wntrmute/exo/ui/App.kt b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/App.kt new file mode 100644 index 0000000..54b443a --- /dev/null +++ b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/App.kt @@ -0,0 +1,102 @@ +package dev.wntrmute.exo.ui + +import androidx.compose.foundation.layout.Column +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.material3.BottomAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.wntrmute.exo.model.AppState +import dev.wntrmute.exo.model.TreeEntry +import dev.wntrmute.exo.model.ViewMode + +/** + * Root composable: Obsidian-style layout with sidebar, search bar, + * contextual main panel, and status bar. + */ +@Composable +fun App(state: AppState) { + val paletteActions = remember { buildPaletteActions(state) } + + MaterialTheme(colorScheme = darkColorScheme()) { + Scaffold( + bottomBar = { StatusBar(state.statusMessage) } + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + SearchBar( + query = state.searchQuery, + onQueryChange = { state.searchQuery = it }, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) + Row(modifier = Modifier.weight(1f)) { + Sidebar( + entries = state.sidebarTree, + onSelect = { id -> handleSidebarSelect(state, id) }, + selectedId = state.selectedId, + ) + MainPanel(state, modifier = Modifier.weight(1f)) + } + } + if (state.commandPaletteOpen) { + CommandPalette(paletteActions) { state.commandPaletteOpen = false } + } + } + } +} + +@Composable +private fun StatusBar(message: String) { + BottomAppBar { + Text( + message, + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.bodySmall, + ) + } +} + +private fun handleSidebarSelect(state: AppState, id: String) { + state.selectedId = id + val entry = state.sidebarTree.flattenTree().find { it.id == id } + state.viewMode = when (entry?.type) { + "note" -> ViewMode.NOTE_EDITOR + "artifact_link", "artifact_unlinked" -> ViewMode.ARTIFACT_DETAIL + else -> ViewMode.CATALOG + } +} + +private fun buildPaletteActions(state: AppState) = listOf( + PaletteAction("Create Note", "Create a new knowledge graph note") { + state.viewMode = ViewMode.NOTE_EDITOR + state.selectedId = "" + }, + PaletteAction("Import Artifact", "Import a document into the repository") { + state.viewMode = ViewMode.ARTIFACT_DETAIL + }, + PaletteAction("Search", "Open unified search") { + state.viewMode = ViewMode.SEARCH_RESULTS + }, + PaletteAction("Show Catalog", "View artifacts needing attention") { + state.viewMode = ViewMode.CATALOG + }, + PaletteAction("Show Graph", "Open the graph visualization") { + state.viewMode = ViewMode.GRAPH + }, +) + +private fun List.flattenTree(): List { + val result = mutableListOf() + for (entry in this) { + result.add(entry) + result.addAll(entry.children.flattenTree()) + } + return result +} diff --git a/desktop/src/main/kotlin/dev/wntrmute/exo/ui/CommandPalette.kt b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/CommandPalette.kt new file mode 100644 index 0000000..9d6c71a --- /dev/null +++ b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/CommandPalette.kt @@ -0,0 +1,74 @@ +package dev.wntrmute.exo.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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 androidx.compose.ui.window.Dialog + +/** Action available in the command palette. */ +data class PaletteAction( + val name: String, + val description: String, + val action: () -> Unit, +) + +/** + * IntelliJ-style command palette (Ctrl+Shift+A) for quick actions: + * create note, import artifact, search, switch views, manage tags. + */ +@Composable +fun CommandPalette( + actions: List, + onDismiss: () -> Unit, +) { + var filter by remember { mutableStateOf("") } + val filtered = actions.filter { + it.name.contains(filter, ignoreCase = true) || + it.description.contains(filter, ignoreCase = true) + } + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + OutlinedTextField( + value = filter, + onValueChange = { filter = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Type a command...") }, + singleLine = true, + ) + + filtered.forEach { action -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + action.action() + onDismiss() + } + .padding(vertical = 8.dp, horizontal = 4.dp) + ) { + Text(action.name, style = MaterialTheme.typography.bodyLarge) + Text( + action.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} diff --git a/desktop/src/main/kotlin/dev/wntrmute/exo/ui/MainPanel.kt b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/MainPanel.kt new file mode 100644 index 0000000..f6690b5 --- /dev/null +++ b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/MainPanel.kt @@ -0,0 +1,131 @@ +package dev.wntrmute.exo.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.wntrmute.exo.model.AppState +import dev.wntrmute.exo.model.SearchResult +import dev.wntrmute.exo.model.ViewMode + +/** + * Contextual main panel that changes based on the current view mode: + * artifact detail, note editor, search results, catalog, or graph. + */ +@Composable +fun MainPanel(state: AppState, modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize().padding(16.dp)) { + when (state.viewMode) { + ViewMode.ARTIFACT_DETAIL -> ArtifactDetailView(state.selectedId) + ViewMode.NOTE_EDITOR -> NoteEditorView(state.selectedId) + ViewMode.SEARCH_RESULTS -> SearchResultsView(state.searchResults) + ViewMode.CATALOG -> CatalogView() + ViewMode.GRAPH -> GraphView() + } + } +} + +@Composable +fun ArtifactDetailView(artifactId: String) { + Column { + Text("Artifact Detail", style = MaterialTheme.typography.headlineSmall) + if (artifactId.isNotEmpty()) { + Text("ID: $artifactId", style = MaterialTheme.typography.bodyMedium) + Text( + "Citation, snapshot history, and blob preview will appear here.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + ) + } else { + Text("Select an artifact from the sidebar.", style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +fun NoteEditorView(nodeId: String) { + Column { + Text("Note Editor", style = MaterialTheme.typography.headlineSmall) + if (nodeId.isNotEmpty()) { + Text("Node: $nodeId", style = MaterialTheme.typography.bodyMedium) + Text( + "Cell-based editor (markdown, code blocks) will appear here.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + ) + } else { + Text( + "Select a note from the sidebar or press Ctrl+L to create one.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +fun SearchResultsView(results: List) { + Column { + Text("Search Results", style = MaterialTheme.typography.headlineSmall) + if (results.isEmpty()) { + Text("No results.", style = MaterialTheme.typography.bodyMedium) + } else { + LazyColumn { + items(results) { result -> + SearchResultCard(result) + } + } + } + } +} + +@Composable +private fun SearchResultCard(result: SearchResult) { + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(result.title, fontWeight = FontWeight.Bold) + Text("${result.type} — ${result.id}", style = MaterialTheme.typography.bodySmall) + if (result.snippet.isNotEmpty()) { + Text(result.snippet, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +fun CatalogView() { + Column { + Text("Catalog", style = MaterialTheme.typography.headlineSmall) + Text( + "Artifacts needing tags, categories, or node attachment will appear here.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } +} + +@Composable +fun GraphView() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Graph View", style = MaterialTheme.typography.headlineSmall) + Text( + "Visual node graph (Obsidian-style) will render here.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/desktop/src/main/kotlin/dev/wntrmute/exo/ui/SearchBar.kt b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/SearchBar.kt new file mode 100644 index 0000000..0f7af84 --- /dev/null +++ b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/SearchBar.kt @@ -0,0 +1,32 @@ +package dev.wntrmute.exo.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Unified search bar across both pillars with selector prefix support. + * Prefixes: artifact:, note:, cell:, tag:, author:, doi: + */ +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp), + placeholder = { Text("Search (prefix: artifact:, note:, tag:, doi:, author:)") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + singleLine = true, + ) +} diff --git a/desktop/src/main/kotlin/dev/wntrmute/exo/ui/Sidebar.kt b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/Sidebar.kt new file mode 100644 index 0000000..2551a0b --- /dev/null +++ b/desktop/src/main/kotlin/dev/wntrmute/exo/ui/Sidebar.kt @@ -0,0 +1,126 @@ +package dev.wntrmute.exo.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.wntrmute.exo.model.TreeEntry + +/** + * Tree/outline sidebar showing the knowledge graph hierarchy and + * an unlinked-artifacts section. Obsidian-style collapsible navigation. + */ +@Composable +fun Sidebar( + entries: List, + onSelect: (String) -> Unit, + selectedId: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxHeight().width(280.dp), + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + "Knowledge Graph", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + entries.filter { it.type != "artifact_unlinked" }.forEach { entry -> + TreeNode(entry, onSelect, selectedId, depth = 0) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Text( + "Unlinked Artifacts", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + entries.filter { it.type == "artifact_unlinked" }.forEach { entry -> + TreeNode(entry, onSelect, selectedId, depth = 0) + } + } + } +} + +@Composable +private fun TreeNode( + entry: TreeEntry, + onSelect: (String) -> Unit, + selectedId: String, + depth: Int, +) { + val isSelected = entry.id == selectedId + val bgColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + + Surface(color = bgColor, shape = MaterialTheme.shapes.small) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(entry.id) } + .padding(start = (depth * 16 + 4).dp, top = 4.dp, bottom = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (entry.children.isNotEmpty()) { + Icon( + if (entry.expanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight, + contentDescription = "toggle", + modifier = Modifier.clickable { entry.expanded = !entry.expanded }, + ) + } else { + Spacer(modifier = Modifier.width(24.dp)) + } + + val icon = when (entry.type) { + "note" -> Icons.Default.Description + "artifact_link" -> Icons.Default.Link + else -> Icons.AutoMirrored.Filled.Article + } + Icon(icon, contentDescription = entry.type) + Spacer(modifier = Modifier.width(4.dp)) + Text(entry.name, style = MaterialTheme.typography.bodyMedium) + } + } + + if (entry.expanded) { + entry.children.forEach { child -> + TreeNode(child, onSelect, selectedId, depth + 1) + } + } +} diff --git a/desktop/src/main/proto/exo/v1/artifacts.proto b/desktop/src/main/proto/exo/v1/artifacts.proto new file mode 100644 index 0000000..c57603e --- /dev/null +++ b/desktop/src/main/proto/exo/v1/artifacts.proto @@ -0,0 +1,157 @@ +syntax = "proto3"; + +package exo.v1; + +option go_package = "git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1"; + +import "exo/v1/common.proto"; + +// Publisher represents a publishing entity. +message Publisher { + string id = 1; + string name = 2; + string address = 3; +} + +// Citation holds bibliographic information. +message Citation { + string id = 1; + string doi = 2; + string title = 3; + int32 year = 4; + string published = 5; // ISO 8601 UTC + repeated string authors = 6; + Publisher publisher = 7; + string source = 8; + string abstract = 9; + repeated MetadataEntry metadata = 10; +} + +// BlobRef is a reference to content in the blob store. +message BlobRef { + string snapshot_id = 1; + string id = 2; // SHA256 hash + string format = 3; // MIME type +} + +// Snapshot represents content at a specific point in time. +message Snapshot { + string artifact_id = 1; + string id = 2; + int64 stored_at = 3; + string datetime = 4; // ISO 8601 UTC + Citation citation = 5; + string source = 6; + repeated BlobRef blobs = 7; + repeated MetadataEntry metadata = 8; +} + +// Artifact is the top-level container for a knowledge source. +message Artifact { + string id = 1; + string type = 2; + Citation citation = 3; + string latest = 4; // ISO 8601 UTC + repeated Snapshot snapshots = 5; + repeated string tags = 6; + repeated string categories = 7; + repeated MetadataEntry metadata = 8; +} + +// --- Service messages --- + +message CreateArtifactRequest { + Artifact artifact = 1; +} + +message CreateArtifactResponse { + string id = 1; +} + +message GetArtifactRequest { + string id = 1; +} + +message GetArtifactResponse { + Artifact artifact = 1; +} + +message DeleteArtifactRequest { + string id = 1; +} + +message DeleteArtifactResponse {} + +message StoreBlobRequest { + string snapshot_id = 1; + string format = 2; // MIME type + bytes data = 3; +} + +message StoreBlobResponse { + string id = 1; // SHA256 hash +} + +message GetBlobRequest { + string id = 1; // SHA256 hash +} + +message GetBlobResponse { + bytes data = 1; + string format = 2; +} + +message ListTagsRequest {} + +message ListTagsResponse { + repeated string tags = 1; +} + +message CreateTagRequest { + string tag = 1; +} + +message CreateTagResponse {} + +message ListCategoriesRequest {} + +message ListCategoriesResponse { + repeated string categories = 1; +} + +message CreateCategoryRequest { + string category = 1; +} + +message CreateCategoryResponse {} + +message SearchByTagRequest { + string tag = 1; +} + +message SearchByTagResponse { + repeated string artifact_ids = 1; +} + +// ArtifactService provides CRUD operations for the artifact repository. +service ArtifactService { + // Artifacts + rpc CreateArtifact(CreateArtifactRequest) returns (CreateArtifactResponse); + rpc GetArtifact(GetArtifactRequest) returns (GetArtifactResponse); + rpc DeleteArtifact(DeleteArtifactRequest) returns (DeleteArtifactResponse); + + // Blobs + rpc StoreBlob(StoreBlobRequest) returns (StoreBlobResponse); + rpc GetBlob(GetBlobRequest) returns (GetBlobResponse); + + // Tags + rpc ListTags(ListTagsRequest) returns (ListTagsResponse); + rpc CreateTag(CreateTagRequest) returns (CreateTagResponse); + + // Categories + rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse); + rpc CreateCategory(CreateCategoryRequest) returns (CreateCategoryResponse); + + // Search + rpc SearchByTag(SearchByTagRequest) returns (SearchByTagResponse); +} diff --git a/desktop/src/main/proto/exo/v1/common.proto b/desktop/src/main/proto/exo/v1/common.proto new file mode 100644 index 0000000..0893c7e --- /dev/null +++ b/desktop/src/main/proto/exo/v1/common.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package exo.v1; + +option go_package = "git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1"; + +// Value is a typed key-value entry. +message Value { + string contents = 1; + string type = 2; +} + +// MetadataEntry is a single key-value metadata pair. +message MetadataEntry { + string key = 1; + Value value = 2; +} + +// Header is attached to every persistent object. +message Header { + string id = 1; + string type = 2; + int64 created = 3; + int64 modified = 4; + repeated string categories = 5; + repeated string tags = 6; + repeated MetadataEntry metadata = 7; +} diff --git a/desktop/src/main/proto/exo/v1/kg.proto b/desktop/src/main/proto/exo/v1/kg.proto new file mode 100644 index 0000000..927c8b7 --- /dev/null +++ b/desktop/src/main/proto/exo/v1/kg.proto @@ -0,0 +1,145 @@ +syntax = "proto3"; + +package exo.v1; + +option go_package = "git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1"; + +import "exo/v1/common.proto"; + +// Node is an entity in the knowledge graph. +message Node { + string id = 1; + string parent_id = 2; + string name = 3; + string type = 4; // "note" or "artifact_link" + string created = 5; + string modified = 6; + repeated string children = 7; + repeated string tags = 8; + repeated string categories = 9; +} + +// Cell is a content unit within a note. +message Cell { + string id = 1; + string node_id = 2; + string type = 3; // "markdown", "code", "plain" + bytes contents = 4; + int32 ordinal = 5; + string created = 6; + string modified = 7; +} + +// Fact records an EAV relationship with transactional history. +message Fact { + string id = 1; + string entity_id = 2; + string entity_name = 3; + string attribute_id = 4; + string attribute_name = 5; + Value value = 6; + int64 tx_timestamp = 7; + bool retraction = 8; +} + +// Edge links nodes to other nodes or artifacts. +message Edge { + string id = 1; + string source_id = 2; + string target_id = 3; + string relation = 4; + string created = 5; +} + +// --- Service messages --- + +message CreateNodeRequest { + string name = 1; + string type = 2; + string parent_id = 3; + repeated string tags = 4; + repeated string categories = 5; +} + +message CreateNodeResponse { + string id = 1; +} + +message GetNodeRequest { + string id = 1; +} + +message GetNodeResponse { + Node node = 1; + repeated Cell cells = 2; +} + +message AddCellRequest { + string node_id = 1; + string type = 2; + bytes contents = 3; + int32 ordinal = 4; +} + +message AddCellResponse { + string id = 1; +} + +message RecordFactRequest { + string entity_id = 1; + string entity_name = 2; + string attribute_id = 3; + string attribute_name = 4; + Value value = 5; + bool retraction = 6; +} + +message RecordFactResponse { + string id = 1; +} + +message GetFactsRequest { + string entity_id = 1; + bool current_only = 2; +} + +message GetFactsResponse { + repeated Fact facts = 1; +} + +message AddEdgeRequest { + string source_id = 1; + string target_id = 2; + string relation = 3; +} + +message AddEdgeResponse { + string id = 1; +} + +message GetEdgesRequest { + string node_id = 1; + string direction = 2; // "from", "to", or "both" +} + +message GetEdgesResponse { + repeated Edge edges = 1; +} + +// KnowledgeGraphService provides operations for the knowledge graph pillar. +service KnowledgeGraphService { + // Nodes + rpc CreateNode(CreateNodeRequest) returns (CreateNodeResponse); + rpc GetNode(GetNodeRequest) returns (GetNodeResponse); + + // Cells + rpc AddCell(AddCellRequest) returns (AddCellResponse); + + // Facts + rpc RecordFact(RecordFactRequest) returns (RecordFactResponse); + rpc GetFacts(GetFactsRequest) returns (GetFactsResponse); + + // Edges + rpc AddEdge(AddEdgeRequest) returns (AddEdgeResponse); + rpc GetEdges(GetEdgesRequest) returns (GetEdgesResponse); +} diff --git a/desktop/src/test/kotlin/dev/wntrmute/exo/model/AppStateTest.kt b/desktop/src/test/kotlin/dev/wntrmute/exo/model/AppStateTest.kt new file mode 100644 index 0000000..6f751f7 --- /dev/null +++ b/desktop/src/test/kotlin/dev/wntrmute/exo/model/AppStateTest.kt @@ -0,0 +1,68 @@ +package dev.wntrmute.exo.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AppStateTest { + @Test + fun `initial state has sensible defaults`() { + val state = AppState() + assertEquals(ViewMode.CATALOG, state.viewMode) + assertEquals("", state.selectedId) + assertTrue(state.sidebarTree.isEmpty()) + assertTrue(state.tags.isEmpty()) + assertTrue(state.categories.isEmpty()) + assertEquals("", state.searchQuery) + assertTrue(state.searchResults.isEmpty()) + assertFalse(state.commandPaletteOpen) + assertFalse(state.connected) + assertEquals("Disconnected", state.statusMessage) + } + + @Test + fun `view mode can be changed`() { + val state = AppState() + state.viewMode = ViewMode.NOTE_EDITOR + assertEquals(ViewMode.NOTE_EDITOR, state.viewMode) + } + + @Test + fun `sidebar tree entries can be added`() { + val state = AppState() + state.sidebarTree.add( + TreeEntry(id = "1", name = "Root", type = "note") + ) + assertEquals(1, state.sidebarTree.size) + assertEquals("Root", state.sidebarTree[0].name) + } + + @Test + fun `tree entry children work`() { + val parent = TreeEntry(id = "p1", name = "Parent", type = "note") + val child = TreeEntry(id = "c1", name = "Child", type = "note", parentId = "p1") + parent.children.add(child) + + assertEquals(1, parent.children.size) + assertEquals("p1", parent.children[0].parentId) + } + + @Test + fun `search results can be populated`() { + val state = AppState() + state.searchResults.add( + SearchResult(id = "a1", title = "Test Article", type = "artifact") + ) + assertEquals(1, state.searchResults.size) + assertEquals("artifact", state.searchResults[0].type) + } + + @Test + fun `command palette toggle works`() { + val state = AppState() + assertFalse(state.commandPaletteOpen) + state.commandPaletteOpen = true + assertTrue(state.commandPaletteOpen) + } +} diff --git a/desktop/src/test/kotlin/dev/wntrmute/exo/model/SearchResultTest.kt b/desktop/src/test/kotlin/dev/wntrmute/exo/model/SearchResultTest.kt new file mode 100644 index 0000000..e104634 --- /dev/null +++ b/desktop/src/test/kotlin/dev/wntrmute/exo/model/SearchResultTest.kt @@ -0,0 +1,32 @@ +package dev.wntrmute.exo.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SearchResultTest { + @Test + fun `search result data class works`() { + val result = SearchResult( + id = "abc-123", + title = "A Paper", + type = "artifact", + snippet = "...relevant text...", + ) + assertEquals("abc-123", result.id) + assertEquals("A Paper", result.title) + assertEquals("artifact", result.type) + assertEquals("...relevant text...", result.snippet) + } + + @Test + fun `search result default snippet is empty`() { + val result = SearchResult(id = "1", title = "T", type = "note") + assertEquals("", result.snippet) + } + + @Test + fun `view mode enum covers all cases`() { + val modes = ViewMode.entries + assertEquals(5, modes.size) + } +}