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
|
||||
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()
|
||||
|
||||
@@ -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<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
|
||||
* 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) }
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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 —
|
||||
|
||||
+18
@@ -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<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
|
||||
fun `ClarificationResponse decodes from the client wire format`() {
|
||||
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