refactor(sessions): extract search formatting and paging logic

Extract SessionSearchFormatting and SessionsPaging helpers.
Deduplicate paging calculation in SessionsViewModel and add unit tests.
This commit is contained in:
Hy4ri committed 2026-09-11 17:53:02 +03:00
1 parent f0c2d2f87b
commit 6876db0393
7 files changed
+315 -112

No files matched your search

@@ -0,0 +1,98 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionSearchResult
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.abs
/**
* Converts a backend search hit into a display model WITHOUT faking a title.
* The backend returns no session name, only a matched `snippet` + metadata, so the
* card must render the snippet as a match excerpt (never as the title). `title` is
* deliberately left null so [SearchResultCard] shows the honest "Match" layout.
*/
internal fun SessionSearchResult.toSessionInfo(): SessionInfo =
SessionInfo(
id = session_id,
title = null,
preview = snippet,
source = source,
model = model,
started_at = session_started,
// message_count/status aren't in the search payload; leave null so the
// search card hides those normal-list affordances.
)
/**
* Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute
* string. Used for search results where the session name is unknown.
*/
internal fun formatPlayedAt(epochSeconds: Double?): String? {
if (epochSeconds == null || epochSeconds <= 0.0) return null
val instant =
try {
Instant.ofEpochSecond(epochSeconds.toLong())
} catch (_: Exception) {
return null
}
val now = Instant.now()
val diffSec = abs(now.epochSecond - instant.epochSecond)
val absFormatter =
DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withZone(ZoneId.systemDefault())
return when {
diffSec < 60 -> "just now"
diffSec < 3600 -> "${diffSec / 60}m ago"
diffSec < 86400 -> "${diffSec / 3600}h ago"
diffSec < 7 * 86400 -> "${diffSec / 86400}d ago"
else -> absFormatter.format(instant)
}
}
private val searchSnippetJson = Json { isLenient = true }
/**
* Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as
* JSON, so prefer their human-readable text fields rather than rendering the entire payload.
*/
fun cleanSearchSnippet(snippet: String): String {
val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim()
val parsedSnippet =
runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull()
val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers
return displayText
.replace(Regex("\\s+"), " ")
.trim()
}
private fun JsonElement.searchText(): String? =
when (this) {
is JsonPrimitive -> {
contentOrNull?.takeIf(String::isNotBlank)
}
is JsonArray -> {
mapNotNull { it.searchText() }
.joinToString(" ")
.takeIf(String::isNotBlank)
}
is JsonObject -> {
val textFieldNames =
listOf("content", "text", "message", "body", "prompt", "summary", "title")
val wrapperFieldNames = listOf("data", "result", "payload", "parts")
textFieldNames
.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
}
}
@@ -0,0 +1,42 @@
package com.m57.hermescontrol.ui.sessions
data class SessionPaginationResolution(
val hasMore: Boolean,
val total: Int,
)
object SessionsPaging {
/**
* Computes hasMore and effective total when refreshing or loading the initial page of sessions.
*/
fun resolveInitialPaging(
receivedCount: Int,
pageSize: Int,
backendTotal: Int,
accumulatedCount: Int,
): SessionPaginationResolution {
val hasMore = receivedCount >= pageSize && backendTotal > accumulatedCount
return SessionPaginationResolution(
hasMore = hasMore,
total = if (hasMore) backendTotal else accumulatedCount,
)
}
/**
* Computes hasMore and effective total when appending a page of sessions.
*/
fun resolveLoadMorePaging(
receivedCount: Int,
pageSize: Int,
addedNewItems: Boolean,
backendTotal: Int,
accumulatedCount: Int,
): SessionPaginationResolution {
val receivedFullPage = receivedCount >= pageSize
val hasMore = receivedFullPage && addedNewItems && backendTotal > accumulatedCount
return SessionPaginationResolution(
hasMore = hasMore,
total = if (hasMore) backendTotal else accumulatedCount,
)
}
}
@@ -116,17 +116,6 @@ import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listItemSpacing
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.abs
/**
* Auto-load the next history page when the user scrolls to within this many
@@ -197,89 +186,6 @@ private fun highlightText(
}
}
/**
* Converts a backend search hit into a display model WITHOUT faking a title.
* The backend returns no session name, only a matched `snippet` + metadata, so the
* card must render the snippet as a match excerpt (never as the title). `title` is
* deliberately left null so [SearchResultCard] shows the honest "Match" layout.
*/
private fun SessionSearchResult.toSessionInfo(): SessionInfo =
SessionInfo(
id = session_id,
title = null,
preview = snippet,
source = source,
model = model,
started_at = session_started,
// message_count/status aren't in the search payload; leave null so the
// search card hides those normal-list affordances.
)
/**
* Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute
* string. Used for search results where the session name is unknown.
*/
private fun formatPlayedAt(epochSeconds: Double?): String? {
if (epochSeconds == null || epochSeconds <= 0.0) return null
val instant =
try {
Instant.ofEpochSecond(epochSeconds.toLong())
} catch (_: Exception) {
return null
}
val now = Instant.now()
val diffSec = abs(now.epochSecond - instant.epochSecond)
val absFormatter =
DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withZone(ZoneId.systemDefault())
return when {
diffSec < 60 -> "just now"
diffSec < 3600 -> "${diffSec / 60}m ago"
diffSec < 86400 -> "${diffSec / 3600}h ago"
diffSec < 7 * 86400 -> "${diffSec / 86400}d ago"
else -> absFormatter.format(instant)
}
}
private val searchSnippetJson = Json { isLenient = true }
/**
* Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as
* JSON, so prefer their human-readable text fields rather than rendering the entire payload.
*/
internal fun cleanSearchSnippet(snippet: String): String {
val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim()
val parsedSnippet =
runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull()
val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers
return displayText
.replace(Regex("\\s+"), " ")
.trim()
}
private fun JsonElement.searchText(): String? =
when (this) {
is JsonPrimitive -> {
contentOrNull?.takeIf(String::isNotBlank)
}
is JsonArray -> {
mapNotNull { it.searchText() }
.joinToString(" ")
.takeIf(String::isNotBlank)
}
is JsonObject -> {
val textFieldNames =
listOf("content", "text", "message", "body", "prompt", "summary", "title")
val wrapperFieldNames = listOf("data", "result", "payload", "parts")
textFieldNames
.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
}
}
private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
if (state.isSearchMode) {
state.searchResults.map { searchResult ->
@@ -147,11 +147,17 @@ class SessionsViewModel :
rawPaginationOffset = data.nextOffset(0)
_uiState.update {
val newSessions = data.sessions.orEmpty().pinnedFirst()
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
val paging =
SessionsPaging.resolveInitialPaging(
receivedCount = data.sessions.size,
pageSize = PAGE_SIZE,
backendTotal = data.total,
accumulatedCount = newSessions.size,
)
it.copy(
sessions = newSessions,
total = if (hasMore) data.total else newSessions.size,
hasMore = hasMore,
total = paging.total,
hasMore = paging.hasMore,
)
}
}
@@ -229,14 +235,20 @@ class SessionsViewModel :
if (requestGeneration != generation) return@safeLaunchLoad
rawPaginationOffset = data.nextOffset(0)
val sessionsList = data.sessions.orEmpty().pinnedFirst()
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size
val paging =
SessionsPaging.resolveInitialPaging(
receivedCount = data.sessions.size,
pageSize = PAGE_SIZE,
backendTotal = data.total,
accumulatedCount = sessionsList.size,
)
_uiState.update {
it.copy(
isLoading = false,
isLoadingMore = false,
sessions = sessionsList,
total = if (hasMore) data.total else sessionsList.size,
hasMore = hasMore,
total = paging.total,
hasMore = paging.hasMore,
selectedIds = emptySet(),
)
}
@@ -284,14 +296,20 @@ class SessionsViewModel :
(it.sessions + data.sessions)
.distinctBy { s -> s.id }
.pinnedFirst()
val receivedFullPage = data.sessions.size >= PAGE_SIZE
val addedNewItems = newSessions.size > it.sessions.size
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
val paging =
SessionsPaging.resolveLoadMorePaging(
receivedCount = data.sessions.size,
pageSize = PAGE_SIZE,
addedNewItems = addedNewItems,
backendTotal = data.total,
accumulatedCount = newSessions.size,
)
it.copy(
isLoadingMore = false,
sessions = newSessions,
total = if (hasMore) data.total else newSessions.size,
hasMore = hasMore,
total = paging.total,
hasMore = paging.hasMore,
)
}
}