perf: optimize screen load times with progressive un-gating and SWR caching (#1192)
This commit is contained in:
55 files changed
+2494
-401
No files matched your search
@@ -10,6 +10,7 @@ import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.SessionListCacheStore
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
@@ -29,6 +30,7 @@ class HermesControlApp :
|
||||
super.onCreate()
|
||||
AuthManager.init(this)
|
||||
NetworkMonitor.init(this)
|
||||
SessionListCacheStore.init(this)
|
||||
appScope.launch {
|
||||
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
|
||||
// Issue #537 follow-up (A): preload analytics in the background after launch
|
||||
|
||||
@@ -127,6 +127,41 @@ object AuthManager {
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
private val _authGenerationFlow = MutableStateFlow(0L)
|
||||
val authGenerationFlow: StateFlow<Long> = _authGenerationFlow.asStateFlow()
|
||||
|
||||
fun invalidateAuthGeneration() {
|
||||
_authGenerationFlow.value = _authGenerationFlow.value + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical observable data-scope identity covering connection profile, base URL,
|
||||
* active server-side Hermes profile, and in-memory auth generation.
|
||||
*/
|
||||
val dataScopeFlow: StateFlow<DataScope?> =
|
||||
combine(
|
||||
selectedProfileFlow,
|
||||
baseUrlFlow,
|
||||
activeProfileId,
|
||||
authGenerationFlow,
|
||||
) { selectedProfile, baseUrl, activeProfile, authGen ->
|
||||
val cleanUrl = baseUrl.trimEnd('/')
|
||||
if (cleanUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
DataScope(
|
||||
connectionProfileId = selectedProfile ?: DEFAULT_PROFILE_ID,
|
||||
baseUrl = cleanUrl,
|
||||
activeProfileId = activeProfile?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID,
|
||||
inMemoryAuthGeneration = authGen,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = contextScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Initialise the encrypted preferences.
|
||||
* Call this once from Application.onCreate() or MainActivity.onCreate().
|
||||
@@ -595,6 +630,9 @@ object AuthManager {
|
||||
}
|
||||
|
||||
fun setToken(token: String?) {
|
||||
if (token == null) {
|
||||
invalidateAuthGeneration()
|
||||
}
|
||||
val selectedId =
|
||||
getSelectedProfileId() ?: run {
|
||||
ensureDefaultSelected()
|
||||
@@ -611,6 +649,23 @@ object AuthManager {
|
||||
|
||||
fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl
|
||||
|
||||
fun currentDataScope(): DataScope? {
|
||||
val rawUrl = runCatching { getBaseUrl() }.getOrNull() ?: _baseUrlFlow.value
|
||||
val cleanUrl = rawUrl.trimEnd('/')
|
||||
if (cleanUrl.isBlank()) return null
|
||||
val connectionProfileId =
|
||||
runCatching { getSelectedProfileId() }.getOrNull()
|
||||
?: _selectedProfileFlow.value
|
||||
?: DEFAULT_PROFILE_ID
|
||||
val activeProfile = _activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
||||
return DataScope(
|
||||
connectionProfileId = connectionProfileId,
|
||||
baseUrl = cleanUrl,
|
||||
activeProfileId = activeProfile,
|
||||
inMemoryAuthGeneration = _authGenerationFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
fun endpoint(): ServerEndpoint =
|
||||
ServerEndpoint.parse(
|
||||
getBaseUrl(),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
/**
|
||||
* Canonical data-scope identity representing the authenticated server and profile context.
|
||||
* Used to partition caches (both in-memory and persistent) so that switching servers,
|
||||
* changing server URLs, switching server-side profiles, or logging out/re-logging in
|
||||
* never leaks data across boundaries.
|
||||
*
|
||||
* Contains no credentials, tokens, cookies, passwords, or secrets.
|
||||
*/
|
||||
data class DataScope(
|
||||
val connectionProfileId: String,
|
||||
val baseUrl: String,
|
||||
val activeProfileId: String,
|
||||
val inMemoryAuthGeneration: Long = 0L,
|
||||
) {
|
||||
/**
|
||||
* Deterministic in-memory cache key.
|
||||
* Uses length-prefixing for unambiguous separation of components, and includes
|
||||
* [inMemoryAuthGeneration] so logging out and re-logging in automatically invalidates
|
||||
* all in-memory caches.
|
||||
*/
|
||||
fun inMemoryKey(localKey: String = "default"): String = "g$inMemoryAuthGeneration:${persistentKey(localKey)}"
|
||||
|
||||
/**
|
||||
* Deterministic persistent cache key for disk storage (e.g. SessionListCacheStore).
|
||||
* Uses length-prefixing so no delimiter collision is possible.
|
||||
* Does NOT include volatile [inMemoryAuthGeneration] so disk caches survive normal process restarts.
|
||||
* Disk caches are explicitly wiped on logout.
|
||||
*/
|
||||
fun persistentKey(localKey: String = "default"): String =
|
||||
"c${connectionProfileId.length}:$connectionProfileId:u${baseUrl.length}:$baseUrl:p${activeProfileId.length}:$activeProfileId:k${localKey.length}:$localKey"
|
||||
|
||||
fun scopedKey(localKey: String = "default"): String = inMemoryKey(localKey)
|
||||
|
||||
companion object {
|
||||
val EMPTY =
|
||||
DataScope(
|
||||
connectionProfileId = AuthManager.DEFAULT_PROFILE_ID,
|
||||
baseUrl = "",
|
||||
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
|
||||
inMemoryAuthGeneration = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
|
||||
/**
|
||||
* Lightweight disk cache for the initial page of sessions.
|
||||
* Persists the first page JSON to SharedPreferences so the History screen
|
||||
* can render instantly even after app kill / process death.
|
||||
*/
|
||||
object SessionListCacheStore {
|
||||
private const val PREFS_NAME = "hermes_sessions_cache"
|
||||
private const val KEY_PREFIX = "sessions_page_"
|
||||
|
||||
@Volatile
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (prefs == null) {
|
||||
synchronized(this) {
|
||||
if (prefs == null) {
|
||||
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(key: String): SessionListResponse? {
|
||||
val p = prefs ?: return null
|
||||
val raw = p.getString(KEY_PREFIX + key, null) ?: return null
|
||||
return try {
|
||||
OkHttpProvider.json.decodeFromString<SessionListResponse>(raw)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun put(
|
||||
key: String,
|
||||
data: SessionListResponse,
|
||||
) {
|
||||
val p = prefs ?: return
|
||||
try {
|
||||
val json = OkHttpProvider.json.encodeToString(SessionListResponse.serializer(), data)
|
||||
p.edit().putString(KEY_PREFIX + key, json).apply()
|
||||
} catch (_: Exception) {
|
||||
// Ignore serialization issues on cache save
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs?.edit()?.remove(KEY_PREFIX + key)?.apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs?.edit()?.clear()?.apply()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun resetForTesting(testPrefs: SharedPreferences? = null) {
|
||||
synchronized(this) {
|
||||
prefs = testPrefs
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
/**
|
||||
* Lightweight thread-safe in-memory cache for Stale-While-Revalidate screen state.
|
||||
* Stores entries with optional TTL and a bounded capacity.
|
||||
*
|
||||
* All map operations are synchronized on a private lock so that compound
|
||||
* operations (e.g. read + TTL expiry check + remove) are atomic and safe against races.
|
||||
*/
|
||||
class SwrCache<K : Any, V : Any>(
|
||||
private val maxCapacity: Int = DEFAULT_MAX_CAPACITY,
|
||||
private val ttlMillis: Long = DEFAULT_TTL_MILLIS,
|
||||
private val timeProvider: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
private data class Entry<V>(
|
||||
val value: V,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val map =
|
||||
object : LinkedHashMap<K, Entry<V>>(maxCapacity, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, Entry<V>>?): Boolean = size > maxCapacity
|
||||
}
|
||||
|
||||
fun get(key: K): V? =
|
||||
synchronized(lock) {
|
||||
val entry = map[key] ?: return null
|
||||
if (ttlMillis > 0 && timeProvider() - entry.timestamp > ttlMillis) {
|
||||
map.remove(key)
|
||||
return null
|
||||
}
|
||||
entry.value
|
||||
}
|
||||
|
||||
fun put(
|
||||
key: K,
|
||||
value: V,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
map[key] = Entry(value, timeProvider())
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: K): V? =
|
||||
synchronized(lock) {
|
||||
map.remove(key)?.value
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
map.clear()
|
||||
}
|
||||
}
|
||||
|
||||
val size: Int
|
||||
get() = synchronized(lock) { map.size }
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MAX_CAPACITY = 50
|
||||
const val DEFAULT_TTL_MILLIS = 5 * 60 * 1000L // 5 minutes
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -132,6 +133,8 @@ suspend inline fun <reified T> safeApiCall(
|
||||
}
|
||||
val backoff = jitteredBackoff(500L * (1 shl attempt))
|
||||
delay(backoff)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
return NetworkResult.Failure(
|
||||
NetworkError.Unknown(
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -31,6 +32,18 @@ import kotlinx.coroutines.withContext
|
||||
* 4. Re-dial the WebSocket so the gateway re-homes chat to the new profile.
|
||||
*/
|
||||
object ProfileSwitchCoordinator {
|
||||
/**
|
||||
* Dispatcher for the blocking network hops below.
|
||||
*
|
||||
* Injectable so tests can drive the whole switch on a TestDispatcher. With the
|
||||
* real Dispatchers.IO these paths hop to a live thread pool and the ORDER of
|
||||
* the mocked calls becomes load-dependent -- ProfileSwitchCoordinatorTest's
|
||||
* Ordering.SEQUENCE checks passed on an idle machine but lost 2 tests while the
|
||||
* emulator saturated the CPU, and its setMain-less sibling tests failed
|
||||
* outright whenever another class had left Dispatchers.Main broken.
|
||||
*/
|
||||
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val switched: SharedFlow<String> = _switched.asSharedFlow()
|
||||
|
||||
@@ -39,7 +52,7 @@ object ProfileSwitchCoordinator {
|
||||
|
||||
suspend fun switchProfile(name: String): NetworkResult<Unit> {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) }
|
||||
}
|
||||
if (result !is NetworkResult.Success) return result
|
||||
@@ -50,7 +63,7 @@ object ProfileSwitchCoordinator {
|
||||
// run off the main thread or the dial crashes with
|
||||
// NetworkOnMainThreadException and falls back to the 1s reconnect
|
||||
// retry (visible in the 2026-08-06 live logcat).
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
@@ -81,7 +94,7 @@ object ProfileSwitchCoordinator {
|
||||
AuthManager.setSelectedProfileId(profileId)
|
||||
ApiClient.rebuild()
|
||||
_connectionSwitched.emit(profileId.orEmpty())
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
// The WS ticket mint reads the cookie jar's ACTIVE store; the
|
||||
// selection change swaps that store asynchronously, so a dial
|
||||
// that races it mints with the PREVIOUS server's cookie → 401 →
|
||||
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.Achievement
|
||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
@@ -72,6 +73,7 @@ fun AchievementsScreen(
|
||||
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
@@ -92,7 +94,8 @@ fun AchievementsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadAchievements()
|
||||
viewModel.loadRecentUnlocks()
|
||||
}
|
||||
@@ -129,7 +132,10 @@ fun AchievementsScreen(
|
||||
title = { Text(stringResource(R.string.screen_achievements)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadAchievements() },
|
||||
onRefresh = {
|
||||
viewModel.loadAchievements(forceRefresh = true)
|
||||
viewModel.loadRecentUnlocks()
|
||||
},
|
||||
modifier = modifier,
|
||||
actions = {
|
||||
Box {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.m57.hermescontrol.ui.achievements
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.Achievement
|
||||
import com.m57.hermescontrol.data.model.AchievementsResponse
|
||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -46,9 +50,53 @@ class AchievementsViewModel :
|
||||
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val api get() = ApiClient.hermesApi
|
||||
private val achievementsCache = SwrCache<String, AchievementsResponse>()
|
||||
|
||||
fun loadAchievements() {
|
||||
safeLaunchLoad(
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
achievements = emptyList(),
|
||||
unlockedCount = 0,
|
||||
discoveredCount = 0,
|
||||
secretCount = 0,
|
||||
totalCount = 0,
|
||||
categories = emptyList(),
|
||||
recentUnlocks = emptyList(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAchievements(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = achievementsCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
val categories =
|
||||
data.achievements
|
||||
?.mapNotNull { it.category }
|
||||
?.distinct()
|
||||
?.sorted() ?: emptyList()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
achievements = data.achievements.orEmpty(),
|
||||
unlockedCount = data.unlockedCount,
|
||||
discoveredCount = data.discoveredCount,
|
||||
secretCount = data.secretCount,
|
||||
totalCount = data.totalCount,
|
||||
isStale = data.isStale,
|
||||
generatedAt = data.generatedAt,
|
||||
scanState = data.scanMeta?.status?.state ?: "idle",
|
||||
scanLastError = data.scanMeta?.status?.lastError,
|
||||
scanLastDurationMs = data.scanMeta?.status?.lastDurationMs,
|
||||
scanRunCount = data.scanMeta?.status?.runCount ?: 0,
|
||||
categories = categories,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { api.getAchievements() } },
|
||||
onStart = {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
@@ -100,6 +148,7 @@ class AchievementsViewModel :
|
||||
}
|
||||
|
||||
fun rescan() {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { api.rescanAchievements() } },
|
||||
onStart = {
|
||||
@@ -111,6 +160,12 @@ class AchievementsViewModel :
|
||||
?.mapNotNull { it.category }
|
||||
?.distinct()
|
||||
?.sorted() ?: emptyList()
|
||||
// A successful rescan is a mutation: publish the fresh payload into the
|
||||
// scoped cache, otherwise the next SWR revisit replays the pre-rescan list.
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope == requestScope) {
|
||||
achievementsCache.put(requestScope.scopedKey("default"), data)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRescanning = false,
|
||||
@@ -155,7 +210,7 @@ class AchievementsViewModel :
|
||||
toastMessage = "Achievement state reset",
|
||||
)
|
||||
}
|
||||
loadAchievements()
|
||||
loadAchievements(forceRefresh = true)
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -235,68 +237,72 @@ class BotsViewModel(
|
||||
}
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
|
||||
var profilesWithMeta: List<ProfileInfo>? = null
|
||||
try {
|
||||
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
|
||||
val jsonElement =
|
||||
when (rpcResult) {
|
||||
is JsonElement -> rpcResult
|
||||
null -> null
|
||||
else -> rpcResult.toJsonElement()
|
||||
coroutineScope {
|
||||
val activeDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
|
||||
|
||||
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
|
||||
var profilesWithMeta: List<ProfileInfo>? = null
|
||||
try {
|
||||
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
|
||||
val jsonElement =
|
||||
when (rpcResult) {
|
||||
is JsonElement -> rpcResult
|
||||
null -> null
|
||||
else -> rpcResult.toJsonElement()
|
||||
}
|
||||
if (jsonElement != null) {
|
||||
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
|
||||
if (!resp.profiles.isNullOrEmpty()) {
|
||||
profilesWithMeta = resp.profiles
|
||||
}
|
||||
}
|
||||
if (jsonElement != null) {
|
||||
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
|
||||
if (!resp.profiles.isNullOrEmpty()) {
|
||||
profilesWithMeta = resp.profiles
|
||||
} catch (_: Exception) {
|
||||
// Fallback to REST API below
|
||||
}
|
||||
|
||||
val profilesResult =
|
||||
if (profilesWithMeta != null) {
|
||||
null
|
||||
} else {
|
||||
safeApiCall { ApiClient.hermesApi.getProfiles() }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Fallback to REST API below
|
||||
}
|
||||
val activeResult = activeDeferred.await()
|
||||
|
||||
val profilesResult =
|
||||
if (profilesWithMeta != null) {
|
||||
null
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesWithMeta,
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else if (profilesResult is NetworkResult.Success) {
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesResult.data.profiles.orEmpty(),
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
safeApiCall { ApiClient.hermesApi.getProfiles() }
|
||||
}
|
||||
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
|
||||
|
||||
if (profilesWithMeta != null) {
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesWithMeta,
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else if (profilesResult is NetworkResult.Success) {
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesResult.data.profiles.orEmpty(),
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val err =
|
||||
(profilesResult as? NetworkResult.Failure)?.error?.message
|
||||
?: "Failed to load bots"
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
errorMessage = err,
|
||||
)
|
||||
val err =
|
||||
(profilesResult as? NetworkResult.Failure)?.error?.message
|
||||
?: "Failed to load bots"
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
errorMessage = err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.ws.ChangeEventHub
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -34,6 +38,69 @@ inline fun <T> ViewModel.safeLaunchLoad(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a read-only API call with Stale-While-Revalidate caching.
|
||||
* If cached data exists in [cache] for [cacheKey] and [forceRefresh] is false, [onCacheHit] is invoked immediately
|
||||
* on the caller thread, avoiding full-screen loading spinners on screen revisits.
|
||||
* The network call runs in the background to update the cache and invoke [onSuccess].
|
||||
* If a previous request is active:
|
||||
* - when [forceRefresh] is false, the active job is returned to avoid duplicate in-flight work
|
||||
* - when [forceRefresh] is true, the active job is cancelled and superseded by the new forced refresh
|
||||
* Results are checked against [requestScope] and coroutine cancellation so late-arriving responses
|
||||
* cannot overwrite newer state.
|
||||
*/
|
||||
inline fun <T : Any> ViewModel.safeLaunchSwrLoad(
|
||||
cache: SwrCache<String, T>,
|
||||
cacheKey: String = "default",
|
||||
forceRefresh: Boolean = false,
|
||||
currentJob: Job? = null,
|
||||
requestScope: DataScope? = runCatching { AuthManager.currentDataScope() }.getOrNull(),
|
||||
crossinline scopeIsCurrent: () -> Boolean = {
|
||||
requestScope == null || AuthManager.currentDataScope() == requestScope
|
||||
},
|
||||
crossinline onCacheHit: (T) -> Unit,
|
||||
crossinline apiCall: suspend () -> NetworkResult<T>,
|
||||
crossinline onStart: () -> Unit,
|
||||
crossinline onSuccess: (T) -> Unit,
|
||||
crossinline onError: (String) -> Unit,
|
||||
): Job {
|
||||
if (currentJob?.isActive == true) {
|
||||
if (!forceRefresh) return currentJob
|
||||
currentJob.cancel()
|
||||
}
|
||||
val scopedKey = requestScope?.scopedKey(cacheKey)
|
||||
if (forceRefresh && scopedKey != null) {
|
||||
cache.remove(scopedKey)
|
||||
}
|
||||
val cached = if (!forceRefresh && scopedKey != null) cache.get(scopedKey) else null
|
||||
if (cached != null) {
|
||||
onCacheHit(cached)
|
||||
} else {
|
||||
onStart()
|
||||
}
|
||||
return viewModelScope.launch {
|
||||
val result = apiCall()
|
||||
coroutineContext.ensureActive()
|
||||
if (!scopeIsCurrent()) {
|
||||
return@launch
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
if (scopedKey != null) {
|
||||
cache.put(scopedKey, result.data)
|
||||
}
|
||||
onSuccess(result.data)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
if (cached == null || forceRefresh) {
|
||||
onError(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks.
|
||||
* Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
|
||||
@@ -86,9 +153,12 @@ inline fun <T> ViewModel.refreshOnChange(
|
||||
.collect { _ ->
|
||||
if (refreshInFlight) return@collect
|
||||
refreshInFlight = true
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
try {
|
||||
// No Dispatchers.IO hop — see safeLaunchLoad.
|
||||
val result = apiCall()
|
||||
coroutineContext.ensureActive()
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@collect
|
||||
if (result is NetworkResult.Success) {
|
||||
onSuccess(result.data)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ fun ConfigScreen(
|
||||
title = { Text(stringResource(R.string.config_screen_title)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadAll() },
|
||||
onRefresh = { viewModel.loadAll(forceRefresh = true) },
|
||||
actions = {
|
||||
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.saveConfig() }) {
|
||||
|
||||
@@ -2,12 +2,16 @@ package com.m57.hermescontrol.ui.config
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
|
||||
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -50,35 +54,114 @@ class ConfigViewModel :
|
||||
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val pendingChanges = mutableMapOf<String, JsonElement>()
|
||||
private val schemaCache = SwrCache<String, ConfigSchemaResponse>(maxCapacity = 10)
|
||||
private val defaultsCache = SwrCache<String, Map<String, JsonElement>>(maxCapacity = 10)
|
||||
|
||||
init {
|
||||
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
|
||||
// connection switch). An additional AuthManager.dataScopeFlow collector would
|
||||
// double-fire on the same transition and issue a duplicate loadAll().
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.switched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
loadAll()
|
||||
}
|
||||
|
||||
fun loadAll() {
|
||||
viewModelScope.launch {
|
||||
private fun onDataScopeChanged(newScope: DataScope?) {
|
||||
pendingChanges.clear()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
values = null,
|
||||
schema = null,
|
||||
defaults = null,
|
||||
uncoveredPaths = emptyList(),
|
||||
path = null,
|
||||
yamlText = null,
|
||||
modifiedKeys = emptySet(),
|
||||
isSaving = false,
|
||||
yamlIsSaving = false,
|
||||
yamlIsLoading = false,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
// A scope switch must load the new context. The scoped caches mean this paints the
|
||||
// new scope's own entry instantly when one exists, then revalidates in the background.
|
||||
loadAll()
|
||||
}
|
||||
|
||||
fun loadAll(forceRefresh: Boolean = false) {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val scopeKey = requestScope?.inMemoryKey("schema") ?: ""
|
||||
if (forceRefresh && scopeKey.isNotBlank()) {
|
||||
schemaCache.remove(scopeKey)
|
||||
defaultsCache.remove(scopeKey)
|
||||
}
|
||||
val cachedSchema = if (scopeKey.isNotBlank()) schemaCache.get(scopeKey) else null
|
||||
val cachedDefaults = if (scopeKey.isNotBlank()) defaultsCache.get(scopeKey) else null
|
||||
val hasCachedData = _uiState.value.values != null
|
||||
if (!hasCachedData) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
|
||||
val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
|
||||
val schemaDeferred =
|
||||
if (cachedSchema == null) {
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val defaultsDeferred =
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
|
||||
if (cachedDefaults == null) {
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
|
||||
|
||||
val configResult = configDeferred.await()
|
||||
val schemaResult = schemaDeferred.await()
|
||||
val defaultsResult = defaultsDeferred.await()
|
||||
val schema: ConfigSchemaResponse? =
|
||||
(schemaDeferred?.await() as? NetworkResult.Success)?.data?.also {
|
||||
if (scopeKey.isNotBlank()) schemaCache.put(scopeKey, it)
|
||||
} ?: cachedSchema
|
||||
val defaults: Map<String, JsonElement>? =
|
||||
(defaultsDeferred?.await() as? NetworkResult.Success)?.data?.also {
|
||||
if (scopeKey.isNotBlank()) defaultsCache.put(scopeKey, it)
|
||||
} ?: cachedDefaults
|
||||
val rawResult = rawDeferred.await()
|
||||
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@coroutineScope
|
||||
|
||||
if (configResult is NetworkResult.Success) {
|
||||
val values = flattenConfig(configResult.data)
|
||||
val schema = (schemaResult as? NetworkResult.Success)?.data
|
||||
val defaults = (defaultsResult as? NetworkResult.Success)?.data
|
||||
val path = (rawResult as? NetworkResult.Success)?.data?.path
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
_uiState.update { state ->
|
||||
val categories = schema?.category_order ?: emptyList()
|
||||
val validCategory =
|
||||
when {
|
||||
state.activeCategory.isNotBlank() && state.activeCategory in categories -> {
|
||||
state.activeCategory
|
||||
}
|
||||
|
||||
categories.isNotEmpty() -> {
|
||||
categories.first()
|
||||
}
|
||||
|
||||
else -> {
|
||||
state.activeCategory
|
||||
}
|
||||
}
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
values = values,
|
||||
schema = schema,
|
||||
@@ -89,15 +172,11 @@ class ConfigViewModel :
|
||||
schema?.fields?.keys ?: emptySet(),
|
||||
),
|
||||
path = path,
|
||||
activeCategory =
|
||||
if (schema?.category_order?.isNotEmpty() == true) {
|
||||
schema.category_order.first()
|
||||
} else {
|
||||
""
|
||||
},
|
||||
activeCategory = validCategory,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (!hasCachedData) {
|
||||
val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
|
||||
@@ -105,8 +184,11 @@ class ConfigViewModel :
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope == requestScope && !hasCachedData) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +280,7 @@ class ConfigViewModel :
|
||||
|
||||
fun saveConfig() {
|
||||
if (pendingChanges.isEmpty()) return
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { it.copy(isSaving = true) }
|
||||
viewModelScope.launch {
|
||||
val changeset = buildChangeset(pendingChanges)
|
||||
@@ -209,6 +292,8 @@ class ConfigViewModel :
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
pendingChanges.clear()
|
||||
@@ -216,6 +301,8 @@ class ConfigViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getConfig() }
|
||||
}
|
||||
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
@@ -242,6 +329,7 @@ class ConfigViewModel :
|
||||
|
||||
fun saveYamlConfig() {
|
||||
val yamlText = _uiState.value.yamlText ?: return
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { it.copy(yamlIsSaving = true) }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
@@ -252,12 +340,16 @@ class ConfigViewModel :
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val configResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getConfig() }
|
||||
}
|
||||
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
yamlIsSaving = false,
|
||||
|
||||
@@ -55,6 +55,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||
import com.m57.hermescontrol.data.model.CronBlueprintField
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
@@ -82,10 +83,12 @@ fun CronJobsScreen(
|
||||
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val spacing = LocalSpacing.current
|
||||
var selectedJob by remember { mutableStateOf<CronJob?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadCronJobs()
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@ fun CronJobsScreen(
|
||||
title = { Text(stringResource(R.string.screen_cron)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadCronJobs() },
|
||||
onRefresh = { viewModel.loadCronJobs(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.openNewJobDialog() }) {
|
||||
Icon(
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.cron
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
||||
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
@@ -16,6 +19,7 @@ import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.refreshOnChange
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -95,6 +99,19 @@ class CronJobsViewModel :
|
||||
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private val jobsCache = SwrCache<String, List<CronJob>>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
jobs = emptyList(),
|
||||
errorMessage = null,
|
||||
deleteTarget = null,
|
||||
editorState = CronJobEditorState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Issue #784: gateway broadcasts cron.changed — refresh the list
|
||||
@@ -102,14 +119,24 @@ class CronJobsViewModel :
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.CRON,
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
|
||||
onSuccess = { data -> _uiState.update { it.copy(jobs = data.orEmpty()) } },
|
||||
onSuccess = { data ->
|
||||
val jobs = data.orEmpty()
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null) jobsCache.put(requestScope.inMemoryKey("default"), jobs)
|
||||
_uiState.update { it.copy(jobs = jobs) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun loadCronJobs() {
|
||||
fun loadCronJobs(forceRefresh: Boolean = false) {
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
safeLaunchSwrLoad(
|
||||
cache = jobsCache,
|
||||
forceRefresh = forceRefresh,
|
||||
currentJob = loadJob,
|
||||
onCacheHit = { cached ->
|
||||
_uiState.update { it.copy(isLoading = false, jobs = cached, errorMessage = null) }
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -128,6 +155,7 @@ class CronJobsViewModel :
|
||||
|
||||
fun pauseCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
jobs =
|
||||
@@ -141,14 +169,24 @@ class CronJobsViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.pauseCronJob(id) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertJobs(originalJobs, "Failed to pause cron job: ${result.error.message}")
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
jobs =
|
||||
@@ -162,8 +200,17 @@ class CronJobsViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.resumeCronJob(id) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertJobs(originalJobs, "Failed to resume cron job: ${result.error.message}")
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +235,7 @@ class CronJobsViewModel :
|
||||
|
||||
fun deleteCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(jobs = state.jobs.filter { it.id != id })
|
||||
}
|
||||
@@ -196,8 +244,17 @@ class CronJobsViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.deleteCronJob(id) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertJobs(originalJobs, "Failed to delete cron job: ${result.error.message}")
|
||||
if (requestScope != null) {
|
||||
jobsCache.put(requestScope.scopedKey("default"), _uiState.value.jobs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +504,7 @@ class CronJobsViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
closeEditor()
|
||||
loadCronJobs()
|
||||
loadCronJobs(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -57,6 +58,7 @@ import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.ManagedFileEntry
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
@@ -79,6 +81,7 @@ fun FilesScreen(
|
||||
viewModel: FilesViewModel = viewModel { FilesViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
val uploadLauncher =
|
||||
@@ -104,6 +107,13 @@ fun FilesScreen(
|
||||
onDispose { viewModel.clearTransientState() }
|
||||
}
|
||||
|
||||
// Scope-aware load: a new server/profile context must never keep the old
|
||||
// server's directory path or entries on screen (PR #1192 cache scoping).
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
val hasEntries = state.entries.isNotEmpty()
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
@@ -3,15 +3,18 @@ package com.m57.hermescontrol.ui.files
|
||||
import android.util.Base64
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
|
||||
import com.m57.hermescontrol.data.model.ManagedFileActionResponse
|
||||
import com.m57.hermescontrol.data.model.ManagedFileDelete
|
||||
import com.m57.hermescontrol.data.model.ManagedFileEntry
|
||||
import com.m57.hermescontrol.data.model.ManagedFilesListResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -76,8 +79,52 @@ class FilesViewModel :
|
||||
private val _uiState = MutableStateFlow(FilesUiState())
|
||||
val uiState: StateFlow<FilesUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun load(path: String? = null) {
|
||||
safeLaunchLoad(
|
||||
private val filesCache = SwrCache<String, ManagedFilesListResponse>()
|
||||
|
||||
/**
|
||||
* Reset state owned by the previous server/profile context (issue: PR #1192 cache scoping).
|
||||
* The current directory path belongs to the old server and must not leak into the new one.
|
||||
*/
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentPath = "",
|
||||
parentPath = null,
|
||||
crumbs = emptyList(),
|
||||
entries = emptyList(),
|
||||
isLoading = false,
|
||||
errorMessage = null,
|
||||
busyPaths = emptySet(),
|
||||
isCreatingDir = false,
|
||||
newDirName = "",
|
||||
isUploading = false,
|
||||
deleteTarget = null,
|
||||
openingPath = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun load(
|
||||
path: String? = null,
|
||||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
val cacheKey = path ?: ""
|
||||
safeLaunchSwrLoad(
|
||||
cache = filesCache,
|
||||
cacheKey = cacheKey,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentPath = data.path,
|
||||
parentPath = data.parent,
|
||||
crumbs = buildCrumbs(data.path),
|
||||
entries = data.entries,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = {
|
||||
safeApiCall { ApiClient.hermesApi.listManagedFiles(path) }
|
||||
},
|
||||
@@ -104,7 +151,7 @@ class FilesViewModel :
|
||||
)
|
||||
}
|
||||
|
||||
fun refresh() = load(_uiState.value.currentPath.ifBlank { null })
|
||||
fun refresh() = load(_uiState.value.currentPath.ifBlank { null }, forceRefresh = true)
|
||||
|
||||
fun navigateTo(entry: ManagedFileEntry) {
|
||||
if (!entry.isDirectory) return
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
@@ -51,9 +52,11 @@ fun GatewayScreen(
|
||||
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadStatus()
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ fun GatewayScreen(
|
||||
title = { Text(stringResource(R.string.screen_gateway)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadStatus() },
|
||||
onRefresh = { viewModel.loadStatus(forceRefresh = true) },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.status == null -> {
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.m57.hermescontrol.ui.gateway
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.StatusResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -28,8 +30,19 @@ class GatewayViewModel :
|
||||
private val _uiState = MutableStateFlow(GatewayUiState())
|
||||
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadStatus() {
|
||||
safeLaunchLoad(
|
||||
private val statusCache = SwrCache<String, StatusResponse>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update { it.copy(isLoading = false, status = null, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun loadStatus(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = statusCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
_uiState.update { it.copy(isLoading = false, status = data, errorMessage = null) }
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getStatus() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -73,7 +86,7 @@ class GatewayViewModel :
|
||||
toastMessage = "Gateway ${actionName}ed successfully",
|
||||
)
|
||||
}
|
||||
loadStatus()
|
||||
loadStatus(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||
import com.m57.hermescontrol.data.model.ExportBoardBody
|
||||
import com.m57.hermescontrol.data.model.ImportBoardBody
|
||||
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||
import com.m57.hermescontrol.data.model.KanbanProfile
|
||||
import com.m57.hermescontrol.data.model.KanbanProject
|
||||
@@ -32,7 +33,10 @@ import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
|
||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -168,34 +172,71 @@ class KanbanViewModel(
|
||||
val savedSlug = preferences.getSelectedBoard(endpoint)
|
||||
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
|
||||
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val hasExistingContent = _uiState.value.columns.isNotEmpty() || _uiState.value.tasks.isNotEmpty()
|
||||
if (!hasExistingContent) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
loadProfiles()
|
||||
loadProjects()
|
||||
loadOrchestration()
|
||||
loadModelOptions()
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.getBoards(includeArchived = false)) {
|
||||
is NetworkResult.Success -> {
|
||||
val boards = result.data.boards
|
||||
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
||||
val targetBoard =
|
||||
boards.find { it.id == previouslySelectedId }
|
||||
?: boards.find { it.id == result.data.current }
|
||||
?: boards.find { it.id.equals("default", ignoreCase = true) }
|
||||
?: boards.firstOrNull()
|
||||
if (targetBoard != null) {
|
||||
selectBoard(targetBoard)
|
||||
coroutineScope {
|
||||
val prefetchedBoardDeferred =
|
||||
if (previouslySelectedId != null) {
|
||||
async(Dispatchers.IO) {
|
||||
repository.getBoard(
|
||||
board = previouslySelectedId,
|
||||
includeArchived = _uiState.value.includeArchived,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
|
||||
)
|
||||
when (val result = repository.getBoards(includeArchived = false)) {
|
||||
is NetworkResult.Success -> {
|
||||
val boards = result.data.boards
|
||||
val targetBoard =
|
||||
boards.find { it.id == previouslySelectedId }
|
||||
?: boards.find { it.id == result.data.current }
|
||||
?: boards.find { it.id.equals("default", ignoreCase = true) }
|
||||
?: boards.firstOrNull()
|
||||
|
||||
_uiState.update { it.copy(boards = boards) }
|
||||
|
||||
if (targetBoard != null) {
|
||||
if (prefetchedBoardDeferred != null && targetBoard.id == previouslySelectedId) {
|
||||
preferences.setSelectedBoard(endpoint, targetBoard.id)
|
||||
val gen = ++currentLoadGen
|
||||
_uiState.update { it.copy(selectedBoard = targetBoard) }
|
||||
val prefetchedResult = prefetchedBoardDeferred.await()
|
||||
applyBoardResult(targetBoard, prefetchedResult, gen)
|
||||
connectEvents(targetBoard)
|
||||
} else {
|
||||
selectBoard(targetBoard)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
selectedBoard = null,
|
||||
columns = emptyList(),
|
||||
tasks = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
if (!hasExistingContent) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -906,15 +947,11 @@ class KanbanViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBoardIntoState(
|
||||
private fun applyBoardResult(
|
||||
board: KanbanBoard,
|
||||
gen: Int = currentLoadGen,
|
||||
result: NetworkResult<KanbanBoardResponse>,
|
||||
gen: Int,
|
||||
) {
|
||||
val result =
|
||||
repository.getBoard(
|
||||
board = board.id,
|
||||
includeArchived = _uiState.value.includeArchived,
|
||||
)
|
||||
if (gen != currentLoadGen) return
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
@@ -928,6 +965,7 @@ class KanbanViewModel(
|
||||
isLoading = false,
|
||||
columns = body.columns,
|
||||
tasks = allTasks,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -948,6 +986,18 @@ class KanbanViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBoardIntoState(
|
||||
board: KanbanBoard,
|
||||
gen: Int = currentLoadGen,
|
||||
) {
|
||||
val result =
|
||||
repository.getBoard(
|
||||
board = board.id,
|
||||
includeArchived = _uiState.value.includeArchived,
|
||||
)
|
||||
applyBoardResult(board, result, gen)
|
||||
}
|
||||
|
||||
fun showToast(message: String) {
|
||||
_uiState.update { it.copy(toastMessage = message) }
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.EnvVarConfig
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
@@ -114,6 +115,7 @@ fun KeysScreen(
|
||||
viewModel: KeysViewModel = viewModel { KeysViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var selectedCategory by remember { mutableStateOf(FILTER_ALL) }
|
||||
|
||||
@@ -150,7 +152,8 @@ fun KeysScreen(
|
||||
|
||||
val hasAnyVars = state.categories.any { it.vars.isNotEmpty() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadKeys()
|
||||
}
|
||||
|
||||
@@ -211,7 +214,7 @@ fun KeysScreen(
|
||||
title = { Text(stringResource(R.string.screen_keys)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadKeys() },
|
||||
onRefresh = { viewModel.loadKeys(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.openAddDialog() }) {
|
||||
Icon(
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.keys
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.EnvVarConfig
|
||||
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
|
||||
@@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -55,6 +57,19 @@ class KeysViewModel :
|
||||
private val _uiState = MutableStateFlow(KeysUiState())
|
||||
val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val keysCache = SwrCache<String, Map<String, EnvVarConfig>>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
categories = emptyList(),
|
||||
revealedValues = emptyMap(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCategoryList(
|
||||
envVars: Map<String, EnvVarConfig>,
|
||||
expandedCategories: Set<String> =
|
||||
@@ -89,8 +104,21 @@ class KeysViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
fun loadKeys() {
|
||||
safeLaunchLoad(
|
||||
fun loadKeys(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = keysCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { envVars ->
|
||||
val currentCategories = _uiState.value.categories
|
||||
val expandedCategories = currentCategories.map { it.name to it.expanded }.toMap()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
categories = buildCategoryList(envVars, expandedCategories.keys),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -178,7 +206,7 @@ class KeysViewModel :
|
||||
toastMessage = "Key added successfully",
|
||||
)
|
||||
}
|
||||
loadKeys()
|
||||
loadKeys(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -223,7 +251,7 @@ class KeysViewModel :
|
||||
toastMessage = "Key deleted successfully",
|
||||
)
|
||||
}
|
||||
loadKeys()
|
||||
loadKeys(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -281,7 +309,7 @@ class KeysViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(keysChanged = true, toastMessage = "Key updated successfully") }
|
||||
loadKeys()
|
||||
loadKeys(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
@@ -70,6 +71,7 @@ fun LogsScreen(
|
||||
viewModel: LogsViewModel = viewModel { LogsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val spacing = LocalSpacing.current
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -115,7 +117,8 @@ fun LogsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadLogs()
|
||||
}
|
||||
|
||||
@@ -146,7 +149,7 @@ fun LogsScreen(
|
||||
title = { Text(stringResource(R.string.screen_logs)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadLogs() },
|
||||
onRefresh = { viewModel.loadLogs(forceRefresh = true) },
|
||||
actions = {
|
||||
// Pause / Resume toggle
|
||||
IconButton(onClick = { pauseScroll = !pauseScroll }) {
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.m57.hermescontrol.ui.logs
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.LogResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -40,43 +42,51 @@ class LogsViewModel :
|
||||
private val _uiState = MutableStateFlow(LogsUiState())
|
||||
val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var loadInProgress = false
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var autoRefreshJob: Job? = null
|
||||
private val logsCache = SwrCache<String, LogResponse>()
|
||||
|
||||
fun loadLogs() {
|
||||
if (loadInProgress) return
|
||||
loadInProgress = true
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update { it.copy(isLoading = false, logs = emptyList(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun loadLogs(forceRefresh: Boolean = false) {
|
||||
val filters = _uiState.value.filters
|
||||
safeLaunchLoad(
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getLogs(
|
||||
file = filters.file,
|
||||
lines = filters.lines,
|
||||
level = filters.level,
|
||||
component = filters.component,
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
val body = data
|
||||
val logsList = body.lines ?: body.logs ?: emptyList()
|
||||
_uiState.update { it.copy(isLoading = false, logs = logsList) }
|
||||
loadInProgress = false
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load logs: $errorMsg",
|
||||
)
|
||||
}
|
||||
loadInProgress = false
|
||||
},
|
||||
)
|
||||
val cacheKey = "${filters.file}:${filters.level}:${filters.component}:${filters.lines}"
|
||||
loadJob =
|
||||
safeLaunchSwrLoad(
|
||||
cache = logsCache,
|
||||
cacheKey = cacheKey,
|
||||
forceRefresh = forceRefresh,
|
||||
currentJob = loadJob,
|
||||
onCacheHit = { data ->
|
||||
val logsList = data.lines ?: data.logs ?: emptyList()
|
||||
_uiState.update { it.copy(isLoading = false, logs = logsList, errorMessage = null) }
|
||||
},
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getLogs(
|
||||
file = filters.file,
|
||||
lines = filters.lines,
|
||||
level = filters.level,
|
||||
component = filters.component,
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
val logsList = data.lines ?: data.logs ?: emptyList()
|
||||
_uiState.update { it.copy(isLoading = false, logs = logsList) }
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load logs: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Update the server-side filters and immediately reload. */
|
||||
|
||||
@@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.McpCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
@@ -99,6 +100,7 @@ fun McpServersScreen(
|
||||
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val spacing = LocalSpacing.current
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
@@ -127,14 +129,17 @@ fun McpServersScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.loadServers() }
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadServers()
|
||||
}
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_mcp_servers)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadServers() },
|
||||
onRefresh = { viewModel.loadServers(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { viewModel.testAllServers() },
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.mcp
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||
import com.m57.hermescontrol.data.model.McpCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
||||
@@ -9,11 +10,13 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -69,10 +72,39 @@ class McpServersViewModel :
|
||||
private val _uiState = MutableStateFlow(McpServersUiState())
|
||||
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val mcpCache = SwrCache<String, McpServersResponse>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
servers = emptyList(),
|
||||
serverTestResults = emptyMap(),
|
||||
testingServers = emptySet(),
|
||||
isTestingAll = false,
|
||||
errorMessage = null,
|
||||
activeOAuthFlow = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────
|
||||
|
||||
fun loadServers() {
|
||||
safeLaunchLoad(
|
||||
fun loadServers(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = mcpCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
servers = data.servers.orEmpty(),
|
||||
serverTestResults = emptyMap(),
|
||||
testingServers = emptySet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -247,7 +279,7 @@ class McpServersViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Server '$name' deleted") }
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -305,7 +337,9 @@ class McpServersViewModel :
|
||||
toastMessage = msg,
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
if (successCount > 0) {
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,7 +405,7 @@ class McpServersViewModel :
|
||||
toastMessage = "Server '${request.name}' added",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -428,7 +462,7 @@ class McpServersViewModel :
|
||||
toastMessage = "Env var '$key' added",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -453,7 +487,7 @@ class McpServersViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Env var '$key' removed") }
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -520,7 +554,7 @@ class McpServersViewModel :
|
||||
toastMessage = "Catalog entry '${entry.name}' installed",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -605,7 +639,7 @@ class McpServersViewModel :
|
||||
toastMessage = "OAuth authorization successful!",
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
loadServers(forceRefresh = true)
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
|
||||
@@ -112,7 +112,7 @@ fun MemoryScreen(
|
||||
title = { Text(stringResource(R.string.screen_memory)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.load() },
|
||||
onRefresh = { viewModel.load(forceRefresh = true) },
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
when {
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.memory
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
||||
import com.m57.hermescontrol.data.model.MemoryResetRequest
|
||||
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||
@@ -10,6 +13,9 @@ import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -39,50 +45,89 @@ class MemoryViewModel :
|
||||
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
|
||||
// connection switch). An additional AuthManager.dataScopeFlow collector would
|
||||
// double-fire on the same transition and issue a duplicate request, so the
|
||||
// coordinator flows remain the single trigger.
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.switched.collect {
|
||||
load(silent = true)
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched.collect {
|
||||
load(silent = true)
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(silent: Boolean = false) {
|
||||
private var loadJob: Job? = null
|
||||
private val memoryCache = SwrCache<String, MemoryResponse>()
|
||||
|
||||
private fun onDataScopeChanged(newScope: DataScope?) {
|
||||
loadJob?.cancel()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
memory = null,
|
||||
learningGraph = null,
|
||||
resetting = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
// A scope switch must silently reload the new context's data. Clearing alone
|
||||
// would leave the screen empty when it is not the composition performing the load.
|
||||
load(silent = true)
|
||||
}
|
||||
|
||||
fun load(
|
||||
silent: Boolean = false,
|
||||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
if (_uiState.value.isLoading) return
|
||||
if (!silent) {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val scopedKey = requestScope?.inMemoryKey("default")
|
||||
if (forceRefresh && scopedKey != null) memoryCache.remove(scopedKey)
|
||||
val cached = if (!forceRefresh && scopedKey != null) memoryCache.get(scopedKey) else null
|
||||
if (cached != null) {
|
||||
_uiState.update { it.copy(isLoading = false, memory = cached, errorMessage = null) }
|
||||
} else if (!silent) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false, memory = result.data) }
|
||||
}
|
||||
loadJob =
|
||||
viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val memoryDeferred = async { safeApiCall { ApiClient.hermesApi.getMemory() } }
|
||||
val graphDeferred = async { safeApiCall { ApiClient.hermesApi.getLearningGraph() } }
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.error.message,
|
||||
)
|
||||
val memoryResult = memoryDeferred.await()
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (currentScope != requestScope) return@coroutineScope
|
||||
|
||||
when (memoryResult) {
|
||||
is NetworkResult.Success -> {
|
||||
if (scopedKey != null) memoryCache.put(scopedKey, memoryResult.data)
|
||||
_uiState.update { it.copy(isLoading = false, memory = memoryResult.data) }
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
if (cached == null || forceRefresh) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = memoryResult.error.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val graphResult = graphDeferred.await()
|
||||
if (graphResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(learningGraph = graphResult.data) }
|
||||
}
|
||||
}
|
||||
}
|
||||
loadLearningGraph()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLearningGraph() {
|
||||
viewModelScope.launch {
|
||||
val result = safeApiCall { ApiClient.hermesApi.getLearningGraph() }
|
||||
if (result is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(learningGraph = result.data) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetMemory(target: String) {
|
||||
@@ -109,7 +154,7 @@ class MemoryViewModel :
|
||||
}
|
||||
}
|
||||
}
|
||||
load()
|
||||
load(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -32,6 +33,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -98,15 +100,15 @@ fun ModelScreen(
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_models)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
isRefreshing = state.isLoading || state.catalogLoading,
|
||||
onRefresh = { viewModel.loadAll(refresh = true) },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.providers.isEmpty() -> {
|
||||
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() -> {
|
||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadAll() },
|
||||
@@ -114,7 +116,10 @@ fun ModelScreen(
|
||||
)
|
||||
}
|
||||
|
||||
state.providers.isEmpty() -> {
|
||||
!state.isLoading &&
|
||||
!state.catalogLoading &&
|
||||
state.providers.isEmpty() &&
|
||||
state.mainModelProvider.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.model_empty_title),
|
||||
subtitle = stringResource(R.string.model_empty_desc),
|
||||
@@ -191,24 +196,38 @@ fun ModelScreen(
|
||||
}
|
||||
|
||||
// ── Provider List ──
|
||||
items(filteredProviders, key = { it.slug }) { provider ->
|
||||
val isExpanded = expandedProviderSlug == provider.slug
|
||||
val isCurrent = provider.is_current == true
|
||||
if (state.catalogLoading && state.providers.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(filteredProviders, key = { it.slug }) { provider ->
|
||||
val isExpanded = expandedProviderSlug == provider.slug
|
||||
val isCurrent = provider.is_current == true
|
||||
|
||||
ProviderCard(
|
||||
provider = provider,
|
||||
isExpanded = isExpanded,
|
||||
isCurrent = isCurrent,
|
||||
query = query,
|
||||
activeProfile = state.activeProfile,
|
||||
pinnedModels = state.pinnedModels,
|
||||
onToggleExpand = {
|
||||
expandedProviderSlug = if (isExpanded) null else provider.slug
|
||||
},
|
||||
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
|
||||
onPin = { slug, model -> viewModel.pinModel(slug, model) },
|
||||
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
|
||||
)
|
||||
ProviderCard(
|
||||
provider = provider,
|
||||
isExpanded = isExpanded,
|
||||
isCurrent = isCurrent,
|
||||
query = query,
|
||||
activeProfile = state.activeProfile,
|
||||
pinnedModels = state.pinnedModels,
|
||||
onToggleExpand = {
|
||||
expandedProviderSlug = if (isExpanded) null else provider.slug
|
||||
},
|
||||
onModelClick = { slug, model -> viewModel.selectModel(slug, model) },
|
||||
onPin = { slug, model -> viewModel.pinModel(slug, model) },
|
||||
onUnpin = { slug, model -> viewModel.unpinModel(slug, model) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +260,7 @@ fun ModelScreen(
|
||||
ModelPickerDialog(
|
||||
providers = state.providers,
|
||||
title = stringResource(R.string.model_set_main),
|
||||
isLoading = state.isLoading && state.providers.isEmpty(),
|
||||
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
|
||||
pinnedModels = state.pinnedModels,
|
||||
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
|
||||
onSelect = { provider, model ->
|
||||
@@ -267,7 +286,7 @@ fun ModelScreen(
|
||||
ModelPickerDialog(
|
||||
providers = state.providers,
|
||||
title = stringResource(R.string.model_set_aux, state.auxPickerTask),
|
||||
isLoading = state.isLoading && state.providers.isEmpty(),
|
||||
isLoading = (state.isLoading || state.catalogLoading) && state.providers.isEmpty(),
|
||||
pinnedModels = state.pinnedModels,
|
||||
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
|
||||
onSelect = { provider, model ->
|
||||
|
||||
@@ -42,6 +42,7 @@ val AUX_TASKS =
|
||||
|
||||
data class ModelUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val catalogLoading: Boolean = false,
|
||||
val providers: List<ModelProvider> = emptyList(),
|
||||
val activeProfile: ProfileInfo? = null,
|
||||
val errorMessage: String? = null,
|
||||
@@ -79,7 +80,7 @@ class ModelViewModel(
|
||||
}
|
||||
|
||||
fun loadAll(refresh: Boolean = false) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
_uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
// Phase 1: Launch fast lightweight calls first (profiles, aux, moa)
|
||||
val activeProfileDeferred =
|
||||
@@ -145,6 +146,7 @@ class ModelViewModel(
|
||||
// Render Phase 1 results right away so UI controls update without waiting for model options
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
activeProfile = activeProfile ?: it.activeProfile,
|
||||
pinnedModels = AuthManager.getPinnedModels(),
|
||||
mainModelProvider = mainModel.first,
|
||||
@@ -160,7 +162,7 @@ class ModelViewModel(
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
catalogLoading = false,
|
||||
providers = optionsResult.data.providers.orEmpty(),
|
||||
)
|
||||
}
|
||||
@@ -169,9 +171,9 @@ class ModelViewModel(
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
catalogLoading = false,
|
||||
errorMessage =
|
||||
if (it.providers.isEmpty()) {
|
||||
if (it.providers.isEmpty() && it.mainModelProvider.isEmpty()) {
|
||||
"Failed to load model options: ${optionsResult.error.message}"
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.PairingItem
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
@@ -50,8 +51,10 @@ fun PairingScreen(
|
||||
viewModel: PairingViewModel = viewModel { PairingViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadPairing()
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ fun PairingScreen(
|
||||
title = { Text(stringResource(R.string.screen_pairing)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadPairing() },
|
||||
onRefresh = { viewModel.loadPairing(forceRefresh = true) },
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
val pairing = state.pairing
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.pairing
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||
import com.m57.hermescontrol.data.model.PairingResponse
|
||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||
@@ -12,6 +14,7 @@ import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.refreshOnChange
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -37,6 +40,11 @@ class PairingViewModel :
|
||||
val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var launchJob: Job? = null
|
||||
private val pairingCache = SwrCache<String, PairingResponse>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update { it.copy(isLoading = false, pairing = null, errorMessage = null, actionKey = null) }
|
||||
}
|
||||
|
||||
init {
|
||||
// Issue #784: gateway broadcasts pairing.changed — refresh silently
|
||||
@@ -44,14 +52,23 @@ class PairingViewModel :
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.PAIRING,
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
|
||||
onSuccess = { data -> _uiState.update { it.copy(pairing = data) } },
|
||||
onSuccess = { data ->
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null) pairingCache.put(requestScope.inMemoryKey("default"), data)
|
||||
_uiState.update { it.copy(pairing = data) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun loadPairing() {
|
||||
fun loadPairing(forceRefresh: Boolean = false) {
|
||||
launchJob =
|
||||
safeLaunchLoad(
|
||||
safeLaunchSwrLoad(
|
||||
cache = pairingCache,
|
||||
forceRefresh = forceRefresh,
|
||||
currentJob = launchJob,
|
||||
onCacheHit = { cached ->
|
||||
_uiState.update { it.copy(isLoading = false, pairing = cached, errorMessage = null) }
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
|
||||
onStart = {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
@@ -127,7 +144,7 @@ class PairingViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { onSuccess(it.copy(actionKey = null)) }
|
||||
loadPairing()
|
||||
loadPairing(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.MemoryProviderDetailKey
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
@@ -75,11 +76,17 @@ fun PluginsScreen(
|
||||
viewModel: PluginsViewModel = viewModel { PluginsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var showDetail by remember { mutableStateOf<PluginInfo?>(null) }
|
||||
var showCatalogDetail by remember { mutableStateOf<PluginCatalogEntry?>(null) }
|
||||
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadPlugins()
|
||||
}
|
||||
|
||||
val filteredPlugins =
|
||||
remember(query, state.plugins) {
|
||||
state.plugins.filter { plugin ->
|
||||
@@ -111,10 +118,6 @@ fun PluginsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadPlugins()
|
||||
}
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
// Remove confirmation dialog
|
||||
@@ -152,7 +155,7 @@ fun PluginsScreen(
|
||||
isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading,
|
||||
onRefresh = {
|
||||
if (state.selectedTab == PluginsTab.INSTALLED) {
|
||||
viewModel.loadPlugins()
|
||||
viewModel.loadPlugins(forceRefresh = true)
|
||||
} else {
|
||||
viewModel.loadCatalog(isRefresh = true)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package com.m57.hermescontrol.ui.plugins
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.model.ProviderOption
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
@@ -13,6 +17,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -73,8 +78,56 @@ class PluginsViewModel :
|
||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
||||
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadPlugins() {
|
||||
safeLaunchLoad(
|
||||
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
plugins = emptyList(),
|
||||
orphanPlugins = emptyList(),
|
||||
errorMessage = null,
|
||||
memoryProvider = "",
|
||||
memoryOptions = emptyList(),
|
||||
contextEngine = "compressor",
|
||||
contextOptions = emptyList(),
|
||||
providerBusy = false,
|
||||
rowBusy = null,
|
||||
removeConfirmPlugin = null,
|
||||
rescanBusy = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPlugins(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = pluginsCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
val plugins = data.plugins.orEmpty()
|
||||
val providers = data.providers
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
plugins = plugins,
|
||||
orphanPlugins =
|
||||
data.orphanDashboardPlugins.orEmpty().map { orphan ->
|
||||
PluginInfo(
|
||||
name = orphan.name ?: "unknown",
|
||||
description = orphan.description ?: orphan.label,
|
||||
version = null,
|
||||
source = null,
|
||||
runtimeStatus = null,
|
||||
)
|
||||
},
|
||||
memoryProvider = providers?.memoryProvider ?: "",
|
||||
memoryOptions = providers?.memoryOptions.orEmpty(),
|
||||
contextEngine = providers?.contextEngine ?: "compressor",
|
||||
contextOptions = providers?.contextOptions.orEmpty(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getPlugins() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -85,10 +138,10 @@ class PluginsViewModel :
|
||||
isLoading = false,
|
||||
plugins = plugins,
|
||||
orphanPlugins =
|
||||
data.orphanDashboardPlugins.orEmpty().map {
|
||||
data.orphanDashboardPlugins.orEmpty().map { orphan ->
|
||||
PluginInfo(
|
||||
name = it.name ?: "unknown",
|
||||
description = it.description ?: it.label,
|
||||
name = orphan.name ?: "unknown",
|
||||
description = orphan.description ?: orphan.label,
|
||||
version = null,
|
||||
source = null,
|
||||
runtimeStatus = null,
|
||||
@@ -153,7 +206,7 @@ class PluginsViewModel :
|
||||
toastMessage = "Plugin installed successfully",
|
||||
)
|
||||
}
|
||||
loadPlugins()
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -194,7 +247,7 @@ class PluginsViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(providerBusy = false, toastMessage = "Plugin providers saved") }
|
||||
loadPlugins()
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -219,7 +272,7 @@ class PluginsViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(rescanBusy = false, toastMessage = "Plugins rescanned") }
|
||||
loadPlugins()
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -237,6 +290,7 @@ class PluginsViewModel :
|
||||
fun togglePlugin(plugin: PluginInfo) {
|
||||
val originalEnabled = plugin.enabled
|
||||
val targetEnabled = !originalEnabled
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
|
||||
// Optimistically update
|
||||
_uiState.update { state ->
|
||||
@@ -261,8 +315,19 @@ class PluginsViewModel :
|
||||
safeApiCall { ApiClient.hermesApi.disablePlugin(plugin.name) }
|
||||
}
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
val updated = _uiState.value.plugins
|
||||
if (requestScope != null) {
|
||||
pluginsCache.put(requestScope.scopedKey("default"), PluginsHubResponse(plugins = updated))
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: ${result.error.message}")
|
||||
val reverted = _uiState.value.plugins
|
||||
if (requestScope != null) {
|
||||
pluginsCache.put(requestScope.scopedKey("default"), PluginsHubResponse(plugins = reverted))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +341,7 @@ class PluginsViewModel :
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
||||
loadPlugins()
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -309,7 +374,7 @@ class PluginsViewModel :
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
|
||||
clearRowBusy(name)
|
||||
loadPlugins()
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -331,7 +396,7 @@ class PluginsViewModel :
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
|
||||
clearRowBusy(name)
|
||||
loadPlugins()
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -359,7 +424,7 @@ class PluginsViewModel :
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin visibility updated") }
|
||||
clearRowBusy(plugin.name)
|
||||
loadPlugins()
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -469,7 +534,7 @@ class PluginsViewModel :
|
||||
toastMessage = "Plugin \"${entry.displayName}\" installed successfully",
|
||||
)
|
||||
}
|
||||
loadPlugins()
|
||||
loadPlugins(forceRefresh = true)
|
||||
loadCatalog(isRefresh = true)
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
|
||||
@@ -64,6 +64,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
@@ -83,6 +84,7 @@ fun ProfilesScreen(
|
||||
viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
|
||||
var soulEditProfileName by remember { mutableStateOf<String?>(null) }
|
||||
var modelEditProfileName by remember { mutableStateOf<String?>(null) }
|
||||
@@ -106,7 +108,8 @@ fun ProfilesScreen(
|
||||
|
||||
var isBuildingProfile by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadProfiles()
|
||||
}
|
||||
|
||||
@@ -133,7 +136,7 @@ fun ProfilesScreen(
|
||||
if (isBuildingProfile) {
|
||||
null
|
||||
} else {
|
||||
{ viewModel.loadProfiles() }
|
||||
{ viewModel.loadProfiles(forceRefresh = true) }
|
||||
},
|
||||
actions = {
|
||||
if (!isBuildingProfile) {
|
||||
|
||||
@@ -3,12 +3,15 @@ package com.m57.hermescontrol.ui.profiles
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.CreateProfileRequest
|
||||
import com.m57.hermescontrol.data.model.HubSkill
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||
import com.m57.hermescontrol.data.model.RenameProfileRequest
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
@@ -112,8 +115,53 @@ class ProfilesViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun loadProfiles() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
private val profilesCache = SwrCache<String, ProfilesResponse>()
|
||||
|
||||
/**
|
||||
* Reset state owned by the previous server/profile context (PR #1192 cache scoping).
|
||||
* Profiles, their soul content, setup commands, and builder data all belong to the
|
||||
* server that served them and must not be shown for a new context.
|
||||
*/
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
profiles = emptyList(),
|
||||
activeProfileName = null,
|
||||
sharedGatewayProfiles = emptyList(),
|
||||
selectedSoulContent = null,
|
||||
isLoadingSoul = false,
|
||||
errorMessage = null,
|
||||
isAutoDescribing = false,
|
||||
setupCommand = null,
|
||||
isLoadingSetupCommand = false,
|
||||
modelPickerPinned = emptyList(),
|
||||
modelProviders = emptyList(),
|
||||
isLoadingBuilderData = false,
|
||||
availableSkills = emptyList(),
|
||||
hubSearchResults = emptyList(),
|
||||
isSearchingHub = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadProfiles(forceRefresh: Boolean = false) {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val scopedKey = requestScope?.inMemoryKey("default")
|
||||
if (forceRefresh && scopedKey != null) profilesCache.remove(scopedKey)
|
||||
val cached = if (!forceRefresh && scopedKey != null) profilesCache.get(scopedKey) else null
|
||||
if (cached != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
profiles = cached.profiles.orEmpty(),
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
@@ -125,7 +173,11 @@ class ProfilesViewModel(
|
||||
val profilesResult = profilesDeferred.await()
|
||||
val activeResult = activeDeferred.await()
|
||||
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (currentScope != requestScope) return@coroutineScope
|
||||
|
||||
if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) {
|
||||
if (scopedKey != null) profilesCache.put(scopedKey, profilesResult.data)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
@@ -133,6 +185,7 @@ class ProfilesViewModel(
|
||||
activeProfileName = activeResult.data.active,
|
||||
sharedGatewayProfiles = emptyList(),
|
||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,7 +198,7 @@ class ProfilesViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (cached == null || forceRefresh) {
|
||||
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
|
||||
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
|
||||
_uiState.update {
|
||||
@@ -159,8 +212,11 @@ class ProfilesViewModel(
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (currentScope == requestScope && (cached == null || forceRefresh)) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +234,7 @@ class ProfilesViewModel(
|
||||
when (val result = ProfileSwitchCoordinator.switchProfile(name)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Switched to profile $name") }
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -244,7 +300,7 @@ class ProfilesViewModel(
|
||||
toastMessage = "Soul updated successfully",
|
||||
)
|
||||
}
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -276,7 +332,7 @@ class ProfilesViewModel(
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Model settings updated") }
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -322,7 +378,7 @@ class ProfilesViewModel(
|
||||
toastMessage = "Profile '$sourceProfileName' cloned successfully to '$newProfileName'",
|
||||
)
|
||||
}
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -359,7 +415,7 @@ class ProfilesViewModel(
|
||||
toastMessage = "Profile '$profileName' description updated",
|
||||
)
|
||||
}
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -397,7 +453,7 @@ class ProfilesViewModel(
|
||||
toastMessage = "Profile '$oldName' renamed to '$newName'",
|
||||
)
|
||||
}
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -426,7 +482,7 @@ class ProfilesViewModel(
|
||||
toastMessage = "Profile '$name' deleted",
|
||||
)
|
||||
}
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -463,7 +519,7 @@ class ProfilesViewModel(
|
||||
toastMessage = "Auto-described profile '$name'",
|
||||
)
|
||||
}
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
} else {
|
||||
// Backend returns generation failures as ok:false + reason,
|
||||
// NOT an HTTP error — surface the reason to the user.
|
||||
@@ -656,7 +712,7 @@ class ProfilesViewModel(
|
||||
toastMessage = "Profile ${request.name} created successfully",
|
||||
)
|
||||
}
|
||||
loadProfiles()
|
||||
loadProfiles(forceRefresh = true)
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.OAuthProvider
|
||||
import com.m57.hermescontrol.data.model.OAuthStartResponse
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
@@ -65,9 +66,13 @@ fun ProvidersScreen(
|
||||
viewModel: ProvidersViewModel = viewModel { ProvidersViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.load() }
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
@@ -98,7 +103,7 @@ fun ProvidersScreen(
|
||||
title = { Text(stringResource(R.string.screen_providers)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.load() },
|
||||
onRefresh = { viewModel.load(forceRefresh = true) },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.providers.isEmpty() -> {
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.ui.providers
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.OAuthPollResponse
|
||||
import com.m57.hermescontrol.data.model.OAuthProvider
|
||||
import com.m57.hermescontrol.data.model.OAuthProvidersResponse
|
||||
import com.m57.hermescontrol.data.model.OAuthStartResponse
|
||||
import com.m57.hermescontrol.data.model.OAuthSubmitRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
@@ -12,6 +14,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -66,9 +69,35 @@ class ProvidersViewModel :
|
||||
val uiState: StateFlow<ProvidersUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var pollJob: Job? = null
|
||||
private val providersCache = SwrCache<String, OAuthProvidersResponse>()
|
||||
|
||||
fun load() {
|
||||
safeLaunchLoad(
|
||||
fun clearScopeOwnedState() {
|
||||
pollJob?.cancel()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
providers = emptyList(),
|
||||
errorMessage = null,
|
||||
flowPhase = OAuthFlowPhase.IDLE,
|
||||
flowProvider = null,
|
||||
flowErrorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun load(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = providersCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
providers = data.providers.orEmpty(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getOAuthProviders() } },
|
||||
onStart = {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
@@ -105,7 +134,7 @@ class ProvidersViewModel :
|
||||
toastMessage = "Disconnected",
|
||||
)
|
||||
}
|
||||
load()
|
||||
load(forceRefresh = true)
|
||||
},
|
||||
onError = { error ->
|
||||
_uiState.update {
|
||||
@@ -301,7 +330,7 @@ class ProvidersViewModel :
|
||||
|
||||
private fun finishFlow(status: String) {
|
||||
pollJob?.cancel()
|
||||
load()
|
||||
load(forceRefresh = true)
|
||||
// Stay on DONE so the dialog can show a success confirmation; the Screen's
|
||||
// Done button calls dismissFlow() to reset to IDLE. (Setting IDLE here would
|
||||
// conflate with DONE via StateFlow and unmount the dialog before the user sees it.)
|
||||
|
||||
@@ -468,7 +468,7 @@ fun SessionsScreen(
|
||||
title = { Text(stringResource(R.string.screen_history)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadSessions() },
|
||||
onRefresh = { viewModel.loadSessions(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { NavigationController.openNewChat() },
|
||||
|
||||
@@ -3,16 +3,21 @@ package com.m57.hermescontrol.ui.sessions
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SessionListCacheStore
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.ProjectInfo
|
||||
import com.m57.hermescontrol.data.model.PruneRequest
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.data.ws.ChangeEventHub
|
||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
@@ -140,15 +145,31 @@ class SessionsViewModel(
|
||||
private var liveStatusRefreshPending = false
|
||||
private var generation: Long = 0
|
||||
private var rawPaginationOffset: Int = 0
|
||||
private val sessionsPageCache = SwrCache<String, SessionListResponse>()
|
||||
|
||||
init {
|
||||
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
|
||||
// connection switch). An additional AuthManager.dataScopeFlow collector would
|
||||
// double-fire on the same transition and reset/refetch History twice.
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.switched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #784: gateway broadcasts sessions.changed — refresh the list
|
||||
// silently (no spinner, no selection reset) instead of blind polling.
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.SESSIONS,
|
||||
apiCall = {
|
||||
val requestGeneration = generation
|
||||
val section = _uiState.value.section
|
||||
val capturedSection = _uiState.value.section
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
when (
|
||||
val result =
|
||||
safeApiCall {
|
||||
@@ -156,17 +177,31 @@ class SessionsViewModel(
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
source = capturedSection.source,
|
||||
excludeSources = capturedSection.excludeSources,
|
||||
)
|
||||
}
|
||||
) {
|
||||
is NetworkResult.Success -> NetworkResult.Success(requestGeneration to result.data)
|
||||
is NetworkResult.Failure -> result
|
||||
is NetworkResult.Success -> {
|
||||
NetworkResult.Success(
|
||||
Triple(requestGeneration, Pair(requestScope, capturedSection), result.data),
|
||||
)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
result
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess = { (requestGeneration, data) ->
|
||||
if (requestGeneration == generation) {
|
||||
onSuccess = { (requestGeneration, scopeAndSection, data) ->
|
||||
val (requestScope, capturedSection) = scopeAndSection
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration == generation && currentScope == requestScope) {
|
||||
val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}"
|
||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||
val persistentKey = requestScope?.persistentKey(localKey)
|
||||
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
|
||||
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty()
|
||||
@@ -189,6 +224,41 @@ class SessionsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun onDataScopeChanged(newScope: DataScope? = AuthManager.currentDataScope()) {
|
||||
generation++
|
||||
searchGeneration++
|
||||
trackingGeneration++
|
||||
loadJob?.cancel()
|
||||
pageJob?.cancel()
|
||||
statsJob?.cancel()
|
||||
searchJob?.cancel()
|
||||
searchPageJob?.cancel()
|
||||
projectsJob?.cancel()
|
||||
rawPaginationOffset = 0
|
||||
resolvedParentCache.clear()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
sessions = emptyList(),
|
||||
total = 0,
|
||||
hasMore = false,
|
||||
selectedIds = emptySet(),
|
||||
isSelecting = false,
|
||||
errorMessage = null,
|
||||
searchResults = emptyList(),
|
||||
searchError = null,
|
||||
searchLoadMoreError = null,
|
||||
searchHasMore = false,
|
||||
searchNextOffset = null,
|
||||
isSearching = false,
|
||||
isLoadingMoreSearch = false,
|
||||
stats = SessionStats(),
|
||||
emptyCount = 0,
|
||||
projects = emptyList(),
|
||||
)
|
||||
}
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Page size sent to the server. Matches the desktop sidebar's
|
||||
* SIDEBAR_SESSIONS_PAGE_SIZE (50); the backend caps `limit` at 100.
|
||||
@@ -283,9 +353,43 @@ class SessionsViewModel(
|
||||
}
|
||||
|
||||
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||
fun loadSessions() {
|
||||
fun loadSessions(forceRefresh: Boolean = false) {
|
||||
val requestGeneration = generation
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val section = _uiState.value.section
|
||||
val localKey = "${section.name}:${section.source}:${section.excludeSources}"
|
||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||
val persistentKey = requestScope?.persistentKey(localKey)
|
||||
if (forceRefresh) {
|
||||
if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey)
|
||||
if (persistentKey != null) SessionListCacheStore.remove(persistentKey)
|
||||
}
|
||||
val cached =
|
||||
if (!forceRefresh && inMemoryKey != null && persistentKey != null) {
|
||||
sessionsPageCache.get(inMemoryKey)
|
||||
?: SessionListCacheStore.get(persistentKey)?.also { sessionsPageCache.put(inMemoryKey, it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (cached != null) {
|
||||
val sessionsList = cached.sessions.orEmpty()
|
||||
val paging =
|
||||
SessionsPaging.resolveInitialPaging(
|
||||
receivedCount = minOf(cached.sessions.size, PAGE_SIZE),
|
||||
pageSize = PAGE_SIZE,
|
||||
backendTotal = cached.total,
|
||||
accumulatedCount = sessionsList.size,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
sessions = sessionsList,
|
||||
total = paging.total,
|
||||
hasMore = paging.hasMore,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
pageJob?.cancel()
|
||||
loadEmptyCount()
|
||||
if (trackingJob?.isActive == true) {
|
||||
@@ -306,9 +410,16 @@ class SessionsViewModel(
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onStart = {
|
||||
if (cached == null) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
},
|
||||
onSuccess = { data ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
|
||||
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
|
||||
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
val sessionsList = data.sessions.orEmpty()
|
||||
val paging =
|
||||
@@ -331,13 +442,15 @@ class SessionsViewModel(
|
||||
stitchMissingParents(requestGeneration)
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
errorMessage = "Failed to load sessions: $errorMsg",
|
||||
)
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
|
||||
if (cached == null || forceRefresh) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load sessions: $errorMsg",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -361,6 +474,7 @@ class SessionsViewModel(
|
||||
val state = _uiState.value
|
||||
if (state.isLoading || state.isLoadingMore || !state.hasMore || state.isSearchMode) return
|
||||
val requestGeneration = generation
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val offset = rawPaginationOffset
|
||||
|
||||
_uiState.update { it.copy(isLoadingMore = true) }
|
||||
@@ -376,7 +490,8 @@ class SessionsViewModel(
|
||||
excludeSources = state.section.excludeSources,
|
||||
)
|
||||
}
|
||||
if (requestGeneration != generation) return@launch
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = result.data
|
||||
@@ -450,10 +565,11 @@ class SessionsViewModel(
|
||||
if (_uiState.value.sessions.isEmpty()) loadSessions()
|
||||
return
|
||||
}
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
searchJob =
|
||||
viewModelScope.launch {
|
||||
delay(SEARCH_DEBOUNCE_MS)
|
||||
requestSearchPage(query, section, requestGeneration, 0)
|
||||
requestSearchPage(query, section, requestGeneration, requestScope, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,10 +578,11 @@ class SessionsViewModel(
|
||||
val offset = state.searchNextOffset ?: return
|
||||
if (!state.isSearchMode || state.isSearching || state.isLoadingMoreSearch || !state.searchHasMore) return
|
||||
val requestGeneration = searchGeneration
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { it.copy(isLoadingMoreSearch = true, searchLoadMoreError = null) }
|
||||
searchPageJob =
|
||||
viewModelScope.launch {
|
||||
requestSearchPage(state.searchQuery, state.section, requestGeneration, offset)
|
||||
requestSearchPage(state.searchQuery, state.section, requestGeneration, requestScope, offset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +590,7 @@ class SessionsViewModel(
|
||||
query: String,
|
||||
section: HistorySection,
|
||||
requestGeneration: Long,
|
||||
requestScope: DataScope?,
|
||||
offset: Int,
|
||||
) {
|
||||
val result =
|
||||
@@ -486,7 +604,12 @@ class SessionsViewModel(
|
||||
offset = offset,
|
||||
)
|
||||
}
|
||||
if (requestGeneration != searchGeneration || _uiState.value.section != section) return
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != searchGeneration || _uiState.value.section != section ||
|
||||
currentScope != requestScope
|
||||
) {
|
||||
return
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
@@ -906,11 +1029,15 @@ class SessionsViewModel(
|
||||
|
||||
/** Refresh the empty-session count (REST; auxiliary — failures are silent). */
|
||||
fun loadEmptyCount() {
|
||||
val requestGeneration = generation
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
runCatching {
|
||||
safeApiCall { ApiClient.hermesApi.getEmptySessionCount() }
|
||||
}.getOrNull()
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@launch
|
||||
val count = (result as? NetworkResult.Success)?.data?.count
|
||||
if (count != null) {
|
||||
_uiState.update { it.copy(emptyCount = count) }
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
import com.m57.hermescontrol.data.config.resolveBaseUrl
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.SessionListCacheStore
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
@@ -365,6 +366,7 @@ class SettingsViewModel(
|
||||
AuthManager.setSessionCookie(null)
|
||||
AuthManager.setWsAuthParam("token")
|
||||
AuthManager.clearLastOpenedSessionId()
|
||||
SessionListCacheStore.clear()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
// Don't rebuild ApiClient here — let the navigation complete first
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
@@ -43,11 +44,13 @@ fun SkillsScreen(
|
||||
viewModel: SkillsViewModel = viewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var selectedStatus by remember { mutableStateOf(SkillFilter.ALL_STATUSES) }
|
||||
var selectedCategory by remember { mutableStateOf(CATEGORY_ALL) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadSkills()
|
||||
}
|
||||
|
||||
@@ -58,7 +61,7 @@ fun SkillsScreen(
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = {
|
||||
if (state.viewMode == SkillsViewMode.INSTALLED) {
|
||||
viewModel.loadSkills()
|
||||
viewModel.loadSkills(forceRefresh = true)
|
||||
} else {
|
||||
viewModel.loadHubSources()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.HubSkill
|
||||
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
@@ -17,6 +20,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -93,11 +97,35 @@ class SkillsViewModel(
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var searchJob: Job? = null
|
||||
private val skillsCache = SwrCache<String, List<Skill>>()
|
||||
|
||||
fun loadSkills() {
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
skills = emptyList(),
|
||||
errorMessage = null,
|
||||
editingSkillName = null,
|
||||
skillContent = null,
|
||||
isLoadingContent = false,
|
||||
isSavingContent = false,
|
||||
saveContentSuccess = false,
|
||||
previewSkillName = null,
|
||||
previewSkillContent = null,
|
||||
isLoadingPreview = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSkills(forceRefresh: Boolean = false) {
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
safeLaunchSwrLoad(
|
||||
cache = skillsCache,
|
||||
forceRefresh = forceRefresh,
|
||||
currentJob = loadJob,
|
||||
onCacheHit = { cached ->
|
||||
_uiState.update { it.copy(isLoading = false, skills = cached, errorMessage = null) }
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -328,7 +356,7 @@ class SkillsViewModel(
|
||||
)
|
||||
}
|
||||
// Refresh installed skills
|
||||
loadSkills()
|
||||
loadSkills(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -365,7 +393,7 @@ class SkillsViewModel(
|
||||
toastMessage = "Uninstalled: $name",
|
||||
)
|
||||
}
|
||||
loadSkills()
|
||||
loadSkills(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -396,7 +424,9 @@ class SkillsViewModel(
|
||||
fun toggleSkill(skill: Skill) {
|
||||
val originalEnabled = skill.enabled
|
||||
val targetEnabled = !originalEnabled
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
|
||||
// Optimistically update
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
skills =
|
||||
@@ -411,12 +441,23 @@ class SkillsViewModel(
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
val updated = _uiState.value.skills
|
||||
if (requestScope != null) {
|
||||
skillsCache.put(requestScope.scopedKey("default"), updated)
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertSkillToggle(
|
||||
skill.name,
|
||||
originalEnabled,
|
||||
"Failed to toggle skill: ${result.error.message}",
|
||||
)
|
||||
val reverted = _uiState.value.skills
|
||||
if (requestScope != null) {
|
||||
skillsCache.put(requestScope.scopedKey("default"), reverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,7 +566,7 @@ class SkillsViewModel(
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Skills updated") }
|
||||
loadSkills()
|
||||
loadSkills(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -154,48 +154,86 @@ class SystemViewModel(
|
||||
val statsResult = statsDeferred.await()
|
||||
val statusResult = statusDeferred.await()
|
||||
|
||||
val portalResult = portalDeferred.await()
|
||||
val curatorResult = curatorDeferred.await()
|
||||
|
||||
val credResult = credDeferred.await()
|
||||
val checkpointsResult = checkpointsDeferred.await()
|
||||
val hooksResult = hooksDeferred.await()
|
||||
val updateResult = updateDeferred.await()
|
||||
val doctorResult = doctorDeferred.await()
|
||||
|
||||
// Phase 1: Un-gate UI immediately with fast host stats & gateway status (~100-200ms)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
stats = (statsResult as? NetworkResult.Success)?.data,
|
||||
status = (statusResult as? NetworkResult.Success)?.data,
|
||||
portal = (portalResult as? NetworkResult.Success)?.data,
|
||||
curator = (curatorResult as? NetworkResult.Success)?.data,
|
||||
credentials =
|
||||
((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(),
|
||||
checkpoints = (checkpointsResult as? NetworkResult.Success)?.data,
|
||||
hooks = (hooksResult as? NetworkResult.Success)?.data,
|
||||
updateInfo = (updateResult as? NetworkResult.Success)?.data,
|
||||
doctorReport = (doctorResult as? NetworkResult.Success)?.data,
|
||||
stats = (statsResult as? NetworkResult.Success)?.data ?: state.stats,
|
||||
status = (statusResult as? NetworkResult.Success)?.data ?: state.status,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
|
||||
// Log failures in debug builds
|
||||
if (BuildConfig.DEBUG) {
|
||||
listOf(
|
||||
"stats" to statsResult,
|
||||
"status" to statusResult,
|
||||
"portal" to portalResult,
|
||||
"curator" to curatorResult,
|
||||
"credentials" to credResult,
|
||||
"checkpoints" to checkpointsResult,
|
||||
"hooks" to hooksResult,
|
||||
"update" to updateResult,
|
||||
"doctor" to doctorResult,
|
||||
).forEach { (name, result) ->
|
||||
if (result is NetworkResult.Failure) {
|
||||
Log.w(TAG, "$name endpoint: ${result.error.message}")
|
||||
}
|
||||
if (statsResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "stats endpoint: ${statsResult.error.message}")
|
||||
}
|
||||
if (statusResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "status endpoint: ${statusResult.error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Progressively hydrate remaining sections as each endpoint resolves
|
||||
launch {
|
||||
val portalResult = portalDeferred.await()
|
||||
if (portalResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(portal = portalResult.data) }
|
||||
} else if (BuildConfig.DEBUG && portalResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "portal endpoint: ${portalResult.error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
val curatorResult = curatorDeferred.await()
|
||||
if (curatorResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(curator = curatorResult.data) }
|
||||
} else if (BuildConfig.DEBUG && curatorResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "curator endpoint: ${curatorResult.error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
val credResult = credDeferred.await()
|
||||
if (credResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(credentials = credResult.data.providers.orEmpty()) }
|
||||
} else if (BuildConfig.DEBUG && credResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "credentials endpoint: ${credResult.error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
val checkpointsResult = checkpointsDeferred.await()
|
||||
if (checkpointsResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(checkpoints = checkpointsResult.data) }
|
||||
} else if (BuildConfig.DEBUG && checkpointsResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "checkpoints endpoint: ${checkpointsResult.error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
val hooksResult = hooksDeferred.await()
|
||||
if (hooksResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(hooks = hooksResult.data) }
|
||||
} else if (BuildConfig.DEBUG && hooksResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "hooks endpoint: ${hooksResult.error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
val updateResult = updateDeferred.await()
|
||||
if (updateResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(updateInfo = updateResult.data) }
|
||||
} else if (BuildConfig.DEBUG && updateResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "update endpoint: ${updateResult.error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
val doctorResult = doctorDeferred.await()
|
||||
if (doctorResult is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(doctorReport = doctorResult.data) }
|
||||
} else if (BuildConfig.DEBUG && doctorResult is NetworkResult.Failure) {
|
||||
Log.w(TAG, "doctor endpoint: ${doctorResult.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ToolsetDetailKey
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
@@ -60,9 +61,15 @@ fun ToolsetsScreen(
|
||||
viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadToolsets()
|
||||
}
|
||||
|
||||
val filteredToolsets =
|
||||
remember(query, state.toolsets) {
|
||||
state.toolsets.filter { toolset ->
|
||||
@@ -72,17 +79,13 @@ fun ToolsetsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadToolsets()
|
||||
}
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_toolsets)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadToolsets() },
|
||||
onRefresh = { viewModel.loadToolsets(forceRefresh = true) },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.toolsets.isEmpty() -> {
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.toolsets
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.Toolset
|
||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
@@ -9,6 +12,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -30,8 +34,19 @@ class ToolsetsViewModel :
|
||||
private val _uiState = MutableStateFlow(ToolsetsUiState())
|
||||
val uiState: StateFlow<ToolsetsUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadToolsets() {
|
||||
safeLaunchLoad(
|
||||
private val toolsetsCache = SwrCache<String, List<Toolset>>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update { it.copy(isLoading = false, toolsets = emptyList(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun loadToolsets(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = toolsetsCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { cached ->
|
||||
_uiState.update { it.copy(isLoading = false, toolsets = cached, errorMessage = null) }
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getToolsets() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -51,6 +66,7 @@ class ToolsetsViewModel :
|
||||
fun toggleToolset(toolset: Toolset) {
|
||||
val originalEnabled = toolset.enabled
|
||||
val targetEnabled = !originalEnabled
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
|
||||
// Optimistic UI update
|
||||
_uiState.update { state ->
|
||||
@@ -67,8 +83,19 @@ class ToolsetsViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled)) }
|
||||
}
|
||||
if (result is NetworkResult.Failure) {
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
val updated = _uiState.value.toolsets
|
||||
if (requestScope != null) {
|
||||
toolsetsCache.put(requestScope.scopedKey("default"), updated)
|
||||
}
|
||||
} else if (result is NetworkResult.Failure) {
|
||||
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${result.error.message}")
|
||||
val reverted = _uiState.value.toolsets
|
||||
if (requestScope != null) {
|
||||
toolsetsCache.put(requestScope.scopedKey("default"), reverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.WebhookSubscription
|
||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
@@ -71,6 +72,7 @@ fun WebhooksScreen(
|
||||
viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var showDetail by remember { mutableStateOf<WebhookSubscription?>(null) }
|
||||
@@ -84,7 +86,8 @@ fun WebhooksScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(dataScope) {
|
||||
viewModel.clearScopeOwnedState()
|
||||
viewModel.loadWebhooks()
|
||||
}
|
||||
|
||||
@@ -232,7 +235,7 @@ fun WebhooksScreen(
|
||||
title = { Text(stringResource(R.string.screen_webhooks)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadWebhooks() },
|
||||
onRefresh = { viewModel.loadWebhooks(forceRefresh = true) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.showCreateDialog() }) {
|
||||
Icon(
|
||||
|
||||
@@ -2,15 +2,18 @@ package com.m57.hermescontrol.ui.webhooks
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.CreateWebhookRequest
|
||||
import com.m57.hermescontrol.data.model.WebhookSubscription
|
||||
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
|
||||
import com.m57.hermescontrol.data.model.WebhooksResponse
|
||||
import com.m57.hermescontrol.data.model.WebhooksToggleRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -48,8 +51,37 @@ class WebhooksViewModel :
|
||||
private val _uiState = MutableStateFlow(WebhooksUiState())
|
||||
val uiState: StateFlow<WebhooksUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadWebhooks() {
|
||||
safeLaunchLoad(
|
||||
private val webhooksCache = SwrCache<String, WebhooksResponse>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
enabled = false,
|
||||
baseUrl = null,
|
||||
subscriptions = emptyList(),
|
||||
errorMessage = null,
|
||||
deleteTarget = null,
|
||||
togglingName = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadWebhooks(forceRefresh: Boolean = false) {
|
||||
safeLaunchSwrLoad(
|
||||
cache = webhooksCache,
|
||||
forceRefresh = forceRefresh,
|
||||
onCacheHit = { data ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
enabled = data.enabled,
|
||||
baseUrl = data.base_url,
|
||||
subscriptions = data.subscriptions.orEmpty(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getWebhooks() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
@@ -90,7 +122,7 @@ class WebhooksViewModel :
|
||||
toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}",
|
||||
)
|
||||
}
|
||||
loadWebhooks()
|
||||
loadWebhooks(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -185,7 +217,7 @@ class WebhooksViewModel :
|
||||
toastMessage = "Subscription \"$name\" created",
|
||||
)
|
||||
}
|
||||
loadWebhooks()
|
||||
loadWebhooks(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -226,7 +258,7 @@ class WebhooksViewModel :
|
||||
toastMessage = "Subscription \"$name\" ${if (enabled) "enabled" else "disabled"}",
|
||||
)
|
||||
}
|
||||
loadWebhooks()
|
||||
loadWebhooks(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -263,7 +295,7 @@ class WebhooksViewModel :
|
||||
_uiState.update {
|
||||
it.copy(toastMessage = "Subscription \"${target.name}\" deleted")
|
||||
}
|
||||
loadWebhooks()
|
||||
loadWebhooks(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
Reference in new issue
Block a user