diff --git a/CLAUDE.md b/CLAUDE.md index 5d91e412b..ed4c939f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,8 @@ Custom Gradle plugins in `build-logic/convention/` standardize module setup: - **Shizuku (Android):** Optional silent install via `ShizukuProvider` (registered in AndroidManifest). Requires Shizuku app running with ADB or root. AIDL service passes APK via `ParcelFileDescriptor` to `pm install -S`. Falls back to standard installer on failure. - **Gradle properties:** Config cache enabled, build cache enabled, 4GB Gradle heap, 3GB Kotlin daemon heap - **Code style:** Official Kotlin style (`kotlin.code.style=official`) +- **Desktop logs:** `CrashReporter` (installed as the first line of `DesktopApp.main`) tees `System.out`/`System.err` to a rotating `session.log` and writes `crash-.log` on uncaught exceptions. Paths: `~/Library/Logs/GitHub-Store/` (macOS), `%LOCALAPPDATA%/GitHub-Store/logs/` (Windows), `$XDG_STATE_HOME/GitHub-Store/logs/` (Linux). Android uses Logcat — no CrashReporter. +- **`X-GitHub-Token` header:** Forwarded to the backend *only* on `/v1/search` and `/v1/search/explore` (so the backend's live GitHub passthrough runs under the user's own 5000/hr quota). Sourced from `TokenStore.currentToken()` via `BackendApiClient.currentUserGithubToken()` — the helper is `private` so other endpoints can't leak it accidentally. Never sent on other endpoints (`/v1/categories`, `/v1/topics`, `/v1/repo`, `/v1/events`), never logged (no Ktor `Logging` plugin installed). ## Coding Conventions diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendSearchResponse.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendSearchResponse.kt index 6e6a1c1a5..c65874ee7 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendSearchResponse.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendSearchResponse.kt @@ -8,4 +8,5 @@ data class BackendSearchResponse( val totalHits: Int, val processingTimeMs: Int, val source: String? = null, + val passthroughAttempted: Boolean? = null, ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/logging/KermitLogger.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/logging/KermitLogger.kt index 4a17bfc8b..21148cf47 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/logging/KermitLogger.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/logging/KermitLogger.kt @@ -22,4 +22,29 @@ object KermitLogger : GitHubStoreLogger { ) { Logger.e(message, throwable) } + + override fun withTag(tag: String): GitHubStoreLogger = TaggedKermitLogger(Logger.withTag(tag)) +} + +private class TaggedKermitLogger(private val delegate: Logger) : GitHubStoreLogger { + override fun debug(message: String) { + delegate.d(message) + } + + override fun info(message: String) { + delegate.i(message) + } + + override fun warn(message: String) { + delegate.w(message) + } + + override fun error( + message: String, + throwable: Throwable?, + ) { + delegate.e(message, throwable) + } + + override fun withTag(tag: String): GitHubStoreLogger = TaggedKermitLogger(delegate.withTag(tag)) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index c5a908a3d..485ef4412 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -140,7 +140,12 @@ class BackendApiClient( parameter("q", query) if (platform != null) parameter("platform", platform) parameter("page", page) - timeout { requestTimeoutMillis = 20_000 } + + timeout { + requestTimeoutMillis = 30_000 + socketTimeoutMillis = 30_000 + } + if (token != null) header(X_GITHUB_TOKEN_HEADER, token) } if (response.status.isSuccess()) { diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/logging/GitHubStoreLogger.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/logging/GitHubStoreLogger.kt index b91808286..cf79055fa 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/logging/GitHubStoreLogger.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/logging/GitHubStoreLogger.kt @@ -11,4 +11,6 @@ interface GitHubStoreLogger { message: String, throwable: Throwable? = null, ) + + fun withTag(tag: String): GitHubStoreLogger = this } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt index 4bc3b2c1d..bcb77defa 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt @@ -8,4 +8,5 @@ data class PaginatedDiscoveryRepositories( val hasMore: Boolean, val nextPageIndex: Int, val totalCount: Int? = null, + val passthroughAttempted: Boolean? = null, ) diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt index a8e6fa81a..dc1074bcc 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt @@ -147,6 +147,7 @@ class SearchRepositoryImpl( hasMore = hasMore, nextPageIndex = page + 1, totalCount = searchResponse.totalHits, + passthroughAttempted = searchResponse.passthroughAttempted, ) } } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt index 4dd4be76b..0f3221c76 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt @@ -541,6 +541,35 @@ fun SearchScreen( } } + if (!state.isLoading && + !state.isLoadingMore && + state.errorMessage == null && + state.repositories.isEmpty() && + state.query.isNotBlank() && + !state.hasMorePages + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = stringResource(Res.string.no_repositories_found)) + + // Backend already did its own passthrough and still found + // nothing — don't tease a manual explore that would just + // redo the same work. Any other case (false / null for + // older backends) keeps the CTA. + if (state.passthroughAttempted != true) { + Spacer(Modifier.height(8.dp)) + ExploreFromGithubButton( + status = state.exploreStatus, + onExplore = { onAction(SearchAction.ExploreFromGithub) }, + ) + } + } + } + } + if (state.visibleRepos.isNotEmpty()) { val isScrollbarEnabled = LocalScrollbarEnabled.current ScrollbarContainer( diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt index dc7d46092..306daa79b 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt @@ -33,6 +33,7 @@ data class SearchState( val autoDetectClipboardEnabled: Boolean = true, val recentSearches: ImmutableList = persistentListOf(), val exploreStatus: ExploreStatus = ExploreStatus.IDLE, + val passthroughAttempted: Boolean? = null, ) { enum class ExploreStatus { IDLE, diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index 7d3d2c14c..0fd83e3c7 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -39,7 +39,6 @@ import zed.rainxch.githubstore.core.presentation.res.failed_to_share_link import zed.rainxch.githubstore.core.presentation.res.link_copied_to_clipboard import zed.rainxch.githubstore.core.presentation.res.no_github_link_in_clipboard import zed.rainxch.githubstore.core.presentation.res.explore_error -import zed.rainxch.githubstore.core.presentation.res.no_repositories_found import zed.rainxch.githubstore.core.presentation.res.search_failed import zed.rainxch.search.presentation.mappers.toDomain import zed.rainxch.search.presentation.utils.isEntirelyGithubUrls @@ -66,6 +65,8 @@ class SearchViewModel( private var explorePage = 1 private var lastExploreQuery = "" + private val exploreLog = logger.withTag("SearchExplore") + companion object { private const val MIN_QUERY_LENGTH = 3 } @@ -295,7 +296,12 @@ class SearchViewModel( currentPage = 1 explorePage = 1 lastExploreQuery = query - _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) } + _state.update { + it.copy( + exploreStatus = SearchState.ExploreStatus.IDLE, + passthroughAttempted = null, + ) + } } currentSearchJob = @@ -312,6 +318,8 @@ class SearchViewModel( it.repositories }, totalCount = if (isInitial) null else it.totalCount, + passthroughAttempted = + if (isInitial) null else it.passthroughAttempted, ) } @@ -390,12 +398,8 @@ class SearchViewModel( repositories = allRepos, hasMorePages = paginatedRepos.hasMore, totalCount = allRepos.size, - errorMessage = - if (allRepos.isEmpty() && !paginatedRepos.hasMore) { - getString(Res.string.no_repositories_found) - } else { - null - }, + errorMessage = null, + passthroughAttempted = paginatedRepos.passthroughAttempted, ) } } @@ -682,10 +686,27 @@ class SearchViewModel( private fun performExplore() { val query = _state.value.query.trim() - if (query.isBlank() || _state.value.exploreStatus == SearchState.ExploreStatus.LOADING) return + val platformUi = _state.value.selectedSearchPlatform + val prevStatus = _state.value.exploreStatus + + exploreLog.debug( + "click: query='$query' platform=$platformUi " + + "page=$explorePage lastQuery='$lastExploreQuery' status=$prevStatus", + ) + + if (query.isBlank()) { + exploreLog.debug("skipped: query is blank") + return + } + if (prevStatus == SearchState.ExploreStatus.LOADING) { + exploreLog.debug("skipped: already LOADING") + return + } - // Reset page if query changed if (query != lastExploreQuery) { + exploreLog.debug( + "query changed ('$lastExploreQuery' -> '$query'); resetting page to 1", + ) explorePage = 1 lastExploreQuery = query } @@ -696,24 +717,40 @@ class SearchViewModel( try { val exploreResult = searchRepository.exploreFromGithub( query = query, - platform = _state.value.selectedSearchPlatform.toDomain(), + platform = platformUi.toDomain(), page = explorePage, ) + val existingCount = _state.value.repositories.size + exploreLog.debug( + "response: items=${exploreResult.repos.size} " + + "returnedPage=${exploreResult.page} hasMore=${exploreResult.hasMore} " + + "existingVisible=$existingCount", + ) - if (exploreResult.repos.isEmpty() || !exploreResult.hasMore) { - if (exploreResult.repos.isNotEmpty()) { - appendExploreResults(exploreResult.repos) - } - _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.EXHAUSTED) } - } else { + val before = _state.value.repositories.size + if (exploreResult.repos.isNotEmpty()) { appendExploreResults(exploreResult.repos) + } + val added = _state.value.repositories.size - before + val dupes = exploreResult.repos.size - added + + if (exploreResult.hasMore) { explorePage++ + exploreLog.debug( + "-> IDLE: appended=$added dupes=$dupes nextPage=$explorePage", + ) _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) } + } else { + exploreLog.debug( + "-> EXHAUSTED: appended=$added dupes=$dupes " + + "rawItems=${exploreResult.repos.size}", + ) + _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.EXHAUSTED) } } } catch (e: CancellationException) { throw e } catch (e: Exception) { - logger.error("Explore failed: ${e.message}") + exploreLog.error("failed: ${e::class.simpleName}: ${e.message}", e) _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) } _events.send(SearchEvent.OnMessage(getString(Res.string.explore_error))) }