diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index b0c2037..c364b75 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -7,39 +7,54 @@ import android.net.Uri import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable +import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryRef +import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen +import com.ubisam.ubgitea.feature.branchdetail.isReadingFullScreen import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind +import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences import com.ubisam.ubgitea.feature.history.HistoryTarget import com.ubisam.ubgitea.feature.history.HistoryViewModel +import com.ubisam.ubgitea.feature.home.HomeDestination +import com.ubisam.ubgitea.feature.home.HomeScreen +import com.ubisam.ubgitea.feature.home.HomeViewModel import com.ubisam.ubgitea.feature.notifications.NotificationsScreen import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel import com.ubisam.ubgitea.feature.repositories.ReferenceKind import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen +import com.ubisam.ubgitea.feature.repositories.RepositoryListUiState +import com.ubisam.ubgitea.feature.repositories.accountNameOrNull import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel import com.ubisam.ubgitea.feature.settings.SettingsScreen import com.ubisam.ubgitea.feature.settings.SettingsViewModel +import com.ubisam.ubgitea.feature.work.WorkScreen +import com.ubisam.ubgitea.feature.work.WorkViewModel import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel import com.ubisam.ubgitea.ui.navigation.Route import com.ubisam.ubgitea.ui.navigation.RouteStackSaver +import com.ubisam.ubgitea.ui.shell.AppShell +import com.ubisam.ubgitea.ui.shell.AppTab import com.ubisam.ubgitea.ui.theme.UBGiteaTheme class MainActivity : ComponentActivity() { @@ -61,6 +76,12 @@ class MainActivity : ComponentActivity() { private val settingsViewModel: SettingsViewModel by viewModels { SettingsViewModel.Factory(application) } + private val homeViewModel: HomeViewModel by viewModels { + HomeViewModel.Factory(application) + } + private val workViewModel: WorkViewModel by viewModels { + WorkViewModel.Factory(application) + } // TASK-036: no-op if already granted or already permanently denied -- the OS itself decides // whether to actually show a prompt, this call is safe to make on every launch. Must be @@ -87,15 +108,29 @@ class MainActivity : ComponentActivity() { val historyState by historyViewModel.state.collectAsState() val workItemsState by workItemsViewModel.state.collectAsState() val notificationsState by notificationsViewModel.state.collectAsState() + val unreadNotifications by notificationsViewModel.unreadCount.collectAsState() val settingsState by settingsViewModel.state.collectAsState() + val homeState by homeViewModel.state.collectAsState() + val workState by workViewModel.state.collectAsState() var routeStack by rememberSaveable(stateSaver = RouteStackSaver) { - mutableStateOf(listOf(Route.Repositories)) + mutableStateOf(listOf(Route.Home)) } fun push(route: Route) { routeStack = routeStack + route } - fun popToRepositories() { routeStack = listOf(Route.Repositories) } - fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } } + fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Home) } } + fun selectTab(tab: AppTab) { routeStack = listOf(tab.route) } val currentRoute = routeStack.last() + val currentTab = routeStack.firstNotNullOfOrNull { it.tab } ?: AppTab.HOME + + // The repository list's ViewModel owns sign-in, so its state says whether there is a + // session. While it is merely loading, whatever was true before still is. + var sessionReady by rememberSaveable { mutableStateOf(false) } + var accountName by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(state) { + state.sessionReadyOrNull()?.let { sessionReady = it } + if (state is RepositoryListUiState.SignedOut) accountName = null + state.accountNameOrNull()?.let { accountName = it } + } // TASK-036: tapping a favorite-push notification. Pushed on top of whatever's // currently showing rather than resetting the stack -- consistent with how every other @@ -107,139 +142,258 @@ class MainActivity : ComponentActivity() { } } - LaunchedEffect(currentRoute) { + LaunchedEffect(currentRoute, sessionReady) { + if (!sessionReady) return@LaunchedEffect + if (currentRoute.isTab) notificationsViewModel.refreshUnreadCount() when (val route = currentRoute) { + Route.Home -> homeViewModel.open() Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation. + Route.Work -> workViewModel.open() is Route.BranchDetail -> { val repository = RepositoryCoordinates.of(route.owner, route.name) val ref = RepositoryRef.of(route.ref) codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind))) historyViewModel.open(HistoryTarget(repository, ref)) } - is Route.WorkItems -> workItemsViewModel.open( - WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)), - ) + is Route.WorkItems -> { + val target = WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)) + if (route.number != null) { + workItemsViewModel.openItem(target, route.number, route.isPullRequest) + } else { + workItemsViewModel.open(target) + } + } Route.Notifications -> notificationsViewModel.open() Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction. } } - fun authenticationExpired() { + fun closeFeatures() { codeBrowserViewModel.close() historyViewModel.close() workItemsViewModel.close() notificationsViewModel.close() - popToRepositories() + notificationsViewModel.clearUnreadCount() + homeViewModel.close() + workViewModel.close() + } + fun signOut() { + closeFeatures() + selectTab(AppTab.HOME) viewModel.logout() } + fun leaveBranchDetail() { + codeBrowserViewModel.close() + historyViewModel.close() + pop() + } + + val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty() + val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) + val showNavigation = sessionReady && when (currentRoute) { + is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState) + Route.Settings -> false + else -> true + } UBGiteaTheme { - when (currentRoute) { - Route.Settings -> SettingsScreen( - state = settingsState, - onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled, - onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled, - onBack = { pop() }, - ) - Route.Notifications -> NotificationsScreen( - state = notificationsState, - onShowUnreadOnly = notificationsViewModel::showUnreadOnly, - onShowAll = notificationsViewModel::showAll, - onNotificationSelected = { notification -> - push(Route.WorkItems(notification.repositoryOwner, notification.repositoryName)) - }, - onNextPage = notificationsViewModel::nextPage, - onPreviousPage = notificationsViewModel::previousPage, - onRetry = notificationsViewModel::retry, - onBack = { - notificationsViewModel.close() - pop() - }, - onAuthenticationExpired = ::authenticationExpired, - ) - is Route.WorkItems -> WorkItemsScreen( - state = workItemsState, - onShowIssues = workItemsViewModel::showIssues, - onShowPullRequests = workItemsViewModel::showPullRequests, - onIssueSelected = workItemsViewModel::openIssue, - onPullRequestSelected = workItemsViewModel::openPullRequest, - onNextPage = workItemsViewModel::nextPage, - onPreviousPage = workItemsViewModel::previousPage, - onGoToPage = workItemsViewModel::goToPage, - onRetryList = workItemsViewModel::retryList, - onRetryDetail = workItemsViewModel::retryDetail, - onBackToList = workItemsViewModel::backToList, - onBack = { - workItemsViewModel.close() - pop() - }, - onAuthenticationExpired = ::authenticationExpired, - ) - is Route.BranchDetail -> BranchDetailScreen( - codeBrowserState = codeBrowserState, - onEntrySelected = codeBrowserViewModel::openEntry, - onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb, - onRetryCodeBrowser = codeBrowserViewModel::retry, - onCodeBrowserBack = { - if (!codeBrowserViewModel.navigateUp()) { - codeBrowserViewModel.close() - historyViewModel.close() - pop() - } - }, - onNavigateImage = codeBrowserViewModel::navigateImage, - historyState = historyState, - onCommitSelected = historyViewModel::openCommit, - onHistoryNextPage = historyViewModel::nextPage, - onHistoryPreviousPage = historyViewModel::previousPage, - onRetryHistoryList = historyViewModel::retryList, - onRetryHistoryDetail = historyViewModel::retryDetail, - onRetryPatch = historyViewModel::retryPatch, - onFileSelected = historyViewModel::openFileDiff, - onClearFileSelection = historyViewModel::clearFileSelection, - onHistoryBackToList = historyViewModel::backToList, - onHistoryBack = { + AppShell( + selectedTab = currentTab, + showNavigation = showNavigation, + unreadNotifications = unreadNotifications, + onTabSelected = { tab -> + if (currentRoute is Route.BranchDetail) { codeBrowserViewModel.close() historyViewModel.close() - pop() - }, - onAuthenticationExpired = ::authenticationExpired, - ) - Route.Repositories -> RepositoryListScreen( - state = state, - onSignIn = viewModel::signIn, - onCancelSignIn = viewModel::cancelSignIn, - onRetry = viewModel::retry, - onLogout = viewModel::logout, - onRepositorySelected = viewModel::selectRepository, - onReferenceSelected = { repository, name, kind -> - viewModel.selectReference(name, kind) - val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG - push( - Route.BranchDetail( - owner = repository.coordinates.owner, - name = repository.coordinates.name, - refKind = refKind.name, - ref = name, - ), + } + if (currentRoute is Route.WorkItems) workItemsViewModel.close() + selectTab(tab) + }, + ) { isWide -> + // Back on any tab but Home goes Home; back on Home leaves the app. + BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) { + selectTab(AppTab.HOME) + } + val repositoryList: @Composable () -> Unit = { + RepositoryListScreen( + state = state, + instanceHost = instanceHost, + onSignIn = viewModel::signIn, + onCancelSignIn = viewModel::cancelSignIn, + onRetry = viewModel::retry, + onLogout = ::signOut, + onRepositorySelected = viewModel::selectRepository, + onReferenceSelected = { repository, name, kind -> + viewModel.selectReference(name, kind) + val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG + push( + Route.BranchDetail( + owner = repository.coordinates.owner, + name = repository.coordinates.name, + refKind = refKind.name, + ref = name, + ), + ) + }, + onReferencesRefresh = viewModel::refreshReferences, + onBackToRepositories = viewModel::backToRepositories, + onNextPage = viewModel::nextPage, + onPreviousPage = viewModel::previousPage, + onRefreshRepositories = viewModel::refreshRepositories, + onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch, + onToggleFavorite = viewModel::toggleFavorite, + onShowAllRepositories = viewModel::showAllRepositories, + onShowFavorites = viewModel::showFavorites, + onViewWorkItems = { repository -> + push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name)) + }, + onSelectOrganization = viewModel::selectOrganization, + onViewSettings = { push(Route.Settings) }, + ) + } + when (val route = currentRoute.takeIf { sessionReady } ?: Route.Repositories) { + Route.Home -> HomeScreen( + state = homeState, + accountName = accountName, + instanceHost = instanceHost, + isWide = isWide, + onNavigate = { destination -> + when (destination) { + HomeDestination.Repositories -> selectTab(AppTab.REPOSITORIES) + is HomeDestination.Work -> { + workViewModel.open(destination.scope) + selectTab(AppTab.WORK) + } + is HomeDestination.Branch -> push( + Route.BranchDetail( + owner = destination.owner, + name = destination.name, + refKind = (if (destination.isTag) CodeReferenceKind.TAG else CodeReferenceKind.BRANCH).name, + ref = destination.ref, + ), + ) + is HomeDestination.WorkItem -> push( + Route.WorkItems(destination.owner, destination.name, destination.number, destination.isPullRequest), + ) + } + }, + onSelectOrganization = homeViewModel::selectOrganization, + onSelectFilter = homeViewModel::selectFilter, + onLoadMore = homeViewModel::loadMore, + onRefresh = homeViewModel::refresh, + onViewSettings = { push(Route.Settings) }, + onLogout = ::signOut, + onAuthenticationExpired = ::signOut, + ) + Route.Work -> WorkScreen( + state = workState, + accountName = accountName, + instanceHost = instanceHost, + onSelectScope = workViewModel::selectScope, + onSelectState = workViewModel::selectState, + onSelectType = workViewModel::selectType, + onItemSelected = { hit -> + push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest)) + }, + onNextPage = workViewModel::nextPage, + onPreviousPage = workViewModel::previousPage, + onRefresh = workViewModel::refresh, + onViewSettings = { push(Route.Settings) }, + onLogout = ::signOut, + onAuthenticationExpired = ::signOut, + ) + Route.Settings -> SettingsScreen( + state = settingsState, + onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled, + onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled, + accountName = accountName, + instanceHost = instanceHost, + onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled, + onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp, + onLogout = ::signOut, + onBack = { pop() }, + ) + Route.Notifications -> NotificationsScreen( + state = notificationsState, + onShowUnreadOnly = notificationsViewModel::showUnreadOnly, + onShowAll = notificationsViewModel::showAll, + onNotificationSelected = { notification -> + push( + Route.WorkItems( + owner = notification.repositoryOwner, + name = notification.repositoryName, + number = notification.subjectNumber, + isPullRequest = notification.subjectType == NotificationSubjectType.PULL_REQUEST, + ), + ) + }, + onNextPage = notificationsViewModel::nextPage, + onPreviousPage = notificationsViewModel::previousPage, + onRetry = notificationsViewModel::retry, + onBack = { selectTab(AppTab.HOME) }, + onAuthenticationExpired = ::signOut, + accountName = accountName, + instanceHost = instanceHost, + onViewSettings = { push(Route.Settings) }, + onLogout = ::signOut, + ) + is Route.WorkItems -> { + val leave = { + workItemsViewModel.close() + pop() + } + WorkItemsScreen( + state = workItemsState, + onShowIssues = workItemsViewModel::showIssues, + onShowPullRequests = workItemsViewModel::showPullRequests, + onIssueSelected = workItemsViewModel::openIssue, + onPullRequestSelected = workItemsViewModel::openPullRequest, + onNextPage = workItemsViewModel::nextPage, + onPreviousPage = workItemsViewModel::previousPage, + onGoToPage = workItemsViewModel::goToPage, + onRetryList = workItemsViewModel::retryList, + onRetryDetail = workItemsViewModel::retryDetail, + // An item opened directly came from a list elsewhere (a search, the + // timeline, a notification), so back returns there, not to this + // repository's own list. + onBackToList = if (route.number != null) leave else workItemsViewModel::backToList, + onBack = leave, + onAuthenticationExpired = ::signOut, ) - }, - onReferencesRefresh = viewModel::refreshReferences, - onBackToRepositories = viewModel::backToRepositories, - onNextPage = viewModel::nextPage, - onPreviousPage = viewModel::previousPage, - onRefreshRepositories = viewModel::refreshRepositories, - onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch, - onToggleFavorite = viewModel::toggleFavorite, - onShowAllRepositories = viewModel::showAllRepositories, - onShowFavorites = viewModel::showFavorites, - onViewWorkItems = { repository -> - push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name)) - }, - onSelectOrganization = viewModel::selectOrganization, - onViewNotifications = { push(Route.Notifications) }, - onViewSettings = { push(Route.Settings) }, - ) + } + is Route.BranchDetail -> BranchDetailScreen( + owner = route.owner, + name = route.name, + ref = route.ref, + codeBrowserState = codeBrowserState, + codeViewerPreferences = codeViewerPreferences, + onEntrySelected = codeBrowserViewModel::openEntry, + onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb, + onRetryCodeBrowser = codeBrowserViewModel::retry, + onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() }, + onNavigateImage = codeBrowserViewModel::navigateImage, + onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled, + onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp, + onOpenSibling = codeBrowserViewModel::openSibling, + onLoadCounterpart = codeBrowserViewModel::loadCounterpart, + historyState = historyState, + onCommitSelected = historyViewModel::openCommit, + onHistoryNextPage = historyViewModel::nextPage, + onHistoryPreviousPage = historyViewModel::previousPage, + onRetryHistoryList = historyViewModel::retryList, + onRetryHistoryDetail = historyViewModel::retryDetail, + onRetryPatch = historyViewModel::retryPatch, + onFileSelected = historyViewModel::openFileDiff, + onShowAdjacentFile = historyViewModel::openAdjacentFileDiff, + onClearFileSelection = historyViewModel::clearFileSelection, + onHistoryBackToList = historyViewModel::backToList, + onHistoryBack = ::leaveBranchDetail, + onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) }, + onClose = ::leaveBranchDetail, + onAuthenticationExpired = ::signOut, + ) + Route.Repositories -> repositoryList() + } } LaunchedEffect(viewModel) { viewModel.browserRequests.collect(::openAuthorizationTab) @@ -293,3 +447,28 @@ class MainActivity : ComponentActivity() { pendingFavoritePushRoute = Route.BranchDetail(owner, name, CodeReferenceKind.BRANCH.name, ref) } } + +private val AppTab.route: Route + get() = when (this) { + AppTab.HOME -> Route.Home + AppTab.REPOSITORIES -> Route.Repositories + AppTab.WORK -> Route.Work + AppTab.NOTIFICATIONS -> Route.Notifications + } + +private val Route.tab: AppTab? + get() = when (this) { + Route.Home -> AppTab.HOME + Route.Repositories -> AppTab.REPOSITORIES + Route.Work -> AppTab.WORK + Route.Notifications -> AppTab.NOTIFICATIONS + else -> null + } + +/** Whether there is a signed-in session, or null when this state does not say either way. */ +private fun RepositoryListUiState.sessionReadyOrNull(): Boolean? = when (this) { + RepositoryListUiState.SignedOut, RepositoryListUiState.WaitingForBrowser -> false + RepositoryListUiState.Loading -> null + is RepositoryListUiState.Error -> canLogout + else -> true +} diff --git a/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt b/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt index dcdcd01..47065b5 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt @@ -17,6 +17,19 @@ interface AppSettingsStore { fun setReactiveAuthRetryEnabled(enabled: Boolean) fun isFavoritePushNotificationsEnabled(): Boolean fun setFavoritePushNotificationsEnabled(enabled: Boolean) + + // How source code is shown (TASK-053). Wrapping defaults ON: a phone shows about 45 characters + // of code per line, so an unwrapped file needs sideways scrolling on nearly every line. These + // have defaults here so an in-memory test store only has to implement what it exercises. + fun isCodeWordWrapEnabled(): Boolean = true + fun setCodeWordWrapEnabled(enabled: Boolean) = Unit + fun codeFontSizeSp(): Int = DEFAULT_CODE_FONT_SIZE_SP + fun setCodeFontSizeSp(size: Int) = Unit + + companion object { + const val DEFAULT_CODE_FONT_SIZE_SP = 13 + val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16) + } } class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore { @@ -36,7 +49,24 @@ class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore { preferences.edit().putBoolean(KEY_FAVORITE_PUSH_NOTIFICATIONS, enabled).apply() } + override fun isCodeWordWrapEnabled(): Boolean = preferences.getBoolean(KEY_CODE_WORD_WRAP, true) + + override fun setCodeWordWrapEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_CODE_WORD_WRAP, enabled).apply() + } + + override fun codeFontSizeSp(): Int = + preferences.getInt(KEY_CODE_FONT_SIZE, AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP) + .takeIf { it in AppSettingsStore.CODE_FONT_SIZES_SP } ?: AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP + + override fun setCodeFontSizeSp(size: Int) { + if (size !in AppSettingsStore.CODE_FONT_SIZES_SP) return + preferences.edit().putInt(KEY_CODE_FONT_SIZE, size).apply() + } + private companion object { + const val KEY_CODE_WORD_WRAP = "code_word_wrap_enabled" + const val KEY_CODE_FONT_SIZE = "code_font_size_sp" const val PREFERENCES_NAME = "ubgitea_app_settings" const val KEY_REACTIVE_AUTH_RETRY = "reactive_auth_retry_enabled" const val KEY_FAVORITE_PUSH_NOTIFICATIONS = "favorite_push_notifications_enabled" diff --git a/app/src/main/java/com/ubisam/ubgitea/data/GiteaApiSupport.kt b/app/src/main/java/com/ubisam/ubgitea/data/GiteaApiSupport.kt new file mode 100644 index 0000000..78c6686 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/data/GiteaApiSupport.kt @@ -0,0 +1,82 @@ +package com.ubisam.ubgitea.data + +import com.google.gson.Gson +import com.google.gson.JsonParseException +import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.core.network.HttpResponse +import com.ubisam.ubgitea.core.network.HttpTransport +import com.ubisam.ubgitea.core.network.requireSuccess +import com.ubisam.ubgitea.data.repositories.BearerTokenProvider +import com.ubisam.ubgitea.data.repositories.callAuthenticated +import com.ubisam.ubgitea.domain.model.Page +import java.io.IOException +import java.lang.reflect.Type +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +/** + * The request/response plumbing the activity and work-search API classes share. The older API + * classes each carry their own private copy of these helpers; new ones use this instead of adding + * a fifth and sixth copy. + */ +internal class GiteaApiSupport( + private val config: OAuthClientConfig, + private val transport: HttpTransport, + private val tokenProvider: BearerTokenProvider, + private val reactiveAuthRetryEnabled: () -> Boolean, + val gson: Gson, +) { + fun apiUrl(vararg segments: String): HttpUrl.Builder = config.baseUri.toString().toHttpUrl() + .newBuilder() + .addPathSegment("api") + .addPathSegment("v1") + .apply { segments.forEach(::addPathSegment) } + + fun HttpUrl.Builder.withPage(page: Int, pageSize: Int): HttpUrl.Builder = + addQueryParameter("page", page.toString()).addQueryParameter("limit", pageSize.toString()) + + suspend fun get(url: HttpUrl): HttpResponse = + callAuthenticated(tokenProvider, reactiveAuthRetryEnabled) { token -> + try { + transport.get(url.toString(), token).requireSuccess() + } catch (failure: IOException) { + throw ApiFailure.Network(failure) + } + } + + /** A literal JSON `null` body is an empty list: Gitea sends one for an empty slice on some endpoints (TASK-023). */ + fun parseList(body: String, type: Type): List = try { + gson.fromJson>(body, type) ?: emptyList() + } catch (_: JsonParseException) { + throw ApiFailure.InvalidResponse() + } + + fun HttpResponse.toPage(items: List, page: Int, pageSize: Int, receivedCount: Int = items.size): Page { + val hasMore = header("X-HasMore")?.equals("true", ignoreCase = true) + val needsNext = hasMore == true || hasMore == null && receivedCount == pageSize + val nextPage = if (!needsNext) null else if (page == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else page + 1 + return Page.of(items, nextPage, totalCount()) + } + + fun HttpResponse.totalCount(): Int? = header("X-Total-Count")?.toIntOrNull()?.takeIf { it >= 0 } + + private fun HttpResponse.header(name: String): String? = + headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value + + fun validatePage(page: Int, pageSize: Int) { + require(page > 0) { "Page must be positive" } + require(pageSize in 1..100) { "Page size must be between 1 and 100" } + } +} + +internal fun String?.requiredText(): String = takeIf { !it.isNullOrBlank() } ?: throw ApiFailure.InvalidResponse() + +/** Accepts both `...Z` and an explicit offset such as `+09:00`, which a Gitea server in a local time zone sends. */ +internal fun String.toEpochMillisOrInvalid(): Long = try { + OffsetDateTime.parse(this).toInstant().toEpochMilli() +} catch (_: DateTimeParseException) { + throw ApiFailure.InvalidResponse() +} diff --git a/app/src/main/java/com/ubisam/ubgitea/data/activity/GiteaActivityApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/activity/GiteaActivityApi.kt new file mode 100644 index 0000000..0a34bb5 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/data/activity/GiteaActivityApi.kt @@ -0,0 +1,174 @@ +package com.ubisam.ubgitea.data.activity + +import com.google.gson.Gson +import com.google.gson.JsonParseException +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.core.network.HttpTransport +import com.ubisam.ubgitea.data.GiteaApiSupport +import com.ubisam.ubgitea.data.repositories.BearerTokenProvider +import com.ubisam.ubgitea.data.requiredText +import com.ubisam.ubgitea.data.toEpochMillisOrInvalid +import com.ubisam.ubgitea.domain.activity.ActivityCategory +import com.ubisam.ubgitea.domain.activity.ActivityCommit +import com.ubisam.ubgitea.domain.activity.ActivityItem +import com.ubisam.ubgitea.domain.activity.ActivityKind +import com.ubisam.ubgitea.domain.activity.ActivityRepository +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import java.time.LocalDate +import okhttp3.HttpUrl + +/** + * Read-only Gitea activity feeds (`/users/{name}/activities/feeds`, `/orgs/{org}/activities/feeds`). + * + * An activity's `content` has no schema in Gitea's API description; its shape depends on the + * activity type and was taken from Gitea's source rather than confirmed against this instance: a + * push carries a JSON object listing its commits, an issue or pull-request activity carries + * `"|"`. Both are therefore read leniently -- content that does not parse costs a row + * its detail, never the feed. Starring, watching and mirror syncing are dropped: they are not team + * work anyone needs to see on a timeline. + */ +class GiteaActivityApi( + config: OAuthClientConfig, + transport: HttpTransport, + tokenProvider: BearerTokenProvider, + gson: Gson = Gson(), + reactiveAuthRetryEnabled: () -> Boolean = { false }, +) : ActivityRepository { + private val support = GiteaApiSupport(config, transport, tokenProvider, reactiveAuthRetryEnabled, gson) + + override suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int): Page { + require(login.isNotBlank()) { "Login cannot be blank" } + return listFeed(support.apiUrl("users", login, "activities", "feeds"), date, page, pageSize) + } + + override suspend fun listOrganizationFeed( + organization: String, + date: LocalDate?, + page: Int, + pageSize: Int, + ): Page { + require(organization.isNotBlank()) { "Organization cannot be blank" } + return listFeed(support.apiUrl("orgs", organization, "activities", "feeds"), date, page, pageSize) + } + + private suspend fun listFeed(url: HttpUrl.Builder, date: LocalDate?, page: Int, pageSize: Int): Page = + with(support) { + validatePage(page, pageSize) + if (date != null) url.addQueryParameter("date", date.toString()) + val response = get(url.withPage(page, pageSize).build()) + val dtos = parseList(response.body, ACTIVITY_LIST_TYPE) + // The next page is decided by how many rows the server sent, not how many survived + // mapping: a page full of starring activity still has a page after it. + response.toPage(dtos.mapNotNull(::mapActivity), page, pageSize, receivedCount = dtos.size) + } + + private fun mapActivity(dto: ActivityDto): ActivityItem? { + val kind = dto.opType.toActivityKind() ?: return null + val repository = dto.repo?.toCoordinatesOrNull() ?: return null + val content = dto.content.orEmpty() + val push = if (kind == ActivityKind.PUSH) parsePush(content) else null + val subject = if (kind.hasSubject()) parseSubject(content) else null + return ActivityItem( + id = dto.id ?: throw ApiFailure.InvalidResponse(), + kind = kind, + actorLogin = dto.actUser?.login.requiredText(), + actorDisplayName = dto.actUser?.fullName?.takeIf(String::isNotBlank), + repository = repository, + refName = dto.refName?.removeRefPrefix()?.takeIf(String::isNotBlank), + subjectNumber = subject?.first, + subjectText = subject?.second, + commits = push?.commits.orEmpty(), + commitCount = push?.count ?: 0, + createdAtEpochMillis = dto.created.requiredText().toEpochMillisOrInvalid(), + ) + } + + private fun ActivityKind.hasSubject(): Boolean = + category == ActivityCategory.PULL_REQUEST || category == ActivityCategory.ISSUE + + private class Push(val commits: List, val count: Int) + + private fun parsePush(content: String): Push? { + val dto = try { + support.gson.fromJson(content, PushContentDto::class.java) + } catch (_: JsonParseException) { + null + } ?: return null + val commits = dto.commits.orEmpty().mapNotNull { commit -> + val id = commit.sha1?.takeIf(String::isNotBlank) ?: return@mapNotNull null + ActivityCommit(id.take(SHORT_ID_LENGTH), commit.message.orEmpty().lineSequence().firstOrNull().orEmpty().trim()) + } + return Push(commits, maxOf(dto.len ?: 0, commits.size)) + } + + private fun parseSubject(content: String): Pair { + val separator = content.indexOf('|') + if (separator <= 0) return null to content.trim().takeIf(String::isNotEmpty) + val number = content.substring(0, separator).trim().toLongOrNull() + val text = content.substring(separator + 1).trim().takeIf(String::isNotEmpty) + return number to text + } + + private fun RepoDto.toCoordinatesOrNull(): RepositoryCoordinates? { + val owner = owner?.login?.takeIf(String::isNotBlank) ?: return null + val name = name?.takeIf(String::isNotBlank) ?: return null + return try { + RepositoryCoordinates.of(owner, name) + } catch (_: IllegalArgumentException) { + null + } + } + + private fun String.removeRefPrefix(): String = removePrefix("refs/heads/").removePrefix("refs/tags/") + + private fun String?.toActivityKind(): ActivityKind? = when (this) { + "commit_repo" -> ActivityKind.PUSH + "push_tag" -> ActivityKind.TAG_PUSHED + "delete_branch" -> ActivityKind.BRANCH_DELETED + "delete_tag" -> ActivityKind.TAG_DELETED + "create_pull_request", "pull_request_ready_for_review" -> ActivityKind.PULL_REQUEST_OPENED + "merge_pull_request", "auto_merge_pull_request" -> ActivityKind.PULL_REQUEST_MERGED + "close_pull_request" -> ActivityKind.PULL_REQUEST_CLOSED + "reopen_pull_request" -> ActivityKind.PULL_REQUEST_REOPENED + "approve_pull_request" -> ActivityKind.PULL_REQUEST_APPROVED + "reject_pull_request" -> ActivityKind.PULL_REQUEST_CHANGES_REQUESTED + "comment_pull", "pull_review_dismissed" -> ActivityKind.PULL_REQUEST_COMMENTED + "create_issue" -> ActivityKind.ISSUE_OPENED + "close_issue" -> ActivityKind.ISSUE_CLOSED + "reopen_issue" -> ActivityKind.ISSUE_REOPENED + "comment_issue" -> ActivityKind.ISSUE_COMMENTED + "publish_release" -> ActivityKind.RELEASE_PUBLISHED + "create_repo" -> ActivityKind.REPOSITORY_CREATED + "star_repo", "watch_repo", "mirror_sync_push", "mirror_sync_create", "mirror_sync_delete" -> null + else -> ActivityKind.OTHER + } + + private data class UserDto(val login: String?, @SerializedName("full_name") val fullName: String?) + private data class RepoDto(val name: String?, val owner: UserDto?) + private data class ActivityDto( + val id: Long?, + @SerializedName("op_type") val opType: String?, + @SerializedName("act_user") val actUser: UserDto?, + val repo: RepoDto?, + @SerializedName("ref_name") val refName: String?, + val content: String?, + val created: String?, + ) + private data class PushCommitDto( + @SerializedName("Sha1") val sha1: String?, + @SerializedName("Message") val message: String?, + ) + private data class PushContentDto( + @SerializedName("Commits") val commits: List?, + @SerializedName("Len") val len: Int?, + ) + + private companion object { + const val SHORT_ID_LENGTH = 7 + val ACTIVITY_LIST_TYPE = object : TypeToken>() {}.type + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/data/notifications/GiteaNotificationsApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/notifications/GiteaNotificationsApi.kt index 148c49c..772824b 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/notifications/GiteaNotificationsApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/notifications/GiteaNotificationsApi.kt @@ -49,6 +49,15 @@ class GiteaNotificationsApi( return response.toPage(items, page, pageSize) } + override suspend fun countUnread(): Int? { + val body = get(apiUrl("api", "v1", "notifications", "new")).body + return try { + gson.fromJson(body, UnreadCountDto::class.java)?.new?.takeIf { it >= 0 } + } catch (_: JsonParseException) { + null + } + } + private fun mapNotification(dto: NotificationDto): NotificationSummary { val subject = dto.subject ?: throw ApiFailure.InvalidResponse() val repositoryOwner = dto.repository?.owner?.login.required() @@ -137,6 +146,7 @@ class GiteaNotificationsApi( private fun Int.safeNextPage(): Int = if (this == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else this + 1 + private data class UnreadCountDto(val new: Int?) private data class OwnerDto(val login: String?) private data class RepositoryDto(val owner: OwnerDto?, val name: String?) private data class SubjectDto(val title: String?, val url: String?, val type: String?, val state: String?) diff --git a/app/src/main/java/com/ubisam/ubgitea/data/workitems/GiteaWorkSearchApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/workitems/GiteaWorkSearchApi.kt new file mode 100644 index 0000000..1c112c7 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/data/workitems/GiteaWorkSearchApi.kt @@ -0,0 +1,112 @@ +package com.ubisam.ubgitea.data.workitems + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.core.network.HttpTransport +import com.ubisam.ubgitea.data.GiteaApiSupport +import com.ubisam.ubgitea.data.repositories.BearerTokenProvider +import com.ubisam.ubgitea.data.requiredText +import com.ubisam.ubgitea.data.toEpochMillisOrInvalid +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor +import com.ubisam.ubgitea.domain.workitems.WorkItemHit +import com.ubisam.ubgitea.domain.workitems.WorkScope +import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery +import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository +import com.ubisam.ubgitea.domain.workitems.WorkStateFilter +import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter +import okhttp3.HttpUrl + +/** + * Read-only `/repos/issues/search`: issues and pull requests across every repository the signed-in + * account can see. Gitea applies the `assigned` / `created` / `review_requested` flags together + * (each narrows the result), so a [WorkScope] sets exactly one of them. + */ +class GiteaWorkSearchApi( + config: OAuthClientConfig, + transport: HttpTransport, + tokenProvider: BearerTokenProvider, + gson: Gson = Gson(), + reactiveAuthRetryEnabled: () -> Boolean = { false }, +) : WorkSearchRepository { + private val support = GiteaApiSupport(config, transport, tokenProvider, reactiveAuthRetryEnabled, gson) + + override suspend fun search(query: WorkSearchQuery, page: Int, pageSize: Int): Page = with(support) { + validatePage(page, pageSize) + val response = get(searchUrl(query).withPage(page, pageSize).build()) + val dtos = parseList(response.body, HIT_LIST_TYPE) + response.toPage(dtos.map(::mapHit), page, pageSize) + } + + override suspend fun count(query: WorkSearchQuery): Int? = with(support) { + get(searchUrl(query).withPage(1, 1).build()).totalCount() + } + + private fun searchUrl(query: WorkSearchQuery): HttpUrl.Builder { + val url = support.apiUrl("repos", "issues", "search") + url.addQueryParameter("state", if (query.state == WorkStateFilter.OPEN) "open" else "closed") + when (query.type) { + WorkTypeFilter.ALL -> Unit + WorkTypeFilter.ISSUES -> url.addQueryParameter("type", "issues") + WorkTypeFilter.PULL_REQUESTS -> url.addQueryParameter("type", "pulls") + } + when (query.scope) { + WorkScope.EVERYONE -> Unit + WorkScope.REVIEW_REQUESTED -> url.addQueryParameter("review_requested", "true") + WorkScope.ASSIGNED_TO_ME -> url.addQueryParameter("assigned", "true") + WorkScope.CREATED_BY_ME -> url.addQueryParameter("created", "true") + } + query.owner?.takeIf(String::isNotBlank)?.let { url.addQueryParameter("owner", it) } + return url + } + + private fun mapHit(dto: HitDto): WorkItemHit { + val repository = try { + RepositoryCoordinates.of(dto.repository?.owner.requiredText(), dto.repository?.name.requiredText()) + } catch (_: IllegalArgumentException) { + throw ApiFailure.InvalidResponse() + } + val login = dto.user?.login?.takeIf(String::isNotBlank) + return WorkItemHit( + repository = repository, + number = dto.number ?: throw ApiFailure.InvalidResponse(), + title = dto.title.requiredText(), + isPullRequest = dto.pullRequest != null, + isOpen = when (dto.state) { + "open" -> true + "closed" -> false + else -> throw ApiFailure.InvalidResponse() + }, + isMerged = dto.pullRequest?.merged == true, + author = if (login == null) { + WorkItemAuthor.Unavailable + } else { + WorkItemAuthor.Known(login, dto.user?.fullName?.takeIf(String::isNotBlank)) + }, + updatedAtEpochMillis = dto.updatedAt.requiredText().toEpochMillisOrInvalid(), + commentCount = dto.comments ?: 0, + ) + } + + private data class UserDto(val login: String?, @SerializedName("full_name") val fullName: String?) + private data class RepositoryMetaDto(val name: String?, val owner: String?) + private data class PullRequestMetaDto(val merged: Boolean?) + private data class HitDto( + val number: Long?, + val title: String?, + val state: String?, + val user: UserDto?, + val comments: Int?, + val repository: RepositoryMetaDto?, + @SerializedName("updated_at") val updatedAt: String?, + @SerializedName("pull_request") val pullRequest: PullRequestMetaDto?, + ) + + private companion object { + val HIT_LIST_TYPE = object : TypeToken>() {}.type + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/activity/ActivityModels.kt b/app/src/main/java/com/ubisam/ubgitea/domain/activity/ActivityModels.kt new file mode 100644 index 0000000..74c62cc --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/activity/ActivityModels.kt @@ -0,0 +1,62 @@ +package com.ubisam.ubgitea.domain.activity + +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import java.time.LocalDate + +/** + * What happened, as the home timeline tells it. OTHER covers any Gitea `op_type` this client does + * not yet recognize, so a new server-side activity type shows as a generic row instead of failing + * the whole feed closed. + */ +enum class ActivityKind(val category: ActivityCategory) { + PUSH(ActivityCategory.PUSH), + TAG_PUSHED(ActivityCategory.PUSH), + BRANCH_DELETED(ActivityCategory.PUSH), + TAG_DELETED(ActivityCategory.PUSH), + PULL_REQUEST_OPENED(ActivityCategory.PULL_REQUEST), + PULL_REQUEST_MERGED(ActivityCategory.PULL_REQUEST), + PULL_REQUEST_CLOSED(ActivityCategory.PULL_REQUEST), + PULL_REQUEST_REOPENED(ActivityCategory.PULL_REQUEST), + PULL_REQUEST_APPROVED(ActivityCategory.PULL_REQUEST), + PULL_REQUEST_CHANGES_REQUESTED(ActivityCategory.PULL_REQUEST), + PULL_REQUEST_COMMENTED(ActivityCategory.PULL_REQUEST), + ISSUE_OPENED(ActivityCategory.ISSUE), + ISSUE_CLOSED(ActivityCategory.ISSUE), + ISSUE_REOPENED(ActivityCategory.ISSUE), + ISSUE_COMMENTED(ActivityCategory.ISSUE), + RELEASE_PUBLISHED(ActivityCategory.OTHER), + REPOSITORY_CREATED(ActivityCategory.OTHER), + OTHER(ActivityCategory.OTHER), +} + +enum class ActivityCategory { PUSH, PULL_REQUEST, ISSUE, OTHER } + +data class ActivityCommit(val shortId: String, val message: String) + +data class ActivityItem( + val id: Long, + val kind: ActivityKind, + val actorLogin: String, + val actorDisplayName: String?, + val repository: RepositoryCoordinates, + /** Branch or tag name with any `refs/heads/` / `refs/tags/` prefix removed; null when the activity has none. */ + val refName: String?, + /** Issue or pull-request number; null for anything that is not about one. */ + val subjectNumber: Long?, + /** Issue or pull-request title, or for a comment activity the start of the comment. */ + val subjectText: String?, + /** The commits a push carried, newest first, as far as the feed lists them. */ + val commits: List, + /** How many commits the push carried in total; can exceed [commits]' size. */ + val commitCount: Int, + val createdAtEpochMillis: Long, +) + +/** Read-only. Activity is reported by Gitea; nothing here records or changes any. */ +interface ActivityRepository { + /** The signed-in user's dashboard feed: activity in every repository that account follows. */ + suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int): Page + + suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int): Page +} diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/history/UnifiedDiffParser.kt b/app/src/main/java/com/ubisam/ubgitea/domain/history/UnifiedDiffParser.kt index f262834..78c54a4 100644 --- a/app/src/main/java/com/ubisam/ubgitea/domain/history/UnifiedDiffParser.kt +++ b/app/src/main/java/com/ubisam/ubgitea/domain/history/UnifiedDiffParser.kt @@ -19,6 +19,23 @@ data class DiffRow(val left: DiffLine?, val right: DiffLine?) { } } +/** + * One row of a diff as it is shown, which is more than [DiffRow] carries: line numbers on each + * side, and a row of its own for each hunk's header. A header row has neither side and carries + * [hunkLabel] -- the text Git puts after the second `@@`, usually the enclosing function -- which + * is empty when Git supplied none. + */ +data class DiffEntry( + val left: DiffLine?, + val right: DiffLine?, + val leftNumber: Int? = null, + val rightNumber: Int? = null, + val hunkLabel: String? = null, +) { + val isHunkHeader: Boolean get() = hunkLabel != null + val isChange: Boolean get() = left is DiffLine.Removed || right is DiffLine.Added +} + /** * Splits one file's slice out of a combined multi-file `.diff`/patch body (as returned by * Gitea's raw diff endpoints) and lays it out as side-by-side rows. Reused across commit and @@ -38,6 +55,31 @@ object UnifiedDiffParser { return rows } + /** The same rows as [parseFile], numbered, with a header row in front of every hunk. */ + fun parseFileEntries(combinedPatch: String, path: String): List { + val section = splitIntoFileSections(combinedPatch).firstOrNull { sectionPath(it) == path } + ?: return emptyList() + val headers = section.filter { it.startsWith("@@") } + val entries = mutableListOf() + parseHunks(section).forEachIndexed { index, hunk -> + val header = HUNK_HEADER.find(headers.getOrElse(index) { "" }) + var leftNumber = header?.groupValues?.get(1)?.toIntOrNull() ?: 1 + var rightNumber = header?.groupValues?.get(2)?.toIntOrNull() ?: 1 + entries += DiffEntry(null, null, hunkLabel = header?.groupValues?.get(3)?.trim().orEmpty()) + hunkToRows(hunk).forEach { row -> + entries += DiffEntry( + left = row.left, + right = row.right, + leftNumber = if (row.left != null) leftNumber++ else null, + rightNumber = if (row.right != null) rightNumber++ else null, + ) + } + } + return entries + } + + private val HUNK_HEADER = Regex("^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@(.*)$") + private fun splitIntoFileSections(patch: String): List> { val sections = mutableListOf>() for (line in patch.lineSequence()) { diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/notifications/NotificationsRepository.kt b/app/src/main/java/com/ubisam/ubgitea/domain/notifications/NotificationsRepository.kt index b2c7df9..b99ba22 100644 --- a/app/src/main/java/com/ubisam/ubgitea/domain/notifications/NotificationsRepository.kt +++ b/app/src/main/java/com/ubisam/ubgitea/domain/notifications/NotificationsRepository.kt @@ -8,4 +8,7 @@ import com.ubisam.ubgitea.domain.model.Page */ interface NotificationsRepository { suspend fun listNotifications(page: Int, pageSize: Int, unreadOnly: Boolean): Page + + /** How many notifications are unread; null when that cannot be told. */ + suspend fun countUnread(): Int? = null } diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/workitems/WorkSearchModels.kt b/app/src/main/java/com/ubisam/ubgitea/domain/workitems/WorkSearchModels.kt new file mode 100644 index 0000000..5483a1c --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/workitems/WorkSearchModels.kt @@ -0,0 +1,40 @@ +package com.ubisam.ubgitea.domain.workitems + +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates + +/** Whose work a cross-repository search is about. Each maps to exactly one Gitea search flag. */ +enum class WorkScope { EVERYONE, REVIEW_REQUESTED, ASSIGNED_TO_ME, CREATED_BY_ME } + +enum class WorkStateFilter { OPEN, CLOSED } + +enum class WorkTypeFilter { ALL, ISSUES, PULL_REQUESTS } + +data class WorkSearchQuery( + val scope: WorkScope = WorkScope.EVERYONE, + val state: WorkStateFilter = WorkStateFilter.OPEN, + val type: WorkTypeFilter = WorkTypeFilter.ALL, + /** Limits results to repositories owned by this organization or user; null for every accessible repository. */ + val owner: String? = null, +) + +/** One issue or pull request found across repositories. A merged pull request is CLOSED with [isMerged] set. */ +data class WorkItemHit( + val repository: RepositoryCoordinates, + val number: Long, + val title: String, + val isPullRequest: Boolean, + val isOpen: Boolean, + val isMerged: Boolean, + val author: WorkItemAuthor, + val updatedAtEpochMillis: Long, + val commentCount: Int, +) + +/** Read-only search across every repository the signed-in account can see. */ +interface WorkSearchRepository { + suspend fun search(query: WorkSearchQuery, page: Int, pageSize: Int): Page + + /** How many items match, without fetching them. Null when the server does not report a total. */ + suspend fun count(query: WorkSearchQuery): Int? +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt index a0d771e..752f692 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt @@ -2,9 +2,10 @@ package com.ubisam.ubgitea.feature.branchdetail import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -12,33 +13,49 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import com.ubisam.ubgitea.R import com.ubisam.ubgitea.domain.history.FileChange +import com.ubisam.ubgitea.domain.model.CommitSummary import com.ubisam.ubgitea.domain.model.RepositoryPath import com.ubisam.ubgitea.domain.model.TreeEntry -import com.ubisam.ubgitea.domain.model.CommitSummary import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState -import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification +import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences +import com.ubisam.ubgitea.feature.codebrowser.FileViewerScreen +import com.ubisam.ubgitea.feature.codebrowser.isFileLevel +import com.ubisam.ubgitea.feature.history.FileDiffScreen import com.ubisam.ubgitea.feature.history.HistoryScreen import com.ubisam.ubgitea.feature.history.HistoryUiState +import com.ubisam.ubgitea.feature.history.showsFileDiff +import com.ubisam.ubgitea.ui.components.ScreenTopBar +import com.ubisam.ubgitea.ui.components.UBIcon import kotlinx.coroutines.launch /** - * One branch/tag/commit ref, shown as two swipeable tabs — Files (the code browser, default) and - * Commit — over the same ref. Each tab keeps its own existing ViewModel and internal drill-down - * (a subfolder, a commit's detail) entirely as before; this screen only adds the tab chrome and - * the pager, and only intercepts "back" once a tab's own internal back-handling has nothing left - * to unwind (see `onCodeBrowserBack`/`onHistoryBack`). + * One branch/tag/commit ref, shown as two swipeable tabs — Files (the directory listing, default) + * and Commit — over the same ref. Each tab keeps its own ViewModel and its own drill-down. + * + * The tabs only ever hold lists. A file, and a file's diff, open full screen in place of the + * tabs: both scroll sideways, and neither belongs inside a pager that swipes sideways. With only + * lists left in it, the pager's swipe is always on. */ @Composable fun BranchDetailScreen( + owner: String, + name: String, + ref: String, codeBrowserState: CodeBrowserUiState, + codeViewerPreferences: CodeViewerPreferences, onEntrySelected: (TreeEntry) -> Unit, onBreadcrumbSelected: (RepositoryPath) -> Unit, onRetryCodeBrowser: () -> Unit, onCodeBrowserBack: () -> Unit, onNavigateImage: (Int) -> Unit, + onSetCodeWordWrap: (Boolean) -> Unit, + onSetCodeFontSize: (Int) -> Unit, + onOpenSibling: (RepositoryPath, String?) -> Unit, + onLoadCounterpart: () -> Unit, historyState: HistoryUiState, onCommitSelected: (CommitSummary) -> Unit, onHistoryNextPage: () -> Unit, @@ -47,73 +64,88 @@ fun BranchDetailScreen( onRetryHistoryDetail: () -> Unit, onRetryPatch: () -> Unit, onFileSelected: (FileChange) -> Unit, + onShowAdjacentFile: (Int) -> Unit, onClearFileSelection: () -> Unit, onHistoryBackToList: () -> Unit, onHistoryBack: () -> Unit, + onViewWorkItems: () -> Unit, + onClose: () -> Unit, onAuthenticationExpired: () -> Unit, modifier: Modifier = Modifier, ) { val pagerState = rememberPagerState(pageCount = { 2 }) val scope = rememberCoroutineScope() - // A page showing horizontally-gestured content — scrollable source text, a diff's before/after - // panels, an image gallery's swipe-to-navigate, or a CSV table's horizontal scroll — has its - // own left/right drag handling that directly conflicts with the pager's own — both are - // horizontal, so a drag meant for that content was instead partly consumed by the pager, which - // is what made scrolling code feel jittery and occasionally scroll text out of view entirely. - // Swiping between tabs only makes sense one level up from any of that anyway (Files' folder - // listing, History's changed-file list), so the pager's own gesture is disabled while either - // tab is showing one of these, and re-enabled once back at that list. - val currentPageShowsScrollableCode = when (pagerState.currentPage) { - 0 -> codeBrowserState is CodeBrowserUiState.File && - (codeBrowserState.content is ContentClassification.Text || - codeBrowserState.content is ContentClassification.Image || - codeBrowserState.content is ContentClassification.Csv || - codeBrowserState.content is ContentClassification.Pdf) - else -> historyState is HistoryUiState.CommitDetailContent && historyState.selectedFile != null - } - Column(modifier.fillMaxSize().statusBarsPadding()) { - TabRow(selectedTabIndex = pagerState.currentPage) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(stringResource(R.string.branchdetail_tab_files)) }, + when { + pagerState.currentPage == FILES_PAGE && codeBrowserState.isFileLevel -> FileViewerScreen( + state = codeBrowserState, + preferences = codeViewerPreferences, + onBack = onCodeBrowserBack, + onRetry = onRetryCodeBrowser, + onAuthenticationExpired = onAuthenticationExpired, + onNavigateImage = onNavigateImage, + onSetWordWrap = onSetCodeWordWrap, + onSetFontSize = onSetCodeFontSize, + onOpenSibling = onOpenSibling, + onOpenEntry = onEntrySelected, + onLoadCounterpart = onLoadCounterpart, + modifier = modifier, + ) + pagerState.currentPage == COMMIT_PAGE && historyState is HistoryUiState.CommitDetailContent && historyState.showsFileDiff -> + FileDiffScreen( + state = historyState, + fontSizeSp = codeViewerPreferences.fontSizeSp, + onShowAdjacentFile = onShowAdjacentFile, + onClose = onClearFileSelection, + modifier = modifier, ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(stringResource(R.string.branchdetail_tab_history)) }, - ) - } - HorizontalPager( - state = pagerState, - userScrollEnabled = !currentPageShowsScrollableCode, - modifier = Modifier.weight(1f), - ) { page -> - when (page) { - 0 -> CodeBrowserScreen( - state = codeBrowserState, - onEntrySelected = onEntrySelected, - onBreadcrumbSelected = onBreadcrumbSelected, - onRetry = onRetryCodeBrowser, - onBack = onCodeBrowserBack, - onAuthenticationExpired = onAuthenticationExpired, - onNavigateImage = onNavigateImage, - ) - else -> HistoryScreen( - state = historyState, - onCommitSelected = onCommitSelected, - onNextPage = onHistoryNextPage, - onPreviousPage = onHistoryPreviousPage, - onRetryList = onRetryHistoryList, - onRetryDetail = onRetryHistoryDetail, - onRetryPatch = onRetryPatch, - onFileSelected = onFileSelected, - onClearFileSelection = onClearFileSelection, - onBackToList = onHistoryBackToList, - onBack = onHistoryBack, - onAuthenticationExpired = onAuthenticationExpired, - ) + else -> Column(modifier.fillMaxSize()) { + ScreenTopBar(title = name, subtitle = stringResource(R.string.branchdetail_subtitle, owner, ref), onBack = onClose) { + IconButton(onClick = onViewWorkItems) { + UBIcon(R.drawable.ic_ub_pull_request, stringResource(R.string.repositories_work_items)) + } + } + TabRow(selectedTabIndex = pagerState.currentPage, containerColor = MaterialTheme.colorScheme.surface) { + listOf(R.string.branchdetail_tab_files, R.string.branchdetail_tab_history).forEachIndexed { page, label -> + val selected = pagerState.currentPage == page + Tab( + selected = selected, + onClick = { scope.launch { pagerState.animateScrollToPage(page) } }, + text = { Text(stringResource(label), fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium) }, + ) + } + } + HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page -> + when (page) { + FILES_PAGE -> CodeBrowserScreen( + state = codeBrowserState, + onEntrySelected = onEntrySelected, + onBreadcrumbSelected = onBreadcrumbSelected, + onRetry = onRetryCodeBrowser, + onBack = onCodeBrowserBack, + onAuthenticationExpired = onAuthenticationExpired, + ) + else -> HistoryScreen( + state = historyState, + onCommitSelected = onCommitSelected, + onNextPage = onHistoryNextPage, + onPreviousPage = onHistoryPreviousPage, + onRetryList = onRetryHistoryList, + onRetryDetail = onRetryHistoryDetail, + onRetryPatch = onRetryPatch, + onFileSelected = onFileSelected, + onBackToList = onHistoryBackToList, + onBack = onHistoryBack, + onAuthenticationExpired = onAuthenticationExpired, + ) + } } } } } + +/** Whether the screen is showing a file or a diff full screen, so the app's navigation should step aside. */ +fun isReadingFullScreen(codeBrowserState: CodeBrowserUiState, historyState: HistoryUiState): Boolean = + codeBrowserState.isFileLevel || historyState.showsFileDiff + +private const val FILES_PAGE = 0 +private const val COMMIT_PAGE = 1 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt index 7685a7f..6803ceb 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -29,20 +29,19 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -65,37 +64,42 @@ import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.ubisam.ubgitea.R import com.ubisam.ubgitea.domain.model.RepositoryPath import com.ubisam.ubgitea.domain.model.SourceContent import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntryType -import com.ubisam.ubgitea.feature.codebrowser.content.CodeSyntaxColors import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification -import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage -import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines -import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors -import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath +import com.ubisam.ubgitea.ui.components.CenteredLoading +import com.ubisam.ubgitea.ui.components.CenteredMessage +import com.ubisam.ubgitea.ui.components.ScreenTopBar +import com.ubisam.ubgitea.ui.components.UBIcon import java.io.File import java.io.IOException import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +/** True while the browser is showing one file (or failing to) rather than a directory listing. */ +val CodeBrowserUiState.isFileLevel: Boolean + get() = when (this) { + is CodeBrowserUiState.File, is CodeBrowserUiState.UnsupportedEntry -> true + is CodeBrowserUiState.Loading -> isFile + is CodeBrowserUiState.Error -> isFile + CodeBrowserUiState.Idle, is CodeBrowserUiState.Directory -> false + } + +/** The Files tab: a directory listing with the path above it. A file opens in [FileViewerScreen]. */ @Composable fun CodeBrowserScreen( state: CodeBrowserUiState, @@ -104,118 +108,181 @@ fun CodeBrowserScreen( onRetry: () -> Unit, onBack: () -> Unit, onAuthenticationExpired: () -> Unit, - onNavigateImage: (Int) -> Unit, modifier: Modifier = Modifier, ) { BackHandler(onBack = onBack) - Scaffold(modifier = modifier.fillMaxSize()) { padding -> - Column(Modifier.fillMaxSize().padding(padding)) { - when (state) { - CodeBrowserUiState.Idle -> Unit - is CodeBrowserUiState.Loading -> { - BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected) - CenterMessage { CircularProgressIndicator() } - } - is CodeBrowserUiState.Directory -> { - BrowserHeader(state.target, state.path, false, onBreadcrumbSelected) - if (state.entries.isEmpty()) { - CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) } - } else { - LazyColumn(Modifier.weight(1f)) { - items(state.entries, key = { it.path.value }) { entry -> - EntryRow(entry, onEntrySelected) - } - } - } - } - is CodeBrowserUiState.File -> { - BrowserHeader(state.target, state.path, true, onBreadcrumbSelected) - FileContent(state.content, state.path, state.siblingImagePaths, onNavigateImage) - } - is CodeBrowserUiState.UnsupportedEntry -> { - BrowserHeader(state.target, state.path, true, onBreadcrumbSelected) - CenterMessage { - Text( - stringResource( - if (state.type == TreeEntryType.SUBMODULE) R.string.codebrowser_submodule - else R.string.codebrowser_symlink, - ), - ) - } - } - is CodeBrowserUiState.Error -> { - BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected) - CenterMessage { - Text(stringResource(state.type.messageResource)) - if (state.type == CodeBrowserError.AUTHENTICATION_EXPIRED) { - Button(onClick = onAuthenticationExpired) { - Text(stringResource(R.string.codebrowser_sign_in_again)) - } - } else { - Button(onClick = onRetry) { Text(stringResource(R.string.codebrowser_retry)) } + Column(modifier.fillMaxSize()) { + when (state) { + CodeBrowserUiState.Idle -> Unit + is CodeBrowserUiState.Loading -> { + Breadcrumbs(state.path, onBreadcrumbSelected) + CenteredLoading(null) + } + is CodeBrowserUiState.Directory -> { + Breadcrumbs(state.path, onBreadcrumbSelected) + if (state.entries.isEmpty()) { + CenteredMessage(stringResource(R.string.codebrowser_directory_empty)) + } else { + LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) { + itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry -> + EntryRow(entry, index == 0, index == state.entries.lastIndex, onEntrySelected) } } } } + is CodeBrowserUiState.Error -> { + Breadcrumbs(state.path, onBreadcrumbSelected) + BrowserError(state.type, onRetry, onAuthenticationExpired) + } + // Shown by FileViewerScreen; the tab never receives these. + is CodeBrowserUiState.File, is CodeBrowserUiState.UnsupportedEntry -> Unit } } } +/** + * One file, full screen, outside the Files/Commit pager. Being outside it is the point: code that + * scrolls sideways inside a pager that swipes sideways is what made TASK-048 take three attempts. + */ @Composable -private fun BrowserHeader( - target: CodeBrowserTarget, - path: RepositoryPath, - leafIsFile: Boolean, - onBreadcrumbSelected: (RepositoryPath) -> Unit, +fun FileViewerScreen( + state: CodeBrowserUiState, + preferences: CodeViewerPreferences, + onBack: () -> Unit, + onRetry: () -> Unit, + onAuthenticationExpired: () -> Unit, + onNavigateImage: (Int) -> Unit, + onSetWordWrap: (Boolean) -> Unit, + onSetFontSize: (Int) -> Unit, + onOpenSibling: (RepositoryPath, String?) -> Unit, + onOpenEntry: (TreeEntry) -> Unit, + onLoadCounterpart: () -> Unit, + modifier: Modifier = Modifier, ) { - Column(Modifier.fillMaxWidth().padding(16.dp)) { - Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge) - val refKind = when (target.kind) { - CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch) - CodeReferenceKind.TAG -> stringResource(R.string.codebrowser_ref_tag) - CodeReferenceKind.COMMIT -> stringResource(R.string.codebrowser_ref_commit) - } - Text(stringResource(R.string.codebrowser_ref, refKind, target.ref.value)) - Row(Modifier.horizontalScroll(rememberScrollState()).padding(top = 12.dp)) { - val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path) - breadcrumbs.forEachIndexed { index, crumb -> - if (index > 0) Text(" / ", modifier = Modifier.padding(horizontal = 2.dp)) - val canNavigate = !(leafIsFile && index == breadcrumbs.lastIndex) - Text( - crumb.value.substringAfterLast('/').ifEmpty { stringResource(R.string.codebrowser_root) }, - color = if (canNavigate) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - modifier = if (canNavigate) { - Modifier.clickable(role = Role.Button) { onBreadcrumbSelected(crumb) } - } else { - Modifier - }, - ) - } + if (state is CodeBrowserUiState.File && state.content is ContentClassification.Text) { + CodeViewerScreen( + state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onOpenEntry, + onLoadCounterpart, modifier, + ) + return + } + BackHandler(onBack = onBack) + val path = when (state) { + is CodeBrowserUiState.File -> state.path + is CodeBrowserUiState.Loading -> state.path + is CodeBrowserUiState.Error -> state.path + is CodeBrowserUiState.UnsupportedEntry -> state.path + else -> RepositoryPath.Root + } + Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding()) { + ScreenTopBar( + title = path.value.substringAfterLast('/'), + subtitle = CodeBrowserNavigation.parent(path).value.ifEmpty { stringResource(R.string.codebrowser_root) }, + onBack = onBack, + monospaceTitle = true, + ) + when (state) { + is CodeBrowserUiState.File -> FileContent(state.content, state.path, state.siblingImagePaths, onNavigateImage) + is CodeBrowserUiState.Loading -> CenteredLoading(null) + is CodeBrowserUiState.Error -> BrowserError(state.type, onRetry, onAuthenticationExpired) + is CodeBrowserUiState.UnsupportedEntry -> CenteredMessage( + stringResource( + if (state.type == TreeEntryType.SUBMODULE) R.string.codebrowser_submodule else R.string.codebrowser_symlink, + ), + ) + else -> Unit } } - HorizontalDivider() } @Composable -private fun EntryRow(entry: TreeEntry, onSelected: (TreeEntry) -> Unit) { +private fun BrowserError(type: CodeBrowserError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) { + val expired = type == CodeBrowserError.AUTHENTICATION_EXPIRED + CenteredMessage( + stringResource(type.messageResource), + action = stringResource(if (expired) R.string.codebrowser_sign_in_again else R.string.codebrowser_retry), + onAction = if (expired) onAuthenticationExpired else onRetry, + ) +} + +@Composable +private fun Breadcrumbs(path: RepositoryPath, onBreadcrumbSelected: (RepositoryPath) -> Unit) { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path) + breadcrumbs.forEachIndexed { index, crumb -> + if (index > 0) { + Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, modifier = Modifier.padding(horizontal = 6.dp)) + } + val isLast = index == breadcrumbs.lastIndex + Text( + crumb.value.substringAfterLast('/').ifEmpty { stringResource(R.string.codebrowser_root) }, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isLast) FontWeight.Bold else FontWeight.Medium, + color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary, + modifier = Modifier + .then(if (!isLast) Modifier.clickable(role = Role.Button) { onBreadcrumbSelected(crumb) } else Modifier) + .padding(vertical = 14.dp), + ) + } + } +} + +@Composable +private fun EntryRow(entry: TreeEntry, isFirst: Boolean, isLast: Boolean, onSelected: (TreeEntry) -> Unit) { val kind = when (entry.type) { TreeEntryType.DIRECTORY -> stringResource(R.string.codebrowser_entry_directory) TreeEntryType.FILE -> stringResource(R.string.codebrowser_entry_file) TreeEntryType.SYMLINK -> stringResource(R.string.codebrowser_entry_symlink) TreeEntryType.SUBMODULE -> stringResource(R.string.codebrowser_entry_submodule) } - Column( - Modifier.fillMaxWidth().clickable(role = Role.Button) { onSelected(entry) } - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - Text(entry.name, style = MaterialTheme.typography.titleMedium) - Text(kind, style = MaterialTheme.typography.bodySmall) + val isDirectory = entry.type == TreeEntryType.DIRECTORY + val shape = RoundedCornerShape( + topStart = if (isFirst) 20.dp else 0.dp, + topEnd = if (isFirst) 20.dp else 0.dp, + bottomStart = if (isLast) 20.dp else 0.dp, + bottomEnd = if (isLast) 20.dp else 0.dp, + ) + Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth()) { + Column { + if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Row( + Modifier.fillMaxWidth() + .clickable(role = Role.Button) { onSelected(entry) } + .heightIn(min = 52.dp) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UBIcon( + if (isDirectory) R.drawable.ic_ub_folder else R.drawable.ic_ub_file, + kind, + size = 20.dp, + tint = if (isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + entry.name, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (!isDirectory && entry.type != TreeEntryType.FILE) { + Text(kind, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else if (isDirectory) { + UBIcon(R.drawable.ic_ub_chevron_right, null, size = 18.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } } - HorizontalDivider() } @Composable -@Suppress("DEPRECATION") private fun FileContent( content: ContentClassification, path: RepositoryPath, @@ -223,31 +290,8 @@ private fun FileContent( onNavigateImage: (Int) -> Unit, ) { when (content) { - is ContentClassification.Text -> { - val clipboard = LocalClipboardManager.current - var wordWrap by remember(path) { mutableStateOf(false) } - Column(Modifier.fillMaxSize().padding(16.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedButton(onClick = { clipboard.setText(AnnotatedString(content.renderedText)) }) { - Text(stringResource(R.string.codebrowser_copy)) - } - OutlinedButton(onClick = { wordWrap = !wordWrap }) { - Text( - stringResource( - if (wordWrap) R.string.codebrowser_word_wrap_off else R.string.codebrowser_word_wrap_on, - ), - ) - } - if (content.isTruncated) Text(stringResource(R.string.codebrowser_text_truncated)) - } - CodeBlock( - text = content.renderedText, - language = syntaxLanguageForPath(path), - wordWrap = wordWrap, - modifier = Modifier.fillMaxSize().padding(top = 12.dp), - ) - } - } + // Text is shown by CodeViewerScreen and never reaches here. + is ContentClassification.Text -> Unit is ContentClassification.Image -> { val index = siblingImagePaths.indexOf(path) ImageContent(content, index, siblingImagePaths.size, onNavigateImage) @@ -264,104 +308,6 @@ private fun FileContent( } } -// Line-numbered, syntax-highlighted source view (see SyntaxHighlighter.kt for what the highlighting -// actually is/isn't). -// -// Four things this shape specifically works around, all found live on-device -- none of them -// visible from build/lint/unit tests, which can't see a real touch gesture or a real wrapped-text -// layout: -// 1. An earlier version paired gutter+code in a LazyColumn with one horizontalScroll per row. -// Horizontal drags simply did nothing -- most likely BranchDetailScreen's HorizontalPager -// (userScrollEnabled is turned off for scrollable code, but apparently still not fully out of -// the nested-scroll picture for a LazyColumn specifically, the way it is for a plain -// Modifier.horizontalScroll chain -- see that file's own comment on this exact class of gesture -// conflict) intercepting the same-orientation drag first. Switched the outer container from -// LazyColumn to a plain Column + Modifier.verticalScroll, matching the shape of the file -// viewer's original, already-known-working implementation -- confirmed live afterward that -// horizontal drag actually moves the code again. -// 2. A version in between paired every row's gutter+code into a single Row (fix for #3 below) but -// gave each row's code Text its own independent Modifier.horizontalScroll -- reasoned at the -// time to be visually invisible since every row's text starts at column 0. Wrong in practice: -// dragging one line only scrolled that one line, leaving the rest of the file visually -// unscrolled and out of step, which reads as broken (a real product report: "선택한 곳만 좌우 -// 스크롤 되는건 아닌 것 같다"). -// 3. Tried making every row share one hoisted horizontalScrollState instead. That made horizontal -// drag do *nothing at all*, on any row, including the one being dragged -- multiple separate -// Modifier.horizontalScroll gesture-detector regions apparently don't cleanly share one -// ScrollableState the way a single shared scroll *region* does; each row still had its own -// pointer-input node competing over the same state instead of there being one region to drag. -// 4. **Final:** don't try to synchronize N separate horizontalScroll regions at all -- give the -// *whole* code column exactly one horizontalScroll region, wrapping every line's Text stacked in -// a single Column, with the gutter as a separate sibling Column that never scrolls horizontally. -// This only works correctly (gutter height staying matched to code height) as long as no line -// ever wraps to multiple visual rows, which is exactly true whenever this two-Column layout is -// even in play: it's used only when word wrap is off (softWrap = false, so every line is exactly -// one visual row no matter how long). When word wrap is on, wrapping can make a line take -// multiple visual rows, so gutter+code go back to the Row-per-line pairing from fix 2 instead -// (needed for correct number alignment, and horizontalScroll isn't used in this branch anyway -- -// wrapped lines have nothing to scroll to). -// -// Not lazy (every line is a real composable, not virtualized) -- the original single-Text version -// wasn't virtualized either, and SourceContentClassifier already bounds how many lines a file view -// ever receives, so this isn't a new performance ceiling. -@Composable -private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean, modifier: Modifier = Modifier) { - val colors = rememberCodeSyntaxColors() - val lines = remember(text) { text.split("\n") } - // Whole-file, once per file: block comments carry state across lines (see tokenizeLines). - val highlighted = remember(lines, language, colors) { highlightLines(lines, language, colors) } - val lineNumberWidth = remember(lines.size) { lines.size.toString().length } - val gutterWidth = (lineNumberWidth * 8 + 12).dp - SelectionContainer(modifier = modifier) { - if (wordWrap) { - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - highlighted.forEachIndexed { index, line -> - Row(Modifier.fillMaxWidth()) { - LineNumber(index, gutterWidth, colors) - Text( - text = line, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = colors.text, - softWrap = true, - modifier = Modifier.weight(1f), - ) - } - } - } - } else { - Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - Column { - lines.indices.forEach { index -> LineNumber(index, gutterWidth, colors) } - } - Column(Modifier.horizontalScroll(rememberScrollState())) { - highlighted.forEach { line -> - Text( - text = line, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = colors.text, - softWrap = false, - ) - } - } - } - } - } -} - -@Composable -private fun LineNumber(index: Int, width: Dp, colors: CodeSyntaxColors) { - Text( - text = (index + 1).toString(), - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = colors.comment, - textAlign = TextAlign.End, - modifier = Modifier.width(width).padding(end = 8.dp), - ) -} - // currentIndex is -1 when this file isn't part of a sibling image gallery (e.g. opened directly // via a retry rather than from a directory listing) — swipe navigation is simply unavailable then, // same as any single-image folder (totalCount <= 1). diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt index b314855..91b6805 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt @@ -18,6 +18,7 @@ import com.ubisam.ubgitea.domain.model.TreeEntryType import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification import com.ubisam.ubgitea.feature.codebrowser.content.SourceContentClassifier +import com.ubisam.ubgitea.feature.codebrowser.content.counterpartOf import java.util.Collections import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -39,6 +40,14 @@ enum class CodeReferenceKind { BRANCH, TAG, COMMIT } enum class CodeBrowserError { OFFLINE, AUTHENTICATION_EXPIRED, NOT_FOUND, PERMISSION_DENIED, SERVER } +/** The other half of a header/source pair, fetched only when a window wide enough to show both asks for it. */ +sealed interface CounterpartUiState { + data object NotLoaded : CounterpartUiState + data object Loading : CounterpartUiState + data class Loaded(val content: ContentClassification) : CounterpartUiState + data object Failed : CounterpartUiState +} + sealed interface CodeBrowserUiState { data object Idle : CodeBrowserUiState data class Loading( @@ -62,6 +71,16 @@ sealed interface CodeBrowserUiState { // second directory fetch. Empty for anything opened outside a directory listing (e.g. a // retry from Error) or when the containing folder has no other images. val siblingImagePaths: List = emptyList(), + // Everything in the directory this file was opened from, in listing order: what a wide + // window's file pane shows beside the code, and where a C/C++ file's counterpart is + // looked for. Empty when the file was not opened from a directory listing. + val siblingEntries: List = emptyList(), + // The header of this source file or the source of this header, when the directory has one. + val counterpartPath: RepositoryPath? = null, + val counterpart: CounterpartUiState = CounterpartUiState.NotLoaded, + // A symbol to bring into view once the file is shown: set when the file was reached by + // following a symbol from its counterpart. + val focusSymbol: String? = null, ) : CodeBrowserUiState data class UnsupportedEntry( val target: CodeBrowserTarget, @@ -93,6 +112,7 @@ class CodeBrowserViewModel( private val mutableState = MutableStateFlow(CodeBrowserUiState.Idle) val state: StateFlow = mutableState.asStateFlow() private var operation: Job? = null + private var counterpartOperation: Job? = null fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root) @@ -100,14 +120,7 @@ class CodeBrowserViewModel( val target = currentTarget() ?: return when (entry.type) { TreeEntryType.DIRECTORY -> loadDirectory(target, entry.path) - TreeEntryType.FILE -> { - val siblingImagePaths = (mutableState.value as? CodeBrowserUiState.Directory) - ?.entries - ?.filter { it.type == TreeEntryType.FILE && it.path.hasImageExtension() } - ?.map { it.path } - ?: emptyList() - loadFile(target, entry.path, siblingImagePaths) - } + TreeEntryType.FILE -> loadFile(target, entry.path, currentSiblingEntries()) TreeEntryType.SYMLINK, TreeEntryType.SUBMODULE -> { operation?.cancel() mutableState.value = CodeBrowserUiState.UnsupportedEntry(target, entry.path, entry.type) @@ -124,7 +137,38 @@ class CodeBrowserViewModel( if (index == -1) return val newIndex = index + delta if (newIndex !in siblings.indices) return - loadFile(current.target, siblings[newIndex], siblings) + loadFile(current.target, siblings[newIndex], current.siblingEntries) + } + + /** Opens another file of the directory the current file came from, optionally at a symbol. */ + fun openSibling(path: RepositoryPath, focusSymbol: String? = null) { + val current = mutableState.value as? CodeBrowserUiState.File ?: return + if (path == current.path) return + loadFile(current.target, path, current.siblingEntries, focusSymbol) + } + + /** Fetches the current file's counterpart so both can be shown side by side. */ + fun loadCounterpart() { + val current = mutableState.value as? CodeBrowserUiState.File ?: return + val path = current.counterpartPath ?: return + if (current.counterpart is CounterpartUiState.Loading || current.counterpart is CounterpartUiState.Loaded) return + mutableState.value = current.copy(counterpart = CounterpartUiState.Loading) + counterpartOperation?.cancel() + counterpartOperation = viewModelScope.launch { + val result = try { + val classification = withContext(defaultDispatcher) { + classifier.classify(browser.getFile(current.target.repository, current.target.ref, path).content, path) + } + CounterpartUiState.Loaded(classification) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + CounterpartUiState.Failed + } + val latest = mutableState.value as? CodeBrowserUiState.File ?: return@launch + if (latest.path == current.path && latest.counterpartPath == path) { + mutableState.value = latest.copy(counterpart = result) + } + } } fun openBreadcrumb(path: RepositoryPath) { @@ -150,9 +194,16 @@ class CodeBrowserViewModel( fun close() { operation?.cancel() operation = null + counterpartOperation?.cancel() mutableState.value = CodeBrowserUiState.Idle } + private fun currentSiblingEntries(): List = when (val current = mutableState.value) { + is CodeBrowserUiState.Directory -> current.entries + is CodeBrowserUiState.File -> current.siblingEntries + else -> emptyList() + } + // Network parsing, directory sort/dedupe, and file classification are CPU/allocation-heavy // enough on large trees or large files that they're moved off viewModelScope's Main dispatcher // here, rather than left to run on the UI thread after the network suspend point resumes. @@ -174,15 +225,26 @@ class CodeBrowserViewModel( private fun loadFile( target: CodeBrowserTarget, path: RepositoryPath, - siblingImagePaths: List = emptyList(), + siblingEntries: List = emptyList(), + focusSymbol: String? = null, ) = startOperation { + counterpartOperation?.cancel() mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true) try { val classification = withContext(defaultDispatcher) { val file = browser.getFile(target.repository, target.ref, path) classifier.classify(file.content, path) } - mutableState.value = CodeBrowserUiState.File(target, path, classification, siblingImagePaths) + val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path) + mutableState.value = CodeBrowserUiState.File( + target = target, + path = path, + content = classification, + siblingImagePaths = siblingFiles.filter { it.hasImageExtension() }, + siblingEntries = siblingEntries, + counterpartPath = counterpartOf(path, siblingFiles), + focusSymbol = focusSymbol, + ) } catch (failure: Throwable) { handleFailure(target, path, failure, isFile = true) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt new file mode 100644 index 0000000..030dd05 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt @@ -0,0 +1,267 @@ +package com.ubisam.ubgitea.feature.codebrowser + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.produceState +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.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.feature.codebrowser.content.CodeSymbol +import com.ubisam.ubgitea.feature.codebrowser.content.SearchMatch +import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage +import com.ubisam.ubgitea.feature.codebrowser.content.extractSymbols +import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines +import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors +import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** One file as the viewer needs it: its lines, their colors, and its outline. */ +@Immutable +class CodeDocument( + val path: RepositoryPath, + val language: SyntaxLanguage, + val lines: List, + val highlighted: List, + val symbols: List, + val longestLineLength: Int, +) { + val name: String get() = path.value.substringAfterLast('/') +} + +/** + * Splits and colors [text] off the main thread. Until that finishes the document shows its lines + * uncolored and without an outline, so a large file appears at once and gains color a moment later + * rather than holding the screen blank. + */ +@Composable +fun rememberCodeDocument(path: RepositoryPath, text: String): CodeDocument { + val colors = rememberCodeSyntaxColors() + val language = remember(path) { syntaxLanguageForPath(path) } + val lines = remember(text) { text.split("\n") } + val plain = remember(path, lines) { + CodeDocument(path, language, lines, lines.map(::AnnotatedString), emptyList(), lines.maxOfOrNull(String::length) ?: 0) + } + val document by produceState(plain, plain, colors) { + value = withContext(Dispatchers.Default) { + CodeDocument( + path = path, + language = language, + lines = lines, + highlighted = highlightLines(lines, language, colors), + symbols = extractSymbols(lines, language), + longestLineLength = plain.longestLineLength, + ) + } + } + // Until the producer has caught up with a newly opened file, its value is still the previous + // file's document. + return if (document.path == path && document.lines === lines) document else plain +} + +@Immutable +class SearchHighlight(val byLine: Map>, val current: SearchMatch?) { + companion object { + val None = SearchHighlight(emptyMap(), null) + } +} + +/** + * The code itself: numbered, colored lines in a lazily composed list, so only the lines on screen + * cost anything. With wrapping on, a long line continues on the next row, indented past the line's + * own indentation so the structure still reads. With wrapping off, every line is one row and the + * whole pane scrolls sideways together. + * + * Sideways scrolling is one hand-driven offset shared by every row, not a scroll modifier per row: + * rows that each own a scroll modifier scroll one at a time (TASK-048). This is the approach the + * commit diff and the CSV table already use. + */ +@Composable +fun CodePane( + document: CodeDocument, + listState: LazyListState, + wordWrap: Boolean, + fontSizeSp: Int, + modifier: Modifier = Modifier, + search: SearchHighlight = SearchHighlight.None, + focusedLine: Int? = null, + contentPadding: PaddingValues = PaddingValues(vertical = 8.dp), +) { + val colors = rememberCodeSyntaxColors() + val style = remember(fontSizeSp, colors.text) { + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = fontSizeSp.sp, + lineHeight = (fontSizeSp * LINE_HEIGHT_FACTOR).sp, + color = colors.text, + ) + } + val density = LocalDensity.current + val measurer = rememberTextMeasurer() + val characterWidthPx = remember(style) { measurer.measure("0", style).size.width.toFloat() } + val digits = document.lines.size.toString().length + val gutterWidth = with(density) { (characterWidthPx * digits).toDp() } + GutterPadding + val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant + val focusColor = MaterialTheme.colorScheme.primaryContainer + val hit = searchHitColors() + + var viewportWidthPx by remember { mutableIntStateOf(0) } + val gutterWidthPx = with(density) { gutterWidth.toPx() } + val maxOffsetPx = ((document.longestLineLength + 2) * characterWidthPx - (viewportWidthPx - gutterWidthPx)).coerceAtLeast(0f) + val offsetPx = remember(document.path, wordWrap) { mutableFloatStateOf(0f) } + val sideways = rememberScrollableState { delta -> + val previous = offsetPx.floatValue + val next = (previous - delta).coerceIn(0f, maxOffsetPx) + offsetPx.floatValue = next + previous - next + } + + SelectionContainer(modifier.fillMaxSize().onSizeChanged { viewportWidthPx = it.width }) { + LazyColumn(state = listState, contentPadding = contentPadding, modifier = Modifier.fillMaxSize()) { + items(document.lines.size, key = { it }) { index -> + val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) } + val shown = remember(line, search.byLine[index], search.current) { + withSearchHits(line, search.byLine[index], search.current, hit) + } + Row( + Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent), + verticalAlignment = Alignment.Top, + ) { + DisableSelection { + Text( + (index + 1).toString(), + style = style, + color = gutterColor, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(gutterWidth).padding(end = GutterGap), + ) + } + if (wordWrap) { + val indent = document.lines[index].indentWidth() + val hanging = with(density) { (characterWidthPx * (indent + CONTINUATION_INDENT)).toSp() } + Text( + shown, + style = style.copy(textIndent = TextIndent(restLine = hanging)), + softWrap = true, + modifier = Modifier.weight(1f).padding(end = 16.dp), + ) + } else { + Box(Modifier.weight(1f).clipToBounds().scrollable(sideways, Orientation.Horizontal)) { + Text( + shown, + style = style, + softWrap = false, + maxLines = 1, + modifier = Modifier + .offset { IntOffset(-offsetPx.floatValue.roundToInt(), 0) } + .wrapContentWidth(Alignment.Start, unbounded = true), + ) + } + } + } + } + } + } +} + +/** The zero-based line at the top of the pane: what the "you are here" strip is about. */ +@Composable +fun rememberFirstVisibleLine(listState: LazyListState): State = + remember(listState) { derivedStateOf { listState.firstVisibleItemIndex } } + +private class SearchHitColors(val other: Color, val current: Color, val onCurrent: Color) + +@Composable +private fun searchHitColors(): SearchHitColors { + val dark = isSystemInDarkTheme() + return remember(dark) { + if (dark) { + SearchHitColors(Color(0xFF5A4713), Color(0xFFFFB84D), Color(0xFF1A1200)) + } else { + SearchHitColors(Color(0xFFFFE9A8), Color(0xFFFFB84D), Color(0xFF0E1B2C)) + } + } +} + +private fun withSearchHits( + line: AnnotatedString, + matches: List?, + current: SearchMatch?, + colors: SearchHitColors, +): AnnotatedString { + if (matches.isNullOrEmpty()) return line + return buildAnnotatedString { + append(line) + matches.forEach { match -> + if (match.end > line.length) return@forEach + val style = if (match == current) { + SpanStyle(background = colors.current, color = colors.onCurrent) + } else { + SpanStyle(background = colors.other) + } + addStyle(style, match.start, match.end) + } + } +} + +private fun String.indentWidth(): Int { + var width = 0 + for (character in this) { + when (character) { + ' ' -> width++ + '\t' -> width += TAB_WIDTH + else -> return width + } + } + return width +} + +private const val LINE_HEIGHT_FACTOR = 1.5f +private const val CONTINUATION_INDENT = 4 +private const val TAB_WIDTH = 4 +private val GutterPadding = 24.dp +private val GutterGap = 10.dp diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt new file mode 100644 index 0000000..026da3c --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt @@ -0,0 +1,998 @@ +package com.ubisam.ubgitea.feature.codebrowser + +import androidx.activity.compose.BackHandler +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ubisam.ubgitea.R +import com.ubisam.ubgitea.core.settings.AppSettingsStore +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.model.TreeEntryType +import com.ubisam.ubgitea.feature.codebrowser.content.CodeSymbol +import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification +import com.ubisam.ubgitea.feature.codebrowser.content.CppFileRole +import com.ubisam.ubgitea.feature.codebrowser.content.SymbolKind +import com.ubisam.ubgitea.feature.codebrowser.content.cppRoleOf +import com.ubisam.ubgitea.feature.codebrowser.content.enclosingSymbols +import com.ubisam.ubgitea.feature.codebrowser.content.findMatches +import com.ubisam.ubgitea.feature.codebrowser.content.findSymbolNamed +import com.ubisam.ubgitea.feature.codebrowser.content.firstMatchFrom +import com.ubisam.ubgitea.feature.codebrowser.content.parseLineNumber +import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors +import com.ubisam.ubgitea.ui.components.CautionBanner +import com.ubisam.ubgitea.ui.components.CenteredLoading +import com.ubisam.ubgitea.ui.components.CenteredMessage +import com.ubisam.ubgitea.ui.components.MonoChip +import com.ubisam.ubgitea.ui.components.ScreenTopBar +import com.ubisam.ubgitea.ui.components.StatusBadge +import com.ubisam.ubgitea.ui.components.ActivityTone +import com.ubisam.ubgitea.ui.components.UBIcon +import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch + +data class CodeViewerPreferences(val wordWrap: Boolean, val fontSizeSp: Int) + +/** The window shapes the viewer lays itself out for. */ +private enum class ViewerLayout { + /** A phone held upright: one pane, tools along the bottom within thumb reach. */ + PHONE, + + /** A phone on its side: wide but short, so every row of chrome is spent on code instead. */ + LANDSCAPE, + + /** A tablet or an unfolded Z Fold: room for a second pane beside the code. */ + WIDE, +} + +private val CompactHeightBreakpoint = 480.dp +private val SidePaneWidth = 268.dp +private val NarrowSidePaneWidth = 220.dp +private val NarrowWideBreakpoint = 700.dp + +/** + * Source code, full screen. Reading code on a phone is mostly finding the part that matters, so + * the screen is built around getting there: search, an outline, a strip that says which function + * the top line belongs to, and -- for a C/C++ header or source -- its counterpart one tap away, or + * beside it when the window is wide enough. + */ +@Composable +fun CodeViewerScreen( + state: CodeBrowserUiState.File, + text: ContentClassification.Text, + preferences: CodeViewerPreferences, + onBack: () -> Unit, + onSetWordWrap: (Boolean) -> Unit, + onSetFontSize: (Int) -> Unit, + onOpenSibling: (RepositoryPath, String?) -> Unit, + onOpenEntry: (TreeEntry) -> Unit, + onLoadCounterpart: () -> Unit, + modifier: Modifier = Modifier, +) { + val document = rememberCodeDocument(state.path, text.renderedText) + val listState = remember(state.path) { LazyListState() } + val scope = rememberCoroutineScope() + val firstVisibleLine by rememberFirstVisibleLine(listState) + + var searching by rememberSaveable(state.path.value) { mutableStateOf(false) } + var query by rememberSaveable(state.path.value) { mutableStateOf("") } + var currentMatch by remember(state.path, query) { mutableIntStateOf(0) } + val matches = remember(document.lines, query) { findMatches(document.lines, query) } + val highlight = remember(matches, currentMatch) { + if (matches.isEmpty()) SearchHighlight.None else SearchHighlight(matches.groupBy { it.line }, matches.getOrNull(currentMatch)) + } + var outlineOpen by remember(state.path) { mutableStateOf(false) } + var focusedLine by remember(state.path) { mutableStateOf(null) } + var sideBySide by rememberSaveable { mutableStateOf(true) } + var syncPanes by rememberSaveable { mutableStateOf(true) } + + fun goToLine(line: Int) { + focusedLine = line + scope.launch { listState.scrollToItem(line.coerceIn(0, (document.lines.size - 1).coerceAtLeast(0))) } + } + fun goToMatch(index: Int) { + if (matches.isEmpty()) return + currentMatch = index.mod(matches.size) + val line = matches[currentMatch].line + scope.launch { listState.scrollToItem((line - SEARCH_CONTEXT_LINES).coerceAtLeast(0)) } + } + + // A search starts at what is on screen, not at the top of the file. + LaunchedEffect(query, matches) { + if (matches.isNotEmpty()) goToMatch(firstMatchFrom(matches, listState.firstVisibleItemIndex)) + } + // Arriving by following a symbol from the counterpart file: bring that symbol into view. + LaunchedEffect(state.path, state.focusSymbol, document.symbols) { + val name = state.focusSymbol ?: return@LaunchedEffect + findSymbolNamed(document.symbols, name)?.let { goToLine(it.line) } + } + + BackHandler { + when { + outlineOpen -> outlineOpen = false + searching -> { + searching = false + query = "" + } + else -> onBack() + } + } + + BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding().imePadding()) { + val layout = when { + maxHeight < CompactHeightBreakpoint -> ViewerLayout.LANDSCAPE + maxWidth >= WideLayoutBreakpoint -> ViewerLayout.WIDE + else -> ViewerLayout.PHONE + } + val sidePaneWidth = if (maxWidth < NarrowWideBreakpoint) NarrowSidePaneWidth else SidePaneWidth + val counterpartPath = state.counterpartPath + val showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide + if (showsPair) LaunchedEffect(state.path, counterpartPath) { onLoadCounterpart() } + + val tools = ViewerTools( + preferences = preferences, + searching = searching, + canPair = layout == ViewerLayout.WIDE && counterpartPath != null, + sideBySide = sideBySide, + syncPanes = syncPanes, + onSearch = { searching = true }, + onOutline = { outlineOpen = true }, + onToggleWrap = { onSetWordWrap(!preferences.wordWrap) }, + onSetFontSize = onSetFontSize, + onToggleSideBySide = { sideBySide = !sideBySide }, + onToggleSync = { syncPanes = !syncPanes }, + text = text.renderedText, + ) + val location = stringResource( + R.string.codeviewer_location, + state.target.repository.name, + CodeBrowserNavigation.parent(state.path).value.ifEmpty { stringResource(R.string.codebrowser_root) }, + ) + + Column(Modifier.fillMaxSize()) { + when { + searching -> SearchBar( + query = query, + onQueryChange = { query = it }, + position = if (matches.isEmpty()) null else currentMatch + 1, + total = matches.size, + onPrevious = { goToMatch(currentMatch - 1) }, + onNext = { goToMatch(currentMatch + 1) }, + onClose = { + searching = false + query = "" + }, + ) + layout == ViewerLayout.LANDSCAPE -> LandscapeBar(document, firstVisibleLine, onBack, tools) + else -> ScreenTopBar( + title = if (showsPair) document.name.substringBeforeLast('.') else document.name, + subtitle = location, + onBack = onBack, + monospaceTitle = true, + ) + } + if (text.isTruncated) CautionBanner(stringResource(R.string.codebrowser_text_truncated)) + if (layout == ViewerLayout.PHONE && counterpartPath != null && !searching) { + PairSwitch(state.path, counterpartPath) { onOpenSibling(counterpartPath, null) } + } + if (layout != ViewerLayout.LANDSCAPE && !showsPair) { + FileInfoStrip(state, document) + LocationStrip(enclosingSymbols(document.symbols, firstVisibleLine)) + } + + Row(Modifier.weight(1f).fillMaxWidth()) { + when { + showsPair -> PairPanes( + state = state, + document = document, + listState = listState, + preferences = preferences, + highlight = highlight, + focusedLine = focusedLine, + syncPanes = syncPanes, + counterpartPath = counterpartPath!!, + ) + else -> { + if (layout == ViewerLayout.WIDE) { + SidePane( + state = state, + document = document, + currentLine = firstVisibleLine, + onOpenFile = { onOpenSibling(it, null) }, + onOpenEntry = onOpenEntry, + onSymbolSelected = { goToLine(it.line) }, + modifier = Modifier.width(sidePaneWidth).fillMaxHeight(), + ) + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + Box(Modifier.weight(1f).fillMaxHeight()) { + CodePane( + document = document, + listState = listState, + wordWrap = preferences.wordWrap, + fontSizeSp = preferences.fontSizeSp, + search = highlight, + focusedLine = focusedLine, + contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp), + ) + if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching) { + val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD } + if (method != null) { + JumpToCounterpart( + counterpartPath, + Modifier.align(Alignment.BottomCenter).padding(horizontal = 16.dp, vertical = 12.dp), + ) { onOpenSibling(counterpartPath, method.name) } + } + } + } + } + } + } + if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools) + } + } + + if (outlineOpen) { + OutlineSheet( + document = document, + currentLine = firstVisibleLine, + onSymbolSelected = { + outlineOpen = false + goToLine(it.line) + }, + onLineEntered = { + outlineOpen = false + goToLine(it - 1) + }, + onDismiss = { outlineOpen = false }, + ) + } +} + +private class ViewerTools( + val preferences: CodeViewerPreferences, + val searching: Boolean, + val canPair: Boolean, + val sideBySide: Boolean, + val syncPanes: Boolean, + val onSearch: () -> Unit, + val onOutline: () -> Unit, + val onToggleWrap: () -> Unit, + val onSetFontSize: (Int) -> Unit, + val onToggleSideBySide: () -> Unit, + val onToggleSync: () -> Unit, + val text: String, +) + +@Composable +private fun Toolbar(tools: ViewerTools) { + val label = stringResource(R.string.codeviewer_tools) + Surface(color = MaterialTheme.colorScheme.surface) { + Column(Modifier.navigationBarsPadding()) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Row( + Modifier.fillMaxWidth().semantics { contentDescription = label }.padding(horizontal = 6.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + ToolButton(R.drawable.ic_ub_search, R.string.codeviewer_tool_search, tools.searching, Modifier.weight(1f), onClick = tools.onSearch) + ToolButton(R.drawable.ic_ub_list, R.string.codeviewer_tool_outline, false, Modifier.weight(1f), onClick = tools.onOutline) + ToolButton( + R.drawable.ic_ub_wrap, R.string.codeviewer_tool_wrap, tools.preferences.wordWrap, Modifier.weight(1f), + isToggle = true, onClick = tools.onToggleWrap, + ) + FontSizeButton(tools, Modifier.weight(1f)) { onOpen -> + ToolButton(R.drawable.ic_ub_text_size, R.string.codeviewer_tool_font_size, false, onClick = onOpen) + } + CopyButton(tools.text, Modifier.weight(1f)) { onCopy -> + ToolButton(R.drawable.ic_ub_copy, R.string.codeviewer_tool_copy, false, onClick = onCopy) + } + if (tools.canPair) { + ToolButton( + R.drawable.ic_ub_split, R.string.codeviewer_tool_side_by_side, tools.sideBySide, Modifier.weight(1f), + isToggle = true, onClick = tools.onToggleSideBySide, + ) + if (tools.sideBySide) { + ToolButton( + R.drawable.ic_ub_unfold, R.string.codeviewer_tool_sync, tools.syncPanes, Modifier.weight(1f), + isToggle = true, onClick = tools.onToggleSync, + ) + } + } + } + } + } +} + +@Composable +private fun ToolButton( + @DrawableRes icon: Int, + @StringRes label: Int, + active: Boolean, + modifier: Modifier = Modifier, + isToggle: Boolean = false, + onClick: () -> Unit, +) { + val on = stringResource(R.string.codeviewer_state_on) + val off = stringResource(R.string.codeviewer_state_off) + Column( + modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(role = if (isToggle) Role.Switch else Role.Button, onClick = onClick) + .semantics(mergeDescendants = true) { if (isToggle) stateDescription = if (active) on else off } + .heightIn(min = 52.dp) + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Box( + Modifier.size(width = 52.dp, height = 30.dp) + .clip(CircleShape) + .background(if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + UBIcon( + icon, + null, + size = 22.dp, + tint = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + stringResource(label), + fontSize = 11.sp, + fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, + color = if (active) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + +@Composable +private fun FontSizeButton(tools: ViewerTools, modifier: Modifier = Modifier, button: @Composable (onOpen: () -> Unit) -> Unit) { + var open by remember { mutableStateOf(false) } + Box(modifier, contentAlignment = Alignment.Center) { + button { open = true } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + AppSettingsStore.CODE_FONT_SIZES_SP.forEach { size -> + val selected = size == tools.preferences.fontSizeSp + DropdownMenuItem( + text = { + Text( + stringResource(R.string.codeviewer_font_size_option, size), + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + ) + }, + leadingIcon = { + if (selected) UBIcon(R.drawable.ic_ub_check, null, size = 20.dp) else Spacer(Modifier.size(20.dp)) + }, + onClick = { + open = false + tools.onSetFontSize(size) + }, + modifier = Modifier.semantics { this.selected = selected }, + ) + } + } + } +} + +@Composable +@Suppress("DEPRECATION") +private fun CopyButton(text: String, modifier: Modifier = Modifier, button: @Composable (onCopy: () -> Unit) -> Unit) { + val clipboard = LocalClipboardManager.current + Box(modifier, contentAlignment = Alignment.Center) { + button { clipboard.setText(AnnotatedString(text)) } + } +} + +/** Landscape: back, file name, where the top line is, and the tools as icons -- all on one row. */ +@Composable +private fun LandscapeBar(document: CodeDocument, currentLine: Int, onBack: () -> Unit, tools: ViewerTools) { + Surface(color = MaterialTheme.colorScheme.surface) { + Column { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { UBIcon(R.drawable.ic_ub_back, stringResource(R.string.shell_back)) } + Text( + document.name, + style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(end = 8.dp).semantics { heading() }, + ) + Box(Modifier.weight(1f)) { + LocationStrip(enclosingSymbols(document.symbols, currentLine), inline = true) + } + IconButton(onClick = tools.onSearch) { UBIcon(R.drawable.ic_ub_search, stringResource(R.string.codeviewer_tool_search)) } + IconButton(onClick = tools.onOutline) { UBIcon(R.drawable.ic_ub_list, stringResource(R.string.codeviewer_tool_outline)) } + IconButton(onClick = tools.onToggleWrap) { + UBIcon( + R.drawable.ic_ub_wrap, + stringResource(R.string.codeviewer_tool_wrap), + tint = if (tools.preferences.wordWrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + FontSizeButton(tools) { onOpen -> + IconButton(onClick = onOpen) { UBIcon(R.drawable.ic_ub_text_size, stringResource(R.string.codeviewer_tool_font_size)) } + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } +} + +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + position: Int?, + total: Int, + onPrevious: () -> Unit, + onNext: () -> Unit, + onClose: () -> Unit, +) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + Surface(color = MaterialTheme.colorScheme.surface) { + Column { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onClose) { UBIcon(R.drawable.ic_ub_back, stringResource(R.string.codeviewer_search_close)) } + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + placeholder = { Text(stringResource(R.string.codeviewer_search_hint)) }, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), + suffix = { + if (query.isNotEmpty()) { + Text( + if (position == null) { + stringResource(R.string.codeviewer_search_none) + } else { + stringResource(R.string.codeviewer_search_count, position, total) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onNext() }), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.weight(1f).focusRequester(focus), + ) + IconButton(onClick = onPrevious, enabled = total > 0) { + UBIcon(R.drawable.ic_ub_chevron_up, stringResource(R.string.codeviewer_search_previous)) + } + IconButton(onClick = onNext, enabled = total > 0) { + UBIcon(R.drawable.ic_ub_chevron_down, stringResource(R.string.codeviewer_search_next)) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } +} + +@Composable +private fun FileInfoStrip(state: CodeBrowserUiState.File, document: CodeDocument) { + Row( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MonoChip( + state.target.ref.value, + icon = if (state.target.kind == CodeReferenceKind.TAG) R.drawable.ic_ub_tag else R.drawable.ic_ub_branch, + modifier = Modifier.weight(1f, fill = false), + ) + Text( + stringResource(R.string.codeviewer_line_count, document.lines.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** "You are here": the namespace, type and method the top line of the pane sits inside. */ +@Composable +private fun LocationStrip(symbols: List, inline: Boolean = false) { + if (symbols.isEmpty()) return + val label = stringResource(R.string.codeviewer_current_location) + Row( + Modifier + .then(if (inline) Modifier.clip(CircleShape) else Modifier.fillMaxWidth()) + .background(MaterialTheme.colorScheme.primaryContainer) + .semantics(mergeDescendants = true) { contentDescription = label + " " + symbols.joinToString(" ") { it.label } } + .horizontalScroll(rememberScrollState()) + .padding(horizontal = if (inline) 10.dp else 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + symbols.forEachIndexed { index, symbol -> + if (index > 0) UBIcon(R.drawable.ic_ub_chevron_right, null, size = 12.dp, tint = MaterialTheme.colorScheme.onPrimaryContainer) + Text( + symbol.label, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + maxLines = 1, + ) + } + } +} + +@Composable +private fun PairSwitch(current: RepositoryPath, counterpart: RepositoryPath, onSwitch: () -> Unit) { + val currentIsHeader = cppRoleOf(current) == CppFileRole.HEADER + val header = if (currentIsHeader) current else counterpart + val source = if (currentIsHeader) counterpart else current + val label = stringResource(R.string.codeviewer_pair_label) + Row( + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp, vertical = 6.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .semantics { contentDescription = label } + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + PairSegment(header, R.string.codeviewer_pair_header, currentIsHeader, Modifier.weight(1f), onSwitch) + PairSegment(source, R.string.codeviewer_pair_source, !currentIsHeader, Modifier.weight(1f), onSwitch) + } +} + +@Composable +private fun PairSegment(path: RepositoryPath, @StringRes role: Int, isCurrent: Boolean, modifier: Modifier, onSwitch: () -> Unit) { + Surface( + color = if (isCurrent) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (isCurrent) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(10.dp), + shadowElevation = if (isCurrent) 1.dp else 0.dp, + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .clickable(enabled = !isCurrent, role = Role.Tab, onClick = onSwitch) + .semantics { selected = isCurrent }, + ) { + Row( + Modifier.heightIn(min = 44.dp).padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Text( + path.value.substringAfterLast('/'), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Text(stringResource(role), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + } + } +} + +@Composable +private fun JumpToCounterpart(counterpart: RepositoryPath, modifier: Modifier = Modifier, onJump: () -> Unit) { + val toHeader = cppRoleOf(counterpart) == CppFileRole.HEADER + Surface( + onClick = onJump, + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape, + shadowElevation = 6.dp, + modifier = modifier.fillMaxWidth(), + ) { + Row( + Modifier.heightIn(min = 48.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + UBIcon(R.drawable.ic_ub_split, null, size = 20.dp) + Text( + stringResource( + if (toHeader) R.string.codeviewer_jump_to_header else R.string.codeviewer_jump_to_source, + counterpart.value.substringAfterLast('/'), + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + UBIcon(R.drawable.ic_ub_chevron_right, null, size = 18.dp) + } + } +} + +/** A header and its source, side by side, header on the left whichever of the two was opened. */ +@Composable +private fun PairPanes( + state: CodeBrowserUiState.File, + document: CodeDocument, + listState: LazyListState, + preferences: CodeViewerPreferences, + highlight: SearchHighlight, + focusedLine: Int?, + syncPanes: Boolean, + counterpartPath: RepositoryPath, +) { + val otherText = ((state.counterpart as? CounterpartUiState.Loaded)?.content as? ContentClassification.Text)?.renderedText + val otherDocument = if (otherText != null) rememberCodeDocument(counterpartPath, otherText) else null + val otherState = remember(counterpartPath) { LazyListState() } + if (syncPanes && otherDocument != null) { + FollowSymbols(listState, document, otherState, otherDocument) + FollowSymbols(otherState, otherDocument, listState, document) + } + val currentIsHeader = cppRoleOf(state.path) == CppFileRole.HEADER + val current: @Composable (Modifier) -> Unit = { modifier -> + Column(modifier) { + PaneHeader(document.name, state.path, document.lines.size, enclosingSymbols(document.symbols, listState.firstVisibleItemIndex)) + CodePane(document, listState, preferences.wordWrap, preferences.fontSizeSp, search = highlight, focusedLine = focusedLine) + } + } + val other: @Composable (Modifier) -> Unit = { modifier -> + Column(modifier) { + val name = counterpartPath.value.substringAfterLast('/') + when { + otherDocument != null -> { + PaneHeader(name, counterpartPath, otherDocument.lines.size, enclosingSymbols(otherDocument.symbols, otherState.firstVisibleItemIndex)) + CodePane(otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp) + } + else -> { + PaneHeader(name, counterpartPath, null, emptyList()) + when (state.counterpart) { + CounterpartUiState.Failed -> CenteredMessage(stringResource(R.string.codeviewer_counterpart_failed)) + is CounterpartUiState.Loaded -> CenteredMessage(stringResource(R.string.codeviewer_counterpart_not_text)) + else -> CenteredLoading(stringResource(R.string.codeviewer_counterpart_loading)) + } + } + } + } + } + Row(Modifier.fillMaxSize()) { + (if (currentIsHeader) current else other)(Modifier.weight(1f).fillMaxHeight()) + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + (if (currentIsHeader) other else current)(Modifier.weight(1f).fillMaxHeight()) + } +} + +/** + * While the reader drags [leader], keeps [follower] on the declaration that matches the method at + * the top of [leader]. Only a drag leads: a scroll this effect started itself must not lead the + * other pane back. + */ +@Composable +private fun FollowSymbols(leader: LazyListState, leaderDocument: CodeDocument, follower: LazyListState, followerDocument: CodeDocument) { + val dragged by leader.interactionSource.collectIsDraggedAsState() + LaunchedEffect(leader, follower, leaderDocument, followerDocument, dragged) { + if (!dragged) return@LaunchedEffect + snapshotFlow { + enclosingSymbols(leaderDocument.symbols, leader.firstVisibleItemIndex).lastOrNull { it.kind == SymbolKind.METHOD }?.name + } + .filterNotNull() + .distinctUntilChanged() + .collect { name -> findSymbolNamed(followerDocument.symbols, name)?.let { follower.animateScrollToItem(it.line) } } + } +} + +@Composable +private fun PaneHeader(name: String, path: RepositoryPath, lineCount: Int?, location: List) { + val role = when (cppRoleOf(path)) { + CppFileRole.HEADER -> R.string.codeviewer_pair_header + CppFileRole.SOURCE -> R.string.codeviewer_pair_source + null -> null + } + Column { + Row( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + name, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false).semantics { heading() }, + ) + if (role != null) StatusBadge(stringResource(role), ActivityTone.BRAND) + Spacer(Modifier.weight(1f)) + if (lineCount != null) { + Text( + stringResource(R.string.codeviewer_line_count, lineCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + LocationStrip(location) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } +} + +/** Beside the code on a wide window: the directory's files, and the open file's outline. */ +@Composable +private fun SidePane( + state: CodeBrowserUiState.File, + document: CodeDocument, + currentLine: Int, + onOpenFile: (RepositoryPath) -> Unit, + onOpenEntry: (TreeEntry) -> Unit, + onSymbolSelected: (CodeSymbol) -> Unit, + modifier: Modifier = Modifier, +) { + val current = enclosingSymbols(document.symbols, currentLine).lastOrNull() + LazyColumn(modifier.background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp)) { + if (state.siblingEntries.isNotEmpty()) { + item(key = "files-title") { SidePaneTitle(stringResource(R.string.codeviewer_files_title)) } + items(state.siblingEntries, key = { "file:" + it.path.value }) { entry -> + val isFile = entry.type == TreeEntryType.FILE + val isCurrent = entry.path == state.path + SidePaneRow( + icon = if (entry.type == TreeEntryType.DIRECTORY) R.drawable.ic_ub_folder else R.drawable.ic_ub_file, + text = entry.name, + isCurrent = isCurrent, + accent = entry.type == TreeEntryType.DIRECTORY, + onClick = { if (isFile) onOpenFile(entry.path) else onOpenEntry(entry) }, + ) + } + item(key = "divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant) } + } + if (document.symbols.isNotEmpty()) { + item(key = "outline-title") { SidePaneTitle(stringResource(R.string.codeviewer_outline_title)) } + items(document.symbols, key = { "symbol:" + it.line + ":" + it.name }) { symbol -> + SymbolRow(symbol, isCurrent = symbol == current, compact = true) { onSymbolSelected(symbol) } + } + } + } +} + +@Composable +private fun SidePaneTitle(text: String) { + Text( + text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp).semantics { heading() }, + ) +} + +@Composable +private fun SidePaneRow(@DrawableRes icon: Int, text: String, isCurrent: Boolean, accent: Boolean, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth() + .background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) + .clickable(role = Role.Button, onClick = onClick) + .semantics { selected = isCurrent } + .heightIn(min = 44.dp) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UBIcon(icon, null, size = 18.dp, tint = if (accent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean, onClick: () -> Unit) { + val colors = rememberCodeSyntaxColors() + val (letter, color, kind) = when (symbol.kind) { + SymbolKind.NAMESPACE -> Triple("N", MaterialTheme.colorScheme.onSurfaceVariant, R.string.codeviewer_symbol_namespace) + SymbolKind.TYPE -> Triple("C", colors.type, R.string.codeviewer_symbol_type) + SymbolKind.METHOD -> Triple("M", colors.controlKeyword, R.string.codeviewer_symbol_method) + SymbolKind.FIELD -> Triple("F", colors.keyword, R.string.codeviewer_symbol_field) + } + val kindLabel = stringResource(kind) + val step: Dp = if (compact) 12.dp else 16.dp + val base: Dp = if (compact) 14.dp else 20.dp + Row( + Modifier.fillMaxWidth() + .background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) + .clickable(role = Role.Button, onClick = onClick) + .semantics { selected = isCurrent } + .heightIn(min = if (compact) 44.dp else 48.dp) + .padding(start = base + step * symbol.depth, end = base), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Surface( + color = MaterialTheme.colorScheme.surface, + contentColor = color, + shape = RoundedCornerShape(6.dp), + border = BorderStroke(1.5.dp, color), + modifier = Modifier.size(22.dp).semantics { contentDescription = kindLabel }, + ) { + Box(contentAlignment = Alignment.Center) { + Text(letter, fontFamily = FontFamily.Monospace, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + Text( + symbol.label, + fontFamily = FontFamily.Monospace, + fontSize = if (compact) 13.sp else 14.sp, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (!compact) { + Text( + stringResource(R.string.codeviewer_outline_line, symbol.line + 1), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OutlineSheet( + document: CodeDocument, + currentLine: Int, + onSymbolSelected: (CodeSymbol) -> Unit, + onLineEntered: (Int) -> Unit, + onDismiss: () -> Unit, +) { + var lineInput by remember { mutableStateOf("") } + var outOfRange by remember { mutableStateOf(false) } + val current = enclosingSymbols(document.symbols, currentLine).lastOrNull() + fun submit() { + val line = parseLineNumber(lineInput, document.lines.size) + if (line == null) outOfRange = true else onLineEntered(line) + } + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column(Modifier.fillMaxWidth()) { + Row(Modifier.padding(start = 20.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.codeviewer_outline_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f).semantics { heading() }, + ) + IconButton(onClick = onDismiss) { UBIcon(R.drawable.ic_ub_close, stringResource(R.string.codeviewer_outline_close)) } + } + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = lineInput, + onValueChange = { + lineInput = it.filter(Char::isDigit).take(MAX_LINE_DIGITS) + outOfRange = false + }, + singleLine = true, + label = { Text(stringResource(R.string.codeviewer_outline_hint)) }, + isError = outOfRange, + supportingText = if (outOfRange) { + { Text(stringResource(R.string.codeviewer_outline_line_out_of_range, document.lines.size)) } + } else { + null + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { submit() }), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.weight(1f), + ) + TextButton(onClick = ::submit, enabled = lineInput.isNotEmpty()) { + Text(stringResource(R.string.codeviewer_outline_go), fontWeight = FontWeight.Bold) + } + } + if (document.symbols.isEmpty()) { + Text( + stringResource(R.string.codeviewer_outline_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + ) + } else { + LazyColumn(Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp)) { + items(document.symbols, key = { "${it.line}:${it.name}" }) { symbol -> + SymbolRow(symbol, isCurrent = symbol == current, compact = false) { onSymbolSelected(symbol) } + } + } + } + } + } +} + +private const val SEARCH_CONTEXT_LINES = 3 +private const val MAX_LINE_DIGITS = 6 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearch.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearch.kt new file mode 100644 index 0000000..7c819a5 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearch.kt @@ -0,0 +1,60 @@ +package com.ubisam.ubgitea.feature.codebrowser.content + +import com.ubisam.ubgitea.domain.model.RepositoryPath + +/** One occurrence of a search text: `[start, end)` on zero-based [line]. */ +data class SearchMatch(val line: Int, val start: Int, val end: Int) + +/** Every occurrence of [query] in [lines], in reading order. A blank query matches nothing. */ +fun findMatches(lines: List, query: String, ignoreCase: Boolean = true): List { + if (query.isEmpty()) return emptyList() + val matches = mutableListOf() + lines.forEachIndexed { index, line -> + var from = 0 + while (true) { + val found = line.indexOf(query, from, ignoreCase) + if (found < 0) break + matches += SearchMatch(index, found, found + query.length) + from = found + query.length + } + } + return matches +} + +/** The index in [matches] of the first match at or after [line], wrapping to the first match. */ +fun firstMatchFrom(matches: List, line: Int): Int = + matches.indexOfFirst { it.line >= line }.let { if (it < 0) 0 else it } + +/** What a "go to line" entry means: a one-based line number inside the file, or null when it is not one. */ +fun parseLineNumber(input: String, lineCount: Int): Int? = + input.trim().removePrefix(":").toIntOrNull()?.takeIf { it in 1..lineCount } + +private val HEADER_EXTENSIONS = listOf("h", "hpp", "hh", "hxx") +private val SOURCE_EXTENSIONS = listOf("cpp", "cc", "cxx", "c") + +enum class CppFileRole { HEADER, SOURCE } + +fun cppRoleOf(path: RepositoryPath): CppFileRole? = when (path.extension()) { + in HEADER_EXTENSIONS -> CppFileRole.HEADER + in SOURCE_EXTENSIONS -> CppFileRole.SOURCE + else -> null +} + +/** + * The file that pairs with [path]: the source of a header, or the header of a source. Only a file + * in the same directory with the same name counts. A project that keeps headers under `include/` + * and sources under `src/` has its pairs in different directories, which this does not search. + */ +fun counterpartOf(path: RepositoryPath, siblings: List): RepositoryPath? { + val wanted = when (cppRoleOf(path)) { + CppFileRole.HEADER -> SOURCE_EXTENSIONS + CppFileRole.SOURCE -> HEADER_EXTENSIONS + null -> return null + } + val stem = path.value.substringBeforeLast('.') + return wanted.firstNotNullOfOrNull { extension -> + siblings.firstOrNull { it != path && it.extension() == extension && it.value.substringBeforeLast('.') == stem } + } +} + +private fun RepositoryPath.extension(): String = value.substringAfterLast('/').substringAfterLast('.', "").lowercase() diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt new file mode 100644 index 0000000..62ea011 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt @@ -0,0 +1,284 @@ +package com.ubisam.ubgitea.feature.codebrowser.content + +enum class SymbolKind { NAMESPACE, TYPE, METHOD, FIELD } + +/** + * One entry of a file's outline. + * + * @property name the bare name, which is what a header and its source file share + * @property label the name as shown: a method carries its parameter types, e.g. `NextDelay(int)` + * @property line zero-based line the declaration starts on + * @property endLine zero-based last line of the declaration's body; equal to [line] when it has none + * @property depth how many namespaces and types enclose it + */ +data class CodeSymbol( + val name: String, + val label: String, + val kind: SymbolKind, + val line: Int, + val endLine: Int, + val depth: Int, +) + +/** Whether [extractSymbols] understands [language] at all; the outline is not offered otherwise. */ +fun supportsOutline(language: SyntaxLanguage): Boolean = language in OUTLINE_LANGUAGES + +private val OUTLINE_LANGUAGES = setOf(SyntaxLanguage.CSHARP, SyntaxLanguage.CPP, SyntaxLanguage.JAVA, SyntaxLanguage.KOTLIN) + +/** + * Finds namespaces, types, methods and fields by pattern. This is not a parser: it follows braces + * to know what encloses what, and recognizes declarations by their shape on the line they start + * on. It is right for code written the usual way and can miss or misname a declaration written + * unusually (split across lines before its name, or produced by a macro). Comments and string + * literals are blanked out first, so braces and look-alike text inside them are not counted. + */ +fun extractSymbols(lines: List, language: SyntaxLanguage): List { + if (!supportsOutline(language)) return emptyList() + val code = blankCommentsAndStrings(lines, language) + val symbols = mutableListOf() + val scopes = ArrayDeque() + var awaitingBody: Pending? = null + var linesAwaited = 0 + + code.forEachIndexed { index, line -> + val enclosing = scopes.lastOrNull()?.kind + val canDeclare = enclosing == null || enclosing == ScopeKind.NAMESPACE || enclosing == ScopeKind.TYPE + if (awaitingBody != null && ++linesAwaited > MAX_LINES_AWAITING_BODY) awaitingBody = null + var declared: Pending? = null + if (canDeclare && awaitingBody == null) { + declared = declarationOn(line, language, insideType = enclosing == ScopeKind.TYPE)?.let { found -> + Pending(found, index, depth = scopes.count { it.kind == ScopeKind.NAMESPACE || it.kind == ScopeKind.TYPE }) + } + if (declared != null) { + symbols += declared + if (declared.found.kind != SymbolKind.FIELD) { + awaitingBody = declared + linesAwaited = 0 + } + } + } + for (character in line) { + when (character) { + '{' -> { + val owner = awaitingBody + awaitingBody = null + scopes.addLast(Scope(owner?.found?.kind.toScopeKind(), owner)) + } + '}' -> scopes.removeLastOrNull()?.symbol?.endLine = index + // A declaration that ends before any body opens has none: a prototype, an abstract + // method -- or a forward declaration such as `class Foo;`, which names a type that + // is defined elsewhere and so is not listed. + ';' -> awaitingBody?.let { bodiless -> + if (bodiless.found.kind == SymbolKind.TYPE) symbols -= bodiless + awaitingBody = null + } + } + } + // Kotlin has no `;` to say a declaration is over: `fun a() = 1` simply ends. Only a line + // that visibly continues keeps waiting for a body. + if (language == SyntaxLanguage.KOTLIN && declared != null && awaitingBody === declared) { + val end = line.trimEnd().lastOrNull() + if (end != '(' && end != ',') awaitingBody = null + } + } + return symbols.map { pending -> + CodeSymbol( + name = pending.found.name, + label = pending.found.label, + kind = pending.found.kind, + line = pending.line, + endLine = pending.endLine ?: pending.line, + depth = pending.depth, + ) + } +} + +/** The symbols enclosing [line], outermost first: what a "you are here" strip shows. */ +fun enclosingSymbols(symbols: List, line: Int): List = + symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth) + +/** The symbol in [symbols] that a symbol named [name] in a counterpart file corresponds to. */ +fun findSymbolNamed(symbols: List, name: String, kind: SymbolKind? = null): CodeSymbol? = + symbols.firstOrNull { it.name == name && (kind == null || it.kind == kind) } + ?: symbols.firstOrNull { it.name == name } + +// BLOCK is a brace that belongs to no declaration: a statement block, an initializer, an accessor list. +private enum class ScopeKind { NAMESPACE, TYPE, METHOD, BLOCK } + +private fun SymbolKind?.toScopeKind(): ScopeKind = when (this) { + SymbolKind.NAMESPACE -> ScopeKind.NAMESPACE + SymbolKind.TYPE -> ScopeKind.TYPE + SymbolKind.METHOD -> ScopeKind.METHOD + SymbolKind.FIELD, null -> ScopeKind.BLOCK +} + +// A declaration whose body has not opened after this many lines was not one (a macro, most likely). +private const val MAX_LINES_AWAITING_BODY = 12 + +private class Scope(val kind: ScopeKind, val symbol: Pending?) + +private class Pending(val found: Found, val line: Int, val depth: Int) { + var endLine: Int? = null +} + +private class Found(val name: String, val label: String, val kind: SymbolKind) + +private fun blankCommentsAndStrings(lines: List, language: SyntaxLanguage): List = + tokenizeLines(lines, language).mapIndexed { index, tokens -> + val characters = lines[index].toCharArray() + tokens.filter { it.role == TokenRole.COMMENT || it.role == TokenRole.STRING || it.role == TokenRole.PREPROCESSOR } + .forEach { token -> for (i in token.start until token.end) characters[i] = ' ' } + // A preprocessor line is no declaration, and a macro body's braces are not scopes. + if (tokens.firstOrNull()?.role == TokenRole.PREPROCESSOR) "" else String(characters) + } + +private const val NAME = "[A-Za-z_][A-Za-z0-9_]*" + +private val STATEMENT_WORDS = setOf( + "if", "else", "for", "foreach", "while", "do", "switch", "case", "return", "throw", "catch", "try", + "finally", "using", "lock", "new", "delete", "await", "yield", "goto", "sizeof", "typedef", "friend", + "when", "fixed", "checked", "unchecked", +) + +private const val CS_MODIFIER = + "(?:public|private|protected|internal|static|virtual|override|abstract|async|sealed|extern|unsafe|new|partial|readonly|const|volatile|required|final|synchronized|native|transient|default)" +private const val TYPE_NAME = "[\\w.:]+(?:<[^(){};]*>)?(?:\\[[,\\s]*\\])*\\??" + +private val NAMESPACE = Regex("^\\s*(?:inline\\s+)?namespace\\s+([\\w.:]+)") +private val CS_TYPE = Regex("^\\s*(?:$CS_MODIFIER\\s+)*(?:class|struct|interface|enum|record(?:\\s+(?:class|struct))?)\\s+($NAME)") +private val CS_METHOD = Regex("^\\s*((?:$CS_MODIFIER\\s+)*)(?:$TYPE_NAME\\s+)?($NAME)\\s*(?:<[^(){};]*>)?\\s*\\((.*)$") +private val CS_MEMBER = Regex("^\\s*(?:$CS_MODIFIER\\s+)+$TYPE_NAME\\s+($NAME)\\s*(?:=.*|;|\\{.*|=>.*)?$") +private val CPP_TYPE = Regex("^\\s*(?:template\\s*<[^{};]*>\\s*)?(?:class|struct|union|enum(?:\\s+(?:class|struct))?)\\s+(?:[A-Z_]+\\s+)?($NAME)") +private val CPP_FUNCTION = Regex("^\\s*(?:[\\w:<>,*&~\\s\\[\\]]*?[\\s*&])?((?:$NAME(?:<[^(){};]*>)?::)*~?$NAME)\\s*\\((.*)$") +private val CPP_MEMBER = Regex("^\\s*[\\w:<>,\\s]+?[\\s*&]+($NAME)\\s*(?:\\[[^\\]]*\\])?\\s*(?:=[^;]*|\\{[^}]*\\})?;\\s*$") +private val KOTLIN_TYPE = Regex("^\\s*(?:[a-z]+\\s+)*(?:class|interface|object)\\s+($NAME)") +private val KOTLIN_FUNCTION = Regex("^\\s*(?:[a-z]+\\s+)*fun\\s+(?:<[^(){};]*>\\s*)?(?:[\\w.<>?, ]+\\.)?($NAME)\\s*\\((.*)$") +private val KOTLIN_PROPERTY = Regex("^\\s*(?:[a-z]+\\s+)*(?:val|var)\\s+($NAME)") + +private fun declarationOn(line: String, language: SyntaxLanguage, insideType: Boolean): Found? { + val trimmed = line.trim() + if (trimmed.isEmpty() || trimmed.first() in ":,.)]}=+-*/&|?@") return null + val firstWord = trimmed.takeWhile { it.isLetterOrDigit() || it == '_' } + return when (language) { + SyntaxLanguage.CPP -> cppDeclaration(line, trimmed, firstWord, insideType) + SyntaxLanguage.KOTLIN -> kotlinDeclaration(line, insideType) + else -> csharpDeclaration(line, trimmed, firstWord, insideType) + } +} + +private fun csharpDeclaration(line: String, trimmed: String, firstWord: String, insideType: Boolean): Found? { + NAMESPACE.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.NAMESPACE) } + CS_TYPE.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.TYPE) } + if (!insideType || firstWord in STATEMENT_WORDS || trimmed.startsWith("[")) return null + CS_METHOD.matchEntire(line)?.let { match -> + val hasModifier = match.groupValues[1].isNotBlank() + val name = match.groupValues[2] + val beforeParenthesis = line.substringBefore('(') + val isCall = '=' in beforeParenthesis || '.' in beforeParenthesis.trim().substringAfterLast(' ') + // Without a modifier, `Foo(bar);` is a statement and `void Foo()` is a declaration: the + // difference is whether a second word (the return type) precedes the name. + val hasReturnType = beforeParenthesis.trim().contains(' ') + if (!isCall && name !in STATEMENT_WORDS && (hasModifier || hasReturnType)) { + return Found(name, name + parameterTypes(match.groupValues[3]), SymbolKind.METHOD) + } + } + CS_MEMBER.matchEntire(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.FIELD) } + return null +} + +private fun cppDeclaration(line: String, trimmed: String, firstWord: String, insideType: Boolean): Found? { + NAMESPACE.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.NAMESPACE) } + if (firstWord in STATEMENT_WORDS || trimmed.endsWith(":") && !trimmed.contains("(")) return null + CPP_TYPE.find(line)?.let { match -> + // `enum class Color : int {` and `class Foo final : public Bar {` both name the type first. + if ('(' !in line.substringBefore(match.groupValues[1])) { + return Found(match.groupValues[1], match.groupValues[1], SymbolKind.TYPE) + } + } + if ('(' in line) { + val beforeParenthesis = line.substringBefore('(') + if ('=' in beforeParenthesis && "operator" !in beforeParenthesis) return null + CPP_FUNCTION.matchEntire(line)?.let { match -> + val qualified = match.groupValues[1] + val name = qualified.substringAfterLast("::") + val hasReturnType = beforeParenthesis.trim().contains(' ') || beforeParenthesis.trim().contains('*') + // Inside a function body this would be a call; at namespace or type level a bare + // `Name(...)` is a constructor or a macro-declared function. A free-standing call + // (`DoThing(x);` at namespace level) is not valid C++, so it cannot be one. + val isConstructorLike = insideType || "::" in qualified + if (name !in STATEMENT_WORDS && (hasReturnType || isConstructorLike)) { + return Found(name, name + parameterTypes(match.groupValues[2]), SymbolKind.METHOD) + } + } + return null + } + if (insideType && firstWord !in setOf("public", "private", "protected", "using", "template")) { + CPP_MEMBER.matchEntire(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.FIELD) } + } + return null +} + +private fun kotlinDeclaration(line: String, insideType: Boolean): Found? { + KOTLIN_TYPE.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.TYPE) } + KOTLIN_FUNCTION.matchEntire(line)?.let { match -> + return Found(match.groupValues[1], match.groupValues[1] + kotlinParameterTypes(match.groupValues[2]), SymbolKind.METHOD) + } + KOTLIN_PROPERTY.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.FIELD) } + return null +} + +/** `(int attempt, Exception cause) const {` becomes `(int, Exception)`. `[rest]` starts right after the opening parenthesis. */ +private fun parameterTypes(rest: String): String { + val inside = closedParameterList(rest) ?: return "(…)" + if (inside.isBlank() || inside.trim() == "void") return "()" + return splitTopLevel(inside).joinToString(", ", "(", ")") { parameter -> + val words = parameter.substringBefore('=').trim().replace(PARAMETER_MODIFIER, "").split(WHITESPACE) + // The last word is the parameter's name, unless there is only one word (an unnamed parameter). + if (words.size == 1) { + words[0] + } else { + words.dropLast(1).joinToString(" ") + words.last().takeWhile { it == '*' || it == '&' } + } + } +} + +private val PARAMETER_MODIFIER = Regex("\\b(?:const|ref|out|in|params|this)\\s+") +private val WHITESPACE = Regex("\\s+") + +private fun kotlinParameterTypes(rest: String): String { + val inside = closedParameterList(rest) ?: return "(…)" + if (inside.isBlank()) return "()" + return splitTopLevel(inside).joinToString(", ", "(", ")") { parameter -> + parameter.substringAfter(':', parameter).substringBefore('=').trim() + } +} + +/** The text between the opening parenthesis and its matching close, or null when it closes on a later line. */ +private fun closedParameterList(rest: String): String? { + var depth = 1 + rest.forEachIndexed { index, character -> + when (character) { + '(' -> depth++ + ')' -> if (--depth == 0) return rest.substring(0, index) + } + } + return null +} + +private fun splitTopLevel(text: String): List { + val parts = mutableListOf() + var depth = 0 + var start = 0 + text.forEachIndexed { index, character -> + when (character) { + '<', '(', '[' -> depth++ + '>', ')', ']' -> depth-- + ',' -> if (depth == 0) { + parts += text.substring(start, index) + start = index + 1 + } + } + } + parts += text.substring(start) + return parts.map(String::trim).filter(String::isNotEmpty) +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt index 9d0d55f..77bf906 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt @@ -1,13 +1,13 @@ package com.ubisam.ubgitea.feature.codebrowser.content import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import com.ubisam.ubgitea.domain.model.RepositoryPath @@ -48,36 +48,48 @@ fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage { data class CodeSyntaxColors( val text: Color, val keyword: Color, + val controlKeyword: Color, + val type: Color, + val method: Color, val string: Color, val comment: Color, val number: Color, val preprocessor: Color, ) -// Colors are fixed (not derived from the app's own sky-blue Material theme) and only switch -// between two hand-picked sets for light/dark -- source code highlighting has its own well-known -// conventions (keyword blue, string orange/red, comment green) independent of a given app's brand -// color, and reusing brand colors here would read as less familiar, not more polished. +// Visual Studio's default colors (TASK-053; the team reads C# and C++ in Visual Studio, so the same +// code should look the same here). Token colors are fixed rather than derived from the app's brand +// palette -- source highlighting has its own conventions -- but the plain-text color and the +// background are the app theme's own, so the code pane is not a differently-themed island. Two +// light values are darker than Visual Studio's originals (type #2B91AF, comment #008000), which +// fall below 4.5:1 on white at code size. @Composable fun rememberCodeSyntaxColors(): CodeSyntaxColors { val dark = isSystemInDarkTheme() - return remember(dark) { + val text = MaterialTheme.colorScheme.onSurface + return remember(dark, text) { if (dark) { CodeSyntaxColors( - text = Color(0xFFD4D4D4), + text = text, keyword = Color(0xFF569CD6), - string = Color(0xFFCE9178), - comment = Color(0xFF6A9955), + controlKeyword = Color(0xFFD8A0DF), + type = Color(0xFF4EC9B0), + method = Color(0xFFDCDCAA), + string = Color(0xFFD69D85), + comment = Color(0xFF57A64A), number = Color(0xFFB5CEA8), preprocessor = Color(0xFF9B9B9B), ) } else { CodeSyntaxColors( - text = Color(0xFF1F1F1F), + text = text, keyword = Color(0xFF0000FF), + controlKeyword = Color(0xFF8F08C4), + type = Color(0xFF1F7A99), + method = Color(0xFF74531F), string = Color(0xFFA31515), - comment = Color(0xFF008000), - number = Color(0xFF098658), + comment = Color(0xFF007A00), + number = text, preprocessor = Color(0xFF6B6B6B), ) } @@ -91,8 +103,21 @@ private data class LangSpec( val blockComment: Boolean = false, val xmlComment: Boolean = false, val preprocessor: Boolean = false, + /** Whether identifiers that are not keywords are told apart as types and methods. */ + val identifiers: Boolean = false, ) +// Flow-control words Visual Studio colors differently from other keywords. Applied only to words +// that are keywords of the language at hand, so listing a word another language lacks is harmless. +private val CONTROL_KEYWORDS = setOf( + "if", "else", "switch", "case", "default", "for", "foreach", "while", "do", "break", "continue", + "return", "throw", "try", "catch", "finally", "goto", "yield", "when", "co_return", "co_yield", + "co_await", +) + +// A line that only names a namespace or package: its identifiers are neither types nor methods. +private val NAMESPACE_LINE = Regex("^\\s*(?:using|import|package|namespace)\\s+(?:static\\s+)?[\\w.:*]+(?:\\s*=\\s*[\\w.:<>, ]+)?\\s*;?\\s*\\{?\\s*$") + private val SPECS: Map = mapOf( SyntaxLanguage.KOTLIN to LangSpec( keywords = setOf( @@ -106,6 +131,7 @@ private val SPECS: Map = mapOf( ), lineComment = "//", blockComment = true, + identifiers = true, ), SyntaxLanguage.JAVA to LangSpec( keywords = setOf( @@ -118,6 +144,7 @@ private val SPECS: Map = mapOf( ), lineComment = "//", blockComment = true, + identifiers = true, ), SyntaxLanguage.CSHARP to LangSpec( keywords = setOf( @@ -132,6 +159,7 @@ private val SPECS: Map = mapOf( ), lineComment = "//", blockComment = true, + identifiers = true, preprocessor = true, ), SyntaxLanguage.CPP to LangSpec( @@ -150,6 +178,7 @@ private val SPECS: Map = mapOf( ), lineComment = "//", blockComment = true, + identifiers = true, preprocessor = true, ), SyntaxLanguage.JAVASCRIPT to LangSpec( @@ -163,6 +192,7 @@ private val SPECS: Map = mapOf( ), lineComment = "//", blockComment = true, + identifiers = true, ), SyntaxLanguage.PYTHON to LangSpec( keywords = setOf( @@ -262,9 +292,14 @@ internal fun tokenizeLines(lines: List, language: SyntaxLanguage): List< } } } + val plainIdentifiers = !spec.identifiers || NAMESPACE_LINE.matches(line) + var previousWord: String? = null for (match in pattern.findAll(line, from)) { val text = match.value - val role = classify(text, spec) ?: continue + val role = classify(text, spec) + ?: if (plainIdentifiers) null else identifierRole(line, match.range, previousWord) + if (text.first().isLetter() || text.first() == '_') previousWord = text + if (role == null) continue tokens += SyntaxToken(match.range.first, match.range.last + 1, role) if (role == TokenRole.COMMENT) open = unterminatedBlockComment(text, spec) } @@ -272,6 +307,27 @@ internal fun tokenizeLines(lines: List, language: SyntaxLanguage): List< } } +/** + * Tells a type or a method from a plain identifier by how it is written and what surrounds it -- + * there is no symbol table here, so this is convention, not knowledge. A name followed by `(` is a + * method, unless `new` precedes it (a constructor call names a type). A capitalized name is a type, + * unless it is reached through `.` (a property or member) or is written all in capitals (a + * constant or macro). A name with the `m_` or `_` field prefix is always plain. + */ +private fun identifierRole(line: String, range: IntRange, previousWord: String?): TokenRole? { + val text = line.substring(range.first, range.last + 1) + if (text.startsWith("m_") || text.startsWith("_")) return null + val next = line.drop(range.last + 1).firstOrNull { !it.isWhitespace() } + val before = line.take(range.first).lastOrNull { !it.isWhitespace() } + if (next == '(') return if (previousWord == "new") TokenRole.TYPE else TokenRole.METHOD + val capitalized = text.first().isUpperCase() && text.any(Char::isLowerCase) + return when { + capitalized && before != '.' -> TokenRole.TYPE + text.endsWith("_t") && text.length > 2 -> TokenRole.TYPE + else -> null + } +} + private fun unterminatedBlockComment(text: String, spec: LangSpec): BlockComment? { val block = when { spec.blockComment && text.startsWith(BlockComment.C_STYLE.open) -> BlockComment.C_STYLE @@ -295,7 +351,7 @@ private fun annotate(line: String, tokens: List, colors: CodeSyntax var lastIndex = 0 for (token in tokens) { if (token.start > lastIndex) append(line.substring(lastIndex, token.start)) - withStyle(SpanStyle(color = token.role.colorOf(colors), fontWeight = token.role.weight)) { + withStyle(SpanStyle(color = token.role.colorOf(colors))) { append(line.substring(token.start, token.end)) } lastIndex = token.end @@ -303,12 +359,15 @@ private fun annotate(line: String, tokens: List, colors: CodeSyntax if (lastIndex < line.length) append(line.substring(lastIndex)) } -internal enum class TokenRole(val weight: FontWeight?) { - COMMENT(null), - STRING(null), - NUMBER(null), - KEYWORD(FontWeight.SemiBold), - PREPROCESSOR(null), +internal enum class TokenRole { + COMMENT, + STRING, + NUMBER, + KEYWORD, + CONTROL_KEYWORD, + TYPE, + METHOD, + PREPROCESSOR, ; fun colorOf(colors: CodeSyntaxColors): Color = when (this) { @@ -316,6 +375,9 @@ internal enum class TokenRole(val weight: FontWeight?) { STRING -> colors.string NUMBER -> colors.number KEYWORD -> colors.keyword + CONTROL_KEYWORD -> colors.controlKeyword + TYPE -> colors.type + METHOD -> colors.method PREPROCESSOR -> colors.preprocessor } } @@ -325,7 +387,7 @@ private fun classify(text: String, spec: LangSpec): TokenRole? = when { spec.xmlComment && text.startsWith("