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 abortCalls: List<String> = emptyList()
228 override suspend fun listSessions(): List<Session> = sessions
229 override suspend fun createSession(title: String?): Session = createdSession
230 override suspend fun deleteSession(id: String) { /* no-op */ }
231 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
233 override suspend fun sendPrompt(
238 ): MessageWithParts {
239 sendPromptThrows?.let { throw it }
240 return MessageWithParts(
241 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
246 override suspend fun runCommand(
251 ): MessageWithParts = MessageWithParts(
252 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
256 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = flowOf(messages)
259// ---------------------------------------------------------------------------
260// FakeOpencodeConnection
261// ---------------------------------------------------------------------------
263class FakeOpencodeConnection(
264 override val api: FakeOpencodeApiClient,
265 override val events: FakeEventStreamClient,
266 override val repository: FakeSessionRepository,
267 profile: ServerProfile,
268) : OpencodeConnection(profile) {
269 override suspend fun close() { /* no-op */ }
272// ---------------------------------------------------------------------------
274// ---------------------------------------------------------------------------
276class FakeAppContainer : AppContainer() {
277 val fakeDraftStore = FakeDraftStore()
278 val fakeProfileStore = FakeProfileStore()
279 val fakeUnread = MutableStateFlow<Set<String>>(emptySet())
280 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
282 override val draftStore: DraftStore = fakeDraftStore
283 override val profileStore: ProfileStore = fakeProfileStore
284 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
285 override val unread: StateFlow<Set<String>> = fakeUnread.asStateFlow()
287 var connectResult: OpencodeConnection? = null
288 var connectCalls: List<ServerProfile> = emptyList()
290 var disconnectCalls = 0
293 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
295 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
297 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
298 connectCalls = connectCalls + profile
299 connectResult?.let { return it }
300 // Create a fake connection on the fly if none pre-configured
301 val api = FakeOpencodeApiClient()
302 val events = FakeEventStreamClient()
303 val repo = FakeSessionRepository(api, events)
304 return FakeOpencodeConnection(api, events, repo, profile)
307 override suspend fun disconnect() {
309 fakeActiveConnection.value = null
312 fun setActiveConnection(conn: OpencodeConnection?) {
313 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