diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b40ff14..976e52a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,10 @@ android { applicationId = "com.ubisam.ubgitea" minSdk = 28 targetSdk = 36 - versionCode = 1 - versionName = "0.1.0" + // Raised for every build published to the download page (TASK-066), or the app will not + // see it as newer. + versionCode = 2 + versionName = "0.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index af5c207..8082ad1 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -7,6 +7,7 @@ import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.BackHandler @@ -18,15 +19,23 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier import com.ubisam.ubgitea.core.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.domain.model.RepositoryCoordinates 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.CodeReferenceKind 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.HistoryViewModel import com.ubisam.ubgitea.feature.home.HomeDestination @@ -87,6 +106,12 @@ class MainActivity : ComponentActivity() { private val workViewModel: WorkViewModel by viewModels { 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 // 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 homeState by homeViewModel.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) { mutableStateOf(listOf(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(1) } LaunchedEffect(currentRoute, sessionReady) { + val previous = lastRoute[0] + lastRoute[0] = currentRoute if (!sessionReady) return@LaunchedEffect + if (previous == Route.Compare && currentRoute is Route.BranchDetail) return@LaunchedEffect if (currentRoute.isTab) notificationsViewModel.refreshUnreadCount() when (val route = currentRoute) { Route.Home -> homeViewModel.open() @@ -157,7 +191,12 @@ class MainActivity : ComponentActivity() { is Route.BranchDetail -> { val repository = RepositoryCoordinates.of(route.owner, route.name) val ref = RepositoryRef.of(route.ref) - codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind))) + 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)) } is Route.WorkItems -> { @@ -170,9 +209,32 @@ class MainActivity : ComponentActivity() { } Route.Notifications -> notificationsViewModel.open() 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() { codeBrowserViewModel.close() historyViewModel.close() @@ -197,10 +259,61 @@ class MainActivity : ComponentActivity() { val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) val showNavigation = sessionReady && when (currentRoute) { is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState) - Route.Settings -> false + Route.Settings, Route.Compare -> false 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) { ThemeMode.SYSTEM -> isSystemInDarkTheme() ThemeMode.LIGHT -> false @@ -219,17 +332,44 @@ class MainActivity : ComponentActivity() { } UBGiteaTheme(darkTheme = darkTheme) { + CompositionLocalProvider(LocalCodeWorkbench provides workbench) { AppShell( selectedTab = currentTab, showNavigation = showNavigation, unreadNotifications = unreadNotifications, 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) { codeBrowserViewModel.close() historyViewModel.close() } if (currentRoute is Route.WorkItems) workItemsViewModel.close() - selectTab(tab) + when { + !reselected -> selectTab(tab) + currentRoute == Route.Repositories -> viewModel.reselect() + else -> { + if (currentRoute == Route.Compare) workbenchViewModel.closeCompare() + 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 -> // Back on any tab but Home goes Home; back on Home leaves the app. @@ -337,6 +477,9 @@ class MainActivity : ComponentActivity() { onSetThemeMode = settingsViewModel::setThemeMode, onLogout = ::signOut, onBack = { pop() }, + update = updateState, + onCheckUpdate = updateViewModel::checkNow, + onOpenDownloadPage = ::openDownloadPage, ) Route.Notifications -> NotificationsScreen( state = notificationsState, @@ -419,11 +562,37 @@ class MainActivity : ComponentActivity() { onAuthenticationExpired = ::signOut, ) Route.Repositories -> repositoryList() + Route.Compare -> CompareScreen( + state = workbenchState.compare, + fontSizeSp = settingsState.codeFontSizeSp, + onSwap = workbenchViewModel::swapSides, + onClose = workbenchViewModel::closeCompare, + ) } } + } LaunchedEffect(viewModel) { 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) @@ -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?) { val callback = intent?.dataString ?: return setIntent(Intent(intent).setData(null)) diff --git a/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt b/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt index cfb9ced..22bcce6 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt @@ -1,6 +1,8 @@ package com.ubisam.ubgitea.core.settings 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 @@ -42,6 +44,16 @@ interface AppSettingsStore { fun isOutlineAboveFolders(): Boolean = false 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 = emptyList() + fun setSavedCodeTabs(tabs: List) = Unit + companion object { const val DEFAULT_CODE_FONT_SIZE_SP = 13 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() } + 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 { + 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) { + preferences.edit().putString(KEY_SAVED_CODE_TABS, JSONArray(tabs).toString()).apply() + } + 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_HOME_SOURCE = "home_source" const val KEY_THEME_MODE = "theme_mode" diff --git a/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt new file mode 100644 index 0000000..4cacbb5 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt @@ -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() +} diff --git a/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt new file mode 100644 index 0000000..a00178f --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt @@ -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 = 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 create(modelClass: Class): T { + require(modelClass.isAssignableFrom(AppUpdateViewModel::class.java)) + return AppUpdateViewModel(HttpAppReleaseSource(), BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) as T + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt index 990b126..0b0c19b 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt @@ -24,6 +24,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -130,8 +132,46 @@ class CodeBrowserViewModel( private var counterpartOperation: Job? = null private val folderOperations = mutableMapOf() + // The last few files shown, so going back and forth between saved tabs does not fetch each again. + private val recentFiles = object : LinkedHashMap, CodeBrowserUiState.File>(8, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry, CodeBrowserUiState.File>) = + size > RECENT_FILES + } + 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) { val target = currentTarget() ?: return when (entry.type) { @@ -295,23 +335,46 @@ class CodeBrowserViewModel( val file = browser.getFile(target.repository, target.ref, path) classifier.classify(file.content, path) } - val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path) - mutableState.value = CodeBrowserUiState.File( - target = target, - path = path, - content = classification, - siblingImagePaths = siblingFiles.filter { it.hasImageExtension() }, - siblingEntries = siblingEntries, - counterpartPath = counterpartOf(path, siblingFiles), - focusSymbol = focusSymbol, - // A folder still being fetched would never hear back in the new state. - tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries), - ) + 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, + focusSymbol: String? = null, + tree: FileTree? = null, + ) { + val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path) + val state = CodeBrowserUiState.File( + target = target, + path = path, + content = classification, + siblingImagePaths = siblingFiles.filter { it.hasImageExtension() }, + siblingEntries = siblingEntries, + counterpartPath = counterpartOf(path, siblingFiles), + focusSymbol = focusSymbol, + // A folder still being fetched would never hear back in the new state. + tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries), + ) + mutableState.value = state + 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 = 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( target: CodeBrowserTarget, path: RepositoryPath, @@ -374,6 +437,7 @@ class CodeBrowserViewModel( private companion object { const val PAGE_SIZE = 100 const val MAX_PAGES = 100 + const val RECENT_FILES = 6 val LISTING_ORDER = compareBy { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() } } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt index fa15da7..e9c64c2 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt @@ -1,6 +1,9 @@ package com.ubisam.ubgitea.feature.codebrowser 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.rememberScrollableState import androidx.compose.foundation.gestures.scrollable @@ -33,7 +36,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset 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.platform.LocalDensity 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.style.TextDecoration import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString 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.rememberCodeSyntaxColors import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath +import com.ubisam.ubgitea.feature.codebrowser.content.wordAt import com.ubisam.ubgitea.ui.theme.UBGiteaTheme import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers @@ -139,9 +147,14 @@ fun CodePane( contentPadding: PaddingValues = PaddingValues(vertical = 8.dp), // When given, the name of a function on the line that declares it is a link (TASK-060). 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 tapped by rememberUpdatedState(onFunctionTapped) + val wordTapped by rememberUpdatedState(onWordTapped) val linked = onFunctionTapped != null val functionsByLine = remember(document.symbols, linked) { 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 focusColor = MaterialTheme.colorScheme.primaryContainer val hit = searchHitColors() + val wordHit = wordHitColors() var viewportWidthPx by remember { mutableIntStateOf(0) } val gutterWidthPx = with(density) { gutterWidth.toPx() } @@ -179,8 +193,8 @@ fun CodePane( items(document.lines.size, key = { it }) { index -> val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) } val function = functionsByLine[index] - val shown = remember(line, search.byLine[index], search.current, function) { - val marked = withSearchHits(line, search.byLine[index], search.current, hit) + val shown = remember(line, search.byLine[index], search.current, words.byLine[index], words.current, function) { + 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]) } if (function == null || name == null || name.last >= marked.length) { marked @@ -199,6 +213,17 @@ fun CodePane( } } } + val layout = remember { arrayOfNulls(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( Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent), verticalAlignment = Alignment.Top, @@ -220,7 +245,8 @@ fun CodePane( shown, style = style.copy(textIndent = TextIndent(restLine = hanging)), 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 { Box(Modifier.weight(1f).clipToBounds().scrollable(sideways, Orientation.Horizontal)) { @@ -229,9 +255,11 @@ fun CodePane( style = style, softWrap = false, maxLines = 1, + onTextLayout = { layout[0] = it }, modifier = Modifier .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( line: AnnotatedString, matches: List?, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt index c022267..3a3bea8 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt @@ -53,7 +53,8 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.VerticalDivider +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberModalBottomSheetState 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.heading 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.stateDescription 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.shell.WideLayoutBreakpoint 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.filterNotNull import kotlinx.coroutines.launch @@ -202,6 +215,19 @@ fun CodeViewerScreen( var syncPanes by rememberSaveable { mutableStateOf(true) } // The share of the width the left pane of a header/source pair takes. 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(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) // The sheet is shown from outside the layout below, where the jump is worked out. var jumpForSheet by remember { mutableStateOf(null) } @@ -212,6 +238,28 @@ fun CodeViewerScreen( 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 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) { focusedLine = line @@ -224,6 +272,12 @@ fun CodeViewerScreen( val match = otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) } ?: return 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) { if (matches.isEmpty()) return currentMatch = index.mod(matches.size) @@ -245,6 +299,7 @@ fun CodeViewerScreen( when { drawerState.isOpen -> scope.launch { drawerState.close() } outlineOpen -> outlineOpen = false + selectedWord != null -> selectedWord = null searching -> { searching = false query = "" @@ -259,7 +314,9 @@ fun CodeViewerScreen( maxWidth >= WideLayoutBreakpoint -> ViewerLayout.WIDE 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 showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide if (showsPair) LaunchedEffect(state.path, counterpartPath) { onLoadCounterpart() } @@ -302,6 +359,7 @@ fun CodeViewerScreen( onToggleSideBySide = { sideBySide = !sideBySide }, onToggleSync = { syncPanes = !syncPanes }, text = text.renderedText, + workbench = benchTools, ) val location = stringResource( R.string.codeviewer_location, @@ -397,6 +455,9 @@ fun CodeViewerScreen( counterpartPath = counterpartPath!!, splitFraction = splitFraction, onSplitChange = { splitFraction = it }, + words = words, + otherWords = otherWords, + onWordTapped = onWordTapped, // Beside each other, a function tapped in one pane is shown in the other. onFunctionTapped = { symbol -> otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) }?.let { match -> @@ -424,7 +485,13 @@ fun CodeViewerScreen( onSymbolSelected = { goToSymbol(it) }, 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()) { CodePane( @@ -436,8 +503,10 @@ fun CodeViewerScreen( focusedLine = focusedLine, contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp), 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 } if (method != null) { JumpToCounterpart( @@ -455,6 +524,16 @@ fun CodeViewerScreen( if (!sidePaneIsShown) { 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) } @@ -497,6 +576,17 @@ private class ViewerTools( val onToggleSideBySide: () -> Unit, val onToggleSync: () -> Unit, 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 @@ -521,6 +611,16 @@ private fun Toolbar(tools: ViewerTools) { CopyButton(tools.text, Modifier.weight(1f)) { 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) { ToolButton( 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), ) { Box( - Modifier.size(width = 52.dp, height = 30.dp) + Modifier.widthIn(max = 52.dp).fillMaxWidth().height(30.dp) .clip(CircleShape) .background(if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface), contentAlignment = Alignment.Center, @@ -651,6 +751,24 @@ private fun LandscapeBar(document: CodeDocument, currentLine: Int, onBack: () -> FontSizeButton(tools) { onOpen -> 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) } @@ -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 swipe from the edge is not available to everyone. @@ -889,6 +1040,9 @@ private fun PairPanes( counterpartPath: RepositoryPath, splitFraction: Float, onSplitChange: (Float) -> Unit, + words: SearchHighlight, + otherWords: SearchHighlight, + onWordTapped: (String?) -> Unit, onFunctionTapped: (CodeSymbol) -> Unit, onOtherFunctionTapped: (CodeSymbol) -> Unit, ) { @@ -903,6 +1057,7 @@ private fun PairPanes( CodePane( document, listState, preferences.wordWrap, preferences.fontSizeSp, search = highlight, focusedLine = focusedLine, onFunctionTapped = onFunctionTapped, + words = words, onWordTapped = onWordTapped, ) } } @@ -912,7 +1067,10 @@ private fun PairPanes( when { otherDocument != null -> { 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 -> { PaneHeader(name, counterpartPath, null, emptyList()) @@ -927,12 +1085,20 @@ private fun PairPanes( } BoxWithConstraints(Modifier.fillMaxSize()) { val widthPx = with(LocalDensity.current) { maxWidth.toPx() } - // The latest value, for a drag that outlives the composition it started in. - val fraction by rememberUpdatedState(splitFraction) + val snapPx = with(LocalDensity.current) { SnapDistance.toPx() } + 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()) { (if (currentIsHeader) current else other)(Modifier.weight(splitFraction).fillMaxHeight()) 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) }, ) (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. */ 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. */ @Composable -private fun SplitHandle(onDrag: (Float) -> Unit, onReset: () -> Unit) { - val description = stringResource(R.string.codeviewer_split_handle) +private fun SplitHandle( + 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( Modifier.width(SplitHandleWidth) .fillMaxHeight() .background(MaterialTheme.colorScheme.surface) .semantics { contentDescription = description } .pointerInput(Unit) { - detectHorizontalDragGestures { change, dragAmount -> + detectHorizontalDragGestures( + onDragStart = { start() }, + onDragEnd = { end() }, + onDragCancel = { end() }, + ) { change, dragAmount -> change.consume() - onDrag(dragAmount) + drag(dragAmount) } } - .pointerInput(Unit) { detectTapGestures(onDoubleTap = { onReset() }) }, + .pointerInput(Unit) { detectTapGestures(onDoubleTap = { reset() }) }, contentAlignment = Alignment.Center, ) { 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 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 val MaxDrawerWidth = 320.dp private val SplitHandleWidth = 24.dp diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearch.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearch.kt index 7c819a5..38017e6 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearch.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearch.kt @@ -58,3 +58,43 @@ fun counterpartOf(path: RepositoryPath, siblings: List): Reposit } 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, word: String): List { + if (word.isEmpty()) return emptyList() + val occurrences = mutableListOf() + 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() diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/compare/CodeWorkbench.kt b/app/src/main/java/com/ubisam/ubgitea/feature/compare/CodeWorkbench.kt new file mode 100644 index 0000000..095abec --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/compare/CodeWorkbench.kt @@ -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, + 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 { 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, + 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) + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/compare/CodeWorkbenchViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/compare/CodeWorkbenchViewModel.kt new file mode 100644 index 0000000..45d75fa --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/compare/CodeWorkbenchViewModel.kt @@ -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, + val rightLines: List, + val rows: List, +) { + val differences: List = 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 = 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 = 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) { + mutableState.update { it.copy(tabs = tabs) } + settings.setSavedCodeTabs(tabs.map(SavedCodeTab::encode)) + } + + class Factory(private val application: Application) : ViewModelProvider.Factory { + override fun create(modelClass: Class): 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 = split("\n").map { it.removeSuffix("\r") } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/compare/CompareScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/compare/CompareScreen.kt new file mode 100644 index 0000000..eef43ea --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/compare/CompareScreen.kt @@ -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 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/compare/LineDiff.kt b/app/src/main/java/com/ubisam/ubgitea/feature/compare/LineDiff.kt new file mode 100644 index 0000000..a72d306 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/compare/LineDiff.kt @@ -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, right: List, maxEdits: Int = DEFAULT_MAX_EDITS): List { + 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(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): List = + rows.indices.filter { rows[it].kind != CompareRowKind.SAME && (it == 0 || rows[it - 1].kind == CompareRowKind.SAME) } + +private fun addBlock(rows: MutableList, 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, b: List, maxEdits: Int): List> { + 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() + 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>() + 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 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt index 4edaec5..da22317 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/history/FileDiffScreen.kt @@ -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.rememberCodeSyntaxColors 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.CenteredMessage import com.ubisam.ubgitea.ui.components.ScreenTopBar @@ -125,11 +127,28 @@ fun FileDiffScreen( 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( canJump = changeStarts.isNotEmpty(), onPreviousChange = { jump(-1) }, onNextChange = { jump(1) }, 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 -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) Surface(color = MaterialTheme.colorScheme.surface) { 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)) { 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) + compare?.invoke(Modifier.weight(1f)) DiffTool(R.drawable.ic_ub_files, R.string.history_diff_file_list, true, Modifier.weight(1f), onFileList) } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt index 501d9ad..1e7430b 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt @@ -431,6 +431,25 @@ class RepositoryListViewModel( 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() { startOperation { pendingAuthStore.clear() diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt index ca16d89..72382af 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -24,12 +25,17 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.FontWeight import androidx.compose.ui.unit.dp import com.ubisam.ubgitea.R import com.ubisam.ubgitea.core.settings.AppSettingsStore 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.ListCard import com.ubisam.ubgitea.ui.components.ScreenTopBar @@ -49,6 +55,9 @@ fun SettingsScreen( onLogout: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, + update: AppUpdateUiState? = null, + onCheckUpdate: () -> Unit = {}, + onOpenDownloadPage: () -> Unit = {}, ) { BackHandler(onBack = onBack) Column(modifier.fillMaxSize()) { @@ -152,11 +161,53 @@ fun SettingsScreen( 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)) } } } +@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 get() = when (this) { ThemeMode.SYSTEM -> R.string.settings_theme_system diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt b/app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt index 5f70c77..01da2fd 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt @@ -18,7 +18,14 @@ sealed interface Route { // 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 // 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. data class WorkItems( val owner: String, @@ -28,6 +35,10 @@ sealed interface Route { ) : 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 get() = this is Home || this is Repositories || this is Work || this is Notifications } diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/navigation/RouteStackSaver.kt b/app/src/main/java/com/ubisam/ubgitea/ui/navigation/RouteStackSaver.kt index 93fc629..4e9ef36 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/navigation/RouteStackSaver.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/navigation/RouteStackSaver.kt @@ -30,12 +30,17 @@ internal object RouteEncoding { private const val TYPE_WORK_ITEMS = "workItems" private const val TYPE_NOTIFICATIONS = "notifications" private const val TYPE_SETTINGS = "settings" + private const val TYPE_COMPARE = "compare" fun encode(route: Route): String = when (route) { Route.Home -> TYPE_HOME Route.Repositories -> TYPE_REPOSITORIES 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) { join(TYPE_WORK_ITEMS, route.owner, route.name) } else { @@ -49,6 +54,7 @@ internal object RouteEncoding { } Route.Notifications -> TYPE_NOTIFICATIONS Route.Settings -> TYPE_SETTINGS + Route.Compare -> TYPE_COMPARE } fun decode(encoded: String): Route = decodeOrNull(encoded) ?: Route.Home @@ -56,8 +62,8 @@ internal object RouteEncoding { fun decodeOrNull(encoded: String): Route? { val parts = encoded.split(FIELD_SEPARATOR) return when (parts.first()) { - TYPE_BRANCH_DETAIL -> parts.takeIf { it.size == 5 }?.let { - Route.BranchDetail(owner = it[1], name = it[2], refKind = it[3], ref = it[4]) + 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], path = it.getOrNull(5)) } TYPE_WORK_ITEMS -> when (parts.size) { 3 -> Route.WorkItems(owner = parts[1], name = parts[2]) @@ -75,6 +81,8 @@ internal object RouteEncoding { TYPE_NOTIFICATIONS -> Route.Notifications TYPE_SETTINGS -> Route.Settings TYPE_REPOSITORIES -> Route.Repositories + // The files being compared are not saved with the route. + TYPE_COMPARE -> null else -> null } } diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt b/app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt index a7fa071..4cd7b28 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt @@ -4,6 +4,10 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.layout.Box 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.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight @@ -55,6 +59,9 @@ fun AppShell( unreadNotifications: Int?, onTabSelected: (AppTab) -> Unit, 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, ) { BoxWithConstraints(modifier.fillMaxSize()) { @@ -62,7 +69,14 @@ fun AppShell( Scaffold( containerColor = MaterialTheme.colorScheme.background, 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 -> Row(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding)) { diff --git a/app/src/main/res/drawable/ic_ub_bookmark.xml b/app/src/main/res/drawable/ic_ub_bookmark.xml new file mode 100644 index 0000000..c67c7b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_ub_bookmark.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_ub_bookmark_filled.xml b/app/src/main/res/drawable/ic_ub_bookmark_filled.xml new file mode 100644 index 0000000..1bf42d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_ub_bookmark_filled.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_ub_compare.xml b/app/src/main/res/drawable/ic_ub_compare.xml new file mode 100644 index 0000000..a21275f --- /dev/null +++ b/app/src/main/res/drawable/ic_ub_compare.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/values/strings_compare.xml b/app/src/main/res/values/strings_compare.xml new file mode 100644 index 0000000..d775f09 --- /dev/null +++ b/app/src/main/res/values/strings_compare.xml @@ -0,0 +1,42 @@ + + + + 저장한 탭 + %1$s 열기 · %2$s + %1$s 탭 닫기 + 지금 보는 파일 + 탭은 %1$d개까지 저장할 수 있습니다. 하나를 닫고 다시 저장하세요. + 탭 저장 + 비교 + + + 이 파일을 왼쪽에 두기 + 이 파일을 오른쪽에 두기 + %1$s와 비교하기 + 비교 대기 취소 + 비교 대기 · %1$s (%2$s) + 다른 파일에서 비교를 누르세요 + 왼쪽 + 오른쪽 + 커밋의 파일을 불러오는 중입니다. + 이 커밋의 파일을 불러오지 못했습니다. 삭제된 파일은 비교할 수 없습니다. + 텍스트 파일만 비교할 수 있습니다. + 같은 파일입니다. 다른 커밋이나 다른 파일을 고르세요. + 두 파일을 맞춰 보는 중입니다. + 다른 곳 %1$d군데 + 두 파일이 같습니다. + 이전 차이 + 다음 차이 + 좌우 바꾸기 + 파일이 길어 앞부분만 비교합니다. + %1$s · %2$s + + + %1$s · %2$d곳 + 이전 위치 + 다음 위치 + 표시 지우기 + + + 옆 창 너비 조절. 좌우로 끌어 조절하고, 두 번 누르면 기본 너비로 되돌립니다. + diff --git a/app/src/main/res/values/strings_update.xml b/app/src/main/res/values/strings_update.xml new file mode 100644 index 0000000..db47c3a --- /dev/null +++ b/app/src/main/res/values/strings_update.xml @@ -0,0 +1,15 @@ + + + 앱 정보 + 현재 버전 %1$s + 업데이트 확인 + 확인하는 중… + 최신 버전입니다. + 새 버전을 확인하지 못했습니다. 네트워크를 확인하세요. + 새 버전 %1$s을(를) 받을 수 있습니다. + 다운로드 페이지 열기 + 새 버전이 있습니다 + 현재 %1$s → 새 버전 %2$s\n다운로드 페이지에서 비밀번호를 입력해 받은 뒤 설치하세요. + 다운로드 + 나중에 + diff --git a/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt b/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt new file mode 100644 index 0000000..61573bb --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt @@ -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("404")) + 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) + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModelTest.kt index 86d8e2c..fa4cfcc 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModelTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModelTest.kt @@ -178,6 +178,25 @@ class CodeBrowserViewModelTest { 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 val rootEntryType: TreeEntryType = TreeEntryType.DIRECTORY, var fileFailure: Throwable? = null, diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/SplitTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/SplitTest.kt index e85ad8a..06a1e4d 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/SplitTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/SplitTest.kt @@ -15,4 +15,16 @@ class SplitTest { assertEquals(0.8f, clampSplit(0.97f), 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) + } } diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearchTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearchTest.kt index 5f728ba..e3e9250 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearchTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/CodeSearchTest.kt @@ -164,4 +164,27 @@ class CodeSearchTest { val header = RepositoryPath.of("src/a/Foo.h") 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, + ) + } } diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/compare/CodeWorkbenchViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/compare/CodeWorkbenchViewModelTest.kt new file mode 100644 index 0000000..bde2133 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/compare/CodeWorkbenchViewModelTest.kt @@ -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 = 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 = tabs + override fun setSavedCodeTabs(tabs: List) { + this.tabs = tabs + } + } + + private class FakeBrowser(private val failure: Throwable? = null) : GiteaRepositoryBrowser { + val refs = mutableListOf() + override suspend fun listRepositories(page: Int, pageSize: Int): Page = Page.of(emptyList(), null) + override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page = Page.of(emptyList(), null) + override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page = Page.of(emptyList(), null) + override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page = + 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 = + Page.of(emptyList(), null) + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/compare/LineDiffTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/compare/LineDiffTest.kt new file mode 100644 index 0000000..6bd0e54 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/compare/LineDiffTest.kt @@ -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(), 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, leftSize: Int, rightSize: Int) { + assertEquals((0 until leftSize).toList(), rows.mapNotNull { it.left }) + assertEquals((0 until rightSize).toList(), rows.mapNotNull { it.right }) + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt index fba2b59..4bb4d48 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt @@ -172,6 +172,36 @@ class RepositoryListViewModelTest { 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 fun `refresh invalidates the globally sorted repository snapshot`() = runTest(dispatcher.scheduler) { val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo")))) diff --git a/app/src/test/java/com/ubisam/ubgitea/ui/navigation/RouteEncodingTest.kt b/app/src/test/java/com/ubisam/ubgitea/ui/navigation/RouteEncodingTest.kt index e927911..697291d 100644 --- a/app/src/test/java/com/ubisam/ubgitea/ui/navigation/RouteEncodingTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/ui/navigation/RouteEncodingTest.kt @@ -91,4 +91,17 @@ class RouteEncodingTest { 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)) + } } diff --git a/docs/development-log.md b/docs/development-log.md index c6a9f81..41ae470 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. - Verification: `compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug 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. diff --git a/tasks/065-code-tabs-compare-and-reading-aids.md b/tasks/065-code-tabs-compare-and-reading-aids.md new file mode 100644 index 0000000..8cce8c5 --- /dev/null +++ b/tasks/065-code-tabs-compare-and-reading-aids.md @@ -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. diff --git a/tasks/066-download-page-and-update-check.md b/tasks/066-download-page-and-update-check.md new file mode 100644 index 0000000..9626b07 --- /dev/null +++ b/tasks/066-download-page-and-update-check.md @@ -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. diff --git a/tools/publish-download.py b/tools/publish-download.py new file mode 100644 index 0000000..3585e26 --- /dev/null +++ b/tools/publish-download.py @@ -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 --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 = """ + + + + + +UBGitea 다운로드 + + + +
+

UBGitea

+
버전 {{VERSION}} · {{SIZE}}
+
{{NOTES}}
+
+ + + +
+

+
    +
  1. 받은 APK 파일을 엽니다.
  2. +
  3. "출처를 알 수 없는 앱" 설치를 이 브라우저에 허용합니다.
  4. +
  5. 설치(업데이트)를 누릅니다. 로그인과 설정은 그대로 남습니다.
  6. +
+
+ + + +""" + +if __name__ == "__main__": + main()