1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
3package soy.iko.opencode.ui.testing
5import kotlinx.coroutines.flow.Flow
6import kotlinx.coroutines.flow.MutableSharedFlow
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asSharedFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.flowOf
12import soy.iko.opencode.data.model.Agent
13import soy.iko.opencode.data.model.BusEvent
14import soy.iko.opencode.data.model.Command
15import soy.iko.opencode.data.model.FileContent
16import soy.iko.opencode.data.model.FileNode
17import soy.iko.opencode.data.model.FileStatusEntry
18import soy.iko.opencode.data.model.MessageWithParts
19import soy.iko.opencode.data.model.ModelRef
20import soy.iko.opencode.data.model.PermissionResponse
21import soy.iko.opencode.data.model.ProvidersResponse
22import soy.iko.opencode.data.model.ServerProfile
23import soy.iko.opencode.data.model.Session
24import soy.iko.opencode.data.network.EventStreamClient
25import soy.iko.opencode.data.network.NetworkConfig
26import soy.iko.opencode.data.network.OpencodeApiClient
27import soy.iko.opencode.data.repo.DraftStore
28import soy.iko.opencode.data.repo.ProfileStore
29import soy.iko.opencode.data.repo.SessionRepository
30import soy.iko.opencode.di.AppContainer
31import soy.iko.opencode.di.OpencodeConnection
33// ---------------------------------------------------------------------------
35// ---------------------------------------------------------------------------
37class FakeDraftStore : DraftStore() {
38 private val store = mutableMapOf<String, String>()
39 private val _ready = MutableStateFlow(true)
40 override val ready: StateFlow<Boolean> = _ready.asStateFlow()
42 var lastFlushed: Pair<String, String>? = null
45 override fun get(sessionId: String): String = store[sessionId].orEmpty()
47 override suspend fun set(sessionId: String, text: String) {
48 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
51 override fun setImmediate(sessionId: String, text: String) {
52 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
55 override suspend fun remove(sessionId: String) {
56 store.remove(sessionId)
59 override fun flushDraft(sessionId: String, text: String) {
60 lastFlushed = sessionId to text
61 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
64 override fun shutdown() { /* no-op */ }
67// ---------------------------------------------------------------------------
69// ---------------------------------------------------------------------------
71class FakeProfileStore : ProfileStore() {
72 private val _profiles = MutableStateFlow<List<ServerProfile>>(emptyList())
74 override val profiles: Flow<List<ServerProfile>> = _profiles.asStateFlow()
76 fun setProfiles(profiles: List<ServerProfile>) {
77 _profiles.value = profiles
80 var savedProfile: ServerProfile? = null
83 /** When non-null, [save] throws this exception instead of persisting. */
84 var saveException: Exception? = null
86 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
88 override suspend fun save(profile: ServerProfile) {
89 saveException?.let { throw it }
90 savedProfile = profile
91 val current = _profiles.value.toMutableList()
92 current.removeAll { it.id == profile.id }
94 _profiles.value = current
97 override suspend fun delete(id: String) {
98 _profiles.value = _profiles.value.filterNot { it.id == id }
102// ---------------------------------------------------------------------------
103// FakeOpencodeApiClient
104// ---------------------------------------------------------------------------
106class FakeOpencodeApiClient : OpencodeApiClient() {
107 var pingThrows: Throwable? = null
108 var sessions: List<Session> = emptyList()
109 var createdSession: Session = Session(id = "new-session", title = "New")
110 var deletedSessions: List<String> = emptyList()
111 var messages: List<MessageWithParts> = emptyList()
112 var providersResponse: ProvidersResponse = ProvidersResponse()
113 var agentsList: List<Agent> = emptyList()
114 var commandsList: List<Command> = emptyList()
115 var directoryListing: List<FileNode> = emptyList()
116 var fileSearchResults: List<String> = emptyList()
117 var fileStatusEntries: List<FileStatusEntry> = emptyList()
118 var fileContent: FileContent = FileContent(content = "")
119 var sendPromptThrows: Throwable? = null
120 var respondPermissionThrows: Throwable? = null
121 var cacheInvalidated = false
123 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
125 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
127 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
130 override suspend fun ping() {
131 pingThrows?.let { throw it }
134 override suspend fun listSessions(): List<Session> = sessions
136 override suspend fun createSession(title: String?): Session = createdSession
138 override suspend fun updateSession(id: String, title: String): Session {
139 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
142 override suspend fun deleteSession(id: String) {
143 deletedSessions = deletedSessions + id
146 override suspend fun listMessages(sessionId: String): List<MessageWithParts> = messages
148 override suspend fun sendPrompt(
153 ): MessageWithParts {
154 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
155 sendPromptThrows?.let { throw it }
156 return MessageWithParts(
157 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
162 override suspend fun abort(sessionId: String) { /* no-op */ }
164 override suspend fun runCommand(
169 ): MessageWithParts {
170 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
171 return MessageWithParts(
172 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
177 override suspend fun providers(): ProvidersResponse = providersResponse
178 override suspend fun agents(): List<Agent> = agentsList
179 override suspend fun commands(): List<Command> = commandsList
181 override suspend fun invalidateCache() { cacheInvalidated = true }
183 override suspend fun respondPermission(
185 permissionId: String,
186 response: PermissionResponse,
188 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
189 respondPermissionThrows?.let { throw it }
193 override suspend fun findFiles(query: String): List<String> = fileSearchResults
194 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
195 override suspend fun readFile(path: String): FileContent = fileContent
196 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
199// ---------------------------------------------------------------------------
200// FakeEventStreamClient
201// ---------------------------------------------------------------------------
203class FakeEventStreamClient : EventStreamClient() {
204 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
205 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
207 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
208 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
210 override fun triggerReconnect() { /* no-op */ }
213// ---------------------------------------------------------------------------
214// FakeSessionRepository
215// ---------------------------------------------------------------------------
217class FakeSessionRepository(
218 private val api: FakeOpencodeApiClient,
219 private val eventStream: FakeEventStreamClient,
220) : SessionRepository(api, eventStream) {
221 var sessions: List<Session> = emptyList()
222 var createdSession: Session = Session(id = "new-session", title = "New")
223 var messages: List<MessageWithParts> = emptyList()
224 var sendPromptThrows: Throwable? = null
225 var listSessionsThrows: Throwable? = null
226 var abortCalls: List<String> = emptyList()
229 override suspend fun listSessions(): List<Session> {
230 listSessionsThrows?.let { throw it }
233 override suspend fun createSession(title: String?): Session = createdSession
234 override suspend fun deleteSession(id: String) { /* no-op */ }
235 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
237 override suspend fun sendPrompt(
242 ): MessageWithParts {
243 sendPromptThrows?.let { throw it }
244 return MessageWithParts(
245 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
250 override suspend fun runCommand(
255 ): MessageWithParts = MessageWithParts(
256 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
260 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = flowOf(messages)
263// ---------------------------------------------------------------------------
264// FakeOpencodeConnection
265// ---------------------------------------------------------------------------
267class FakeOpencodeConnection(
268 override val api: FakeOpencodeApiClient,
269 override val events: FakeEventStreamClient,
270 override val repository: FakeSessionRepository,
271 profile: ServerProfile,
272) : OpencodeConnection(profile) {
273 override suspend fun close() { /* no-op */ }
276// ---------------------------------------------------------------------------
278// ---------------------------------------------------------------------------
280class FakeAppContainer : AppContainer() {
281 val fakeDraftStore = FakeDraftStore()
282 val fakeProfileStore = FakeProfileStore()
283 val fakeUnread = MutableStateFlow<Set<String>>(emptySet())
284 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
286 override val draftStore: DraftStore = fakeDraftStore
287 override val profileStore: ProfileStore = fakeProfileStore
288 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
289 override val unread: StateFlow<Set<String>> = fakeUnread.asStateFlow()
291 var connectResult: OpencodeConnection? = null
292 var connectCalls: List<ServerProfile> = emptyList()
294 var disconnectCalls = 0
297 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
299 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
301 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
302 connectCalls = connectCalls + profile
303 connectResult?.let { return it }
304 // Create a fake connection on the fly if none pre-configured
305 val api = FakeOpencodeApiClient()
306 val events = FakeEventStreamClient()
307 val repo = FakeSessionRepository(api, events)
308 return FakeOpencodeConnection(api, events, repo, profile)
311 override suspend fun disconnect() {
313 fakeActiveConnection.value = null
316 fun setActiveConnection(conn: OpencodeConnection?) {
317 fakeActiveConnection.value = conn
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list