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
15 changes: 15 additions & 0 deletions .claude/memory/feedback_coding_boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
name: coding_boundaries
description: User wants to write all non-trivial logic themselves — Claude should only review, suggest, and handle boilerplate
type: feedback
---

Never write the "hard parts" — architecture decisions, core business logic, state management patterns, bug fix implementations, algorithm design. Instead, review the user's code, point out issues, suggest approaches, and explain tradeoffs. Let the user implement it.

**Why:** The user noticed their coding instincts and skills declining from over-delegating to Claude. They want to stay sharp by doing the thinking and implementation themselves.

**How to apply:**
- **Hard parts** (user codes): ViewModel logic, repository implementations, state flows, bug fixes, architectural patterns, cache strategies, concurrency handling, UI interaction logic. For these — review, suggest, explain, but don't write the code.
- **Boilerplate** (Claude codes): repetitive refactors, string resources, migration scaffolding, import fixes, build config, copy-paste patterns, test scaffolding, file moves/renames.
- When the user asks to fix a bug or implement a feature, describe what's wrong and suggest an approach — then let them write it.
- If the user explicitly asks "just do it" for something non-trivial, remind them of this agreement first.
Original file line number Diff line number Diff line change
Expand Up @@ -450,12 +450,15 @@
<!-- Translation feature -->
<string name="translate">Translate</string>
<string name="translating">Translating…</string>
<string name="show_original">Show Original</string>
<string name="show_original">Show original</string>
<string name="translated_to">Translated to %1$s</string>
<string name="translate_to">Translate to…</string>
<string name="search_language">Search language</string>
<string name="change_language">Change language</string>
<string name="translation_failed">Translation failed. Please try again.</string>
<string name="translation_error_retry">Retry</string>
<string name="translated_from">Auto-detected: %1$s</string>
<string name="select_language">Select language</string>

<!-- Search - GitHub Link -->
<string name="open_github_link">Open GitHub Link</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import zed.rainxch.core.data.services.LocalizationManager
import zed.rainxch.core.domain.model.ProxyConfig
import zed.rainxch.details.domain.model.TranslationResult
import zed.rainxch.details.domain.repository.TranslationRepository
import kotlin.time.Clock
import kotlin.time.ExperimentalTime

class TranslationRepositoryImpl(
private val localizationManager: LocalizationManager,
) : TranslationRepository,
AutoCloseable {
) : TranslationRepository {
private val httpClient: HttpClient = createPlatformHttpClient(ProxyConfig.None)

private val json =
Expand All @@ -28,21 +29,26 @@ class TranslationRepositoryImpl(
}

private val cacheMutex = Mutex()
private val cache = LinkedHashMap<String, TranslationResult>(50, 0.75f, true)
private val maxCacheSize = 50
private val cache = LinkedHashMap<CacheKey, CachedTranslation>(MAX_CACHE_SIZE, 0.75f, true)
private val maxChunkSize = 4500

@OptIn(ExperimentalTime::class)
override suspend fun translate(
text: String,
targetLanguage: String,
sourceLanguage: String,
): TranslationResult {
val cacheKey = "${text.hashCode()}:$targetLanguage"
cacheMutex.withLock { cache[cacheKey] }?.let { return it }
val cacheKey = CacheKey(text, targetLanguage, sourceLanguage)

cacheMutex.withLock {
cache[cacheKey]?.let { cached ->
if (!cached.isExpired()) return cached.result
cache.remove(cacheKey)
}
}

val chunks = chunkText(text)
val translatedParts = mutableListOf<Pair<String, String>>()

var detectedLang: String? = null

for ((chunkText, delimiter) in chunks) {
Expand All @@ -64,11 +70,11 @@ class TranslationRepositoryImpl(
)

cacheMutex.withLock {
if (cache.size >= maxCacheSize) {
if (cache.size >= MAX_CACHE_SIZE) {
val firstKey = cache.keys.first()
cache.remove(firstKey)
}
cache[cacheKey] = result
cache[cacheKey] = CachedTranslation(result)
}
return result
}
Expand All @@ -92,14 +98,7 @@ class TranslationRepositoryImpl(
parameter("q", text)
}.bodyAsText()

return try {
parseTranslationResponse(responseText)
} catch (_: Exception) {
TranslationResult(
translatedText = text,
detectedSourceLanguage = null,
)
}
return parseTranslationResponse(responseText)
}

private fun parseTranslationResponse(responseText: String): TranslationResult {
Expand Down Expand Up @@ -187,7 +186,22 @@ class TranslationRepositoryImpl(
}
}

override fun close() {
httpClient.close()
companion object {
private const val MAX_CACHE_SIZE = 50
private const val CACHE_TTL_MS = 30 * 60 * 1000L // 30 minutes
}

@OptIn(ExperimentalTime::class)
private class CachedTranslation(
val result: TranslationResult,
private val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
) {
fun isExpired(): Boolean = Clock.System.now().toEpochMilliseconds() - timestamp > CACHE_TTL_MS
}

private data class CacheKey(
val text: String,
val targetLanguage: String,
val sourceLanguage: String,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState
import zed.rainxch.githubstore.core.presentation.res.Res
import zed.rainxch.githubstore.core.presentation.res.add_to_favourites
import zed.rainxch.githubstore.core.presentation.res.cancel
import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message
import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title
import zed.rainxch.githubstore.core.presentation.res.dismiss
import zed.rainxch.githubstore.core.presentation.res.downgrade_requires_uninstall
import zed.rainxch.githubstore.core.presentation.res.downgrade_warning_message
Expand All @@ -88,6 +90,7 @@ import zed.rainxch.githubstore.core.presentation.res.repository_not_starred
import zed.rainxch.githubstore.core.presentation.res.repository_starred
import zed.rainxch.githubstore.core.presentation.res.share_repository
import zed.rainxch.githubstore.core.presentation.res.star_from_github
import zed.rainxch.githubstore.core.presentation.res.uninstall
import zed.rainxch.githubstore.core.presentation.res.uninstall_first
import zed.rainxch.githubstore.core.presentation.res.unstar_from_github

Expand Down Expand Up @@ -300,6 +303,7 @@ fun DetailsScreen(
TranslationTarget.WhatsNew -> state.whatsNewTranslation.targetLanguageCode
null -> null
},
deviceLanguageCode = state.deviceLanguageCode,
onLanguageSelected = { language ->
when (state.languagePickerTarget) {
TranslationTarget.About -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ class DetailsViewModel(
private var hasLoadedInitialData = false
private var currentDownloadJob: Job? = null
private var currentAssetName: String? = null
private var aboutTranslationJob: Job? = null
private var whatsNewTranslationJob: Job? = null

private var cachedDownloadAssetName: String? = null

Expand Down Expand Up @@ -725,19 +727,22 @@ class DetailsViewModel(
val newSelected = filtered.firstOrNull()
val (installable, primary) = recomputeAssetsForRelease(newSelected)

whatsNewTranslationJob?.cancel()
_state.update {
it.copy(
selectedReleaseCategory = newCategory,
selectedRelease = newSelected,
installableAssets = installable,
primaryAsset = primary,
whatsNewTranslation = TranslationState(),
)
}
}

is DetailsAction.SelectRelease -> {
val release = action.release
val (installable, primary) = recomputeAssetsForRelease(release)
whatsNewTranslationJob?.cancel()

_state.update {
it.copy(
Expand Down Expand Up @@ -770,7 +775,8 @@ class DetailsViewModel(

is DetailsAction.TranslateAbout -> {
val readme = _state.value.readmeMarkdown ?: return
translateContent(
aboutTranslationJob?.cancel()
aboutTranslationJob = translateContent(
text = readme,
targetLanguageCode = action.targetLanguageCode,
updateState = { ts -> _state.update { it.copy(aboutTranslation = ts) } },
Expand All @@ -780,7 +786,8 @@ class DetailsViewModel(

is DetailsAction.TranslateWhatsNew -> {
val description = _state.value.selectedRelease?.description ?: return
translateContent(
whatsNewTranslationJob?.cancel()
whatsNewTranslationJob = translateContent(
text = description,
targetLanguageCode = action.targetLanguageCode,
updateState = { ts -> _state.update { it.copy(whatsNewTranslation = ts) } },
Expand Down Expand Up @@ -1347,8 +1354,8 @@ class DetailsViewModel(
targetLanguageCode: String,
updateState: (TranslationState) -> Unit,
getCurrentState: () -> TranslationState,
) {
viewModelScope.launch {
): Job {
return viewModelScope.launch {
try {
updateState(
getCurrentState().copy(
Expand Down Expand Up @@ -1380,6 +1387,8 @@ class DetailsViewModel(
detectedSourceLanguage = result.detectedSourceLanguage,
),
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error("Translation failed: ${e.message}")
updateState(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
package zed.rainxch.details.presentation.components

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
Expand All @@ -31,6 +35,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
Expand All @@ -43,6 +48,7 @@ import zed.rainxch.githubstore.core.presentation.res.*
fun LanguagePicker(
isVisible: Boolean,
selectedLanguageCode: String?,
deviceLanguageCode: String,
onLanguageSelected: (SupportedLanguage) -> Unit,
onDismiss: () -> Unit,
) {
Expand All @@ -51,12 +57,17 @@ fun LanguagePicker(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var searchQuery by remember { mutableStateOf("") }

val deviceLanguage = remember(deviceLanguageCode) {
SupportedLanguages.all.find { it.code == deviceLanguageCode }
}

val filteredLanguages =
remember(searchQuery) {
val all = SupportedLanguages.all
if (searchQuery.isBlank()) {
SupportedLanguages.all
all
} else {
SupportedLanguages.all.filter {
all.filter {
it.displayName.contains(searchQuery, ignoreCase = true) ||
it.code.contains(searchQuery, ignoreCase = true)
}
Expand Down Expand Up @@ -88,13 +99,60 @@ fun LanguagePicker(
Icon(Icons.Default.Search, contentDescription = null)
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)

HorizontalDivider()
// Device language shortcut — only shown when not searching
if (searchQuery.isBlank() && deviceLanguage != null) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
.clickable { onLanguageSelected(deviceLanguage) }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Smartphone,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = deviceLanguage.displayName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(Res.string.select_language),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
)
}
if (deviceLanguage.code == selectedLanguageCode) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}

Spacer(Modifier.height(4.dp))
}

HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))

LazyColumn(
modifier = Modifier.fillMaxWidth(),
Expand Down
Loading