Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,48 @@ class SessionRepositoryImpl(
_state.value = RepoState.Live(snapshot)
}

suspend fun searchSessions(query: String, directory: String? = null): List<WorkspaceSession> {
val trimmed = query.trim()
if (trimmed.isEmpty()) return emptyList()

val projects = if (directory == null) {
runCatching { client.listProjects() }.getOrElse { emptyList() }
} else {
emptyList()
}
val directories = if (directory != null) {
listOf(directory)
} else {
listOf<String?>(null) + projects.map { it.worktree }
}

return coroutineScope {
val results = directories.map { searchDirectory ->
async {
runCatching {
client.listSessions(
directory = searchDirectory,
scope = null,
roots = true,
search = trimmed,
limit = SEARCH_LIMIT,
).filterNot { dto -> OfishSessionNames.isOfishTitle(dto.title) }
.map { dto -> workspaceSession(SessionMapper.mapToDomain(dto)) }
}.onFailure { error ->
AppLog.e(TAG, "Failed to search sessions for ${searchDirectory ?: "global"}: ${error.message}")
}
}
}.awaitAll()
if (results.all { it.isFailure }) {
throw results.firstNotNullOf { it.exceptionOrNull() }
}
results.map { it.getOrElse { emptyList() } }
.flatten()
.distinctBy { it.id.value }
.sortedByDescending { it.session.updatedAt }
}
}

override suspend fun getSession(id: SessionId): WorkspaceSession? {
val current = state.value.snapshot.sessions[id.value]
if (current != null) return current
Expand Down Expand Up @@ -822,6 +864,7 @@ class SessionRepositoryImpl(
private companion object {
const val FRESHNESS_MS = 30_000L
const val MAX_CONCURRENT = 10
const val SEARCH_LIMIT = 100
const val TAG = "SessionRepository"
const val RESOLVED_QUESTION_TTL_MS = 30_000L
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.MenuAnchorType
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
Expand Down Expand Up @@ -86,29 +88,40 @@ fun SessionListScreen(
var showNewSessionCustomDir by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<Session?>(null) }
var showRenameDialog by remember { mutableStateOf<Session?>(null) }
var showSearch by remember { mutableStateOf(false) }
var sessionSearchQuery by remember { mutableStateOf("") }
var showSearch by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
val keyboardController = LocalSoftwareKeyboardController.current

val filterDirectory = remember(uiState.projects, filterProjectId) {
filterProjectId?.let { filter ->
uiState.projects.find { it.id == filter }?.worktree ?: filter
}
}

val displayedSessions = remember(uiState.sessions, filterDirectory) {
val baseDisplayedSessions = remember(uiState.sessions, filterDirectory) {
if (filterDirectory != null) {
uiState.sessions.filter { it.session.directory == filterDirectory }
} else {
uiState.sessions
}
}
val displayedSessions = if (!uiState.isSearchActive) {
baseDisplayedSessions
} else if (uiState.serverSearchQuery == null) {
baseDisplayedSessions.filter { it.session.title.contains(uiState.searchQuery.trim(), ignoreCase = true) }
} else {
uiState.displayedSearchResults
}

val filteredProject = remember(uiState.projects, filterDirectory) {
filterDirectory?.let { directory -> uiState.projects.find { it.worktree == directory } }
}
val projectName = filteredProject?.name ?: filterDirectory?.substringAfterLast("/")

LaunchedEffect(filterDirectory) {
viewModel.updateSearchDirectory(filterDirectory)
}

LaunchedEffect(autoCreateSession, autoCreateSessionTitle, autoCreateSessionDirectory) {
if (autoCreateSession) {
onAutoCreateSessionConsumed()
Expand Down Expand Up @@ -165,15 +178,13 @@ fun SessionListScreen(
)
}
IconButton(
onClick = {
showSearch = !showSearch
if (!showSearch) sessionSearchQuery = ""
},
onClick = { showSearch = !showSearch },
modifier = Modifier.size(Sizing.iconButtonMd).testTag("sessions_search_button")
) {
Icon(
Icons.Default.Search,
imageVector = if (uiState.isSearchActive) Icons.Default.SearchOff else Icons.Default.Search,
contentDescription = stringResource(R.string.cd_search),
tint = if (uiState.isSearchActive) theme.accent else LocalContentColor.current,
modifier = Modifier.size(Sizing.iconAction)
)
}
Expand Down Expand Up @@ -227,8 +238,8 @@ fun SessionListScreen(

// During search, flatten to matching sessions (tree roots only would
// hide matching child sessions whose parent is filtered out).
val sessionTree = remember(displayedSessions, sessionSearchQuery) {
val q = sessionSearchQuery.trim()
val sessionTree = remember(displayedSessions, uiState.searchQuery) {
val q = uiState.searchQuery.trim()
if (q.isBlank()) {
buildSessionTree(displayedSessions)
} else {
Expand All @@ -243,15 +254,17 @@ fun SessionListScreen(
contentPadding = PaddingValues(Spacing.md),
verticalArrangement = Arrangement.spacedBy(Spacing.xs)
) {
val searchActive = sessionSearchQuery.isNotBlank()
if (showSearch) {
val searchActive = uiState.isSearchActive
if (showSearch || uiState.isSearchActive) {
stickyHeader(key = "session_search") {
SessionSearchField(
query = sessionSearchQuery,
onQueryChange = { sessionSearchQuery = it },
query = uiState.searchQuery,
status = uiState.searchStatus,
onQueryChange = { query -> viewModel.updateSearchQuery(query, filterDirectory) },
onClose = {
showSearch = false
sessionSearchQuery = ""
viewModel.updateSearchQuery("", filterDirectory)
keyboardController?.hide()
},
)
}
Expand Down Expand Up @@ -305,7 +318,11 @@ fun SessionListScreen(
if (searchActive && sessionTree.isEmpty()) {
item(key = "no_match") {
Text(
text = stringResource(R.string.sessions_search_no_match),
text = if (uiState.isSearching || uiState.serverSearchQuery != uiState.searchQuery.trim()) {
stringResource(R.string.sessions_search_waiting)
} else {
stringResource(R.string.sessions_search_no_match)
},
style = MaterialTheme.typography.bodySmall,
color = theme.textMuted,
modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.lg)
Expand Down Expand Up @@ -436,12 +453,17 @@ fun SessionListScreen(
@Composable
private fun SessionSearchField(
query: String,
status: SessionSearchStatus?,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
) {
val theme = LocalOpenCodeTheme.current
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
Surface(color = theme.backgroundPanel, shape = RectangleShape, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
Expand Down Expand Up @@ -473,6 +495,15 @@ private fun SessionSearchField(
unfocusedTextColor = theme.text,
),
)
status?.let { searchStatus ->
val visual = searchStatusVisual(searchStatus, theme)
Icon(
imageVector = visual.icon,
contentDescription = visual.contentDescription,
tint = visual.color,
modifier = Modifier.size(Sizing.iconMd),
)
}
IconButton(onClick = onClose, modifier = Modifier.size(Sizing.iconButtonMd)) {
Icon(
Icons.Default.Close,
Expand All @@ -485,6 +516,39 @@ private fun SessionSearchField(
}
}

@Composable
private fun searchStatusVisual(
status: SessionSearchStatus,
theme: dev.blazelight.p4oc.ui.theme.opencode.OpenCodeTheme,
): SearchStatusVisual = when (status) {
SessionSearchStatus.Searching -> SearchStatusVisual(
icon = Icons.Default.Search,
contentDescription = stringResource(R.string.sessions_search_status_searching),
color = theme.accent,
)
SessionSearchStatus.Refining -> SearchStatusVisual(
icon = Icons.Default.FilterList,
contentDescription = stringResource(R.string.sessions_search_status_refining),
color = theme.warning,
)
SessionSearchStatus.Current -> SearchStatusVisual(
icon = Icons.Default.CheckCircle,
contentDescription = stringResource(R.string.sessions_search_status_current),
color = theme.success,
)
SessionSearchStatus.Failed -> SearchStatusVisual(
icon = Icons.Default.ErrorOutline,
contentDescription = stringResource(R.string.sessions_search_status_failed),
color = theme.error,
)
}

private data class SearchStatusVisual(
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val color: Color,
)

private fun buildSessionTree(sessions: List<SessionWithProject>): List<SessionNode> {
val childrenByParent = sessions
.mapNotNull { swp -> swp.session.parentID?.let { parentId -> parentId to swp } }
Expand Down
Loading
Loading