diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index 550322e7..b7d2fdc6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -44,6 +44,10 @@ sealed class ClientMessage { @Serializable data class ListArtifacts(val sessionId: SessionId) : ClientMessage() + /** Operator request for the session workspace's file paths (the TUI `@` file-ref picker). */ + @Serializable + data class ListFiles(val sessionId: SessionId) : ClientMessage() + /** Operator request for a session's derived metrics (replied to with a SessionStats). */ @Serializable data class GetSessionStats(val sessionId: SessionId) : ClientMessage() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index 31625a57..e840e213 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -464,6 +464,19 @@ sealed interface ServerMessage { override val sessionSequence: Long? = null, ) : ServerMessage, NonEventMessage + /** + * The session workspace's file paths (reply to ListFiles), for the TUI `@` file-ref picker. + * A snapshot directory walk, not event-derived, so cursors are null. + */ + @Serializable + @SerialName("file.list") + data class FileList( + val sessionId: SessionId, + val paths: List, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + /** * The cross-session idea board (reply to ListIdeas / DiscardIdea). Not event-derived (a * snapshot read folded from the whole event log), so cursors are null. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt b/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt new file mode 100644 index 00000000..b2baa73a --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt @@ -0,0 +1,46 @@ +package com.correx.apps.server.workspace + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isRegularFile +import kotlin.io.path.name +import kotlin.io.path.relativeTo + +/** + * Lists workspace-relative file paths under a root for the TUI's `@` file-reference picker. + * + * Deliberately distinct from `RepoMapIndexer`: that one reads every file's *contents* to extract + * symbols and only covers source/markdown extensions (it feeds project memory + context). The `@` + * picker wants *all* file types, by path only, as cheaply as possible — so this never opens a file. + * It does share the indexer's directory-ignore convention (skip any path segment in [ignoreDirs]) + * so both agree on what "the workspace" is. Results are sorted and capped to bound the reply. + */ +object WorkspaceFiles { + + val DEFAULT_IGNORE_DIRS = setOf( + ".git", "node_modules", "build", "target", ".gradle", "dist", ".idea", ".correx", ".venv", + ) + const val DEFAULT_LIMIT = 2000 + private const val MAX_DEPTH = 12 + + fun list( + root: Path, + ignoreDirs: Set = DEFAULT_IGNORE_DIRS, + limit: Int = DEFAULT_LIMIT, + ): List { + if (!Files.isDirectory(root)) return emptyList() + val out = ArrayList() + Files.walk(root, MAX_DEPTH).use { stream -> + val iter = stream.iterator() + while (iter.hasNext()) { + val path = iter.next() + if (!path.isRegularFile()) continue + val rel = path.relativeTo(root) + if (rel.any { it.name in ignoreDirs }) continue + out.add(rel.toString()) + } + } + out.sort() + return out.take(limit) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index a850106b..b937dd06 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -235,6 +235,7 @@ class GlobalStreamHandler(private val module: ServerModule) { .onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) } } is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId)) + is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId)) is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas()) is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame) is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt index 0c1331ff..82c7c94f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt @@ -7,6 +7,7 @@ import com.correx.apps.server.protocol.ArtifactSummaryDto import com.correx.apps.server.protocol.ConfigFieldDto import com.correx.apps.server.protocol.IdeaDto import com.correx.apps.server.protocol.ServerMessage +import com.correx.apps.server.workspace.WorkspaceFiles import com.correx.core.router.IdeaReader import com.correx.core.config.EditableConfig import com.correx.core.events.events.ArtifactContentStoredEvent @@ -14,11 +15,13 @@ import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.SessionId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.nio.file.Paths import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject @@ -48,6 +51,25 @@ class StreamQueries(private val module: ServerModule) { return ServerMessage.SessionStats(sessionId = sessionId, stats = report) } + /** + * The session workspace's file paths for the `@` picker. Resolves the bound workspace root from + * the recorded [SessionWorkspaceBoundEvent] (invariant #9) and lists it by path. Pure snapshot + * read — no events appended (replay-neutral). Empty when the session has no bound workspace. + */ + suspend fun listFiles(sessionId: SessionId): ServerMessage.FileList { + val paths = withContext(Dispatchers.IO) { + workspaceRootOf(sessionId)?.let { WorkspaceFiles.list(Paths.get(it)) } ?: emptyList() + } + return ServerMessage.FileList(sessionId = sessionId, paths = paths) + } + + // The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent, or null. + private fun workspaceRootOf(sessionId: SessionId): String? = + module.eventStore.read(sessionId) + .mapNotNull { it.payload as? SessionWorkspaceBoundEvent } + .lastOrNull() + ?.workspaceRoot + /** * Reads a session's events to assemble its artifact listing (creation order preserved), * resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read — diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index 85ec1665..63c15a84 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -275,6 +275,24 @@ class ServerMessageSerializationTest { assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId) } + @Test + fun `FileList encodes type file_list and paths`() { + val msg = ServerMessage.FileList(sessionId = SessionId("s1"), paths = listOf("README.md", "src/Main.kt")) + val jsonStr = ProtocolSerializer.encodeServerMessage(msg) + assert(jsonStr.contains("\"type\":\"file.list\"")) { "expected type=file.list" } + + val decoded = json.decodeFromString(jsonStr) + assertEquals(listOf("README.md", "src/Main.kt"), decoded.paths) + } + + @Test + fun `ListFiles decodes from the client wire format`() { + val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.ListFiles","sessionId":"s1"}""" + val decoded = ProtocolSerializer.decodeClientMessage(wire) + assert(decoded is ClientMessage.ListFiles) { "expected ListFiles, got $decoded" } + assertEquals("s1", (decoded as ClientMessage.ListFiles).sessionId.value) + } + @Test fun `ClarificationResponse decodes from the client wire format`() { val wire = """ diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt new file mode 100644 index 00000000..62527e61 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt @@ -0,0 +1,55 @@ +package com.correx.apps.server.workspace + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WorkspaceFilesTest { + + @Test + fun `lists all file types by relative path, sorted`(@TempDir root: Path) { + root.resolve("src").createDirectories() + root.resolve("src/Main.kt").writeText("fun main() {}") + root.resolve("config.toml").writeText("a = 1") // not a source ext — must still appear + root.resolve("README.md").writeText("# hi") + + val paths = WorkspaceFiles.list(root) + + assertEquals(listOf("README.md", "config.toml", "src/Main.kt"), paths) + } + + @Test + fun `skips ignored directories`(@TempDir root: Path) { + root.resolve(".git").createDirectories() + root.resolve(".git/HEAD").writeText("ref") + root.resolve("node_modules/pkg").createDirectories() + root.resolve("node_modules/pkg/index.js").writeText("x") + root.resolve("keep.txt").writeText("y") + + val paths = WorkspaceFiles.list(root) + + assertEquals(listOf("keep.txt"), paths) + assertFalse(paths.any { it.startsWith(".git") || it.startsWith("node_modules") }) + } + + @Test + fun `caps the result count`(@TempDir root: Path) { + repeat(10) { root.resolve("f$it.txt").writeText("x") } + val paths = WorkspaceFiles.list(root, limit = 3) + assertEquals(3, paths.size) + assertTrue(paths.all { it.endsWith(".txt") }) + } + + @Test + fun `missing root yields empty`(@TempDir root: Path) { + val missing = root.resolve("nope") + assertTrue(Files.notExists(missing)) + assertEquals(emptyList(), WorkspaceFiles.list(missing)) + } +}