1package soy.iko.opencode.data.repo
3import android.content.Context
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.sync.Mutex
7import kotlinx.coroutines.sync.withLock
8import kotlinx.coroutines.withContext
9import kotlinx.serialization.builtins.ListSerializer
10import soy.iko.opencode.data.model.MessageWithParts
11import soy.iko.opencode.data.network.OpencodeJson
12import soy.iko.opencode.util.runCatchingCancellable
16 * On-disk cache of a session's reduced message list, so a conversation shows instantly on
17 * open (and remains readable while offline / before the network responds) instead of a blank
18 * screen until `GET /session/:id/message` returns.
20 * One JSON file per session under filesDir, encoded with [OpencodeJson] so the same sealed
21 * polymorphism used on the wire round-trips (unknown parts/messages are concrete
22 * `__unknown` variants, so they encode too). Every operation is best-effort — a cache
23 * miss or a write failure just falls back to the network — so problems never surface to
24 * the user. No annotation processing (Room/KSP) per the project's manual-DI convention.
26open class MessageCacheStore private constructor(
27 private val appContext: Context?,
28 @Suppress("unused") private val testMode: Boolean,
30 constructor(context: Context) : this(context.applicationContext, false)
31 protected constructor() : this(null, true)
33 private val serializer = ListSerializer(MessageWithParts.serializer())
35 /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
36 * escape the directory. */
37 private val idRegex = Regex("[^A-Za-z0-9_-]")
39 private val dir: File? by lazy {
40 appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
43 private fun fileFor(sessionId: String): File? =
44 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
46 /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
47 * panes of two-pane mode saving the same session, or a save racing a delete's remove)
48 * can't tear or half-write the cache file. Keyed by session id; the map grows only with
49 * the number of distinct sessions touched, and each [Mutex] is a few bytes. */
50 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
51 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
53 /** Load the cached messages for [sessionId], or empty if none / unreadable. */
54 open suspend fun load(sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
55 withContext(Dispatchers.IO) {
56 val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
57 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
58 .getOrDefault(emptyList())
62 /** Persist [messages] for [sessionId] (an empty list deletes the file). */
63 open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
64 withContext(Dispatchers.IO) {
65 val file = fileFor(sessionId) ?: return@withContext
66 runCatchingCancellable {
67 if (messages.isEmpty()) file.delete()
69 // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
70 // process kill mid-write can't leave a truncated file that fails to decode,
71 // wiping the cached conversation on the next open.
72 val encoded = OpencodeJson.encodeToString(serializer, messages)
73 val tmp = File(file.parentFile, file.name + ".tmp")
74 tmp.writeText(encoded)
75 if (!tmp.renameTo(file)) {
76 file.writeText(encoded)
80 }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
85 /** Remove a session's cached messages (call on deletion). */
86 open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
87 withContext(Dispatchers.IO) {
88 runCatchingCancellable { fileFor(sessionId)?.delete() }
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