diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/App.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/App.kt index 029685b..8fb07a0 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/App.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/App.kt @@ -45,6 +45,7 @@ fun App() { } val snapshotFetchState = vm.state.snapshotFetchState val selectedRepo = vm.repoState.collectAsState().value as? RepoState.Selected + val favoriteRepos = vm.favoriteRepos.collectAsState().value AppEffects(vm = vm) @@ -70,9 +71,11 @@ fun App() { ready = ready, prColorMap = ready?.colorState?.prColorMap ?: emptyMap(), repoSelectionState = vm.state.repoSelectionState, + favoriteRepos = favoriteRepos, onReloadRepoOptions = { vm.loadRepositoryOptions() }, onDismissRepoDialog = { vm.closeRepoDialog() }, onSelectRepo = { vm.selectRepo(it) }, + onToggleFavorite = { vm.toggleFavorite(it) }, onRefresh = { vm.refresh() }, onRetryLoadCommits = { vm.reloadFileDetailsCommits() }, onDismissDialog = { vm.closeDialog() }, diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/VisualizerViewModel.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/VisualizerViewModel.kt index b282d75..2baf8b7 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/VisualizerViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/VisualizerViewModel.kt @@ -12,6 +12,8 @@ import io.github.hayatoyagi.prvisualizer.github.session.FileCommitsServiceImpl import io.github.hayatoyagi.prvisualizer.github.session.GitHubSessionManager import io.github.hayatoyagi.prvisualizer.navigation.NavigationManager import io.github.hayatoyagi.prvisualizer.repository.RepoState +import io.github.hayatoyagi.prvisualizer.repository.store.FavoriteReposStore +import io.github.hayatoyagi.prvisualizer.repository.store.PersistedFavoriteReposStore import io.github.hayatoyagi.prvisualizer.repository.store.SelectedRepositoryStore import io.github.hayatoyagi.prvisualizer.state.AuthState import io.github.hayatoyagi.prvisualizer.state.ColorState @@ -29,12 +31,16 @@ import kotlinx.coroutines.launch @Suppress("TooManyFunctions") class VisualizerViewModel( private val selectedRepositoryStore: SelectedRepositoryStore, + private val favoriteReposStore: FavoriteReposStore = PersistedFavoriteReposStore(), private val fileCommitsService: FileCommitsService = FileCommitsServiceImpl(), initialState: VisualizerState = VisualizerState(), ) : ViewModel() { val repoState: StateFlow get() = selectedRepositoryStore.repoState + val favoriteRepos: StateFlow> + get() = favoriteReposStore.favorites + private var lastAppliedRepoState: RepoState = selectedRepositoryStore.repoState.value // Main state container @@ -256,6 +262,10 @@ class VisualizerViewModel( applyRepositoryState(selectedRepositoryStore.repoState.value) } + fun toggleFavorite(fullName: String) { + favoriteReposStore.toggleFavorite(fullName) + } + // region: PR フィルタ fun updateShowDrafts(value: Boolean) { updateReady { copy(filterState = filterState.copy(showDrafts = value)) } diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/FavoriteReposStore.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/FavoriteReposStore.kt new file mode 100644 index 0000000..957ae3d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/FavoriteReposStore.kt @@ -0,0 +1,16 @@ +package io.github.hayatoyagi.prvisualizer.repository.store + +import kotlinx.coroutines.flow.StateFlow + +/** + * Source of truth for the user's favorite (starred) repositories. + * + * Favorites appear at the top of the repository picker dialog. + */ +interface FavoriteReposStore { + /** The current set of favorite repository full names (e.g. "owner/repo"). */ + val favorites: StateFlow> + + /** Adds [fullName] to favorites if not present, removes it if already present. */ + fun toggleFavorite(fullName: String) +} diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/InMemoryFavoriteReposStore.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/InMemoryFavoriteReposStore.kt new file mode 100644 index 0000000..9164481 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/InMemoryFavoriteReposStore.kt @@ -0,0 +1,18 @@ +package io.github.hayatoyagi.prvisualizer.repository.store + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class InMemoryFavoriteReposStore( + initial: Set = emptySet(), +) : FavoriteReposStore { + private val mutableFavorites = MutableStateFlow(initial) + + override val favorites: StateFlow> = mutableFavorites.asStateFlow() + + override fun toggleFavorite(fullName: String) { + val current = mutableFavorites.value + mutableFavorites.value = if (current.contains(fullName)) current - fullName else current + fullName + } +} diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/PersistedFavoriteReposStore.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/PersistedFavoriteReposStore.kt new file mode 100644 index 0000000..760d079 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/PersistedFavoriteReposStore.kt @@ -0,0 +1,42 @@ +package io.github.hayatoyagi.prvisualizer.repository.store + +import io.github.hayatoyagi.prvisualizer.storage.FileLocalStorage +import io.github.hayatoyagi.prvisualizer.storage.LocalStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class PersistedFavoriteReposStore( + private val localStorage: LocalStorage = FileLocalStorage(appName = "PRsVisualizerForGitHub"), +) : FavoriteReposStore { + private val mutableFavorites = MutableStateFlow(loadFavorites()) + + override val favorites: StateFlow> = mutableFavorites.asStateFlow() + + override fun toggleFavorite(fullName: String) { + val current = mutableFavorites.value + val updated = if (current.contains(fullName)) current - fullName else current + fullName + mutableFavorites.value = updated + persist(updated) + } + + private fun loadFavorites(): Set { + val stored = localStorage.getString(FAVORITES_KEY) ?: return emptySet() + return stored.split(',') + .map { it.trim() } + .filter { it.isNotBlank() } + .toSet() + } + + private fun persist(favorites: Set) { + if (favorites.isEmpty()) { + localStorage.remove(FAVORITES_KEY) + } else { + localStorage.putString(FAVORITES_KEY, favorites.sorted().joinToString(",")) + } + } + + private companion object { + const val FAVORITES_KEY = "favorite_repositories" + } +} diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/dialog/DialogHost.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/dialog/DialogHost.kt index 931eac1..be4dc38 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/dialog/DialogHost.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/dialog/DialogHost.kt @@ -33,9 +33,11 @@ fun DialogHost( ready: SnapshotFetchState.Ready?, prColorMap: Map, repoSelectionState: RepoSelectionState, + favoriteRepos: Set, onReloadRepoOptions: () -> Unit, onDismissRepoDialog: () -> Unit, onSelectRepo: (String) -> Unit, + onToggleFavorite: (String) -> Unit, onRefresh: () -> Unit, onRetryLoadCommits: () -> Unit, onDismissDialog: () -> Unit, @@ -47,12 +49,14 @@ fun DialogHost( is DialogState.RepoPicker -> RepoPickerDialog( initialQuery = "${selectedRepo?.owner.orEmpty()}/${selectedRepo?.repo.orEmpty()}".trim().trim('/'), repoSelectionState = repoSelectionState, + favoriteRepos = favoriteRepos, onReload = onReloadRepoOptions, onDismiss = onDismissRepoDialog, onSelect = { fullName -> onSelectRepo(fullName) onRefresh() }, + onToggleFavorite = onToggleFavorite, ) is DialogState.FileDetails -> if (ready != null) { FileDetailsDialogHost( diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/repo/RepoFilter.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/repo/RepoFilter.kt index 09fd627..123f01f 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/repo/RepoFilter.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/repo/RepoFilter.kt @@ -5,13 +5,18 @@ private const val MAX_REPO_OPTIONS = 200 fun filterRepoOptions( repositoryOptions: List, query: String, + favoriteRepos: Set = emptySet(), ): List { val q = query.trim() - return if (q.isBlank()) { + val filtered = if (q.isBlank()) { repositoryOptions.take(MAX_REPO_OPTIONS) } else { repositoryOptions .filter { it.contains(q, ignoreCase = true) } .take(MAX_REPO_OPTIONS) } + if (favoriteRepos.isEmpty()) return filtered + val favorites = filtered.filter { it in favoriteRepos } + val rest = filtered.filter { it !in favoriteRepos } + return favorites + rest } diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/repo/RepoPickerDialog.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/repo/RepoPickerDialog.kt index b4d25c7..6376b6f 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/repo/RepoPickerDialog.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/repo/RepoPickerDialog.kt @@ -11,8 +11,13 @@ import androidx.compose.foundation.layout.padding 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.Star +import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -23,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.github.hayatoyagi.prvisualizer.state.RepoSelectionState @@ -32,9 +38,11 @@ import io.github.hayatoyagi.prvisualizer.ui.theme.AppColors fun RepoPickerDialog( initialQuery: String, repoSelectionState: RepoSelectionState, + favoriteRepos: Set, onReload: () -> Unit, onDismiss: () -> Unit, onSelect: (String) -> Unit, + onToggleFavorite: (String) -> Unit, ) { val options = when (repoSelectionState) { RepoSelectionState.Idle, @@ -47,8 +55,8 @@ fun RepoPickerDialog( val loadingError = (repoSelectionState as? RepoSelectionState.Error)?.error var query by rememberSaveable { mutableStateOf(initialQuery) } - val filteredOptions = remember(options, query) { - filterRepoOptions(options, query) + val filteredOptions = remember(options, query, favoriteRepos) { + filterRepoOptions(options, query, favoriteRepos) } // Check if query is a valid owner/repo format and not already in the list @@ -116,14 +124,35 @@ fun RepoPickerDialog( .padding(horizontal = 8.dp, vertical = 4.dp), ) { items(displayOptions) { fullName -> - Text( - text = fullName, - color = AppColors.textRepoOption, - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect(fullName) } - .padding(vertical = 8.dp), - ) + val isFavorite = fullName in favoriteRepos + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = fullName, + color = AppColors.textRepoOption, + modifier = Modifier + .weight(1f) + .clickable { onSelect(fullName) } + .padding(vertical = 8.dp), + ) + IconButton(onClick = { onToggleFavorite(fullName) }) { + if (isFavorite) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = "Remove from favorites", + tint = AppColors.starFavorite, + ) + } else { + Icon( + imageVector = Icons.Filled.StarBorder, + contentDescription = "Add to favorites", + tint = AppColors.textSecondary, + ) + } + } + } } } } diff --git a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/theme/AppColors.kt b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/theme/AppColors.kt index 431828b..f37fd4f 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/theme/AppColors.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/hayatoyagi/prvisualizer/ui/theme/AppColors.kt @@ -58,6 +58,7 @@ object AppColors { val treemapFallbackBorderDir = Color(0xFF2F4A5F) val treemapFallbackBorderFile = Color(0xFF2A3D4E) val treemapActivePrDot = Color(0xFFFFB800) + val starFavorite = treemapActivePrDot val treemapConflictStripe = Color(0xAAFFE082) // Tooltip chrome diff --git a/composeApp/src/jvmTest/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/PersistedFavoriteReposStoreTest.kt b/composeApp/src/jvmTest/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/PersistedFavoriteReposStoreTest.kt new file mode 100644 index 0000000..3747bc2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/io/github/hayatoyagi/prvisualizer/repository/store/PersistedFavoriteReposStoreTest.kt @@ -0,0 +1,94 @@ +package io.github.hayatoyagi.prvisualizer.repository.store + +import io.github.hayatoyagi.prvisualizer.storage.LocalStorage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PersistedFavoriteReposStoreTest { + @Test + fun `store should start empty when no persisted value exists`() { + val localStorage = FakeLocalStorage() + + val store = PersistedFavoriteReposStore(localStorage) + + assertTrue(store.favorites.value.isEmpty()) + } + + @Test + fun `store should initialize from persisted favorites`() { + val localStorage = FakeLocalStorage( + initial = mapOf("favorite_repositories" to "owner1/repo1,owner2/repo2"), + ) + + val store = PersistedFavoriteReposStore(localStorage) + + assertEquals(setOf("owner1/repo1", "owner2/repo2"), store.favorites.value) + } + + @Test + fun `toggleFavorite should add a new favorite and persist it`() { + val localStorage = FakeLocalStorage() + val store = PersistedFavoriteReposStore(localStorage) + + store.toggleFavorite("owner/repo") + + assertTrue(store.favorites.value.contains("owner/repo")) + val persisted = localStorage.values["favorite_repositories"] ?: "" + assertTrue(persisted.contains("owner/repo")) + } + + @Test + fun `toggleFavorite should remove an existing favorite and persist`() { + val localStorage = FakeLocalStorage( + initial = mapOf("favorite_repositories" to "owner/repo"), + ) + val store = PersistedFavoriteReposStore(localStorage) + + store.toggleFavorite("owner/repo") + + assertFalse(store.favorites.value.contains("owner/repo")) + assertEquals(null, localStorage.values["favorite_repositories"]) + } + + @Test + fun `toggleFavorite should remove storage key when last favorite is removed`() { + val localStorage = FakeLocalStorage( + initial = mapOf("favorite_repositories" to "owner/repo"), + ) + val store = PersistedFavoriteReposStore(localStorage) + + store.toggleFavorite("owner/repo") + + assertFalse(localStorage.values.containsKey("favorite_repositories")) + } + + @Test + fun `store should handle multiple favorites independently`() { + val localStorage = FakeLocalStorage() + val store = PersistedFavoriteReposStore(localStorage) + + store.toggleFavorite("owner/repo1") + store.toggleFavorite("owner/repo2") + store.toggleFavorite("owner/repo1") + + assertEquals(setOf("owner/repo2"), store.favorites.value) + } + + private class FakeLocalStorage( + initial: Map = emptyMap(), + ) : LocalStorage { + val values: MutableMap = initial.toMutableMap() + + override fun getString(key: String): String? = values[key] + + override fun putString(key: String, value: String) { + values[key] = value + } + + override fun remove(key: String) { + values.remove(key) + } + } +}