refactor(chat): extract ChatSearchDelegate from ChatViewModel (#486)
* refactor(chat): extract ChatSearchDelegate from ChatViewModel god-object Move the 4 search methods (toggleSearch/setSearchQuery/navigateSearchMatch/ clearSearch) + searchJob field into a dedicated ChatSearchDelegate that owns the chat-search state transitions against the shared _uiState flow. ChatViewModel keeps thin one-line delegating wrappers so its public API (used by ChatScreen) is unchanged. This is the first separable slice of the ChatViewModel god-object (1421 LOC, 47 funcs). Streaming/session/WS clusters are intentionally left in place - they are entangled and will be addressed separately to avoid splitting one logical op across classes. Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK). * refactor(chat): extract ChatAttachmentsDelegate from ChatViewModel Move addAttachment/removeAttachment/clearAttachments into a dedicated ChatAttachmentsDelegate that owns pendingAttachments state transitions against the shared _uiState flow. ChatViewModel keeps 3 one-line delegating wrappers - public API unchanged. Read sites (sendMessage) already read pendingAttachments straight off uiState, so no delegate read API needed. Second separable slice of the ChatViewModel god-object (after #486 search). Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK). * refactor(chat): address Sourcery review on search extraction - Inject the CoroutineDispatcher into ChatSearchDelegate instead of hardcoding Dispatchers.Default (defaults to Dispatchers.Default so behavior is unchanged; now customizable per environment). - Group the four search delegating methods in ChatViewModel under a 'Compatibility façade' comment marking them as the transitional API surface around ChatSearchDelegate.
This commit is contained in:
3 files changed
+172
-98
No files matched your search
@@ -0,0 +1,52 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns pending-attachment state for [ChatViewModel], extracted to keep the
|
||||||
|
* god-object focused on messaging/session/streaming concerns.
|
||||||
|
*
|
||||||
|
* Behavior is identical to the original inline implementation: the three
|
||||||
|
* methods only mutate `_uiState.pendingAttachments`. Read sites (e.g. sendMessage)
|
||||||
|
* continue to read `pendingAttachments` straight off the shared uiState flow.
|
||||||
|
*/
|
||||||
|
class ChatAttachmentsDelegate(
|
||||||
|
private val uiState: MutableStateFlow<ChatUiState>,
|
||||||
|
) {
|
||||||
|
fun addAttachment(
|
||||||
|
uri: String,
|
||||||
|
name: String,
|
||||||
|
mimeType: String,
|
||||||
|
size: Long,
|
||||||
|
) {
|
||||||
|
uiState.update { state ->
|
||||||
|
val attachment =
|
||||||
|
Attachment(
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
size = size,
|
||||||
|
)
|
||||||
|
state.copy(
|
||||||
|
pendingAttachments = state.pendingAttachments + attachment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAttachment(index: Int) {
|
||||||
|
uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
pendingAttachments =
|
||||||
|
state.pendingAttachments.toMutableList().apply {
|
||||||
|
if (index in indices) removeAt(index)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAttachments() {
|
||||||
|
uiState.update { it.copy(pendingAttachments = emptyList()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds chat in-message search state and logic, extracted from [ChatViewModel]
|
||||||
|
* to keep the god-object focused on messaging/session concerns.
|
||||||
|
*
|
||||||
|
* Behavior is identical to the original inline implementation: it mutates the
|
||||||
|
* shared [uiState] flow for the four search-related fields and reads `messages`
|
||||||
|
* from it when computing matches.
|
||||||
|
*
|
||||||
|
* @param dispatcher The [CoroutineDispatcher] used for the (CPU-bound) search
|
||||||
|
* work. Defaults to [Dispatchers.Default] — the original behavior — but can
|
||||||
|
* be injected to reuse a caller's context or customize per environment.
|
||||||
|
*/
|
||||||
|
class ChatSearchDelegate(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val uiState: MutableStateFlow<ChatUiState>,
|
||||||
|
private val searchController: ChatSearchController = ChatSearchController(),
|
||||||
|
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
) {
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
|
fun toggleSearch() {
|
||||||
|
val current = uiState.value
|
||||||
|
if (current.isSearchActive) {
|
||||||
|
clearSearch()
|
||||||
|
} else {
|
||||||
|
uiState.update { it.copy(isSearchActive = true, searchQuery = "") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchQuery(query: String) {
|
||||||
|
searchJob?.cancel()
|
||||||
|
|
||||||
|
if (query.isBlank()) {
|
||||||
|
uiState.update {
|
||||||
|
it.copy(
|
||||||
|
searchQuery = query,
|
||||||
|
searchMatchIndices = emptyList(),
|
||||||
|
currentSearchMatchIndex = -1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep local state in sync immediately so UI feels responsive.
|
||||||
|
uiState.update {
|
||||||
|
it.copy(searchQuery = query)
|
||||||
|
}
|
||||||
|
|
||||||
|
searchJob = scope.launch(dispatcher) { runSearch(query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runSearch(query: String) {
|
||||||
|
val messages = uiState.value.messages
|
||||||
|
val indices = searchController.findMatches(messages, query)
|
||||||
|
|
||||||
|
uiState.update {
|
||||||
|
// Only update indices if the search query hasn't changed in the meantime
|
||||||
|
if (it.searchQuery == query) {
|
||||||
|
it.copy(
|
||||||
|
searchMatchIndices = indices,
|
||||||
|
currentSearchMatchIndex = if (indices.isNotEmpty()) 0 else -1,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateSearchMatch(direction: Int) {
|
||||||
|
uiState.update { state ->
|
||||||
|
val indices = state.searchMatchIndices
|
||||||
|
if (indices.isEmpty()) return@update state
|
||||||
|
val newIdx =
|
||||||
|
searchController.navigate(
|
||||||
|
currentIndex = state.currentSearchMatchIndex,
|
||||||
|
matchCount = indices.size,
|
||||||
|
direction = direction,
|
||||||
|
)
|
||||||
|
state.copy(currentSearchMatchIndex = newIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSearch() {
|
||||||
|
uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSearchActive = false,
|
||||||
|
searchQuery = "",
|
||||||
|
searchMatchIndices = emptyList(),
|
||||||
|
currentSearchMatchIndex = -1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,12 @@ class ChatViewModel(
|
|||||||
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
|
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
|
||||||
|
|
||||||
private val slashDispatcher = SlashCommandDispatcher()
|
private val slashDispatcher = SlashCommandDispatcher()
|
||||||
private val searchController = ChatSearchController()
|
private val searchDelegate =
|
||||||
|
ChatSearchDelegate(
|
||||||
|
scope = viewModelScope,
|
||||||
|
uiState = _uiState,
|
||||||
|
)
|
||||||
|
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
|
||||||
|
|
||||||
/** Tracks the ID of the currently streaming assistant message. */
|
/** Tracks the ID of the currently streaming assistant message. */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -742,35 +747,11 @@ class ChatViewModel(
|
|||||||
name: String,
|
name: String,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
size: Long,
|
size: Long,
|
||||||
) {
|
) = attachmentsDelegate.addAttachment(uri, name, mimeType, size)
|
||||||
_uiState.update { state ->
|
|
||||||
val attachment =
|
|
||||||
Attachment(
|
|
||||||
uri = uri,
|
|
||||||
name = name,
|
|
||||||
mimeType = mimeType,
|
|
||||||
size = size,
|
|
||||||
)
|
|
||||||
state.copy(
|
|
||||||
pendingAttachments = state.pendingAttachments + attachment,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeAttachment(index: Int) {
|
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
pendingAttachments =
|
|
||||||
state.pendingAttachments.toMutableList().apply {
|
|
||||||
if (index in indices) removeAt(index)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearAttachments() {
|
fun clearAttachments() = attachmentsDelegate.clearAttachments()
|
||||||
_uiState.update { it.copy(pendingAttachments = emptyList()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSlashCommand(command: String) {
|
private fun handleSlashCommand(command: String) {
|
||||||
val userMsg = ChatMessage(role = MessageRole.USER, content = command)
|
val userMsg = ChatMessage(role = MessageRole.USER, content = command)
|
||||||
@@ -1230,80 +1211,18 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Search ────────────────────────────────────────────────────────────
|
// ── Search ────────────────────────────────────────────────────────────
|
||||||
|
// Compatibility façade: stable public API around ChatSearchDelegate.
|
||||||
|
// These thin delegates keep ChatViewModel's public surface intact while
|
||||||
|
// the search logic now lives in the delegate. Safe to remove once all
|
||||||
|
// callers migrate directly to the delegate.
|
||||||
|
|
||||||
fun toggleSearch() {
|
fun toggleSearch() = searchDelegate.toggleSearch()
|
||||||
val current = _uiState.value
|
|
||||||
if (current.isSearchActive) {
|
|
||||||
clearSearch()
|
|
||||||
} else {
|
|
||||||
_uiState.update { it.copy(isSearchActive = true, searchQuery = "") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var searchJob: Job? = null
|
fun setSearchQuery(query: String) = searchDelegate.setSearchQuery(query)
|
||||||
|
|
||||||
fun setSearchQuery(query: String) {
|
fun navigateSearchMatch(direction: Int) = searchDelegate.navigateSearchMatch(direction)
|
||||||
searchJob?.cancel()
|
|
||||||
|
|
||||||
if (query.isBlank()) {
|
fun clearSearch() = searchDelegate.clearSearch()
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
searchQuery = query,
|
|
||||||
searchMatchIndices = emptyList(),
|
|
||||||
currentSearchMatchIndex = -1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep local state in sync immediately so UI feels responsive.
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(searchQuery = query)
|
|
||||||
}
|
|
||||||
|
|
||||||
searchJob =
|
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
|
||||||
val messages = _uiState.value.messages
|
|
||||||
val indices = searchController.findMatches(messages, query)
|
|
||||||
|
|
||||||
_uiState.update {
|
|
||||||
// Only update indices if the search query hasn't changed in the meantime
|
|
||||||
if (it.searchQuery == query) {
|
|
||||||
it.copy(
|
|
||||||
searchMatchIndices = indices,
|
|
||||||
currentSearchMatchIndex = if (indices.isNotEmpty()) 0 else -1,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun navigateSearchMatch(direction: Int) {
|
|
||||||
_uiState.update { state ->
|
|
||||||
val indices = state.searchMatchIndices
|
|
||||||
if (indices.isEmpty()) return@update state
|
|
||||||
val newIdx =
|
|
||||||
searchController.navigate(
|
|
||||||
currentIndex = state.currentSearchMatchIndex,
|
|
||||||
matchCount = indices.size,
|
|
||||||
direction = direction,
|
|
||||||
)
|
|
||||||
state.copy(currentSearchMatchIndex = newIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearSearch() {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
isSearchActive = false,
|
|
||||||
searchQuery = "",
|
|
||||||
searchMatchIndices = emptyList(),
|
|
||||||
currentSearchMatchIndex = -1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isTestEnv: Boolean? = null
|
private var isTestEnv: Boolean? = null
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user