Merge TASK-065 and TASK-066 into the reviewed branch
Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7aa79f15ce
@ -14,8 +14,10 @@ android {
|
|||||||
applicationId = "com.ubisam.ubgitea"
|
applicationId = "com.ubisam.ubgitea"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
// Raised for every build published to the download page (TASK-066), or the app will not
|
||||||
versionName = "0.1.0"
|
// see it as newer.
|
||||||
|
versionCode = 2
|
||||||
|
versionName = "0.2.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import android.graphics.Color
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.SystemBarStyle
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
@ -18,15 +19,23 @@ import androidx.browser.customtabs.CustomTabsIntent
|
|||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||||
|
import com.ubisam.ubgitea.core.update.AppUpdateLocations
|
||||||
|
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
@ -37,6 +46,16 @@ import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
|
|||||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
|
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
|
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
|
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CodeTabStrip
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CodeWorkbench
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CodeWorkbenchViewModel
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareScreen
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareUiState
|
||||||
|
import com.ubisam.ubgitea.feature.compare.LocalCodeWorkbench
|
||||||
|
import com.ubisam.ubgitea.feature.compare.SavedCodeTab
|
||||||
|
import com.ubisam.ubgitea.feature.compare.WorkbenchMessage
|
||||||
import com.ubisam.ubgitea.feature.history.HistoryTarget
|
import com.ubisam.ubgitea.feature.history.HistoryTarget
|
||||||
import com.ubisam.ubgitea.feature.history.HistoryViewModel
|
import com.ubisam.ubgitea.feature.history.HistoryViewModel
|
||||||
import com.ubisam.ubgitea.feature.home.HomeDestination
|
import com.ubisam.ubgitea.feature.home.HomeDestination
|
||||||
@ -87,6 +106,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val workViewModel: WorkViewModel by viewModels {
|
private val workViewModel: WorkViewModel by viewModels {
|
||||||
WorkViewModel.Factory(application)
|
WorkViewModel.Factory(application)
|
||||||
}
|
}
|
||||||
|
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
|
||||||
|
CodeWorkbenchViewModel.Factory(application)
|
||||||
|
}
|
||||||
|
private val updateViewModel: AppUpdateViewModel by viewModels {
|
||||||
|
AppUpdateViewModel.Factory()
|
||||||
|
}
|
||||||
|
|
||||||
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
|
// 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
|
// whether to actually show a prompt, this call is safe to make on every launch. Must be
|
||||||
@ -117,6 +142,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
val settingsState by settingsViewModel.state.collectAsState()
|
val settingsState by settingsViewModel.state.collectAsState()
|
||||||
val homeState by homeViewModel.state.collectAsState()
|
val homeState by homeViewModel.state.collectAsState()
|
||||||
val workState by workViewModel.state.collectAsState()
|
val workState by workViewModel.state.collectAsState()
|
||||||
|
val workbenchState by workbenchViewModel.state.collectAsState()
|
||||||
|
val updateState by updateViewModel.state.collectAsState()
|
||||||
|
// TASK-066: whether a newer build is published, once per launch.
|
||||||
|
LaunchedEffect(Unit) { updateViewModel.checkAtLaunch() }
|
||||||
|
|
||||||
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
|
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
|
||||||
mutableStateOf(listOf<Route>(Route.Home))
|
mutableStateOf(listOf<Route>(Route.Home))
|
||||||
@ -147,8 +176,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The route shown before this one: coming back from a comparison leaves the code as it was.
|
||||||
|
val lastRoute = remember { arrayOfNulls<Route>(1) }
|
||||||
LaunchedEffect(currentRoute, sessionReady) {
|
LaunchedEffect(currentRoute, sessionReady) {
|
||||||
|
val previous = lastRoute[0]
|
||||||
|
lastRoute[0] = currentRoute
|
||||||
if (!sessionReady) return@LaunchedEffect
|
if (!sessionReady) return@LaunchedEffect
|
||||||
|
if (previous == Route.Compare && currentRoute is Route.BranchDetail) return@LaunchedEffect
|
||||||
if (currentRoute.isTab) notificationsViewModel.refreshUnreadCount()
|
if (currentRoute.isTab) notificationsViewModel.refreshUnreadCount()
|
||||||
when (val route = currentRoute) {
|
when (val route = currentRoute) {
|
||||||
Route.Home -> homeViewModel.open()
|
Route.Home -> homeViewModel.open()
|
||||||
@ -157,7 +191,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
is Route.BranchDetail -> {
|
is Route.BranchDetail -> {
|
||||||
val repository = RepositoryCoordinates.of(route.owner, route.name)
|
val repository = RepositoryCoordinates.of(route.owner, route.name)
|
||||||
val ref = RepositoryRef.of(route.ref)
|
val ref = RepositoryRef.of(route.ref)
|
||||||
codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind)))
|
val target = CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind))
|
||||||
|
if (route.path != null) {
|
||||||
|
codeBrowserViewModel.openFile(target, RepositoryPath.of(route.path))
|
||||||
|
} else {
|
||||||
|
codeBrowserViewModel.open(target)
|
||||||
|
}
|
||||||
historyViewModel.open(HistoryTarget(repository, ref))
|
historyViewModel.open(HistoryTarget(repository, ref))
|
||||||
}
|
}
|
||||||
is Route.WorkItems -> {
|
is Route.WorkItems -> {
|
||||||
@ -170,9 +209,32 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
Route.Notifications -> notificationsViewModel.open()
|
Route.Notifications -> notificationsViewModel.open()
|
||||||
Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction.
|
Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction.
|
||||||
|
Route.Compare -> Unit // CodeWorkbenchViewModel already holds what is compared.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TASK-065: a comparison that has started is shown on its own screen; one that is gone
|
||||||
|
// (closed, or lost with the process) takes that screen away.
|
||||||
|
val comparing = workbenchState.compare != CompareUiState.None
|
||||||
|
LaunchedEffect(comparing) {
|
||||||
|
if (comparing && currentRoute != Route.Compare) push(Route.Compare)
|
||||||
|
}
|
||||||
|
LaunchedEffect(currentRoute, comparing) {
|
||||||
|
if (currentRoute == Route.Compare && !comparing) pop()
|
||||||
|
}
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
LaunchedEffect(workbenchState.message) {
|
||||||
|
val message = workbenchState.message ?: return@LaunchedEffect
|
||||||
|
val text = when (message) {
|
||||||
|
WorkbenchMessage.TABS_FULL -> context.getString(R.string.workbench_tabs_full, SavedCodeTab.MAX_TABS)
|
||||||
|
WorkbenchMessage.FILE_UNAVAILABLE -> context.getString(R.string.compare_file_unavailable)
|
||||||
|
WorkbenchMessage.FILE_NOT_TEXT -> context.getString(R.string.compare_file_not_text)
|
||||||
|
WorkbenchMessage.SAME_FILE -> context.getString(R.string.compare_same_file)
|
||||||
|
}
|
||||||
|
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
|
||||||
|
workbenchViewModel.messageShown()
|
||||||
|
}
|
||||||
|
|
||||||
fun closeFeatures() {
|
fun closeFeatures() {
|
||||||
codeBrowserViewModel.close()
|
codeBrowserViewModel.close()
|
||||||
historyViewModel.close()
|
historyViewModel.close()
|
||||||
@ -197,10 +259,61 @@ class MainActivity : ComponentActivity() {
|
|||||||
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
|
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
|
||||||
val showNavigation = sessionReady && when (currentRoute) {
|
val showNavigation = sessionReady && when (currentRoute) {
|
||||||
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
|
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
|
||||||
Route.Settings -> false
|
Route.Settings, Route.Compare -> false
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A saved tab: the file in place when this screen already shows its repository and
|
||||||
|
// ref, otherwise this screen on that file in place of the code being read.
|
||||||
|
fun openTab(tab: SavedCodeTab) {
|
||||||
|
val route = currentRoute
|
||||||
|
val target = CodeBrowserTarget(
|
||||||
|
RepositoryCoordinates.of(tab.owner, tab.name),
|
||||||
|
RepositoryRef.of(tab.ref),
|
||||||
|
CodeReferenceKind.valueOf(tab.refKind),
|
||||||
|
)
|
||||||
|
if (route is Route.BranchDetail && route.owner == tab.owner && route.name == tab.name &&
|
||||||
|
route.refKind == tab.refKind && route.ref == tab.ref
|
||||||
|
) {
|
||||||
|
codeBrowserViewModel.openFile(target, RepositoryPath.of(tab.path))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val next = Route.BranchDetail(tab.owner, tab.name, tab.refKind, tab.ref, tab.path)
|
||||||
|
if (route is Route.BranchDetail) {
|
||||||
|
codeBrowserViewModel.close()
|
||||||
|
historyViewModel.close()
|
||||||
|
routeStack = routeStack.dropLast(1) + next
|
||||||
|
} else {
|
||||||
|
push(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val shownFile = codeBrowserState as? CodeBrowserUiState.File
|
||||||
|
val currentCodeTab = (currentRoute as? Route.BranchDetail)?.let { route ->
|
||||||
|
shownFile?.takeIf { it.target.repository == RepositoryCoordinates.of(route.owner, route.name) }?.let {
|
||||||
|
SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val workbench = if (sessionReady) {
|
||||||
|
CodeWorkbench(
|
||||||
|
tabs = workbenchState.tabs,
|
||||||
|
pending = workbenchState.pending,
|
||||||
|
fetching = workbenchState.fetching,
|
||||||
|
onToggleTab = workbenchViewModel::toggleTab,
|
||||||
|
onPin = workbenchViewModel::pin,
|
||||||
|
onCompareWith = workbenchViewModel::compareWith,
|
||||||
|
onClearPin = workbenchViewModel::clearPin,
|
||||||
|
onUseCommitFile = { commitId, shortId, path, side ->
|
||||||
|
(currentRoute as? Route.BranchDetail)?.let { route ->
|
||||||
|
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare &&
|
||||||
|
(workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching)
|
||||||
|
|
||||||
val darkTheme = when (settingsState.themeMode) {
|
val darkTheme = when (settingsState.themeMode) {
|
||||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
@ -219,17 +332,44 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UBGiteaTheme(darkTheme = darkTheme) {
|
UBGiteaTheme(darkTheme = darkTheme) {
|
||||||
|
CompositionLocalProvider(LocalCodeWorkbench provides workbench) {
|
||||||
AppShell(
|
AppShell(
|
||||||
selectedTab = currentTab,
|
selectedTab = currentTab,
|
||||||
showNavigation = showNavigation,
|
showNavigation = showNavigation,
|
||||||
unreadNotifications = unreadNotifications,
|
unreadNotifications = unreadNotifications,
|
||||||
onTabSelected = { tab ->
|
onTabSelected = { tab ->
|
||||||
|
// The Repositories tab pressed again steps back toward the list: from a
|
||||||
|
// repository's code to its branches' list page, from there to the first page.
|
||||||
|
val reselected = tab == AppTab.REPOSITORIES && currentTab == AppTab.REPOSITORIES
|
||||||
if (currentRoute is Route.BranchDetail) {
|
if (currentRoute is Route.BranchDetail) {
|
||||||
codeBrowserViewModel.close()
|
codeBrowserViewModel.close()
|
||||||
historyViewModel.close()
|
historyViewModel.close()
|
||||||
}
|
}
|
||||||
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
|
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
|
||||||
|
when {
|
||||||
|
!reselected -> selectTab(tab)
|
||||||
|
currentRoute == Route.Repositories -> viewModel.reselect()
|
||||||
|
else -> {
|
||||||
|
if (currentRoute == Route.Compare) workbenchViewModel.closeCompare()
|
||||||
selectTab(tab)
|
selectTab(tab)
|
||||||
|
viewModel.backToRepositories()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
strip = if (showStrip) {
|
||||||
|
{
|
||||||
|
CodeTabStrip(
|
||||||
|
tabs = workbenchState.tabs,
|
||||||
|
current = currentCodeTab,
|
||||||
|
pending = workbenchState.pending,
|
||||||
|
fetching = workbenchState.fetching,
|
||||||
|
onOpen = ::openTab,
|
||||||
|
onClose = workbenchViewModel::removeTab,
|
||||||
|
onClearPin = workbenchViewModel::clearPin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
},
|
},
|
||||||
) { isWide ->
|
) { isWide ->
|
||||||
// Back on any tab but Home goes Home; back on Home leaves the app.
|
// Back on any tab but Home goes Home; back on Home leaves the app.
|
||||||
@ -337,6 +477,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
onSetThemeMode = settingsViewModel::setThemeMode,
|
onSetThemeMode = settingsViewModel::setThemeMode,
|
||||||
onLogout = ::signOut,
|
onLogout = ::signOut,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
|
update = updateState,
|
||||||
|
onCheckUpdate = updateViewModel::checkNow,
|
||||||
|
onOpenDownloadPage = ::openDownloadPage,
|
||||||
)
|
)
|
||||||
Route.Notifications -> NotificationsScreen(
|
Route.Notifications -> NotificationsScreen(
|
||||||
state = notificationsState,
|
state = notificationsState,
|
||||||
@ -419,11 +562,37 @@ class MainActivity : ComponentActivity() {
|
|||||||
onAuthenticationExpired = ::signOut,
|
onAuthenticationExpired = ::signOut,
|
||||||
)
|
)
|
||||||
Route.Repositories -> repositoryList()
|
Route.Repositories -> repositoryList()
|
||||||
|
Route.Compare -> CompareScreen(
|
||||||
|
state = workbenchState.compare,
|
||||||
|
fontSizeSp = settingsState.codeFontSizeSp,
|
||||||
|
onSwap = workbenchViewModel::swapSides,
|
||||||
|
onClose = workbenchViewModel::closeCompare,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.browserRequests.collect(::openAuthorizationTab)
|
viewModel.browserRequests.collect(::openAuthorizationTab)
|
||||||
}
|
}
|
||||||
|
updateState.prompt?.let { release ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = updateViewModel::dismissPrompt,
|
||||||
|
title = { Text(stringResource(R.string.update_dialog_title)) },
|
||||||
|
text = {
|
||||||
|
val body = stringResource(R.string.update_dialog_body, updateState.installedVersionName, release.versionName)
|
||||||
|
Text(if (release.notes != null) body + "\n\n" + release.notes else body)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
updateViewModel.dismissPrompt()
|
||||||
|
openDownloadPage()
|
||||||
|
}) { Text(stringResource(R.string.update_dialog_download)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
consumeCallback(intent)
|
consumeCallback(intent)
|
||||||
@ -453,6 +622,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TASK-066: the download page asks for its password itself, so it opens in the browser.
|
||||||
|
private fun openDownloadPage() {
|
||||||
|
try {
|
||||||
|
CustomTabsIntent.Builder()
|
||||||
|
.build()
|
||||||
|
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD_PAGE))
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(this, AppUpdateLocations.DOWNLOAD_PAGE, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun consumeCallback(intent: Intent?) {
|
private fun consumeCallback(intent: Intent?) {
|
||||||
val callback = intent?.dataString ?: return
|
val callback = intent?.dataString ?: return
|
||||||
setIntent(Intent(intent).setData(null))
|
setIntent(Intent(intent).setData(null))
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package com.ubisam.ubgitea.core.settings
|
package com.ubisam.ubgitea.core.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two user-facing toggles TASK-038 introduced, both defaulted to preserve exactly what the app
|
* The two user-facing toggles TASK-038 introduced, both defaulted to preserve exactly what the app
|
||||||
@ -42,6 +44,16 @@ interface AppSettingsStore {
|
|||||||
fun isOutlineAboveFolders(): Boolean = false
|
fun isOutlineAboveFolders(): Boolean = false
|
||||||
fun setOutlineAboveFolders(above: Boolean) = Unit
|
fun setOutlineAboveFolders(above: Boolean) = Unit
|
||||||
|
|
||||||
|
// The width of the viewer's side pane on a wide window, in dp, once the reader has dragged it
|
||||||
|
// (TASK-065); null while it is the default.
|
||||||
|
fun codeSidePaneWidthDp(): Int? = null
|
||||||
|
fun setCodeSidePaneWidthDp(widthDp: Int?) = Unit
|
||||||
|
|
||||||
|
// The files kept as tabs under the screen (TASK-065), oldest first, each as encoded by
|
||||||
|
// SavedCodeTab.encode. Names and paths only: never content.
|
||||||
|
fun savedCodeTabs(): List<String> = emptyList()
|
||||||
|
fun setSavedCodeTabs(tabs: List<String>) = Unit
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_CODE_FONT_SIZE_SP = 13
|
const val DEFAULT_CODE_FONT_SIZE_SP = 13
|
||||||
val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16)
|
val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16)
|
||||||
@ -99,7 +111,31 @@ class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
|
|||||||
preferences.edit().putBoolean(KEY_OUTLINE_ABOVE_FOLDERS, above).apply()
|
preferences.edit().putBoolean(KEY_OUTLINE_ABOVE_FOLDERS, above).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun codeSidePaneWidthDp(): Int? =
|
||||||
|
preferences.getInt(KEY_CODE_SIDE_PANE_WIDTH, 0).takeIf { it > 0 }
|
||||||
|
|
||||||
|
override fun setCodeSidePaneWidthDp(widthDp: Int?) {
|
||||||
|
preferences.edit().putInt(KEY_CODE_SIDE_PANE_WIDTH, widthDp ?: 0).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A JSON array keeps the order, which a string set would not.
|
||||||
|
override fun savedCodeTabs(): List<String> {
|
||||||
|
val stored = preferences.getString(KEY_SAVED_CODE_TABS, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val array = JSONArray(stored)
|
||||||
|
List(array.length()) { array.getString(it) }
|
||||||
|
} catch (_: JSONException) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSavedCodeTabs(tabs: List<String>) {
|
||||||
|
preferences.edit().putString(KEY_SAVED_CODE_TABS, JSONArray(tabs).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val KEY_CODE_SIDE_PANE_WIDTH = "code_side_pane_width_dp"
|
||||||
|
const val KEY_SAVED_CODE_TABS = "saved_code_tabs"
|
||||||
const val KEY_OUTLINE_ABOVE_FOLDERS = "code_outline_above_folders"
|
const val KEY_OUTLINE_ABOVE_FOLDERS = "code_outline_above_folders"
|
||||||
const val KEY_HOME_SOURCE = "home_source"
|
const val KEY_HOME_SOURCE = "home_source"
|
||||||
const val KEY_THEME_MODE = "theme_mode"
|
const val KEY_THEME_MODE = "theme_mode"
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.ubisam.ubgitea.core.update
|
||||||
|
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where new builds of the app are published (TASK-066): a page that hands out the APK behind a
|
||||||
|
* password, and beside it a plain file naming the newest version. The app only ever reads that
|
||||||
|
* file; no Gitea token goes there.
|
||||||
|
*/
|
||||||
|
object AppUpdateLocations {
|
||||||
|
const val DOWNLOAD_PAGE = "https://choimy0204.github.io/ubgitea/"
|
||||||
|
const val LATEST_VERSION = DOWNLOAD_PAGE + "version.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest published build. */
|
||||||
|
data class AppRelease(
|
||||||
|
val versionCode: Int,
|
||||||
|
val versionName: String,
|
||||||
|
val notes: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `{"versionCode": 2, "versionName": "0.2.0", "notes": "..."}`. Anything else is null, so
|
||||||
|
* a page that is broken or not there yet never offers an update.
|
||||||
|
*/
|
||||||
|
fun parseAppRelease(json: String): AppRelease? = try {
|
||||||
|
val root = JsonParser.parseString(json).asJsonObject
|
||||||
|
val code = root.get("versionCode")?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asInt
|
||||||
|
val name = root.get("versionName")?.takeIf { it.isJsonPrimitive }?.asString?.trim()
|
||||||
|
val notes = root.get("notes")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifEmpty { null }
|
||||||
|
if (code == null || code <= 0 || name.isNullOrEmpty()) null else AppRelease(code, name, notes)
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface AppReleaseSource {
|
||||||
|
/** The newest published build, or null when it cannot be read. */
|
||||||
|
suspend fun latest(): AppRelease?
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpAppReleaseSource(
|
||||||
|
private val url: String = AppUpdateLocations.LATEST_VERSION,
|
||||||
|
private val client: OkHttpClient = updateClient,
|
||||||
|
) : AppReleaseSource {
|
||||||
|
override suspend fun latest(): AppRelease? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// no-cache: GitHub Pages is cached for minutes, and a check by hand should see a release at once.
|
||||||
|
val request = Request.Builder().url(url).header("Cache-Control", "no-cache").build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) null else parseAppRelease(response.body.string())
|
||||||
|
}
|
||||||
|
} catch (_: java.io.IOException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val updateClient: OkHttpClient by lazy {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
package com.ubisam.ubgitea.core.update
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.ubisam.ubgitea.BuildConfig
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
enum class UpdateCheck { IDLE, CHECKING, UP_TO_DATE, FAILED, AVAILABLE }
|
||||||
|
|
||||||
|
data class AppUpdateUiState(
|
||||||
|
val installedVersionName: String,
|
||||||
|
val check: UpdateCheck = UpdateCheck.IDLE,
|
||||||
|
// Set when a newer build than this one is published.
|
||||||
|
val available: AppRelease? = null,
|
||||||
|
// The newer build offered on its own at launch, until it is answered.
|
||||||
|
val prompt: AppRelease? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether a newer build of the app is published, checked once at launch and again on request. */
|
||||||
|
class AppUpdateViewModel(
|
||||||
|
private val source: AppReleaseSource,
|
||||||
|
private val installedVersionCode: Int,
|
||||||
|
installedVersionName: String,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val mutableState = MutableStateFlow(AppUpdateUiState(installedVersionName))
|
||||||
|
val state: StateFlow<AppUpdateUiState> = mutableState.asStateFlow()
|
||||||
|
private var operation: Job? = null
|
||||||
|
private var checkedAtLaunch = false
|
||||||
|
|
||||||
|
/** The check made when the app starts: quiet unless there is something newer. */
|
||||||
|
fun checkAtLaunch() {
|
||||||
|
if (checkedAtLaunch) return
|
||||||
|
checkedAtLaunch = true
|
||||||
|
check(offer = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A check asked for by hand, as from the settings. */
|
||||||
|
fun checkNow() = check(offer = false)
|
||||||
|
|
||||||
|
fun dismissPrompt() = mutableState.update { it.copy(prompt = null) }
|
||||||
|
|
||||||
|
private fun check(offer: Boolean) {
|
||||||
|
if (operation?.isActive == true) return
|
||||||
|
mutableState.update { it.copy(check = UpdateCheck.CHECKING) }
|
||||||
|
operation = viewModelScope.launch {
|
||||||
|
val latest = source.latest()
|
||||||
|
mutableState.update { state ->
|
||||||
|
when {
|
||||||
|
latest == null -> state.copy(check = UpdateCheck.FAILED)
|
||||||
|
latest.versionCode > installedVersionCode -> state.copy(
|
||||||
|
check = UpdateCheck.AVAILABLE,
|
||||||
|
available = latest,
|
||||||
|
prompt = if (offer) latest else state.prompt,
|
||||||
|
)
|
||||||
|
else -> state.copy(check = UpdateCheck.UP_TO_DATE, available = null, prompt = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
require(modelClass.isAssignableFrom(AppUpdateViewModel::class.java))
|
||||||
|
return AppUpdateViewModel(HttpAppReleaseSource(), BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,8 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@ -130,8 +132,46 @@ class CodeBrowserViewModel(
|
|||||||
private var counterpartOperation: Job? = null
|
private var counterpartOperation: Job? = null
|
||||||
private val folderOperations = mutableMapOf<RepositoryPath, Job>()
|
private val folderOperations = mutableMapOf<RepositoryPath, Job>()
|
||||||
|
|
||||||
|
// The last few files shown, so going back and forth between saved tabs does not fetch each again.
|
||||||
|
private val recentFiles = object : LinkedHashMap<Pair<CodeBrowserTarget, RepositoryPath>, CodeBrowserUiState.File>(8, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Pair<CodeBrowserTarget, RepositoryPath>, CodeBrowserUiState.File>) =
|
||||||
|
size > RECENT_FILES
|
||||||
|
}
|
||||||
|
|
||||||
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a file straight from its path, as a saved tab does, with the folder it is in listed
|
||||||
|
* beside it. A file seen a moment ago comes back as it was, without asking the server again.
|
||||||
|
*/
|
||||||
|
fun openFile(target: CodeBrowserTarget, path: RepositoryPath) {
|
||||||
|
val current = mutableState.value
|
||||||
|
if (current is CodeBrowserUiState.File && current.target == target && current.path == path) return
|
||||||
|
val recent = recentFiles[target to path]
|
||||||
|
if (recent != null) {
|
||||||
|
operation?.cancel()
|
||||||
|
counterpartOperation?.cancel()
|
||||||
|
mutableState.value = recent
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startOperation {
|
||||||
|
counterpartOperation?.cancel()
|
||||||
|
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true)
|
||||||
|
try {
|
||||||
|
val (classification, siblingEntries) = withContext(defaultDispatcher) {
|
||||||
|
coroutineScope {
|
||||||
|
val listing = async { listFolderOrNothing(target, CodeBrowserNavigation.parent(path)) }
|
||||||
|
val file = browser.getFile(target.repository, target.ref, path)
|
||||||
|
classifier.classify(file.content, path) to listing.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showFile(target, path, classification, siblingEntries)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
handleFailure(target, path, failure, isFile = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun openEntry(entry: TreeEntry) {
|
fun openEntry(entry: TreeEntry) {
|
||||||
val target = currentTarget() ?: return
|
val target = currentTarget() ?: return
|
||||||
when (entry.type) {
|
when (entry.type) {
|
||||||
@ -295,8 +335,22 @@ class CodeBrowserViewModel(
|
|||||||
val file = browser.getFile(target.repository, target.ref, path)
|
val file = browser.getFile(target.repository, target.ref, path)
|
||||||
classifier.classify(file.content, path)
|
classifier.classify(file.content, path)
|
||||||
}
|
}
|
||||||
|
showFile(target, path, classification, siblingEntries, focusSymbol, tree)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
handleFailure(target, path, failure, isFile = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFile(
|
||||||
|
target: CodeBrowserTarget,
|
||||||
|
path: RepositoryPath,
|
||||||
|
classification: ContentClassification,
|
||||||
|
siblingEntries: List<TreeEntry>,
|
||||||
|
focusSymbol: String? = null,
|
||||||
|
tree: FileTree? = null,
|
||||||
|
) {
|
||||||
val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path)
|
val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path)
|
||||||
mutableState.value = CodeBrowserUiState.File(
|
val state = CodeBrowserUiState.File(
|
||||||
target = target,
|
target = target,
|
||||||
path = path,
|
path = path,
|
||||||
content = classification,
|
content = classification,
|
||||||
@ -307,9 +361,18 @@ class CodeBrowserViewModel(
|
|||||||
// A folder still being fetched would never hear back in the new state.
|
// A folder still being fetched would never hear back in the new state.
|
||||||
tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries),
|
tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries),
|
||||||
)
|
)
|
||||||
} catch (failure: Throwable) {
|
mutableState.value = state
|
||||||
handleFailure(target, path, failure, isFile = true)
|
recentFiles[target to path] = state.copy(focusSymbol = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The folder a saved tab's file is in. Without it the file still opens, only with nothing beside it.
|
||||||
|
private suspend fun listFolderOrNothing(target: CodeBrowserTarget, folder: RepositoryPath): List<TreeEntry> = try {
|
||||||
|
loadAllPages { page -> browser.listTree(target.repository, target.ref, folder, page, PAGE_SIZE) }
|
||||||
|
.distinctBy(TreeEntry::path)
|
||||||
|
.sortedWith(LISTING_ORDER)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
if (failure is CancellationException) throw failure
|
||||||
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleFailure(
|
private fun handleFailure(
|
||||||
@ -374,6 +437,7 @@ class CodeBrowserViewModel(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val PAGE_SIZE = 100
|
const val PAGE_SIZE = 100
|
||||||
const val MAX_PAGES = 100
|
const val MAX_PAGES = 100
|
||||||
|
const val RECENT_FILES = 6
|
||||||
val LISTING_ORDER = compareBy<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }
|
val LISTING_ORDER = compareBy<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package com.ubisam.ubgitea.feature.codebrowser
|
package com.ubisam.ubgitea.feature.codebrowser
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||||
import androidx.compose.foundation.gestures.Orientation
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
import androidx.compose.foundation.gestures.rememberScrollableState
|
import androidx.compose.foundation.gestures.rememberScrollableState
|
||||||
import androidx.compose.foundation.gestures.scrollable
|
import androidx.compose.foundation.gestures.scrollable
|
||||||
@ -33,7 +36,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@ -41,6 +47,7 @@ import androidx.compose.ui.text.LinkAnnotation
|
|||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@ -60,6 +67,7 @@ import com.ubisam.ubgitea.feature.codebrowser.content.SymbolKind
|
|||||||
import com.ubisam.ubgitea.feature.codebrowser.content.nameRangeOf
|
import com.ubisam.ubgitea.feature.codebrowser.content.nameRangeOf
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.wordAt
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -139,9 +147,14 @@ fun CodePane(
|
|||||||
contentPadding: PaddingValues = PaddingValues(vertical = 8.dp),
|
contentPadding: PaddingValues = PaddingValues(vertical = 8.dp),
|
||||||
// When given, the name of a function on the line that declares it is a link (TASK-060).
|
// When given, the name of a function on the line that declares it is a link (TASK-060).
|
||||||
onFunctionTapped: ((CodeSymbol) -> Unit)? = null,
|
onFunctionTapped: ((CodeSymbol) -> Unit)? = null,
|
||||||
|
// Every place the word the reader tapped appears, marked in its own color (TASK-065).
|
||||||
|
words: SearchHighlight = SearchHighlight.None,
|
||||||
|
// When given, a tap on a word reports it, or null for a tap on anything else.
|
||||||
|
onWordTapped: ((String?) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val colors = rememberCodeSyntaxColors()
|
val colors = rememberCodeSyntaxColors()
|
||||||
val tapped by rememberUpdatedState(onFunctionTapped)
|
val tapped by rememberUpdatedState(onFunctionTapped)
|
||||||
|
val wordTapped by rememberUpdatedState(onWordTapped)
|
||||||
val linked = onFunctionTapped != null
|
val linked = onFunctionTapped != null
|
||||||
val functionsByLine = remember(document.symbols, linked) {
|
val functionsByLine = remember(document.symbols, linked) {
|
||||||
if (linked) document.symbols.filter { it.kind == SymbolKind.METHOD }.associateBy(CodeSymbol::line) else emptyMap()
|
if (linked) document.symbols.filter { it.kind == SymbolKind.METHOD }.associateBy(CodeSymbol::line) else emptyMap()
|
||||||
@ -162,6 +175,7 @@ fun CodePane(
|
|||||||
val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant
|
val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
val focusColor = MaterialTheme.colorScheme.primaryContainer
|
val focusColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
val hit = searchHitColors()
|
val hit = searchHitColors()
|
||||||
|
val wordHit = wordHitColors()
|
||||||
|
|
||||||
var viewportWidthPx by remember { mutableIntStateOf(0) }
|
var viewportWidthPx by remember { mutableIntStateOf(0) }
|
||||||
val gutterWidthPx = with(density) { gutterWidth.toPx() }
|
val gutterWidthPx = with(density) { gutterWidth.toPx() }
|
||||||
@ -179,8 +193,8 @@ fun CodePane(
|
|||||||
items(document.lines.size, key = { it }) { index ->
|
items(document.lines.size, key = { it }) { index ->
|
||||||
val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) }
|
val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) }
|
||||||
val function = functionsByLine[index]
|
val function = functionsByLine[index]
|
||||||
val shown = remember(line, search.byLine[index], search.current, function) {
|
val shown = remember(line, search.byLine[index], search.current, words.byLine[index], words.current, function) {
|
||||||
val marked = withSearchHits(line, search.byLine[index], search.current, hit)
|
val marked = withSearchHits(withSearchHits(line, words.byLine[index], words.current, wordHit), search.byLine[index], search.current, hit)
|
||||||
val name = function?.let { nameRangeOf(it, document.lines[index]) }
|
val name = function?.let { nameRangeOf(it, document.lines[index]) }
|
||||||
if (function == null || name == null || name.last >= marked.length) {
|
if (function == null || name == null || name.last >= marked.length) {
|
||||||
marked
|
marked
|
||||||
@ -199,6 +213,17 @@ fun CodePane(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val layout = remember { arrayOfNulls<TextLayoutResult>(1) }
|
||||||
|
val wordTaps = if (onWordTapped == null) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.pointerInput(index, document.lines) {
|
||||||
|
detectQuietTap { position ->
|
||||||
|
val text = layout[0] ?: return@detectQuietTap
|
||||||
|
wordTapped?.invoke(wordAt(document.lines[index], text.getOffsetForPosition(position)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent),
|
Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
@ -220,7 +245,8 @@ fun CodePane(
|
|||||||
shown,
|
shown,
|
||||||
style = style.copy(textIndent = TextIndent(restLine = hanging)),
|
style = style.copy(textIndent = TextIndent(restLine = hanging)),
|
||||||
softWrap = true,
|
softWrap = true,
|
||||||
modifier = Modifier.weight(1f).padding(end = 16.dp),
|
onTextLayout = { layout[0] = it },
|
||||||
|
modifier = Modifier.weight(1f).padding(end = 16.dp).then(wordTaps),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.weight(1f).clipToBounds().scrollable(sideways, Orientation.Horizontal)) {
|
Box(Modifier.weight(1f).clipToBounds().scrollable(sideways, Orientation.Horizontal)) {
|
||||||
@ -229,9 +255,11 @@ fun CodePane(
|
|||||||
style = style,
|
style = style,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
onTextLayout = { layout[0] = it },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset { IntOffset(-offsetPx.floatValue.roundToInt(), 0) }
|
.offset { IntOffset(-offsetPx.floatValue.roundToInt(), 0) }
|
||||||
.wrapContentWidth(Alignment.Start, unbounded = true),
|
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||||
|
.then(wordTaps),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -260,6 +288,33 @@ private fun searchHitColors(): SearchHitColors {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun wordHitColors(): SearchHitColors {
|
||||||
|
val dark = UBGiteaTheme.isDark
|
||||||
|
return remember(dark) {
|
||||||
|
if (dark) {
|
||||||
|
SearchHitColors(Color(0xFF1F4D2E), Color(0xFF3FA866), Color(0xFF041A0B))
|
||||||
|
} else {
|
||||||
|
SearchHitColors(Color(0xFFC8F0D2), Color(0xFF6FD08C), Color(0xFF0E1B2C))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tap that is only a tap: released where it went down and soon after, and not taken by anything
|
||||||
|
* else on the way -- a link, a scroll, a long press that starts a selection. The release is left
|
||||||
|
* for the others, so selecting text and following links work as before.
|
||||||
|
*/
|
||||||
|
private suspend fun PointerInputScope.detectQuietTap(onTap: (Offset) -> Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val up = waitForUpOrCancellation() ?: return@awaitEachGesture
|
||||||
|
val moved = (up.position - down.position).getDistance() > viewConfiguration.touchSlop
|
||||||
|
val held = up.uptimeMillis - down.uptimeMillis > viewConfiguration.longPressTimeoutMillis
|
||||||
|
if (!moved && !held) onTap(up.position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun withSearchHits(
|
private fun withSearchHits(
|
||||||
line: AnnotatedString,
|
line: AnnotatedString,
|
||||||
matches: List<SearchMatch>?,
|
matches: List<SearchMatch>?,
|
||||||
|
|||||||
@ -53,7 +53,8 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -85,6 +86,8 @@ import androidx.compose.ui.semantics.Role
|
|||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.heading
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.semantics.selected
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@ -126,6 +129,16 @@ import com.ubisam.ubgitea.ui.components.ActivityTone
|
|||||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
|
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.findWordOccurrences
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareMenu
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareSide
|
||||||
|
import com.ubisam.ubgitea.feature.compare.ComparedFile
|
||||||
|
import com.ubisam.ubgitea.feature.compare.LocalCodeWorkbench
|
||||||
|
import com.ubisam.ubgitea.feature.compare.PendingCompare
|
||||||
|
import com.ubisam.ubgitea.feature.compare.SavedCodeTab
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -202,6 +215,19 @@ fun CodeViewerScreen(
|
|||||||
var syncPanes by rememberSaveable { mutableStateOf(true) }
|
var syncPanes by rememberSaveable { mutableStateOf(true) }
|
||||||
// The share of the width the left pane of a header/source pair takes.
|
// The share of the width the left pane of a header/source pair takes.
|
||||||
var splitFraction by rememberSaveable { mutableFloatStateOf(DEFAULT_SPLIT) }
|
var splitFraction by rememberSaveable { mutableFloatStateOf(DEFAULT_SPLIT) }
|
||||||
|
// The width of the side pane on a wide window, in dp, when the reader has dragged it away
|
||||||
|
// from the default; kept between files and launches.
|
||||||
|
var sidePaneDp by remember { mutableStateOf(settings.codeSidePaneWidthDp()?.toFloat()) }
|
||||||
|
// A word tapped in the code, marked wherever it appears, in both panes of a pair (TASK-065).
|
||||||
|
var selectedWord by remember(state.path) { mutableStateOf<String?>(null) }
|
||||||
|
val wordOccurrences = remember(document.lines, selectedWord) {
|
||||||
|
selectedWord?.let { findWordOccurrences(document.lines, it) }.orEmpty()
|
||||||
|
}
|
||||||
|
var currentWord by remember(wordOccurrences) { mutableIntStateOf(-1) }
|
||||||
|
val words = remember(wordOccurrences, currentWord) {
|
||||||
|
if (wordOccurrences.isEmpty()) SearchHighlight.None else SearchHighlight(wordOccurrences.groupBy { it.line }, wordOccurrences.getOrNull(currentWord))
|
||||||
|
}
|
||||||
|
val onWordTapped: (String?) -> Unit = { word -> selectedWord = if (word == null || word == selectedWord) null else word }
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
// The sheet is shown from outside the layout below, where the jump is worked out.
|
// The sheet is shown from outside the layout below, where the jump is worked out.
|
||||||
var jumpForSheet by remember { mutableStateOf<SymbolJump?>(null) }
|
var jumpForSheet by remember { mutableStateOf<SymbolJump?>(null) }
|
||||||
@ -212,6 +238,28 @@ fun CodeViewerScreen(
|
|||||||
val otherText = ((state.counterpart as? CounterpartUiState.Loaded)?.content as? ContentClassification.Text)?.renderedText
|
val otherText = ((state.counterpart as? CounterpartUiState.Loaded)?.content as? ContentClassification.Text)?.renderedText
|
||||||
val otherDocument = if (counterpart != null && otherText != null) rememberCodeDocument(counterpart, otherText) else null
|
val otherDocument = if (counterpart != null && otherText != null) rememberCodeDocument(counterpart, otherText) else null
|
||||||
val otherState = remember(counterpart) { LazyListState() }
|
val otherState = remember(counterpart) { LazyListState() }
|
||||||
|
val otherWords = remember(otherDocument?.lines, selectedWord) {
|
||||||
|
val word = selectedWord
|
||||||
|
val occurrences = if (otherDocument == null || word == null) emptyList() else findWordOccurrences(otherDocument.lines, word)
|
||||||
|
if (occurrences.isEmpty()) SearchHighlight.None else SearchHighlight(occurrences.groupBy { it.line }, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeping this file as a tab, and comparing it with another, where the activity offers them.
|
||||||
|
val workbench = LocalCodeWorkbench.current
|
||||||
|
val benchTools = workbench?.let { bench ->
|
||||||
|
val target = state.target
|
||||||
|
val tab = SavedCodeTab(target.repository.owner, target.repository.name, target.kind.name, target.ref.value, state.path.value)
|
||||||
|
val refLabel = if (target.kind == CodeReferenceKind.COMMIT) target.ref.value.take(SHORT_COMMIT_LENGTH) else target.ref.value
|
||||||
|
val file = ComparedFile(target.repository, refLabel, state.path.value, text.renderedText, text.isTruncated)
|
||||||
|
WorkbenchTools(
|
||||||
|
isSaved = tab in bench.tabs,
|
||||||
|
pending = bench.pending,
|
||||||
|
onToggleSave = { bench.onToggleTab(tab) },
|
||||||
|
onPin = { side -> bench.onPin(file, side) },
|
||||||
|
onCompare = { bench.onCompareWith(file) },
|
||||||
|
onClearPin = bench.onClearPin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun goToLine(line: Int) {
|
fun goToLine(line: Int) {
|
||||||
focusedLine = line
|
focusedLine = line
|
||||||
@ -224,6 +272,12 @@ fun CodeViewerScreen(
|
|||||||
val match = otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) } ?: return
|
val match = otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) } ?: return
|
||||||
scope.launch { otherState.scrollToItem(match.line) }
|
scope.launch { otherState.scrollToItem(match.line) }
|
||||||
}
|
}
|
||||||
|
fun goToWord(index: Int) {
|
||||||
|
if (wordOccurrences.isEmpty()) return
|
||||||
|
currentWord = index.mod(wordOccurrences.size)
|
||||||
|
val line = wordOccurrences[currentWord].line
|
||||||
|
scope.launch { listState.scrollToItem((line - SEARCH_CONTEXT_LINES).coerceAtLeast(0)) }
|
||||||
|
}
|
||||||
fun goToMatch(index: Int) {
|
fun goToMatch(index: Int) {
|
||||||
if (matches.isEmpty()) return
|
if (matches.isEmpty()) return
|
||||||
currentMatch = index.mod(matches.size)
|
currentMatch = index.mod(matches.size)
|
||||||
@ -245,6 +299,7 @@ fun CodeViewerScreen(
|
|||||||
when {
|
when {
|
||||||
drawerState.isOpen -> scope.launch { drawerState.close() }
|
drawerState.isOpen -> scope.launch { drawerState.close() }
|
||||||
outlineOpen -> outlineOpen = false
|
outlineOpen -> outlineOpen = false
|
||||||
|
selectedWord != null -> selectedWord = null
|
||||||
searching -> {
|
searching -> {
|
||||||
searching = false
|
searching = false
|
||||||
query = ""
|
query = ""
|
||||||
@ -259,7 +314,9 @@ fun CodeViewerScreen(
|
|||||||
maxWidth >= WideLayoutBreakpoint -> ViewerLayout.WIDE
|
maxWidth >= WideLayoutBreakpoint -> ViewerLayout.WIDE
|
||||||
else -> ViewerLayout.PHONE
|
else -> ViewerLayout.PHONE
|
||||||
}
|
}
|
||||||
val sidePaneWidth = if (maxWidth < NarrowWideBreakpoint) NarrowSidePaneWidth else SidePaneWidth
|
val defaultSidePane = if (maxWidth < NarrowWideBreakpoint) NarrowSidePaneWidth else SidePaneWidth
|
||||||
|
val widestSidePane = (maxWidth * MAX_SIDE_PANE_FRACTION).coerceAtLeast(MinSidePaneWidth)
|
||||||
|
val sidePaneWidth = (sidePaneDp?.dp ?: defaultSidePane).coerceIn(MinSidePaneWidth, widestSidePane)
|
||||||
val counterpartPath = state.counterpartPath
|
val counterpartPath = state.counterpartPath
|
||||||
val showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide
|
val showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide
|
||||||
if (showsPair) LaunchedEffect(state.path, counterpartPath) { onLoadCounterpart() }
|
if (showsPair) LaunchedEffect(state.path, counterpartPath) { onLoadCounterpart() }
|
||||||
@ -302,6 +359,7 @@ fun CodeViewerScreen(
|
|||||||
onToggleSideBySide = { sideBySide = !sideBySide },
|
onToggleSideBySide = { sideBySide = !sideBySide },
|
||||||
onToggleSync = { syncPanes = !syncPanes },
|
onToggleSync = { syncPanes = !syncPanes },
|
||||||
text = text.renderedText,
|
text = text.renderedText,
|
||||||
|
workbench = benchTools,
|
||||||
)
|
)
|
||||||
val location = stringResource(
|
val location = stringResource(
|
||||||
R.string.codeviewer_location,
|
R.string.codeviewer_location,
|
||||||
@ -397,6 +455,9 @@ fun CodeViewerScreen(
|
|||||||
counterpartPath = counterpartPath!!,
|
counterpartPath = counterpartPath!!,
|
||||||
splitFraction = splitFraction,
|
splitFraction = splitFraction,
|
||||||
onSplitChange = { splitFraction = it },
|
onSplitChange = { splitFraction = it },
|
||||||
|
words = words,
|
||||||
|
otherWords = otherWords,
|
||||||
|
onWordTapped = onWordTapped,
|
||||||
// Beside each other, a function tapped in one pane is shown in the other.
|
// Beside each other, a function tapped in one pane is shown in the other.
|
||||||
onFunctionTapped = { symbol ->
|
onFunctionTapped = { symbol ->
|
||||||
otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) }?.let { match ->
|
otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) }?.let { match ->
|
||||||
@ -424,7 +485,13 @@ fun CodeViewerScreen(
|
|||||||
onSymbolSelected = { goToSymbol(it) },
|
onSymbolSelected = { goToSymbol(it) },
|
||||||
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
|
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
|
||||||
)
|
)
|
||||||
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
SidePaneHandle(
|
||||||
|
width = sidePaneWidth,
|
||||||
|
defaultWidth = defaultSidePane,
|
||||||
|
widest = widestSidePane,
|
||||||
|
onWidthChange = { sidePaneDp = it?.value },
|
||||||
|
onSettled = { settings.setCodeSidePaneWidthDp(sidePaneDp?.roundToInt()) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||||
CodePane(
|
CodePane(
|
||||||
@ -436,8 +503,10 @@ fun CodeViewerScreen(
|
|||||||
focusedLine = focusedLine,
|
focusedLine = focusedLine,
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp),
|
||||||
onFunctionTapped = jump?.onJump,
|
onFunctionTapped = jump?.onJump,
|
||||||
|
words = words,
|
||||||
|
onWordTapped = onWordTapped,
|
||||||
)
|
)
|
||||||
if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching) {
|
if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching && selectedWord == null) {
|
||||||
val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD }
|
val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD }
|
||||||
if (method != null) {
|
if (method != null) {
|
||||||
JumpToCounterpart(
|
JumpToCounterpart(
|
||||||
@ -455,6 +524,16 @@ fun CodeViewerScreen(
|
|||||||
if (!sidePaneIsShown) {
|
if (!sidePaneIsShown) {
|
||||||
EdgeTab(Modifier.align(Alignment.CenterStart)) { scope.launch { drawerState.open() } }
|
EdgeTab(Modifier.align(Alignment.CenterStart)) { scope.launch { drawerState.open() } }
|
||||||
}
|
}
|
||||||
|
selectedWord?.let { word ->
|
||||||
|
WordBar(
|
||||||
|
word = word,
|
||||||
|
count = wordOccurrences.size,
|
||||||
|
onPrevious = { goToWord(if (currentWord < 0) firstMatchFrom(wordOccurrences, listState.firstVisibleItemIndex) - 1 else currentWord - 1) },
|
||||||
|
onNext = { goToWord(if (currentWord < 0) firstMatchFrom(wordOccurrences, listState.firstVisibleItemIndex) else currentWord + 1) },
|
||||||
|
onClear = { selectedWord = null },
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools)
|
if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools)
|
||||||
}
|
}
|
||||||
@ -497,6 +576,17 @@ private class ViewerTools(
|
|||||||
val onToggleSideBySide: () -> Unit,
|
val onToggleSideBySide: () -> Unit,
|
||||||
val onToggleSync: () -> Unit,
|
val onToggleSync: () -> Unit,
|
||||||
val text: String,
|
val text: String,
|
||||||
|
// Null where there is nowhere to keep tabs or compare files.
|
||||||
|
val workbench: WorkbenchTools?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class WorkbenchTools(
|
||||||
|
val isSaved: Boolean,
|
||||||
|
val pending: PendingCompare?,
|
||||||
|
val onToggleSave: () -> Unit,
|
||||||
|
val onPin: (CompareSide) -> Unit,
|
||||||
|
val onCompare: () -> Unit,
|
||||||
|
val onClearPin: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -521,6 +611,16 @@ private fun Toolbar(tools: ViewerTools) {
|
|||||||
CopyButton(tools.text, Modifier.weight(1f)) { onCopy ->
|
CopyButton(tools.text, Modifier.weight(1f)) { onCopy ->
|
||||||
ToolButton(R.drawable.ic_ub_copy, R.string.codeviewer_tool_copy, false, onClick = onCopy)
|
ToolButton(R.drawable.ic_ub_copy, R.string.codeviewer_tool_copy, false, onClick = onCopy)
|
||||||
}
|
}
|
||||||
|
tools.workbench?.let { bench ->
|
||||||
|
ToolButton(
|
||||||
|
if (bench.isSaved) R.drawable.ic_ub_bookmark_filled else R.drawable.ic_ub_bookmark,
|
||||||
|
R.string.codeviewer_tool_save_tab, bench.isSaved, Modifier.weight(1f),
|
||||||
|
isToggle = true, onClick = bench.onToggleSave,
|
||||||
|
)
|
||||||
|
CompareMenu(bench.pending, bench.onPin, bench.onCompare, bench.onClearPin, Modifier.weight(1f)) { onOpen ->
|
||||||
|
ToolButton(R.drawable.ic_ub_compare, R.string.codeviewer_tool_compare, bench.pending != null, onClick = onOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (tools.canPair) {
|
if (tools.canPair) {
|
||||||
ToolButton(
|
ToolButton(
|
||||||
R.drawable.ic_ub_split, R.string.codeviewer_tool_side_by_side, tools.sideBySide, Modifier.weight(1f),
|
R.drawable.ic_ub_split, R.string.codeviewer_tool_side_by_side, tools.sideBySide, Modifier.weight(1f),
|
||||||
@ -560,7 +660,7 @@ private fun ToolButton(
|
|||||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier.size(width = 52.dp, height = 30.dp)
|
Modifier.widthIn(max = 52.dp).fillMaxWidth().height(30.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface),
|
.background(if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@ -651,6 +751,24 @@ private fun LandscapeBar(document: CodeDocument, currentLine: Int, onBack: () ->
|
|||||||
FontSizeButton(tools) { onOpen ->
|
FontSizeButton(tools) { onOpen ->
|
||||||
IconButton(onClick = onOpen) { UBIcon(R.drawable.ic_ub_text_size, stringResource(R.string.codeviewer_tool_font_size)) }
|
IconButton(onClick = onOpen) { UBIcon(R.drawable.ic_ub_text_size, stringResource(R.string.codeviewer_tool_font_size)) }
|
||||||
}
|
}
|
||||||
|
tools.workbench?.let { bench ->
|
||||||
|
IconButton(onClick = bench.onToggleSave) {
|
||||||
|
UBIcon(
|
||||||
|
if (bench.isSaved) R.drawable.ic_ub_bookmark_filled else R.drawable.ic_ub_bookmark,
|
||||||
|
stringResource(R.string.codeviewer_tool_save_tab),
|
||||||
|
tint = if (bench.isSaved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompareMenu(bench.pending, bench.onPin, bench.onCompare, bench.onClearPin) { onOpen ->
|
||||||
|
IconButton(onClick = onOpen) {
|
||||||
|
UBIcon(
|
||||||
|
R.drawable.ic_ub_compare,
|
||||||
|
stringResource(R.string.codeviewer_tool_compare),
|
||||||
|
tint = if (bench.pending != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
}
|
}
|
||||||
@ -843,6 +961,39 @@ private fun JumpToCounterpart(counterpart: RepositoryPath, modifier: Modifier =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The word tapped in the code: how often it appears, a way through those places, and a way out. */
|
||||||
|
@Composable
|
||||||
|
private fun WordBar(word: String, count: Int, onPrevious: () -> Unit, onNext: () -> Unit, onClear: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.inverseSurface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||||
|
shape = CircleShape,
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
modifier = modifier.widthIn(max = 420.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.heightIn(min = 48.dp).padding(start = 18.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.codeviewer_word_count, word, count),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false).semantics { liveRegion = LiveRegionMode.Polite },
|
||||||
|
)
|
||||||
|
IconButton(onClick = onPrevious, enabled = count > 0) {
|
||||||
|
UBIcon(R.drawable.ic_ub_chevron_up, stringResource(R.string.codeviewer_word_previous), size = 20.dp)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNext, enabled = count > 0) {
|
||||||
|
UBIcon(R.drawable.ic_ub_chevron_down, stringResource(R.string.codeviewer_word_next), size = 20.dp)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onClear) {
|
||||||
|
UBIcon(R.drawable.ic_ub_close, stringResource(R.string.codeviewer_word_clear), size = 18.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A small tab on the left edge, where the side pane is drawn out from. It can be tapped as well:
|
* A small tab on the left edge, where the side pane is drawn out from. It can be tapped as well:
|
||||||
* a swipe from the edge is not available to everyone.
|
* a swipe from the edge is not available to everyone.
|
||||||
@ -889,6 +1040,9 @@ private fun PairPanes(
|
|||||||
counterpartPath: RepositoryPath,
|
counterpartPath: RepositoryPath,
|
||||||
splitFraction: Float,
|
splitFraction: Float,
|
||||||
onSplitChange: (Float) -> Unit,
|
onSplitChange: (Float) -> Unit,
|
||||||
|
words: SearchHighlight,
|
||||||
|
otherWords: SearchHighlight,
|
||||||
|
onWordTapped: (String?) -> Unit,
|
||||||
onFunctionTapped: (CodeSymbol) -> Unit,
|
onFunctionTapped: (CodeSymbol) -> Unit,
|
||||||
onOtherFunctionTapped: (CodeSymbol) -> Unit,
|
onOtherFunctionTapped: (CodeSymbol) -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -903,6 +1057,7 @@ private fun PairPanes(
|
|||||||
CodePane(
|
CodePane(
|
||||||
document, listState, preferences.wordWrap, preferences.fontSizeSp,
|
document, listState, preferences.wordWrap, preferences.fontSizeSp,
|
||||||
search = highlight, focusedLine = focusedLine, onFunctionTapped = onFunctionTapped,
|
search = highlight, focusedLine = focusedLine, onFunctionTapped = onFunctionTapped,
|
||||||
|
words = words, onWordTapped = onWordTapped,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -912,7 +1067,10 @@ private fun PairPanes(
|
|||||||
when {
|
when {
|
||||||
otherDocument != null -> {
|
otherDocument != null -> {
|
||||||
PaneHeader(name, counterpartPath, otherDocument.lines.size, enclosingSymbols(otherDocument.symbols, otherState.firstVisibleItemIndex))
|
PaneHeader(name, counterpartPath, otherDocument.lines.size, enclosingSymbols(otherDocument.symbols, otherState.firstVisibleItemIndex))
|
||||||
CodePane(otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp, onFunctionTapped = onOtherFunctionTapped)
|
CodePane(
|
||||||
|
otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp,
|
||||||
|
onFunctionTapped = onOtherFunctionTapped, words = otherWords, onWordTapped = onWordTapped,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
PaneHeader(name, counterpartPath, null, emptyList())
|
PaneHeader(name, counterpartPath, null, emptyList())
|
||||||
@ -927,12 +1085,20 @@ private fun PairPanes(
|
|||||||
}
|
}
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
|
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
|
||||||
// The latest value, for a drag that outlives the composition it started in.
|
val snapPx = with(LocalDensity.current) { SnapDistance.toPx() }
|
||||||
val fraction by rememberUpdatedState(splitFraction)
|
val haptics = LocalHapticFeedback.current
|
||||||
|
// Where the finger has taken the split, before it is snapped to the middle.
|
||||||
|
var dragged by remember { mutableFloatStateOf(splitFraction) }
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
(if (currentIsHeader) current else other)(Modifier.weight(splitFraction).fillMaxHeight())
|
(if (currentIsHeader) current else other)(Modifier.weight(splitFraction).fillMaxHeight())
|
||||||
SplitHandle(
|
SplitHandle(
|
||||||
onDrag = { deltaPx -> onSplitChange(clampSplit(fraction + deltaPx / widthPx)) },
|
onDragStart = { dragged = splitFraction },
|
||||||
|
onDrag = { deltaPx ->
|
||||||
|
dragged = clampSplit(dragged + deltaPx / widthPx)
|
||||||
|
val snapped = snapTo(dragged, DEFAULT_SPLIT, snapPx / widthPx)
|
||||||
|
if (snapped == DEFAULT_SPLIT && splitFraction != DEFAULT_SPLIT) haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
onSplitChange(snapped)
|
||||||
|
},
|
||||||
onReset = { onSplitChange(DEFAULT_SPLIT) },
|
onReset = { onSplitChange(DEFAULT_SPLIT) },
|
||||||
)
|
)
|
||||||
(if (currentIsHeader) other else current)(Modifier.weight(1f - splitFraction).fillMaxHeight())
|
(if (currentIsHeader) other else current)(Modifier.weight(1f - splitFraction).fillMaxHeight())
|
||||||
@ -940,6 +1106,41 @@ private fun PairPanes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [value], or [target] when it is within [threshold] of it: a default that a drag sticks to like a
|
||||||
|
* magnet, so it is easy to find again.
|
||||||
|
*/
|
||||||
|
internal fun snapTo(value: Float, target: Float, threshold: Float): Float = if (abs(value - target) <= threshold) target else value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The handle between the side pane and the code on a wide window. Dragging sets the pane's width
|
||||||
|
* between [MinSidePaneWidth] and [widest]; near [defaultWidth] it snaps there with a tick, and the
|
||||||
|
* default is reported as null. A double tap goes back to the default. [onSettled] is called when a
|
||||||
|
* drag ends, to keep the width.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SidePaneHandle(width: Dp, defaultWidth: Dp, widest: Dp, onWidthChange: (Dp?) -> Unit, onSettled: () -> Unit) {
|
||||||
|
val density = LocalDensity.current.density
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
var dragged by remember { mutableFloatStateOf(width.value) }
|
||||||
|
SplitHandle(
|
||||||
|
description = stringResource(R.string.codeviewer_side_pane_handle),
|
||||||
|
onDragStart = { dragged = width.value },
|
||||||
|
onDrag = { deltaPx ->
|
||||||
|
dragged = (dragged + deltaPx / density).coerceIn(MinSidePaneWidth.value, widest.value)
|
||||||
|
val snapped = snapTo(dragged, defaultWidth.value, SnapDistance.value)
|
||||||
|
val atDefault = snapped == defaultWidth.value
|
||||||
|
if (atDefault && width != defaultWidth) haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
onWidthChange(if (atDefault) null else snapped.dp)
|
||||||
|
},
|
||||||
|
onDragEnd = onSettled,
|
||||||
|
onReset = {
|
||||||
|
onWidthChange(null)
|
||||||
|
onSettled()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Keeps either pane from being dragged down to nothing. */
|
/** Keeps either pane from being dragged down to nothing. */
|
||||||
internal fun clampSplit(fraction: Float): Float = fraction.coerceIn(MIN_SPLIT, 1f - MIN_SPLIT)
|
internal fun clampSplit(fraction: Float): Float = fraction.coerceIn(MIN_SPLIT, 1f - MIN_SPLIT)
|
||||||
|
|
||||||
@ -948,20 +1149,34 @@ internal fun clampSplit(fraction: Float): Float = fraction.coerceIn(MIN_SPLIT, 1
|
|||||||
* middle, inside a strip a finger can land on. Dragging moves the split; a double tap evens it.
|
* middle, inside a strip a finger can land on. Dragging moves the split; a double tap evens it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitHandle(onDrag: (Float) -> Unit, onReset: () -> Unit) {
|
private fun SplitHandle(
|
||||||
val description = stringResource(R.string.codeviewer_split_handle)
|
onDrag: (Float) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
description: String = stringResource(R.string.codeviewer_split_handle),
|
||||||
|
onDragStart: () -> Unit = {},
|
||||||
|
onDragEnd: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
// The latest callbacks, for a gesture that outlives the composition it started in.
|
||||||
|
val drag by rememberUpdatedState(onDrag)
|
||||||
|
val reset by rememberUpdatedState(onReset)
|
||||||
|
val start by rememberUpdatedState(onDragStart)
|
||||||
|
val end by rememberUpdatedState(onDragEnd)
|
||||||
Box(
|
Box(
|
||||||
Modifier.width(SplitHandleWidth)
|
Modifier.width(SplitHandleWidth)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.semantics { contentDescription = description }
|
.semantics { contentDescription = description }
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectHorizontalDragGestures { change, dragAmount ->
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { start() },
|
||||||
|
onDragEnd = { end() },
|
||||||
|
onDragCancel = { end() },
|
||||||
|
) { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
onDrag(dragAmount)
|
drag(dragAmount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pointerInput(Unit) { detectTapGestures(onDoubleTap = { onReset() }) },
|
.pointerInput(Unit) { detectTapGestures(onDoubleTap = { reset() }) },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
|
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
|
||||||
@ -1512,6 +1727,11 @@ private fun OutlineSheet(
|
|||||||
|
|
||||||
private const val DEFAULT_SPLIT = 0.5f
|
private const val DEFAULT_SPLIT = 0.5f
|
||||||
private const val MIN_SPLIT = 0.2f
|
private const val MIN_SPLIT = 0.2f
|
||||||
|
private const val MAX_SIDE_PANE_FRACTION = 0.6f
|
||||||
|
private val MinSidePaneWidth = 160.dp
|
||||||
|
// How near a drag has to come to a default width to stick to it.
|
||||||
|
private val SnapDistance = 16.dp
|
||||||
|
private const val SHORT_COMMIT_LENGTH = 7
|
||||||
private const val DRAWER_WIDTH_FRACTION = 0.85f
|
private const val DRAWER_WIDTH_FRACTION = 0.85f
|
||||||
private val MaxDrawerWidth = 320.dp
|
private val MaxDrawerWidth = 320.dp
|
||||||
private val SplitHandleWidth = 24.dp
|
private val SplitHandleWidth = 24.dp
|
||||||
|
|||||||
@ -58,3 +58,43 @@ fun counterpartOf(path: RepositoryPath, siblings: List<RepositoryPath>): Reposit
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun RepositoryPath.extension(): String = value.substringAfterLast('/').substringAfterLast('.', "").lowercase()
|
private fun RepositoryPath.extension(): String = value.substringAfterLast('/').substringAfterLast('.', "").lowercase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identifier at [offset] in [line], or null when the offset is not on one: a run of letters,
|
||||||
|
* digits and underscores that does not start with a digit. An offset just past the last character
|
||||||
|
* of a word still counts as on it, as a tap at a word's right edge lands there.
|
||||||
|
*/
|
||||||
|
fun wordAt(line: String, offset: Int): String? {
|
||||||
|
if (line.isEmpty()) return null
|
||||||
|
var at = offset.coerceIn(0, line.length)
|
||||||
|
if (at == line.length || !line[at].isWordCharacter()) {
|
||||||
|
if (at > 0 && line[at - 1].isWordCharacter()) at -= 1 else return null
|
||||||
|
}
|
||||||
|
var start = at
|
||||||
|
while (start > 0 && line[start - 1].isWordCharacter()) start--
|
||||||
|
var end = at
|
||||||
|
while (end < line.length && line[end].isWordCharacter()) end++
|
||||||
|
val word = line.substring(start, end)
|
||||||
|
return if (word.first().isDigit()) null else word
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every whole-word occurrence of [word] in [lines], case and all: `count` and not `counter`. */
|
||||||
|
fun findWordOccurrences(lines: List<String>, word: String): List<SearchMatch> {
|
||||||
|
if (word.isEmpty()) return emptyList()
|
||||||
|
val occurrences = mutableListOf<SearchMatch>()
|
||||||
|
lines.forEachIndexed { index, line ->
|
||||||
|
var from = 0
|
||||||
|
while (true) {
|
||||||
|
val start = line.indexOf(word, from)
|
||||||
|
if (start < 0) break
|
||||||
|
val end = start + word.length
|
||||||
|
val before = start == 0 || !line[start - 1].isWordCharacter()
|
||||||
|
val after = end == line.length || !line[end].isWordCharacter()
|
||||||
|
if (before && after) occurrences += SearchMatch(index, start, end)
|
||||||
|
from = start + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return occurrences
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Char.isWordCharacter(): Boolean = this == '_' || isLetterOrDigit()
|
||||||
|
|||||||
@ -0,0 +1,250 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.compare
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
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.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.ubisam.ubgitea.R
|
||||||
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The saved tabs and the comparison, as the code screens use them. Handed down from the activity
|
||||||
|
* rather than through every screen in between; null where there is no session.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class CodeWorkbench(
|
||||||
|
val tabs: List<SavedCodeTab>,
|
||||||
|
val pending: PendingCompare?,
|
||||||
|
val fetching: Boolean,
|
||||||
|
val onToggleTab: (SavedCodeTab) -> Unit,
|
||||||
|
val onPin: (ComparedFile, CompareSide) -> Unit,
|
||||||
|
val onCompareWith: (ComparedFile) -> Unit,
|
||||||
|
val onClearPin: () -> Unit,
|
||||||
|
// A file as it is at a commit: fetched, then pinned on the side given, or compared when null.
|
||||||
|
val onUseCommitFile: (commitId: String, shortId: String, path: String, side: CompareSide?) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalCodeWorkbench = staticCompositionLocalOf<CodeWorkbench?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a compare button in its menu. With nothing waiting, the menu puts the file on screen on
|
||||||
|
* the left or the right. With a file waiting, it compares with that file first, and can still put
|
||||||
|
* this one in its place.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CompareMenu(
|
||||||
|
pending: PendingCompare?,
|
||||||
|
onPin: (CompareSide) -> Unit,
|
||||||
|
onCompare: () -> Unit,
|
||||||
|
onClearPin: () -> Unit,
|
||||||
|
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 }) {
|
||||||
|
if (pending != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.compare_with_pinned, pending.file.fileName), fontWeight = FontWeight.Bold) },
|
||||||
|
leadingIcon = { UBIcon(R.drawable.ic_ub_compare, null, size = 20.dp) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onCompare()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.compare_put_left)) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onPin(CompareSide.LEFT)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.compare_put_right)) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onPin(CompareSide.RIGHT)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (pending != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.compare_unpin)) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onClearPin()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The strip between the screen and the bottom tabs: the file waiting to be compared, and the
|
||||||
|
* saved tabs. Nothing is drawn when there is neither.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CodeTabStrip(
|
||||||
|
tabs: List<SavedCodeTab>,
|
||||||
|
current: SavedCodeTab?,
|
||||||
|
pending: PendingCompare?,
|
||||||
|
fetching: Boolean,
|
||||||
|
onOpen: (SavedCodeTab) -> Unit,
|
||||||
|
onClose: (SavedCodeTab) -> Unit,
|
||||||
|
onClearPin: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (tabs.isEmpty() && pending == null && !fetching) return
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
if (pending != null || fetching) PendingRow(pending, fetching, onClearPin)
|
||||||
|
if (tabs.isNotEmpty()) {
|
||||||
|
val label = stringResource(R.string.workbench_tabs)
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.semantics { contentDescription = label }
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
tabs.forEach { tab -> TabChip(tab, tab == current, { onOpen(tab) }, { onClose(tab) }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PendingRow(pending: PendingCompare?, fetching: Boolean, onClearPin: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.padding(start = 12.dp, end = 4.dp)
|
||||||
|
.heightIn(min = 40.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (fetching) {
|
||||||
|
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
} else {
|
||||||
|
UBIcon(R.drawable.ic_ub_compare, null, size = 18.dp, tint = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
if (pending != null) {
|
||||||
|
val side = stringResource(if (pending.side == CompareSide.LEFT) R.string.compare_side_left else R.string.compare_side_right)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.compare_pending, pending.file.fileName, side),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(if (fetching) R.string.compare_fetching else R.string.compare_pending_hint),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (pending != null) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(40.dp).clip(RoundedCornerShape(20.dp)).clickable(role = Role.Button, onClick = onClearPin),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
UBIcon(R.drawable.ic_ub_close, stringResource(R.string.compare_unpin), size = 18.dp, tint = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TabChip(tab: SavedCodeTab, isCurrent: Boolean, onOpen: () -> Unit, onClose: () -> Unit) {
|
||||||
|
val colors = MaterialTheme.colorScheme
|
||||||
|
val openLabel = stringResource(R.string.workbench_tab_open, tab.fileName, tab.name)
|
||||||
|
val currentLabel = stringResource(R.string.workbench_tab_current)
|
||||||
|
Row(
|
||||||
|
Modifier.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(if (isCurrent) colors.primaryContainer else colors.surfaceVariant)
|
||||||
|
.border(1.dp, if (isCurrent) colors.primary else colors.outlineVariant, RoundedCornerShape(10.dp))
|
||||||
|
.heightIn(min = 40.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.clickable(role = Role.Tab, onClick = onOpen)
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
contentDescription = if (isCurrent) "$openLabel, $currentLabel" else openLabel
|
||||||
|
selected = isCurrent
|
||||||
|
}
|
||||||
|
.padding(start = 10.dp, end = 2.dp, top = 3.dp, bottom = 3.dp)
|
||||||
|
.widthIn(max = 168.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
tab.fileName,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
color = if (isCurrent) colors.onPrimaryContainer else colors.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${tab.name} · ${tab.ref}",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = if (isCurrent) colors.onPrimaryContainer else colors.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier.size(36.dp).clip(RoundedCornerShape(18.dp)).clickable(role = Role.Button, onClick = onClose),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
UBIcon(R.drawable.ic_ub_close, stringResource(R.string.workbench_tab_close, tab.fileName), size = 16.dp, tint = colors.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.compare
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.ubisam.ubgitea.core.auth.android.AuthSession
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
|
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
||||||
|
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||||
|
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||||
|
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
|
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.SourceContentClassifier
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A code file kept as a tab: where it is, never what is in it. Its fields are joined with U+001F
|
||||||
|
* for storage, which owner, repository, ref and path can never contain.
|
||||||
|
*/
|
||||||
|
data class SavedCodeTab(
|
||||||
|
val owner: String,
|
||||||
|
val name: String,
|
||||||
|
val refKind: String,
|
||||||
|
val ref: String,
|
||||||
|
val path: String,
|
||||||
|
) {
|
||||||
|
val fileName: String get() = path.substringAfterLast('/')
|
||||||
|
|
||||||
|
fun encode(): String = listOf(owner, name, refKind, ref, path).joinToString(SEPARATOR)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_TABS = 5
|
||||||
|
private const val SEPARATOR = "\u001F"
|
||||||
|
|
||||||
|
fun decodeOrNull(encoded: String): SavedCodeTab? {
|
||||||
|
val parts = encoded.split(SEPARATOR)
|
||||||
|
if (parts.size != 5 || parts.any(String::isEmpty)) return null
|
||||||
|
return SavedCodeTab(parts[0], parts[1], parts[2], parts[3], parts[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One side of a comparison: a text file as it is at one ref or commit. */
|
||||||
|
data class ComparedFile(
|
||||||
|
val repository: RepositoryCoordinates,
|
||||||
|
// The branch, tag or short commit id shown under the file's name.
|
||||||
|
val refLabel: String,
|
||||||
|
val path: String,
|
||||||
|
val text: String,
|
||||||
|
val isTruncated: Boolean = false,
|
||||||
|
) {
|
||||||
|
val fileName: String get() = path.substringAfterLast('/')
|
||||||
|
|
||||||
|
fun isSameFileAs(other: ComparedFile): Boolean =
|
||||||
|
repository == other.repository && refLabel == other.refLabel && path == other.path
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CompareSide { LEFT, RIGHT }
|
||||||
|
|
||||||
|
/** The first file chosen for a comparison, waiting for the second. */
|
||||||
|
data class PendingCompare(val file: ComparedFile, val side: CompareSide)
|
||||||
|
|
||||||
|
/** Two files and how their lines line up. */
|
||||||
|
class CompareSession(
|
||||||
|
val left: ComparedFile,
|
||||||
|
val right: ComparedFile,
|
||||||
|
val leftLines: List<String>,
|
||||||
|
val rightLines: List<String>,
|
||||||
|
val rows: List<CompareRow>,
|
||||||
|
) {
|
||||||
|
val differences: List<Int> = differenceStarts(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CompareUiState {
|
||||||
|
data object None : CompareUiState
|
||||||
|
data class Working(val left: ComparedFile, val right: ComparedFile) : CompareUiState
|
||||||
|
data class Ready(val session: CompareSession) : CompareUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something to tell the reader once, in passing. */
|
||||||
|
enum class WorkbenchMessage { TABS_FULL, FILE_UNAVAILABLE, FILE_NOT_TEXT, SAME_FILE }
|
||||||
|
|
||||||
|
data class CodeWorkbenchState(
|
||||||
|
val tabs: List<SavedCodeTab> = emptyList(),
|
||||||
|
val pending: PendingCompare? = null,
|
||||||
|
// A commit's file being fetched to become one side of a comparison.
|
||||||
|
val fetching: Boolean = false,
|
||||||
|
val compare: CompareUiState = CompareUiState.None,
|
||||||
|
val message: WorkbenchMessage? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the code screens share across repositories: the files kept as tabs, and a comparison
|
||||||
|
* between two files from anywhere -- two refs, two commits, or two places in the code.
|
||||||
|
*/
|
||||||
|
class CodeWorkbenchViewModel(
|
||||||
|
private val browser: GiteaRepositoryBrowser,
|
||||||
|
private val settings: AppSettingsStore,
|
||||||
|
private val classifier: SourceContentClassifier = SourceContentClassifier(
|
||||||
|
maxInputBytes = 2_000_000,
|
||||||
|
maxRenderedCharacters = 400_000,
|
||||||
|
maxRenderedLines = 20_000,
|
||||||
|
),
|
||||||
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val mutableState = MutableStateFlow(
|
||||||
|
CodeWorkbenchState(tabs = settings.savedCodeTabs().mapNotNull(SavedCodeTab::decodeOrNull).take(SavedCodeTab.MAX_TABS)),
|
||||||
|
)
|
||||||
|
val state: StateFlow<CodeWorkbenchState> = mutableState.asStateFlow()
|
||||||
|
private var fetch: Job? = null
|
||||||
|
private var comparing: Job? = null
|
||||||
|
|
||||||
|
/** Keeps [tab] as a tab, or lets it go if it already is one. A sixth is refused, not swapped in. */
|
||||||
|
fun toggleTab(tab: SavedCodeTab) {
|
||||||
|
val tabs = mutableState.value.tabs
|
||||||
|
when {
|
||||||
|
tab in tabs -> saveTabs(tabs - tab)
|
||||||
|
tabs.size >= SavedCodeTab.MAX_TABS -> mutableState.update { it.copy(message = WorkbenchMessage.TABS_FULL) }
|
||||||
|
else -> saveTabs(tabs + tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeTab(tab: SavedCodeTab) = saveTabs(mutableState.value.tabs - tab)
|
||||||
|
|
||||||
|
/** Puts [file] on one side, to be compared with the next file chosen. */
|
||||||
|
fun pin(file: ComparedFile, side: CompareSide) {
|
||||||
|
mutableState.update { it.copy(pending = PendingCompare(file, side)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearPin() {
|
||||||
|
fetch?.cancel()
|
||||||
|
mutableState.update { it.copy(pending = null, fetching = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compares [file] with the one waiting on the other side. */
|
||||||
|
fun compareWith(file: ComparedFile) {
|
||||||
|
val pending = mutableState.value.pending ?: return
|
||||||
|
if (pending.file.isSameFileAs(file)) {
|
||||||
|
mutableState.update { it.copy(message = WorkbenchMessage.SAME_FILE) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (left, right) = if (pending.side == CompareSide.LEFT) pending.file to file else file to pending.file
|
||||||
|
mutableState.update { it.copy(pending = null) }
|
||||||
|
start(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches a file as it is at a commit, then pins it or compares it with the waiting one. */
|
||||||
|
fun useCommitFile(repository: RepositoryCoordinates, commitId: String, shortId: String, path: String, side: CompareSide?) {
|
||||||
|
fetch?.cancel()
|
||||||
|
mutableState.update { it.copy(fetching = true) }
|
||||||
|
fetch = viewModelScope.launch {
|
||||||
|
val result = try {
|
||||||
|
withContext(defaultDispatcher) {
|
||||||
|
val repositoryPath = RepositoryPath.of(path)
|
||||||
|
classifier.classify(browser.getFile(repository, RepositoryRef.of(commitId), repositoryPath).content, repositoryPath)
|
||||||
|
}
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
if (failure is CancellationException) throw failure
|
||||||
|
null
|
||||||
|
}
|
||||||
|
mutableState.update { it.copy(fetching = false) }
|
||||||
|
when (result) {
|
||||||
|
null -> mutableState.update { it.copy(message = WorkbenchMessage.FILE_UNAVAILABLE) }
|
||||||
|
!is ContentClassification.Text -> mutableState.update { it.copy(message = WorkbenchMessage.FILE_NOT_TEXT) }
|
||||||
|
else -> {
|
||||||
|
val file = ComparedFile(repository, shortId, path, result.renderedText, result.isTruncated)
|
||||||
|
if (side != null) pin(file, side) else compareWith(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun swapSides() {
|
||||||
|
val session = (mutableState.value.compare as? CompareUiState.Ready)?.session ?: return
|
||||||
|
start(session.right, session.left)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeCompare() {
|
||||||
|
comparing?.cancel()
|
||||||
|
mutableState.update { it.copy(compare = CompareUiState.None) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun messageShown() {
|
||||||
|
mutableState.update { it.copy(message = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start(left: ComparedFile, right: ComparedFile) {
|
||||||
|
comparing?.cancel()
|
||||||
|
mutableState.update { it.copy(compare = CompareUiState.Working(left, right)) }
|
||||||
|
comparing = viewModelScope.launch {
|
||||||
|
val session = withContext(defaultDispatcher) {
|
||||||
|
val leftLines = left.text.toLines()
|
||||||
|
val rightLines = right.text.toLines()
|
||||||
|
CompareSession(left, right, leftLines, rightLines, compareLines(leftLines, rightLines))
|
||||||
|
}
|
||||||
|
mutableState.update { it.copy(compare = CompareUiState.Ready(session)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveTabs(tabs: List<SavedCodeTab>) {
|
||||||
|
mutableState.update { it.copy(tabs = tabs) }
|
||||||
|
settings.setSavedCodeTabs(tabs.map(SavedCodeTab::encode))
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
require(modelClass.isAssignableFrom(CodeWorkbenchViewModel::class.java))
|
||||||
|
val settings = AppSettingsSession.store(application)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return CodeWorkbenchViewModel(
|
||||||
|
GiteaRepositoryBrowserApi(
|
||||||
|
UBGiteaOAuthDefaults.config,
|
||||||
|
OkHttpTransport(),
|
||||||
|
AuthSession.tokenProvider(application),
|
||||||
|
reactiveAuthRetryEnabled = { settings.isReactiveAuthRetryEnabled() },
|
||||||
|
),
|
||||||
|
settings,
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lines as the code viewer splits them: a Windows "\r" is not part of a line.
|
||||||
|
internal fun String.toLines(): List<String> = split("\n").map { it.removeSuffix("\r") }
|
||||||
@ -0,0 +1,314 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.compare
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.DisableSelection
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextIndent
|
||||||
|
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.domain.model.RepositoryPath
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLine
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
||||||
|
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.ScreenTopBar
|
||||||
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
|
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
|
||||||
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two files side by side, the way a merge tool shows them: lines that match sit level, a changed
|
||||||
|
* line is amber on both sides, a line only one side has is red on the left or green on the right
|
||||||
|
* with a gap opposite. On a narrow window the two sides are interleaved in one column instead.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CompareScreen(
|
||||||
|
state: CompareUiState,
|
||||||
|
fontSizeSp: Int,
|
||||||
|
onSwap: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
val (left, right) = when (state) {
|
||||||
|
is CompareUiState.Ready -> state.session.left to state.session.right
|
||||||
|
is CompareUiState.Working -> state.left to state.right
|
||||||
|
CompareUiState.None -> return
|
||||||
|
}
|
||||||
|
val session = (state as? CompareUiState.Ready)?.session
|
||||||
|
val listState = remember(session) { LazyListState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun jump(direction: Int) {
|
||||||
|
val differences = session?.differences ?: return
|
||||||
|
val current = listState.firstVisibleItemIndex + CONTEXT_ROWS
|
||||||
|
val target = if (direction > 0) differences.firstOrNull { it > current } else differences.lastOrNull { it < current }
|
||||||
|
if (target != null) scope.launch { listState.animateScrollToItem((target - CONTEXT_ROWS).coerceAtLeast(0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding()) {
|
||||||
|
ScreenTopBar(
|
||||||
|
title = if (left.fileName == right.fileName) left.fileName else "${left.fileName} ↔ ${right.fileName}",
|
||||||
|
subtitle = "${left.refLabel} ↔ ${right.refLabel}",
|
||||||
|
monospaceTitle = true,
|
||||||
|
onBack = onClose,
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSwap, enabled = session != null) {
|
||||||
|
UBIcon(R.drawable.ic_ub_compare, stringResource(R.string.compare_swap), size = 22.dp)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (left.isTruncated || right.isTruncated) CautionBanner(stringResource(R.string.compare_truncated))
|
||||||
|
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
val sideBySide = maxWidth >= WideLayoutBreakpoint
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
if (sideBySide) SideHeaders(left, right)
|
||||||
|
when {
|
||||||
|
session == null -> CenteredLoading(stringResource(R.string.compare_working))
|
||||||
|
session.differences.isEmpty() -> CenteredMessage(stringResource(R.string.compare_identical))
|
||||||
|
else -> CompareList(session, fontSizeSp, sideBySide, listState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val position by remember(session) {
|
||||||
|
derivedStateOf {
|
||||||
|
val current = listState.firstVisibleItemIndex + CONTEXT_ROWS
|
||||||
|
session?.differences?.count { it <= current } ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompareToolbar(
|
||||||
|
summary = session?.let {
|
||||||
|
if (it.differences.isEmpty()) null else stringResource(R.string.compare_summary, it.differences.size)
|
||||||
|
},
|
||||||
|
position = position,
|
||||||
|
canJump = !session?.differences.isNullOrEmpty(),
|
||||||
|
onPrevious = { jump(-1) },
|
||||||
|
onNext = { jump(1) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SideHeaders(left: ComparedFile, right: ComparedFile) {
|
||||||
|
Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant).height(IntrinsicSize.Min)) {
|
||||||
|
SideHeader(R.string.compare_side_left, left, Modifier.weight(1f))
|
||||||
|
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
|
||||||
|
SideHeader(R.string.compare_side_right, right, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SideHeader(@StringRes side: Int, file: ComparedFile, modifier: Modifier) {
|
||||||
|
Column(modifier.padding(horizontal = 12.dp, vertical = 6.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(side) + " · " + file.path,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.compare_side_label, file.repository.name, file.refLabel),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompareList(session: CompareSession, fontSizeSp: Int, sideBySide: Boolean, listState: LazyListState) {
|
||||||
|
val colors = rememberCodeSyntaxColors()
|
||||||
|
val language = remember(session.left.path) { languageOf(session.left.path) }
|
||||||
|
val style = remember(fontSizeSp, colors.text) {
|
||||||
|
TextStyle(fontFamily = FontFamily.Monospace, fontSize = fontSizeSp.sp, lineHeight = (fontSizeSp * 1.5f).sp, color = colors.text)
|
||||||
|
}
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val characterWidthPx = remember(style) { measurer.measure("0", style).size.width.toFloat() }
|
||||||
|
val digits = maxOf(session.leftLines.size, session.rightLines.size).toString().length
|
||||||
|
val numberWidth = with(LocalDensity.current) { (characterWidthPx * digits).toDp() } + 18.dp
|
||||||
|
val cell = CellStyle(style, characterWidthPx, numberWidth, language)
|
||||||
|
|
||||||
|
SelectionContainer {
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||||
|
items(session.rows.size, key = { it }) { index ->
|
||||||
|
val row = session.rows[index]
|
||||||
|
val leftText = row.left?.let(session.leftLines::get)
|
||||||
|
val rightText = row.right?.let(session.rightLines::get)
|
||||||
|
if (sideBySide) {
|
||||||
|
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||||
|
CompareCell(leftText, row.left, row.kind, isLeft = true, cell, Modifier.weight(1f).fillMaxHeight())
|
||||||
|
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
|
||||||
|
CompareCell(rightText, row.right, row.kind, isLeft = false, cell, Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
if (row.kind == CompareRowKind.SAME) {
|
||||||
|
CompareCell(leftText, row.left, row.kind, isLeft = true, cell, Modifier.fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
if (leftText != null) CompareCell(leftText, row.left, row.kind, isLeft = true, cell, Modifier.fillMaxWidth())
|
||||||
|
if (rightText != null) CompareCell(rightText, row.right, row.kind, isLeft = false, cell, Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CellStyle(val text: TextStyle, val characterWidthPx: Float, val numberWidth: Dp, val language: SyntaxLanguage)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompareCell(text: String?, index: Int?, kind: CompareRowKind, isLeft: Boolean, cell: CellStyle, modifier: Modifier) {
|
||||||
|
if (text == null || index == null) {
|
||||||
|
// The other side has a line this side lacks.
|
||||||
|
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val syntax = rememberCodeSyntaxColors()
|
||||||
|
val background = when (kind) {
|
||||||
|
CompareRowKind.SAME -> Color.Transparent
|
||||||
|
CompareRowKind.CHANGED -> UBGiteaTheme.colors.warningContainer
|
||||||
|
CompareRowKind.LEFT_ONLY -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
CompareRowKind.RIGHT_ONLY -> UBGiteaTheme.colors.successContainer
|
||||||
|
}
|
||||||
|
val (sign, signColor, signLabel) = when {
|
||||||
|
kind == CompareRowKind.SAME -> Triple("", Color.Transparent, null)
|
||||||
|
isLeft -> Triple("−", MaterialTheme.colorScheme.error, stringResource(R.string.compare_side_left))
|
||||||
|
else -> Triple("+", UBGiteaTheme.colors.success, stringResource(R.string.compare_side_right))
|
||||||
|
}
|
||||||
|
val shown: AnnotatedString = remember(text, cell.language, syntax) { highlightLine(text, cell.language, syntax) }
|
||||||
|
val indent = text.takeWhile { it == ' ' || it == '\t' }.sumOf { if (it == '\t') 4 else 1 }
|
||||||
|
val hanging = with(LocalDensity.current) { (cell.characterWidthPx * (indent + 4)).toSp() }
|
||||||
|
Row(modifier.background(background), verticalAlignment = Alignment.Top) {
|
||||||
|
DisableSelection {
|
||||||
|
Text(
|
||||||
|
(index + 1).toString(),
|
||||||
|
style = cell.text,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.width(cell.numberWidth).padding(end = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
sign,
|
||||||
|
style = cell.text,
|
||||||
|
color = signColor,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.width(16.dp).semantics { if (signLabel != null) contentDescription = signLabel },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
shown,
|
||||||
|
style = cell.text.copy(textIndent = TextIndent(restLine = hanging)),
|
||||||
|
softWrap = true,
|
||||||
|
modifier = Modifier.weight(1f).padding(end = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompareToolbar(summary: String?, position: Int, canJump: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Column(Modifier.navigationBarsPadding()) {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CompareTool(R.drawable.ic_ub_chevron_up, R.string.compare_previous, canJump, Modifier.weight(1f), onPrevious)
|
||||||
|
Text(
|
||||||
|
if (summary == null) "" else "$position / $summary",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.weight(1.4f),
|
||||||
|
)
|
||||||
|
CompareTool(R.drawable.ic_ub_chevron_down, R.string.compare_next, canJump, Modifier.weight(1f), onNext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompareTool(@DrawableRes icon: Int, @StringRes label: Int, enabled: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
val color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
|
.heightIn(min = 52.dp)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.height(30.dp), contentAlignment = Alignment.Center) { UBIcon(icon, null, size = 22.dp, tint = color) }
|
||||||
|
Text(stringResource(label), fontSize = 11.sp, fontWeight = FontWeight.Medium, color = color, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun languageOf(path: String): SyntaxLanguage =
|
||||||
|
try {
|
||||||
|
syntaxLanguageForPath(RepositoryPath.of(path))
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
SyntaxLanguage.PLAIN
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CONTEXT_ROWS = 2
|
||||||
144
app/src/main/java/com/ubisam/ubgitea/feature/compare/LineDiff.kt
Normal file
144
app/src/main/java/com/ubisam/ubgitea/feature/compare/LineDiff.kt
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.compare
|
||||||
|
|
||||||
|
/** How one row of a two-column comparison differs between its sides. */
|
||||||
|
enum class CompareRowKind {
|
||||||
|
/** The same line on both sides. */
|
||||||
|
SAME,
|
||||||
|
|
||||||
|
/** A line on each side, and they differ. */
|
||||||
|
CHANGED,
|
||||||
|
|
||||||
|
/** A line only on the left; the right is blank. */
|
||||||
|
LEFT_ONLY,
|
||||||
|
|
||||||
|
/** A line only on the right; the left is blank. */
|
||||||
|
RIGHT_ONLY,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of a merge-tool style comparison: the left file's line beside the right file's, or a
|
||||||
|
* blank on the side that has none, so the lines that match stay level. Line numbers are zero-based
|
||||||
|
* indexes into each side's lines, null on a blank side.
|
||||||
|
*/
|
||||||
|
data class CompareRow(val kind: CompareRowKind, val left: Int?, val right: Int?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines the two sides share are found with Myers' algorithm, after the common start and end are set
|
||||||
|
* aside. Between two shared lines, removed and added lines are paired in order into changed rows;
|
||||||
|
* whichever side has more keeps the rest to itself.
|
||||||
|
*
|
||||||
|
* When the files differ in more than [maxEdits] lines the search stops and the differing middle is
|
||||||
|
* shown as one changed block. Each step of the search keeps a copy of its frontier, so memory grows
|
||||||
|
* with the square of the edit count, and an unbounded search on two unrelated large files could take
|
||||||
|
* all of it.
|
||||||
|
*/
|
||||||
|
fun compareLines(left: List<String>, right: List<String>, maxEdits: Int = DEFAULT_MAX_EDITS): List<CompareRow> {
|
||||||
|
var prefix = 0
|
||||||
|
while (prefix < left.size && prefix < right.size && left[prefix] == right[prefix]) prefix++
|
||||||
|
var suffix = 0
|
||||||
|
while (
|
||||||
|
suffix < left.size - prefix && suffix < right.size - prefix &&
|
||||||
|
left[left.size - 1 - suffix] == right[right.size - 1 - suffix]
|
||||||
|
) suffix++
|
||||||
|
|
||||||
|
val rows = ArrayList<CompareRow>(maxOf(left.size, right.size) + 16)
|
||||||
|
repeat(prefix) { rows += CompareRow(CompareRowKind.SAME, it, it) }
|
||||||
|
val leftMiddle = left.subList(prefix, left.size - suffix)
|
||||||
|
val rightMiddle = right.subList(prefix, right.size - suffix)
|
||||||
|
val shared = sharedLines(leftMiddle, rightMiddle, maxEdits)
|
||||||
|
|
||||||
|
var l = 0
|
||||||
|
var r = 0
|
||||||
|
for ((sharedLeft, sharedRight) in shared + (leftMiddle.size to rightMiddle.size)) {
|
||||||
|
addBlock(rows, prefix + l, prefix + sharedLeft, prefix + r, prefix + sharedRight)
|
||||||
|
if (sharedLeft < leftMiddle.size) rows += CompareRow(CompareRowKind.SAME, prefix + sharedLeft, prefix + sharedRight)
|
||||||
|
l = sharedLeft + 1
|
||||||
|
r = sharedRight + 1
|
||||||
|
}
|
||||||
|
repeat(suffix) {
|
||||||
|
rows += CompareRow(CompareRowKind.SAME, left.size - suffix + it, right.size - suffix + it)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The index of the first row of every run of differing rows: where "next difference" lands. */
|
||||||
|
fun differenceStarts(rows: List<CompareRow>): List<Int> =
|
||||||
|
rows.indices.filter { rows[it].kind != CompareRowKind.SAME && (it == 0 || rows[it - 1].kind == CompareRowKind.SAME) }
|
||||||
|
|
||||||
|
private fun addBlock(rows: MutableList<CompareRow>, leftFrom: Int, leftTo: Int, rightFrom: Int, rightTo: Int) {
|
||||||
|
val leftCount = leftTo - leftFrom
|
||||||
|
val rightCount = rightTo - rightFrom
|
||||||
|
for (i in 0 until maxOf(leftCount, rightCount)) {
|
||||||
|
rows += when {
|
||||||
|
i < leftCount && i < rightCount -> CompareRow(CompareRowKind.CHANGED, leftFrom + i, rightFrom + i)
|
||||||
|
i < leftCount -> CompareRow(CompareRowKind.LEFT_ONLY, leftFrom + i, null)
|
||||||
|
else -> CompareRow(CompareRowKind.RIGHT_ONLY, null, rightFrom + i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pairs (left index, right index) of lines kept in a shortest edit script from [a] to [b], in
|
||||||
|
* order. Empty when nothing is shared or when more than [maxEdits] edits would be needed.
|
||||||
|
*/
|
||||||
|
private fun sharedLines(a: List<String>, b: List<String>, maxEdits: Int): List<Pair<Int, Int>> {
|
||||||
|
val n = a.size
|
||||||
|
val m = b.size
|
||||||
|
if (n == 0 || m == 0) return emptyList()
|
||||||
|
val max = minOf(n + m, maxEdits)
|
||||||
|
val offset = max + 1
|
||||||
|
val v = IntArray(2 * max + 3)
|
||||||
|
// trace[d] is v for diagonals -d..d after step d.
|
||||||
|
val trace = ArrayList<IntArray>()
|
||||||
|
var found = -1
|
||||||
|
search@ for (d in 0..max) {
|
||||||
|
for (k in -d..d step 2) {
|
||||||
|
var x = if (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) {
|
||||||
|
v[offset + k + 1]
|
||||||
|
} else {
|
||||||
|
v[offset + k - 1] + 1
|
||||||
|
}
|
||||||
|
var y = x - k
|
||||||
|
while (x < n && y < m && a[x] == b[y]) {
|
||||||
|
x++
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
v[offset + k] = x
|
||||||
|
if (x >= n && y >= m) {
|
||||||
|
trace += v.copyOfRange(offset - d, offset + d + 1)
|
||||||
|
found = d
|
||||||
|
break@search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trace += v.copyOfRange(offset - d, offset + d + 1)
|
||||||
|
}
|
||||||
|
if (found < 0) return emptyList()
|
||||||
|
|
||||||
|
val pairs = ArrayList<Pair<Int, Int>>()
|
||||||
|
var x = n
|
||||||
|
var y = m
|
||||||
|
for (d in found downTo 1) {
|
||||||
|
val previous = trace[d - 1]
|
||||||
|
fun at(k: Int) = previous[k + d - 1]
|
||||||
|
val k = x - y
|
||||||
|
val previousK = if (k == -d || (k != d && at(k - 1) < at(k + 1))) k + 1 else k - 1
|
||||||
|
val previousX = at(previousK)
|
||||||
|
val previousY = previousX - previousK
|
||||||
|
while (x > previousX && y > previousY) {
|
||||||
|
x--
|
||||||
|
y--
|
||||||
|
pairs += x to y
|
||||||
|
}
|
||||||
|
x = previousX
|
||||||
|
y = previousY
|
||||||
|
}
|
||||||
|
while (x > 0 && y > 0) {
|
||||||
|
x--
|
||||||
|
y--
|
||||||
|
pairs += x to y
|
||||||
|
}
|
||||||
|
pairs.reverse()
|
||||||
|
return pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_MAX_EDITS = 3000
|
||||||
@ -62,6 +62,8 @@ import com.ubisam.ubgitea.domain.model.RepositoryPath
|
|||||||
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLine
|
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLine
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareMenu
|
||||||
|
import com.ubisam.ubgitea.feature.compare.LocalCodeWorkbench
|
||||||
import com.ubisam.ubgitea.ui.components.CautionBanner
|
import com.ubisam.ubgitea.ui.components.CautionBanner
|
||||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||||
@ -125,11 +127,28 @@ fun FileDiffScreen(
|
|||||||
DiffList(entries, selected.file.path, fontSizeSp, sideBySide, listState)
|
DiffList(entries, selected.file.path, fontSizeSp, sideBySide, listState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// This file as it is after the commit, to compare with a file from anywhere else (TASK-065).
|
||||||
|
val workbench = LocalCodeWorkbench.current
|
||||||
|
val commit = state.commit
|
||||||
|
val path = selected.file.path
|
||||||
DiffToolbar(
|
DiffToolbar(
|
||||||
canJump = changeStarts.isNotEmpty(),
|
canJump = changeStarts.isNotEmpty(),
|
||||||
onPreviousChange = { jump(-1) },
|
onPreviousChange = { jump(-1) },
|
||||||
onNextChange = { jump(1) },
|
onNextChange = { jump(1) },
|
||||||
onFileList = onClose,
|
onFileList = onClose,
|
||||||
|
compare = workbench?.let { bench ->
|
||||||
|
{ modifier ->
|
||||||
|
CompareMenu(
|
||||||
|
pending = bench.pending,
|
||||||
|
onPin = { side -> bench.onUseCommitFile(commit.id, commit.shortId, path, side) },
|
||||||
|
onCompare = { bench.onUseCommitFile(commit.id, commit.shortId, path, null) },
|
||||||
|
onClearPin = bench.onClearPin,
|
||||||
|
modifier = modifier,
|
||||||
|
) { onOpen ->
|
||||||
|
DiffTool(R.drawable.ic_ub_compare, R.string.codeviewer_tool_compare, true, Modifier.fillMaxWidth(), onOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -323,7 +342,13 @@ private fun DiffCell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiffToolbar(canJump: Boolean, onPreviousChange: () -> Unit, onNextChange: () -> Unit, onFileList: () -> Unit) {
|
private fun DiffToolbar(
|
||||||
|
canJump: Boolean,
|
||||||
|
onPreviousChange: () -> Unit,
|
||||||
|
onNextChange: () -> Unit,
|
||||||
|
onFileList: () -> Unit,
|
||||||
|
compare: (@Composable (Modifier) -> Unit)? = null,
|
||||||
|
) {
|
||||||
val label = stringResource(R.string.history_diff_tools)
|
val label = stringResource(R.string.history_diff_tools)
|
||||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||||
Column(Modifier.navigationBarsPadding()) {
|
Column(Modifier.navigationBarsPadding()) {
|
||||||
@ -331,6 +356,7 @@ private fun DiffToolbar(canJump: Boolean, onPreviousChange: () -> Unit, onNextCh
|
|||||||
Row(Modifier.fillMaxWidth().semantics { contentDescription = label }.padding(horizontal = 6.dp, vertical = 6.dp)) {
|
Row(Modifier.fillMaxWidth().semantics { contentDescription = label }.padding(horizontal = 6.dp, vertical = 6.dp)) {
|
||||||
DiffTool(R.drawable.ic_ub_chevron_up, R.string.history_diff_previous_change, canJump, Modifier.weight(1f), onPreviousChange)
|
DiffTool(R.drawable.ic_ub_chevron_up, R.string.history_diff_previous_change, canJump, Modifier.weight(1f), onPreviousChange)
|
||||||
DiffTool(R.drawable.ic_ub_chevron_down, R.string.history_diff_next_change, canJump, Modifier.weight(1f), onNextChange)
|
DiffTool(R.drawable.ic_ub_chevron_down, R.string.history_diff_next_change, canJump, Modifier.weight(1f), onNextChange)
|
||||||
|
compare?.invoke(Modifier.weight(1f))
|
||||||
DiffTool(R.drawable.ic_ub_files, R.string.history_diff_file_list, true, Modifier.weight(1f), onFileList)
|
DiffTool(R.drawable.ic_ub_files, R.string.history_diff_file_list, true, Modifier.weight(1f), onFileList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -431,6 +431,25 @@ class RepositoryListViewModel(
|
|||||||
loadRowBranches(restored)
|
loadRowBranches(restored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Repositories tab pressed again while it is showing: a repository's branches and tags go
|
||||||
|
* back to the list page they were chosen from, and a later page of the list goes to the first.
|
||||||
|
*/
|
||||||
|
fun reselect() {
|
||||||
|
when (val current = mutableState.value) {
|
||||||
|
is RepositoryListUiState.ReferencesLoading,
|
||||||
|
is RepositoryListUiState.ReferencesContent,
|
||||||
|
is RepositoryListUiState.ReferencesError,
|
||||||
|
-> backToRepositories()
|
||||||
|
is RepositoryListUiState.Content -> {
|
||||||
|
if (current.page <= 1) return
|
||||||
|
val account = activeAccountName ?: return
|
||||||
|
startOperation { loadRepositoryPage(account, 1, current.mode, current.organization) }
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelSignIn() {
|
fun cancelSignIn() {
|
||||||
startOperation {
|
startOperation {
|
||||||
pendingAuthStore.clear()
|
pendingAuthStore.clear()
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -24,12 +25,17 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||||
|
import com.ubisam.ubgitea.core.update.AppUpdateUiState
|
||||||
|
import com.ubisam.ubgitea.core.update.UpdateCheck
|
||||||
import com.ubisam.ubgitea.ui.components.Avatar
|
import com.ubisam.ubgitea.ui.components.Avatar
|
||||||
import com.ubisam.ubgitea.ui.components.ListCard
|
import com.ubisam.ubgitea.ui.components.ListCard
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||||
@ -49,6 +55,9 @@ fun SettingsScreen(
|
|||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
update: AppUpdateUiState? = null,
|
||||||
|
onCheckUpdate: () -> Unit = {},
|
||||||
|
onOpenDownloadPage: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Column(modifier.fillMaxSize()) {
|
Column(modifier.fillMaxSize()) {
|
||||||
@ -152,11 +161,53 @@ fun SettingsScreen(
|
|||||||
onCheckedChange = onSetFavoritePushNotificationsEnabled,
|
onCheckedChange = onSetFavoritePushNotificationsEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (update != null) {
|
||||||
|
SectionTitle(stringResource(R.string.update_section), Modifier.padding(top = 8.dp))
|
||||||
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
|
UpdateRow(update, onCheckUpdate, onOpenDownloadPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onOpenDownloadPage: () -> Unit) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(stringResource(R.string.update_installed_version, update.installedVersionName), style = MaterialTheme.typography.titleMedium)
|
||||||
|
val status = when (update.check) {
|
||||||
|
UpdateCheck.IDLE -> null
|
||||||
|
UpdateCheck.CHECKING -> stringResource(R.string.update_checking)
|
||||||
|
UpdateCheck.UP_TO_DATE -> stringResource(R.string.update_up_to_date)
|
||||||
|
UpdateCheck.FAILED -> stringResource(R.string.update_failed)
|
||||||
|
UpdateCheck.AVAILABLE -> update.available?.let { stringResource(R.string.update_available, it.versionName) }
|
||||||
|
}
|
||||||
|
if (status != null) {
|
||||||
|
Text(
|
||||||
|
status,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (update.check == UpdateCheck.AVAILABLE) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onCheckUpdate,
|
||||||
|
enabled = update.check != UpdateCheck.CHECKING,
|
||||||
|
modifier = Modifier.heightIn(min = 48.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.update_check))
|
||||||
|
}
|
||||||
|
if (update.available != null) {
|
||||||
|
Button(onClick = onOpenDownloadPage, modifier = Modifier.heightIn(min = 48.dp)) {
|
||||||
|
Text(stringResource(R.string.update_download))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val ThemeMode.label: Int
|
private val ThemeMode.label: Int
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||||
|
|||||||
@ -18,7 +18,14 @@ sealed interface Route {
|
|||||||
// one branch/tag/commit ref. Each tab's own internal drill-down (a subfolder, a commit's
|
// one branch/tag/commit ref. Each tab's own internal drill-down (a subfolder, a commit's
|
||||||
// detail) is handled entirely inside that tab's existing ViewModel state machine, not by
|
// detail) is handled entirely inside that tab's existing ViewModel state machine, not by
|
||||||
// pushing further routes, so this one route covers the whole branch-detail experience.
|
// pushing further routes, so this one route covers the whole branch-detail experience.
|
||||||
data class BranchDetail(val owner: String, val name: String, val refKind: String, val ref: String) : Route
|
// path is set when the screen opens straight on one file, as a saved code tab does.
|
||||||
|
data class BranchDetail(
|
||||||
|
val owner: String,
|
||||||
|
val name: String,
|
||||||
|
val refKind: String,
|
||||||
|
val ref: String,
|
||||||
|
val path: String? = null,
|
||||||
|
) : Route
|
||||||
// number is set when the screen opens on one issue or pull request rather than on the list.
|
// number is set when the screen opens on one issue or pull request rather than on the list.
|
||||||
data class WorkItems(
|
data class WorkItems(
|
||||||
val owner: String,
|
val owner: String,
|
||||||
@ -28,6 +35,10 @@ sealed interface Route {
|
|||||||
) : Route
|
) : Route
|
||||||
data object Settings : Route
|
data object Settings : Route
|
||||||
|
|
||||||
|
// Two files side by side. What is compared lives only in memory, so this route is not
|
||||||
|
// restored after the process is gone.
|
||||||
|
data object Compare : Route
|
||||||
|
|
||||||
val isTab: Boolean
|
val isTab: Boolean
|
||||||
get() = this is Home || this is Repositories || this is Work || this is Notifications
|
get() = this is Home || this is Repositories || this is Work || this is Notifications
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,12 +30,17 @@ internal object RouteEncoding {
|
|||||||
private const val TYPE_WORK_ITEMS = "workItems"
|
private const val TYPE_WORK_ITEMS = "workItems"
|
||||||
private const val TYPE_NOTIFICATIONS = "notifications"
|
private const val TYPE_NOTIFICATIONS = "notifications"
|
||||||
private const val TYPE_SETTINGS = "settings"
|
private const val TYPE_SETTINGS = "settings"
|
||||||
|
private const val TYPE_COMPARE = "compare"
|
||||||
|
|
||||||
fun encode(route: Route): String = when (route) {
|
fun encode(route: Route): String = when (route) {
|
||||||
Route.Home -> TYPE_HOME
|
Route.Home -> TYPE_HOME
|
||||||
Route.Repositories -> TYPE_REPOSITORIES
|
Route.Repositories -> TYPE_REPOSITORIES
|
||||||
Route.Work -> TYPE_WORK
|
Route.Work -> TYPE_WORK
|
||||||
is Route.BranchDetail -> join(TYPE_BRANCH_DETAIL, route.owner, route.name, route.refKind, route.ref)
|
is Route.BranchDetail -> if (route.path == null) {
|
||||||
|
join(TYPE_BRANCH_DETAIL, route.owner, route.name, route.refKind, route.ref)
|
||||||
|
} else {
|
||||||
|
join(TYPE_BRANCH_DETAIL, route.owner, route.name, route.refKind, route.ref, route.path)
|
||||||
|
}
|
||||||
is Route.WorkItems -> if (route.number == null) {
|
is Route.WorkItems -> if (route.number == null) {
|
||||||
join(TYPE_WORK_ITEMS, route.owner, route.name)
|
join(TYPE_WORK_ITEMS, route.owner, route.name)
|
||||||
} else {
|
} else {
|
||||||
@ -49,6 +54,7 @@ internal object RouteEncoding {
|
|||||||
}
|
}
|
||||||
Route.Notifications -> TYPE_NOTIFICATIONS
|
Route.Notifications -> TYPE_NOTIFICATIONS
|
||||||
Route.Settings -> TYPE_SETTINGS
|
Route.Settings -> TYPE_SETTINGS
|
||||||
|
Route.Compare -> TYPE_COMPARE
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decode(encoded: String): Route = decodeOrNull(encoded) ?: Route.Home
|
fun decode(encoded: String): Route = decodeOrNull(encoded) ?: Route.Home
|
||||||
@ -56,8 +62,8 @@ internal object RouteEncoding {
|
|||||||
fun decodeOrNull(encoded: String): Route? {
|
fun decodeOrNull(encoded: String): Route? {
|
||||||
val parts = encoded.split(FIELD_SEPARATOR)
|
val parts = encoded.split(FIELD_SEPARATOR)
|
||||||
return when (parts.first()) {
|
return when (parts.first()) {
|
||||||
TYPE_BRANCH_DETAIL -> parts.takeIf { it.size == 5 }?.let {
|
TYPE_BRANCH_DETAIL -> parts.takeIf { it.size == 5 || it.size == 6 }?.let {
|
||||||
Route.BranchDetail(owner = it[1], name = it[2], refKind = it[3], ref = it[4])
|
Route.BranchDetail(owner = it[1], name = it[2], refKind = it[3], ref = it[4], path = it.getOrNull(5))
|
||||||
}
|
}
|
||||||
TYPE_WORK_ITEMS -> when (parts.size) {
|
TYPE_WORK_ITEMS -> when (parts.size) {
|
||||||
3 -> Route.WorkItems(owner = parts[1], name = parts[2])
|
3 -> Route.WorkItems(owner = parts[1], name = parts[2])
|
||||||
@ -75,6 +81,8 @@ internal object RouteEncoding {
|
|||||||
TYPE_NOTIFICATIONS -> Route.Notifications
|
TYPE_NOTIFICATIONS -> Route.Notifications
|
||||||
TYPE_SETTINGS -> Route.Settings
|
TYPE_SETTINGS -> Route.Settings
|
||||||
TYPE_REPOSITORIES -> Route.Repositories
|
TYPE_REPOSITORIES -> Route.Repositories
|
||||||
|
// The files being compared are not saved with the route.
|
||||||
|
TYPE_COMPARE -> null
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import androidx.annotation.DrawableRes
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@ -55,6 +59,9 @@ fun AppShell(
|
|||||||
unreadNotifications: Int?,
|
unreadNotifications: Int?,
|
||||||
onTabSelected: (AppTab) -> Unit,
|
onTabSelected: (AppTab) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
// Drawn between the screen and the bottom tabs, or at the bottom where there are none: the
|
||||||
|
// saved code tabs. Null when there is nothing to show.
|
||||||
|
strip: (@Composable () -> Unit)? = null,
|
||||||
content: @Composable (isWide: Boolean) -> Unit,
|
content: @Composable (isWide: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||||
@ -62,7 +69,14 @@ fun AppShell(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (showNavigation && !isWide) BottomTabs(selectedTab, unreadNotifications, onTabSelected)
|
val tabs = showNavigation && !isWide
|
||||||
|
Column {
|
||||||
|
if (strip != null) {
|
||||||
|
// The bottom tabs keep clear of the system bar themselves; alone, the strip has to.
|
||||||
|
Box(if (tabs) Modifier else Modifier.windowInsetsPadding(WindowInsets.navigationBars)) { strip() }
|
||||||
|
}
|
||||||
|
if (tabs) BottomTabs(selectedTab, unreadNotifications, onTabSelected)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Row(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding)) {
|
Row(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding)) {
|
||||||
|
|||||||
14
app/src/main/res/drawable/ic_ub_bookmark.xml
Normal file
14
app/src/main/res/drawable/ic_ub_bookmark.xml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FF000000"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:pathData="M6.5,3.5h11v17l-5.5,-4 -5.5,4z" />
|
||||||
|
</vector>
|
||||||
13
app/src/main/res/drawable/ic_ub_bookmark_filled.xml
Normal file
13
app/src/main/res/drawable/ic_ub_bookmark_filled.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:strokeColor="#FF000000"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:pathData="M6.5,3.5h11v17l-5.5,-4 -5.5,4z" />
|
||||||
|
</vector>
|
||||||
14
app/src/main/res/drawable/ic_ub_compare.xml
Normal file
14
app/src/main/res/drawable/ic_ub_compare.xml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FF000000"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:pathData="M4,4.5h6v15h-6zM14,4.5h6v15h-6zM6,8.5h2M6,12h2M16,8.5h2M16,15.5h2" />
|
||||||
|
</vector>
|
||||||
42
app/src/main/res/values/strings_compare.xml
Normal file
42
app/src/main/res/values/strings_compare.xml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Saved code tabs (TASK-065) -->
|
||||||
|
<string name="workbench_tabs">저장한 탭</string>
|
||||||
|
<string name="workbench_tab_open">%1$s 열기 · %2$s</string>
|
||||||
|
<string name="workbench_tab_close">%1$s 탭 닫기</string>
|
||||||
|
<string name="workbench_tab_current">지금 보는 파일</string>
|
||||||
|
<string name="workbench_tabs_full">탭은 %1$d개까지 저장할 수 있습니다. 하나를 닫고 다시 저장하세요.</string>
|
||||||
|
<string name="codeviewer_tool_save_tab">탭 저장</string>
|
||||||
|
<string name="codeviewer_tool_compare">비교</string>
|
||||||
|
|
||||||
|
<!-- Comparing two files -->
|
||||||
|
<string name="compare_put_left">이 파일을 왼쪽에 두기</string>
|
||||||
|
<string name="compare_put_right">이 파일을 오른쪽에 두기</string>
|
||||||
|
<string name="compare_with_pinned">%1$s와 비교하기</string>
|
||||||
|
<string name="compare_unpin">비교 대기 취소</string>
|
||||||
|
<string name="compare_pending">비교 대기 · %1$s (%2$s)</string>
|
||||||
|
<string name="compare_pending_hint">다른 파일에서 비교를 누르세요</string>
|
||||||
|
<string name="compare_side_left">왼쪽</string>
|
||||||
|
<string name="compare_side_right">오른쪽</string>
|
||||||
|
<string name="compare_fetching">커밋의 파일을 불러오는 중입니다.</string>
|
||||||
|
<string name="compare_file_unavailable">이 커밋의 파일을 불러오지 못했습니다. 삭제된 파일은 비교할 수 없습니다.</string>
|
||||||
|
<string name="compare_file_not_text">텍스트 파일만 비교할 수 있습니다.</string>
|
||||||
|
<string name="compare_same_file">같은 파일입니다. 다른 커밋이나 다른 파일을 고르세요.</string>
|
||||||
|
<string name="compare_working">두 파일을 맞춰 보는 중입니다.</string>
|
||||||
|
<string name="compare_summary">다른 곳 %1$d군데</string>
|
||||||
|
<string name="compare_identical">두 파일이 같습니다.</string>
|
||||||
|
<string name="compare_previous">이전 차이</string>
|
||||||
|
<string name="compare_next">다음 차이</string>
|
||||||
|
<string name="compare_swap">좌우 바꾸기</string>
|
||||||
|
<string name="compare_truncated">파일이 길어 앞부분만 비교합니다.</string>
|
||||||
|
<string name="compare_side_label">%1$s · %2$s</string>
|
||||||
|
|
||||||
|
<!-- Highlighting a word in the code -->
|
||||||
|
<string name="codeviewer_word_count">%1$s · %2$d곳</string>
|
||||||
|
<string name="codeviewer_word_previous">이전 위치</string>
|
||||||
|
<string name="codeviewer_word_next">다음 위치</string>
|
||||||
|
<string name="codeviewer_word_clear">표시 지우기</string>
|
||||||
|
|
||||||
|
<!-- Side pane width -->
|
||||||
|
<string name="codeviewer_side_pane_handle">옆 창 너비 조절. 좌우로 끌어 조절하고, 두 번 누르면 기본 너비로 되돌립니다.</string>
|
||||||
|
</resources>
|
||||||
15
app/src/main/res/values/strings_update.xml
Normal file
15
app/src/main/res/values/strings_update.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="update_section">앱 정보</string>
|
||||||
|
<string name="update_installed_version">현재 버전 %1$s</string>
|
||||||
|
<string name="update_check">업데이트 확인</string>
|
||||||
|
<string name="update_checking">확인하는 중…</string>
|
||||||
|
<string name="update_up_to_date">최신 버전입니다.</string>
|
||||||
|
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
|
||||||
|
<string name="update_available">새 버전 %1$s을(를) 받을 수 있습니다.</string>
|
||||||
|
<string name="update_download">다운로드 페이지 열기</string>
|
||||||
|
<string name="update_dialog_title">새 버전이 있습니다</string>
|
||||||
|
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n다운로드 페이지에서 비밀번호를 입력해 받은 뒤 설치하세요.</string>
|
||||||
|
<string name="update_dialog_download">다운로드</string>
|
||||||
|
<string name="update_dialog_later">나중에</string>
|
||||||
|
</resources>
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.ubisam.ubgitea.core.update
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AppUpdateTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||||
|
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a version file is read`() {
|
||||||
|
assertEquals(
|
||||||
|
AppRelease(3, "0.3.0", "탭 저장"),
|
||||||
|
parseAppRelease("""{"versionCode": 3, "versionName": " 0.3.0 ", "notes": "탭 저장"}"""),
|
||||||
|
)
|
||||||
|
assertEquals(AppRelease(3, "0.3.0"), parseAppRelease("""{"versionCode": 3, "versionName": "0.3.0", "notes": ""}"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a broken or incomplete version file is no release`() {
|
||||||
|
assertNull(parseAppRelease("<html>404</html>"))
|
||||||
|
assertNull(parseAppRelease("""{"versionName": "0.3.0"}"""))
|
||||||
|
assertNull(parseAppRelease("""{"versionCode": "three", "versionName": "0.3.0"}"""))
|
||||||
|
assertNull(parseAppRelease("""{"versionCode": 0, "versionName": "0.3.0"}"""))
|
||||||
|
assertNull(parseAppRelease("""{"versionCode": 3}"""))
|
||||||
|
assertNull(parseAppRelease("[]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a newer build is offered at launch`() = runTest(dispatcher) {
|
||||||
|
val release = AppRelease(3, "0.3.0")
|
||||||
|
val viewModel = AppUpdateViewModel({ release }, installedVersionCode = 2, installedVersionName = "0.2.0")
|
||||||
|
viewModel.checkAtLaunch()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||||
|
assertEquals(release, viewModel.state.value.prompt)
|
||||||
|
viewModel.dismissPrompt()
|
||||||
|
assertNull(viewModel.state.value.prompt)
|
||||||
|
assertEquals(release, viewModel.state.value.available)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the launch check runs once`() = runTest(dispatcher) {
|
||||||
|
var calls = 0
|
||||||
|
val viewModel = AppUpdateViewModel({ calls++; AppRelease(3, "0.3.0") }, 2, "0.2.0")
|
||||||
|
viewModel.checkAtLaunch()
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.dismissPrompt()
|
||||||
|
viewModel.checkAtLaunch()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, calls)
|
||||||
|
assertNull(viewModel.state.value.prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same or an older build is up to date`() = runTest(dispatcher) {
|
||||||
|
val viewModel = AppUpdateViewModel({ AppRelease(2, "0.2.0") }, 2, "0.2.0")
|
||||||
|
viewModel.checkNow()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(UpdateCheck.UP_TO_DATE, viewModel.state.value.check)
|
||||||
|
assertNull(viewModel.state.value.available)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a check by hand finds a newer build without the launch prompt`() = runTest(dispatcher) {
|
||||||
|
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0")
|
||||||
|
viewModel.checkNow()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||||
|
assertNull(viewModel.state.value.prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unreadable version file is a failed check`() = runTest(dispatcher) {
|
||||||
|
val viewModel = AppUpdateViewModel({ null }, 2, "0.2.0")
|
||||||
|
viewModel.checkNow()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(UpdateCheck.FAILED, viewModel.state.value.check)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -178,6 +178,25 @@ class CodeBrowserViewModelTest {
|
|||||||
assertEquals(2, browser.fileRequests)
|
assertEquals(2, browser.fileRequests)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file opened by path lists its folder and comes back without a second fetch`() = runTest(dispatcher.scheduler) {
|
||||||
|
val browser = FakeBrowser()
|
||||||
|
val viewModel = CodeBrowserViewModel(browser, defaultDispatcher = dispatcher)
|
||||||
|
viewModel.openFile(target, RepositoryPath.of("src/한글.kt"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
val file = viewModel.state.value as CodeBrowserUiState.File
|
||||||
|
assertEquals("src/한글.kt", file.path.value)
|
||||||
|
assertEquals(listOf("src/한글.kt"), file.siblingEntries.map { it.path.value })
|
||||||
|
assertEquals(1, browser.fileRequests)
|
||||||
|
|
||||||
|
viewModel.openFile(target, RepositoryPath.of("gallery/notes.txt"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.openFile(target, RepositoryPath.of("src/한글.kt"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("src/한글.kt", (viewModel.state.value as CodeBrowserUiState.File).path.value)
|
||||||
|
assertEquals(2, browser.fileRequests)
|
||||||
|
}
|
||||||
|
|
||||||
private class FakeBrowser(
|
private class FakeBrowser(
|
||||||
private val rootEntryType: TreeEntryType = TreeEntryType.DIRECTORY,
|
private val rootEntryType: TreeEntryType = TreeEntryType.DIRECTORY,
|
||||||
var fileFailure: Throwable? = null,
|
var fileFailure: Throwable? = null,
|
||||||
|
|||||||
@ -15,4 +15,16 @@ class SplitTest {
|
|||||||
assertEquals(0.8f, clampSplit(0.97f), 0f)
|
assertEquals(0.8f, clampSplit(0.97f), 0f)
|
||||||
assertEquals(0.2f, clampSplit(-1f), 0f)
|
assertEquals(0.2f, clampSplit(-1f), 0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a width near the default snaps onto it`() {
|
||||||
|
assertEquals(240f, snapTo(252f, 240f, 16f), 0f)
|
||||||
|
assertEquals(240f, snapTo(224f, 240f, 16f), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a width beyond the snap distance is kept`() {
|
||||||
|
assertEquals(260f, snapTo(260f, 240f, 16f), 0f)
|
||||||
|
assertEquals(0.3f, snapTo(0.3f, 0.5f, 0.03f), 0f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,4 +164,27 @@ class CodeSearchTest {
|
|||||||
val header = RepositoryPath.of("src/a/Foo.h")
|
val header = RepositoryPath.of("src/a/Foo.h")
|
||||||
assertNull(counterpartOf(header, listOf(header)))
|
assertNull(counterpartOf(header, listOf(header)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the word under a tap includes underscores and digits`() {
|
||||||
|
assertEquals("item_count2", wordAt("val item_count2 = 3", 6))
|
||||||
|
assertEquals("item_count2", wordAt("val item_count2 = 3", 15))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tap on spaces, symbols or a number is no word`() {
|
||||||
|
assertNull(wordAt("a = 12", 2))
|
||||||
|
assertNull(wordAt("a = 12", 5))
|
||||||
|
assertNull(wordAt("", 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `occurrences are whole words and case sensitive`() {
|
||||||
|
val lines = listOf("count = count + 1", "val counter = Count", "count")
|
||||||
|
val hits = findWordOccurrences(lines, "count")
|
||||||
|
assertEquals(
|
||||||
|
listOf(SearchMatch(0, 0, 5), SearchMatch(0, 8, 13), SearchMatch(2, 0, 5)),
|
||||||
|
hits,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,150 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.compare
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||||
|
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||||
|
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.CommitSummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.Page
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.SourceContent
|
||||||
|
import com.ubisam.ubgitea.domain.model.SourceFile
|
||||||
|
import com.ubisam.ubgitea.domain.model.TagSummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||||
|
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class CodeWorkbenchViewModelTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val repository = RepositoryCoordinates.of("owner", "repo")
|
||||||
|
|
||||||
|
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||||
|
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tabs are kept, toggled off, and a sixth is refused`() {
|
||||||
|
val settings = FakeSettings()
|
||||||
|
val viewModel = CodeWorkbenchViewModel(FakeBrowser(), settings, defaultDispatcher = dispatcher)
|
||||||
|
val tabs = (1..6).map { tab("src/F$it.kt") }
|
||||||
|
tabs.take(5).forEach(viewModel::toggleTab)
|
||||||
|
assertEquals(5, viewModel.state.value.tabs.size)
|
||||||
|
|
||||||
|
viewModel.toggleTab(tabs[5])
|
||||||
|
assertEquals(tabs.take(5), viewModel.state.value.tabs)
|
||||||
|
assertEquals(WorkbenchMessage.TABS_FULL, viewModel.state.value.message)
|
||||||
|
|
||||||
|
viewModel.toggleTab(tabs[0])
|
||||||
|
assertEquals(tabs.subList(1, 5), viewModel.state.value.tabs)
|
||||||
|
assertEquals(tabs.subList(1, 5).map(SavedCodeTab::encode), settings.tabs)
|
||||||
|
|
||||||
|
val reopened = CodeWorkbenchViewModel(FakeBrowser(), settings, defaultDispatcher = dispatcher)
|
||||||
|
assertEquals(tabs.subList(1, 5), reopened.state.value.tabs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tab survives encoding, and damaged entries are dropped`() {
|
||||||
|
val tab = tab("dir/a b.kt")
|
||||||
|
assertEquals(tab, SavedCodeTab.decodeOrNull(tab.encode()))
|
||||||
|
assertNull(SavedCodeTab.decodeOrNull("only\u001Ftwo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the pinned side decides which file is on the left`() = runTest(dispatcher.scheduler) {
|
||||||
|
val viewModel = CodeWorkbenchViewModel(FakeBrowser(), FakeSettings(), defaultDispatcher = dispatcher)
|
||||||
|
val mine = file("main", "a\nb")
|
||||||
|
val theirs = file("dev", "a\nc")
|
||||||
|
viewModel.pin(mine, CompareSide.RIGHT)
|
||||||
|
viewModel.compareWith(theirs)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val session = (viewModel.state.value.compare as CompareUiState.Ready).session
|
||||||
|
assertEquals("dev", session.left.refLabel)
|
||||||
|
assertEquals("main", session.right.refLabel)
|
||||||
|
assertEquals(listOf(1), session.differences)
|
||||||
|
assertNull(viewModel.state.value.pending)
|
||||||
|
|
||||||
|
viewModel.swapSides()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("main", (viewModel.state.value.compare as CompareUiState.Ready).session.left.refLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `comparing a file with itself is refused`() {
|
||||||
|
val viewModel = CodeWorkbenchViewModel(FakeBrowser(), FakeSettings(), defaultDispatcher = dispatcher)
|
||||||
|
viewModel.pin(file("main", "a"), CompareSide.LEFT)
|
||||||
|
viewModel.compareWith(file("main", "a"))
|
||||||
|
assertEquals(WorkbenchMessage.SAME_FILE, viewModel.state.value.message)
|
||||||
|
assertTrue(viewModel.state.value.compare is CompareUiState.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a commit's file is fetched at that commit and compared`() = runTest(dispatcher.scheduler) {
|
||||||
|
val browser = FakeBrowser()
|
||||||
|
val viewModel = CodeWorkbenchViewModel(browser, FakeSettings(), defaultDispatcher = dispatcher)
|
||||||
|
viewModel.pin(file("main", "x"), CompareSide.LEFT)
|
||||||
|
viewModel.useCommitFile(repository, "abcdef0123", "abcdef0", "src/F.kt", side = null)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(listOf("abcdef0123"), browser.refs)
|
||||||
|
val session = (viewModel.state.value.compare as CompareUiState.Ready).session
|
||||||
|
assertEquals("abcdef0", session.right.refLabel)
|
||||||
|
assertEquals(listOf("at abcdef0123"), session.rightLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a commit's file that cannot be fetched says so and keeps the pin`() = runTest(dispatcher.scheduler) {
|
||||||
|
val viewModel = CodeWorkbenchViewModel(FakeBrowser(failure = ApiFailure.NotFound()), FakeSettings(), defaultDispatcher = dispatcher)
|
||||||
|
viewModel.pin(file("main", "x"), CompareSide.LEFT)
|
||||||
|
viewModel.useCommitFile(repository, "abc", "abc", "src/F.kt", side = null)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(WorkbenchMessage.FILE_UNAVAILABLE, viewModel.state.value.message)
|
||||||
|
assertEquals("main", viewModel.state.value.pending?.file?.refLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tab(path: String) = SavedCodeTab("owner", "repo", "BRANCH", "main", path)
|
||||||
|
|
||||||
|
private fun file(ref: String, text: String) = ComparedFile(repository, ref, "src/F.kt", text)
|
||||||
|
|
||||||
|
private class FakeSettings : AppSettingsStore {
|
||||||
|
var tabs: List<String> = emptyList()
|
||||||
|
override fun isReactiveAuthRetryEnabled() = false
|
||||||
|
override fun setReactiveAuthRetryEnabled(enabled: Boolean) = Unit
|
||||||
|
override fun isFavoritePushNotificationsEnabled() = false
|
||||||
|
override fun setFavoritePushNotificationsEnabled(enabled: Boolean) = Unit
|
||||||
|
override fun savedCodeTabs(): List<String> = tabs
|
||||||
|
override fun setSavedCodeTabs(tabs: List<String>) {
|
||||||
|
this.tabs = tabs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeBrowser(private val failure: Throwable? = null) : GiteaRepositoryBrowser {
|
||||||
|
val refs = mutableListOf<String>()
|
||||||
|
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> = Page.of(emptyList(), null)
|
||||||
|
override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> = Page.of(emptyList(), null)
|
||||||
|
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> = Page.of(emptyList(), null)
|
||||||
|
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> =
|
||||||
|
Page.of(emptyList(), null)
|
||||||
|
override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile {
|
||||||
|
refs += ref.value
|
||||||
|
failure?.let { throw it }
|
||||||
|
return SourceFile(path, ref, SourceContent.from("at ${ref.value}".encodeToByteArray()), "text/plain")
|
||||||
|
}
|
||||||
|
override suspend fun listCommits(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath?, page: Int, pageSize: Int): Page<CommitSummary> =
|
||||||
|
Page.of(emptyList(), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.compare
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareRowKind.CHANGED
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareRowKind.LEFT_ONLY
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareRowKind.RIGHT_ONLY
|
||||||
|
import com.ubisam.ubgitea.feature.compare.CompareRowKind.SAME
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LineDiffTest {
|
||||||
|
@Test
|
||||||
|
fun identicalFilesAreAllSame() {
|
||||||
|
val lines = listOf("a", "b", "c")
|
||||||
|
assertEquals(
|
||||||
|
listOf(CompareRow(SAME, 0, 0), CompareRow(SAME, 1, 1), CompareRow(SAME, 2, 2)),
|
||||||
|
compareLines(lines, lines),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aChangedLineIsPairedWithItsReplacement() {
|
||||||
|
val rows = compareLines(listOf("a", "b", "c"), listOf("a", "x", "c"))
|
||||||
|
assertEquals(listOf(CompareRow(SAME, 0, 0), CompareRow(CHANGED, 1, 1), CompareRow(SAME, 2, 2)), rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInsertedLineLeavesABlankOnTheLeft() {
|
||||||
|
val rows = compareLines(listOf("a", "c"), listOf("a", "b", "c"))
|
||||||
|
assertEquals(listOf(CompareRow(SAME, 0, 0), CompareRow(RIGHT_ONLY, null, 1), CompareRow(SAME, 1, 2)), rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRemovedLineLeavesABlankOnTheRight() {
|
||||||
|
val rows = compareLines(listOf("a", "b", "c"), listOf("a", "c"))
|
||||||
|
assertEquals(listOf(CompareRow(SAME, 0, 0), CompareRow(LEFT_ONLY, 1, null), CompareRow(SAME, 2, 1)), rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sharedLinesInTheMiddleStayLevel() {
|
||||||
|
val left = listOf("1", "x", "keep", "y", "2")
|
||||||
|
val right = listOf("1", "keep", "z", "w", "2")
|
||||||
|
val rows = compareLines(left, right)
|
||||||
|
val keep = rows.single { it.kind == SAME && it.left == 2 }
|
||||||
|
assertEquals(1, keep.right)
|
||||||
|
assertEveryLineAppearsOnceInOrder(rows, left.size, right.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptySides() {
|
||||||
|
assertEquals(listOf(CompareRow(RIGHT_ONLY, null, 0)), compareLines(emptyList(), listOf("a")))
|
||||||
|
assertEquals(listOf(CompareRow(LEFT_ONLY, 0, null)), compareLines(listOf("a"), emptyList()))
|
||||||
|
assertEquals(emptyList<CompareRow>(), compareLines(emptyList(), emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tooManyEditsFallsBackToOneChangedBlock() {
|
||||||
|
val left = (0 until 50).map { "L$it" } + "end"
|
||||||
|
val right = (0 until 40).map { "R$it" } + "end"
|
||||||
|
val rows = compareLines(left, right, maxEdits = 10)
|
||||||
|
assertEquals(40, rows.count { it.kind == CHANGED })
|
||||||
|
assertEquals(10, rows.count { it.kind == LEFT_ONLY })
|
||||||
|
assertEquals(CompareRow(SAME, 50, 40), rows.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun randomEditsKeepEveryLineInOrder() {
|
||||||
|
val random = java.util.Random(7)
|
||||||
|
repeat(200) {
|
||||||
|
val left = List(random.nextInt(30)) { "l${random.nextInt(6)}" }
|
||||||
|
val right = List(random.nextInt(30)) { "l${random.nextInt(6)}" }
|
||||||
|
val rows = compareLines(left, right)
|
||||||
|
assertEveryLineAppearsOnceInOrder(rows, left.size, right.size)
|
||||||
|
rows.filter { it.kind == SAME }.forEach { assertEquals(left[it.left!!], right[it.right!!]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun differenceStartsMarksTheFirstRowOfEachRun() {
|
||||||
|
val rows = listOf(
|
||||||
|
CompareRow(SAME, 0, 0),
|
||||||
|
CompareRow(CHANGED, 1, 1),
|
||||||
|
CompareRow(LEFT_ONLY, 2, null),
|
||||||
|
CompareRow(SAME, 3, 2),
|
||||||
|
CompareRow(RIGHT_ONLY, null, 3),
|
||||||
|
)
|
||||||
|
assertEquals(listOf(1, 4), differenceStarts(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertEveryLineAppearsOnceInOrder(rows: List<CompareRow>, leftSize: Int, rightSize: Int) {
|
||||||
|
assertEquals((0 until leftSize).toList(), rows.mapNotNull { it.left })
|
||||||
|
assertEquals((0 until rightSize).toList(), rows.mapNotNull { it.right })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -172,6 +172,36 @@ class RepositoryListViewModelTest {
|
|||||||
assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests)
|
assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reselecting the tab goes from a repository to its list page and from there to the first page`() = runTest(dispatcher.scheduler) {
|
||||||
|
val browser = FakeBrowser(
|
||||||
|
repositories = listOf(
|
||||||
|
Page.of(listOf(repo("oldest", 1_000L), repo("middle", 2_000L)), nextPage = 2),
|
||||||
|
Page.of(listOf(repo("newest", 3_000L), repo("newer", 2_500L), repo("older", 1_500L), repo("old", 500L)), nextPage = null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(browser)
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.nextPage()
|
||||||
|
advanceUntilIdle()
|
||||||
|
val secondPage = viewModel.state.value as RepositoryListUiState.Content
|
||||||
|
viewModel.selectRepository(secondPage.repositories.single())
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.state.value is RepositoryListUiState.ReferencesContent)
|
||||||
|
|
||||||
|
viewModel.reselect()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(2, (viewModel.state.value as RepositoryListUiState.Content).page)
|
||||||
|
|
||||||
|
viewModel.reselect()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, (viewModel.state.value as RepositoryListUiState.Content).page)
|
||||||
|
|
||||||
|
viewModel.reselect()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, (viewModel.state.value as RepositoryListUiState.Content).page)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `refresh invalidates the globally sorted repository snapshot`() = runTest(dispatcher.scheduler) {
|
fun `refresh invalidates the globally sorted repository snapshot`() = runTest(dispatcher.scheduler) {
|
||||||
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
|
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
|
||||||
|
|||||||
@ -91,4 +91,17 @@ class RouteEncodingTest {
|
|||||||
|
|
||||||
assertEquals(stack, restored)
|
assertEquals(stack, restored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `branch detail route opened on a file round-trips`() {
|
||||||
|
val route = Route.BranchDetail(owner = "o", name = "r", refKind = "BRANCH", ref = "main", path = "src/a/Foo.cpp")
|
||||||
|
assertEquals(route, RouteEncoding.decode(RouteEncoding.encode(route)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a comparison is not restored`() {
|
||||||
|
val branch = Route.BranchDetail(owner = "o", name = "r", refKind = "BRANCH", ref = "main")
|
||||||
|
val saved = listOf(Route.Repositories, branch, Route.Compare).map(RouteEncoding::encode)
|
||||||
|
assertEquals(listOf(Route.Repositories, branch), RouteStackSaver.restore(saved))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1455,3 +1455,28 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
|||||||
leaves its rows unnumbered instead of numbering them from 1.
|
leaves its rows unnumbered instead of numbering them from 1.
|
||||||
- Verification: `compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug
|
- Verification: `compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug
|
||||||
lintDebug` pass; 440 unit tests, 0 failures, three of them new. Not yet confirmed on a device.
|
lintDebug` pass; 440 unit tests, 0 failures, three of them new. Not yet confirmed on a device.
|
||||||
|
|
||||||
|
## 2026-10-07 — TASK-065: code tabs, comparing two files, and reading aids
|
||||||
|
|
||||||
|
- Request: the human product owner asked for five things: up to five code files saved as tabs
|
||||||
|
above the bottom tabs; comparing a file with one from another commit or place, choosing which
|
||||||
|
side the current one goes on; a draggable side pane width on an unfolded Fold that snaps to its
|
||||||
|
default; every use of a tapped name marked in the code; and the Repositories tab, pressed again
|
||||||
|
inside a repository, going back to the list page it was opened from, then to the first page.
|
||||||
|
- What changed: see `tasks/065-code-tabs-compare-and-reading-aids.md`. A new
|
||||||
|
`CodeWorkbenchViewModel` holds the saved tabs (stored as locations, never content) and the
|
||||||
|
comparison; `Route.Compare` is not restored after the process is gone.
|
||||||
|
- Checks and results: the required checks pass; 461 unit tests, 0 failures; lint 0 errors.
|
||||||
|
- Live verification: none; no device was connected.
|
||||||
|
- Next owner: the product owner, to confirm on the device.
|
||||||
|
|
||||||
|
## 2026-10-07 (continued) — TASK-066: download page and update check
|
||||||
|
|
||||||
|
- Request: the human product owner asked for a password-protected web download and for the app
|
||||||
|
to offer the newest version.
|
||||||
|
- What changed: see `tasks/066-download-page-and-update-check.md`. Version 0.2.0
|
||||||
|
(versionCode 2) is published at `https://choimy0204.github.io/ubgitea/`.
|
||||||
|
- Checks and results: the required checks pass; 468 unit tests, 0 failures; lint 0 errors.
|
||||||
|
- Caution: the published APK is the debug build, signed with this PC's debug key. It installs
|
||||||
|
over copies built on this PC; a copy built elsewhere has to be removed first.
|
||||||
|
- Next owner: the product owner, to try the link and the install on the device.
|
||||||
|
|||||||
76
tasks/065-code-tabs-compare-and-reading-aids.md
Normal file
76
tasks/065-code-tabs-compare-and-reading-aids.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# TASK-065: Saved code tabs, comparing two files, and reading aids
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Owner: Claude
|
||||||
|
Reviewer: human product owner
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Requested by the human product owner, 2026-10-07:
|
||||||
|
|
||||||
|
1. Code files can be kept as tabs. Saving a tab puts a strip between the screen and the bottom
|
||||||
|
tabs; each saved file stays there, at most five, and tapping one goes back to it.
|
||||||
|
2. Two files can be compared side by side like a merge tool: the current file is placed on the
|
||||||
|
left or right, then another file (from another commit or another place) is opened and the
|
||||||
|
same button pressed again to compare the two.
|
||||||
|
3. On the unfolded screen the side pane beside the code can be resized by dragging; near its
|
||||||
|
default width it snaps to the default like a magnet.
|
||||||
|
4. Tapping a word in the code marks every occurrence of the same name.
|
||||||
|
5. Pressing the Repositories tab again while inside a repository returns to the repository list
|
||||||
|
page that was being viewed; pressing it again there goes to the first page.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Included: the five items above.
|
||||||
|
- Excluded: editing or merging files; comparing directories; three-way comparison.
|
||||||
|
|
||||||
|
## Writable paths
|
||||||
|
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/`
|
||||||
|
- `app/src/main/res/values/`
|
||||||
|
- `app/src/test/java/com/ubisam/ubgitea/`
|
||||||
|
- `tasks/`, `docs/`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] The code viewer has a "save tab" button; a saved file shows as a chip in a strip above the
|
||||||
|
bottom tabs (and above the viewer's toolbar when reading full screen). Tapping a chip opens
|
||||||
|
that file in its repository and ref; its close mark removes it. Tabs survive a restart.
|
||||||
|
- [ ] A sixth tab is refused with a message; nothing is evicted silently.
|
||||||
|
- [ ] The compare button asks whether the current file goes left or right, then a banner shows
|
||||||
|
the waiting file. Pressing compare on another file (in the code viewer or a commit's file
|
||||||
|
diff) opens a two-column view where matching lines stay level, differing lines are
|
||||||
|
coloured, and there are next/previous difference and swap-sides actions.
|
||||||
|
- [ ] The line comparison is a pure function with unit tests, and gives up gracefully on files
|
||||||
|
that differ too much.
|
||||||
|
- [ ] The side pane width is dragged with a handle, kept between launches, snaps to the default
|
||||||
|
within 16dp, and resets on double tap.
|
||||||
|
- [ ] Tapping an identifier highlights all whole-word occurrences in a colour distinct from
|
||||||
|
search hits; tapping it again or tapping a non-word clears it.
|
||||||
|
- [ ] Repositories tab reselect: inside a repository → the list page last viewed; on the list
|
||||||
|
with page > 1 → page 1. Other tabs behave as before.
|
||||||
|
- [ ] All new text is in string resources; new controls have spoken labels.
|
||||||
|
|
||||||
|
## Security and privacy
|
||||||
|
|
||||||
|
No additional concerns. Saved tabs hold only owner, repository name, ref and path; no tokens.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- 2026-10-07: the required checks pass; 461 unit tests, 0 failures; lint 0 errors.
|
||||||
|
- New tests: `LineDiffTest`, `CodeWorkbenchViewModelTest`, `openFile` in
|
||||||
|
`CodeBrowserViewModelTest`, `reselect` in `RepositoryListViewModelTest`, `snapTo` in
|
||||||
|
`SplitTest`, `wordAt` and `findWordOccurrences` in `CodeSearchTest`, and a file route and an
|
||||||
|
unrestored comparison in `RouteEncodingTest`.
|
||||||
|
- Live verification: none. No device was connected; the tab strip, the compare screen, the
|
||||||
|
drag handle's snap and the word marks were not seen on a screen by the implementer.
|
||||||
|
|
||||||
|
## Review findings
|
||||||
|
|
||||||
|
None yet.
|
||||||
51
tasks/066-download-page-and-update-check.md
Normal file
51
tasks/066-download-page-and-update-check.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# TASK-066: download page and in-app update check
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Owner: Claude
|
||||||
|
Reviewer: human product owner
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
The human product owner asked for a web link to download the app, protected by a download
|
||||||
|
password they chose, and for the app to offer the newest version when it is not on it. They chose
|
||||||
|
to host it in a folder of the existing `choimy0204.github.io` site.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `https://choimy0204.github.io/ubgitea/` serves a page that asks for the password and only then
|
||||||
|
gives the APK. The APK on the host is encrypted (AES-256-GCM, key from PBKDF2-SHA256), so its
|
||||||
|
address alone gives nothing. The password is not in this repository.
|
||||||
|
- `version.json` beside it names the newest versionCode, versionName and notes.
|
||||||
|
- The app reads it once at launch; when it is newer than the installed build, a dialog offers the
|
||||||
|
download page. Settings shows the installed version, a check button, and the download page when
|
||||||
|
something newer is out. No Gitea token is sent to that host.
|
||||||
|
- `tools/publish-download.py` builds the page from an APK; versionCode is raised for every build
|
||||||
|
published.
|
||||||
|
|
||||||
|
## Writable paths
|
||||||
|
|
||||||
|
`app/build.gradle.kts`, `app/src/main/java/com/ubisam/ubgitea/core/update/`, `MainActivity.kt`,
|
||||||
|
`feature/settings/SettingsScreen.kt`, `res/values/strings_update.xml`, `app/src/test/.../core/update/`,
|
||||||
|
`tools/publish-download.py`, `docs/development-log.md`, this file.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- 2026-10-07: the required checks pass; 468 unit tests, 0 failures; lint 0 errors.
|
||||||
|
- The page's decryption was run with Node's WebCrypto (the browser's API): the right password
|
||||||
|
gives a file byte-identical to `app-debug.apk`, a wrong one is refused.
|
||||||
|
- Published version 0.2.0 (versionCode 2), the debug build, to `choimy0204.github.io/ubgitea/`.
|
||||||
|
- Not seen: the page opened on a phone, the install over an existing copy, the in-app dialog.
|
||||||
|
|
||||||
|
## Review findings
|
||||||
|
|
||||||
|
- 2026-10-07, product owner: a wrong password still downloaded. In a desktop browser a wrong
|
||||||
|
password got nothing, but the page fetched the encrypted APK before checking the password,
|
||||||
|
and a phone browser can keep that fetch as a download (still encrypted, not installable). The
|
||||||
|
page now opens a small sealed check with the password first and fetches nothing when it is
|
||||||
|
wrong; tried in the browser: a wrong password made no request, the right one downloaded.
|
||||||
187
tools/publish-download.py
Normal file
187
tools/publish-download.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"""Builds the password-protected download page for one APK (TASK-066).
|
||||||
|
|
||||||
|
The APK is encrypted with AES-256-GCM under a key derived from the password with PBKDF2-SHA256,
|
||||||
|
so the files can sit on a public static host: the page asks for the password and decrypts in the
|
||||||
|
browser with WebCrypto. version.json beside it is plain; the app reads it to know a newer build
|
||||||
|
is out.
|
||||||
|
|
||||||
|
python tools/publish-download.py <apk> <out-dir> --notes "what changed"
|
||||||
|
|
||||||
|
The password is read from UBGITEA_DOWNLOAD_PASSWORD, or asked for. It is never written anywhere.
|
||||||
|
versionCode and versionName are read from app/build.gradle.kts.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import getpass
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
ITERATIONS = 310_000
|
||||||
|
ENCRYPTED_NAME = "ubgitea.apk.enc"
|
||||||
|
CHECK_TEXT = b"UBGitea"
|
||||||
|
|
||||||
|
|
||||||
|
def gradle_version(root: Path):
|
||||||
|
text = (root / "app" / "build.gradle.kts").read_text(encoding="utf-8")
|
||||||
|
code = re.search(r"versionCode\s*=\s*(\d+)", text)
|
||||||
|
name = re.search(r'versionName\s*=\s*"([^"]+)"', text)
|
||||||
|
if not code or not name:
|
||||||
|
sys.exit("versionCode / versionName not found in app/build.gradle.kts")
|
||||||
|
return int(code.group(1)), name.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("apk", type=Path)
|
||||||
|
parser.add_argument("out", type=Path)
|
||||||
|
parser.add_argument("--notes", default="")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
password = os.environ.get("UBGITEA_DOWNLOAD_PASSWORD") or getpass.getpass("Download password: ")
|
||||||
|
if not password:
|
||||||
|
sys.exit("A password is required.")
|
||||||
|
code, name = gradle_version(Path(__file__).resolve().parent.parent)
|
||||||
|
|
||||||
|
data = args.apk.read_bytes()
|
||||||
|
salt = os.urandom(16)
|
||||||
|
iv = os.urandom(12)
|
||||||
|
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, ITERATIONS, 32)
|
||||||
|
sealed = AESGCM(key).encrypt(iv, data, None)
|
||||||
|
# A few sealed bytes the page opens first: a wrong password is refused before the APK is
|
||||||
|
# fetched at all, so nothing is downloaded with it.
|
||||||
|
check_iv = os.urandom(12)
|
||||||
|
check = AESGCM(key).encrypt(check_iv, CHECK_TEXT, None)
|
||||||
|
|
||||||
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
(args.out / ENCRYPTED_NAME).write_bytes(sealed)
|
||||||
|
release = {"versionCode": code, "versionName": name, "notes": args.notes}
|
||||||
|
(args.out / "version.json").write_text(json.dumps(release, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
meta = {
|
||||||
|
"salt": base64.b64encode(salt).decode(),
|
||||||
|
"iv": base64.b64encode(iv).decode(),
|
||||||
|
"iterations": ITERATIONS,
|
||||||
|
"check": base64.b64encode(check).decode(),
|
||||||
|
"checkIv": base64.b64encode(check_iv).decode(),
|
||||||
|
"file": ENCRYPTED_NAME,
|
||||||
|
"fileName": f"UBGitea-{name}.apk",
|
||||||
|
"size": len(data),
|
||||||
|
}
|
||||||
|
page = PAGE.replace("{{META}}", json.dumps(meta)) \
|
||||||
|
.replace("{{VERSION}}", html.escape(name)) \
|
||||||
|
.replace("{{NOTES}}", html.escape(args.notes)) \
|
||||||
|
.replace("{{SIZE}}", f"{len(data) / 1024 / 1024:.1f} MB")
|
||||||
|
(args.out / "index.html").write_text(page, encoding="utf-8")
|
||||||
|
print(f"UBGitea {name} (versionCode {code}) -> {args.out}")
|
||||||
|
|
||||||
|
|
||||||
|
PAGE = """<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>UBGitea 다운로드</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #f4f7fc; --card: #ffffff; --ink: #14213d; --muted: #5b6b86; --line: #d8e1f0;
|
||||||
|
--blue: #1f5fd6; --blue-ink: #ffffff; --bad: #c62828; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { --bg: #0d1524; --card: #16213a; --ink: #e8eefb; --muted: #9fb0cf; --line: #2a3a5c;
|
||||||
|
--blue: #6d9cff; --blue-ink: #0d1524; --bad: #ff8a80; }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||||
|
main { width: 100%; max-width: 420px; background: var(--card); border: 1px solid var(--line);
|
||||||
|
border-radius: 20px; padding: 28px 24px; }
|
||||||
|
h1 { margin: 0 0 4px; font-size: 24px; }
|
||||||
|
.version { color: var(--blue); font-weight: 600; }
|
||||||
|
p { color: var(--muted); line-height: 1.5; margin: 8px 0; }
|
||||||
|
.notes { white-space: pre-line; color: var(--ink); background: var(--bg); border-radius: 12px;
|
||||||
|
padding: 12px 14px; margin: 16px 0; }
|
||||||
|
.notes:empty { display: none; }
|
||||||
|
label { display: block; font-weight: 600; margin: 20px 0 8px; }
|
||||||
|
input { width: 100%; font-size: 17px; padding: 14px; border-radius: 12px; border: 1px solid var(--line);
|
||||||
|
background: var(--card); color: var(--ink); }
|
||||||
|
button { width: 100%; margin-top: 12px; font-size: 17px; font-weight: 600; padding: 14px; border: 0;
|
||||||
|
border-radius: 12px; background: var(--blue); color: var(--blue-ink); cursor: pointer; }
|
||||||
|
button:disabled { opacity: .6; cursor: default; }
|
||||||
|
#status { min-height: 1.5em; margin-top: 12px; }
|
||||||
|
#status.bad { color: var(--bad); }
|
||||||
|
ol { color: var(--muted); padding-left: 20px; line-height: 1.6; margin: 20px 0 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>UBGitea</h1>
|
||||||
|
<div class="version">버전 {{VERSION}} · {{SIZE}}</div>
|
||||||
|
<div class="notes">{{NOTES}}</div>
|
||||||
|
<form id="form">
|
||||||
|
<label for="password">다운로드 비밀번호</label>
|
||||||
|
<input id="password" type="password" autocomplete="off" required>
|
||||||
|
<button id="go" type="submit">APK 다운로드</button>
|
||||||
|
</form>
|
||||||
|
<p id="status" role="status" aria-live="polite"></p>
|
||||||
|
<ol>
|
||||||
|
<li>받은 APK 파일을 엽니다.</li>
|
||||||
|
<li>"출처를 알 수 없는 앱" 설치를 이 브라우저에 허용합니다.</li>
|
||||||
|
<li>설치(업데이트)를 누릅니다. 로그인과 설정은 그대로 남습니다.</li>
|
||||||
|
</ol>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const META = {{META}};
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
const go = document.getElementById("go");
|
||||||
|
const b64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
||||||
|
function say(text, bad) { status.textContent = text; status.className = bad ? "bad" : ""; }
|
||||||
|
document.getElementById("form").addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
go.disabled = true;
|
||||||
|
try {
|
||||||
|
say("비밀번호 확인 중…");
|
||||||
|
const password = new TextEncoder().encode(document.getElementById("password").value);
|
||||||
|
const base = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveKey"]);
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{ name: "PBKDF2", salt: b64(META.salt), iterations: META.iterations, hash: "SHA-256" },
|
||||||
|
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
|
||||||
|
try {
|
||||||
|
await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.checkIv) }, key, b64(META.check));
|
||||||
|
} catch (e) {
|
||||||
|
say("비밀번호가 맞지 않습니다.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
say("파일을 받는 중…");
|
||||||
|
const response = await fetch(META.file, { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error("fetch");
|
||||||
|
const sealed = await response.arrayBuffer();
|
||||||
|
say("여는 중…");
|
||||||
|
const apk = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.iv) }, key, sealed);
|
||||||
|
const url = URL.createObjectURL(new Blob([apk], { type: "application/vnd.android.package-archive" }));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = META.fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
say("다운로드를 시작했습니다. 받은 파일을 열어 설치하세요.");
|
||||||
|
} catch (e) {
|
||||||
|
say("파일을 받지 못했습니다. 잠시 뒤 다시 시도하세요.", true);
|
||||||
|
} finally {
|
||||||
|
go.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user