feat(server): ListFiles/FileList protocol for the TUI @ file picker
New WS query: ClientMessage.ListFiles{sessionId} -> ServerMessage.FileList{paths}
(@SerialName "file.list"). StreamQueries.listFiles resolves the session's bound
workspace root (latest SessionWorkspaceBoundEvent) and lists it via WorkspaceFiles —
a path-only walk that reuses RepoMapIndexer's directory-ignore convention but, unlike
the indexer, never reads file contents and includes every file type (configs/assets,
not just source+markdown), sorted and capped. Tests: WorkspaceFiles (types/ignore/cap/
missing) + file.list wire round-trip. Go @ autocomplete consumes this next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,10 @@ sealed class ClientMessage {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class ListArtifacts(val sessionId: SessionId) : ClientMessage()
|
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). */
|
/** Operator request for a session's derived metrics (replied to with a SessionStats). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
||||||
|
|||||||
@@ -464,6 +464,19 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long? = null,
|
override val sessionSequence: Long? = null,
|
||||||
) : ServerMessage, NonEventMessage
|
) : 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<String>,
|
||||||
|
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
|
* 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.
|
* snapshot read folded from the whole event log), so cursors are null.
|
||||||
|
|||||||
@@ -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<String> = DEFAULT_IGNORE_DIRS,
|
||||||
|
limit: Int = DEFAULT_LIMIT,
|
||||||
|
): List<String> {
|
||||||
|
if (!Files.isDirectory(root)) return emptyList()
|
||||||
|
val out = ArrayList<String>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,6 +235,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
||||||
}
|
}
|
||||||
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
||||||
|
is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId))
|
||||||
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
|
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
|
||||||
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
|
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
|
||||||
is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
|
is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.correx.apps.server.protocol.ArtifactSummaryDto
|
|||||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
import com.correx.apps.server.protocol.ConfigFieldDto
|
||||||
import com.correx.apps.server.protocol.IdeaDto
|
import com.correx.apps.server.protocol.IdeaDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
|
import com.correx.apps.server.workspace.WorkspaceFiles
|
||||||
import com.correx.core.router.IdeaReader
|
import com.correx.core.router.IdeaReader
|
||||||
import com.correx.core.config.EditableConfig
|
import com.correx.core.config.EditableConfig
|
||||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
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.ArtifactValidatedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||||
import com.correx.core.events.events.InferenceCompletedEvent
|
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.events.StoredEvent
|
||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.nio.file.Paths
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@@ -48,6 +51,25 @@ class StreamQueries(private val module: ServerModule) {
|
|||||||
return ServerMessage.SessionStats(sessionId = sessionId, stats = report)
|
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),
|
* 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 —
|
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
|
||||||
|
|||||||
+18
@@ -275,6 +275,24 @@ class ServerMessageSerializationTest {
|
|||||||
assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId)
|
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<ServerMessage.FileList>(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
|
@Test
|
||||||
fun `ClarificationResponse decodes from the client wire format`() {
|
fun `ClarificationResponse decodes from the client wire format`() {
|
||||||
val wire = """
|
val wire = """
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user