Compare commits
10 Commits
023fa0b728
...
4b03c52975
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b03c52975 | |||
| 7aa79f15ce | |||
| a02a987946 | |||
| 69556b1e23 | |||
| 54530096e0 | |||
| 6a149087c0 | |||
| f6ac20c8ed | |||
| 8bf6e7ad95 | |||
| fb3d059bca | |||
| 17fa248065 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*.bat text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
@ -27,7 +27,7 @@ Do not implement a feature without an acceptance-criteria task file. If requirem
|
||||
|
||||
## Agent roles
|
||||
|
||||
Since 2026-09-28 the project is developed by Claude alone, directed by the human product owner. Earlier tasks and log entries that name Codex as owner or reviewer are historical; a status of "pending Codex review" now means "pending review" by the roles below.
|
||||
Since 2026-09-28 the project is developed by Claude alone, directed by the human product owner. Earlier tasks and log entries that name Codex as owner or reviewer are historical. Codex is not used; no task waits on a Codex review.
|
||||
|
||||
### Claude — implementer and integrator
|
||||
|
||||
|
||||
@ -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 = 3
|
||||
versionName = "0.2.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@ -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>(Route.Home))
|
||||
@ -147,8 +176,13 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// The route shown before this one: coming back from a comparison leaves the code as it was.
|
||||
val lastRoute = remember { arrayOfNulls<Route>(1) }
|
||||
LaunchedEffect(currentRoute, sessionReady) {
|
||||
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()
|
||||
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))
|
||||
|
||||
@ -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<String> = emptyList()
|
||||
fun setSavedCodeTabs(tabs: List<String>) = 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<String> {
|
||||
val stored = preferences.getString(KEY_SAVED_CODE_TABS, null) ?: return emptyList()
|
||||
return try {
|
||||
val array = JSONArray(stored)
|
||||
List(array.length()) { array.getString(it) }
|
||||
} catch (_: JSONException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSavedCodeTabs(tabs: List<String>) {
|
||||
preferences.edit().putString(KEY_SAVED_CODE_TABS, JSONArray(tabs).toString()).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
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"
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
package com.ubisam.ubgitea.core.update
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Where new builds of the app are published (TASK-066): a page that hands out the APK behind a
|
||||
* password, and beside it a plain file naming the newest version. The app only ever reads that
|
||||
* file; no Gitea token goes there.
|
||||
*/
|
||||
object AppUpdateLocations {
|
||||
const val DOWNLOAD_PAGE = "https://choimy0204.github.io/ubgitea/"
|
||||
const val LATEST_VERSION = DOWNLOAD_PAGE + "version.json"
|
||||
}
|
||||
|
||||
/** The newest published build. */
|
||||
data class AppRelease(
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reads `{"versionCode": 2, "versionName": "0.2.0", "notes": "..."}`. Anything else is null, so
|
||||
* a page that is broken or not there yet never offers an update.
|
||||
*/
|
||||
fun parseAppRelease(json: String): AppRelease? = try {
|
||||
val root = JsonParser.parseString(json).asJsonObject
|
||||
val code = root.get("versionCode")?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asInt
|
||||
val name = root.get("versionName")?.takeIf { it.isJsonPrimitive }?.asString?.trim()
|
||||
val notes = root.get("notes")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifEmpty { null }
|
||||
if (code == null || code <= 0 || name.isNullOrEmpty()) null else AppRelease(code, name, notes)
|
||||
} catch (_: RuntimeException) {
|
||||
null
|
||||
}
|
||||
|
||||
fun interface AppReleaseSource {
|
||||
/** The newest published build, or null when it cannot be read. */
|
||||
suspend fun latest(): AppRelease?
|
||||
}
|
||||
|
||||
class HttpAppReleaseSource(
|
||||
private val url: String = AppUpdateLocations.LATEST_VERSION,
|
||||
private val client: OkHttpClient = updateClient,
|
||||
) : AppReleaseSource {
|
||||
override suspend fun latest(): AppRelease? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// no-cache: GitHub Pages is cached for minutes, and a check by hand should see a release at once.
|
||||
val request = Request.Builder().url(url).header("Cache-Control", "no-cache").build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) null else parseAppRelease(response.body.string())
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val updateClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.ubisam.ubgitea.core.update
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ubisam.ubgitea.BuildConfig
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class UpdateCheck { IDLE, CHECKING, UP_TO_DATE, FAILED, AVAILABLE }
|
||||
|
||||
data class AppUpdateUiState(
|
||||
val installedVersionName: String,
|
||||
val check: UpdateCheck = UpdateCheck.IDLE,
|
||||
// Set when a newer build than this one is published.
|
||||
val available: AppRelease? = null,
|
||||
// The newer build offered on its own at launch, until it is answered.
|
||||
val prompt: AppRelease? = null,
|
||||
)
|
||||
|
||||
/** Whether a newer build of the app is published, checked once at launch and again on request. */
|
||||
class AppUpdateViewModel(
|
||||
private val source: AppReleaseSource,
|
||||
private val installedVersionCode: Int,
|
||||
installedVersionName: String,
|
||||
) : ViewModel() {
|
||||
private val mutableState = MutableStateFlow(AppUpdateUiState(installedVersionName))
|
||||
val state: StateFlow<AppUpdateUiState> = mutableState.asStateFlow()
|
||||
private var operation: Job? = null
|
||||
private var checkedAtLaunch = false
|
||||
|
||||
/** The check made when the app starts: quiet unless there is something newer. */
|
||||
fun checkAtLaunch() {
|
||||
if (checkedAtLaunch) return
|
||||
checkedAtLaunch = true
|
||||
check(offer = true)
|
||||
}
|
||||
|
||||
/** A check asked for by hand, as from the settings. */
|
||||
fun checkNow() = check(offer = false)
|
||||
|
||||
fun dismissPrompt() = mutableState.update { it.copy(prompt = null) }
|
||||
|
||||
private fun check(offer: Boolean) {
|
||||
if (operation?.isActive == true) return
|
||||
mutableState.update { it.copy(check = UpdateCheck.CHECKING) }
|
||||
operation = viewModelScope.launch {
|
||||
val latest = source.latest()
|
||||
mutableState.update { state ->
|
||||
when {
|
||||
latest == null -> state.copy(check = UpdateCheck.FAILED)
|
||||
latest.versionCode > installedVersionCode -> state.copy(
|
||||
check = UpdateCheck.AVAILABLE,
|
||||
available = latest,
|
||||
prompt = if (offer) latest else state.prompt,
|
||||
)
|
||||
else -> state.copy(check = UpdateCheck.UP_TO_DATE, available = null, prompt = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
require(modelClass.isAssignableFrom(AppUpdateViewModel::class.java))
|
||||
return AppUpdateViewModel(HttpAppReleaseSource(), BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -62,16 +62,20 @@ object UnifiedDiffParser {
|
||||
val headers = section.filter { it.startsWith("@@") }
|
||||
val entries = mutableListOf<DiffEntry>()
|
||||
parseHunks(section).forEachIndexed { index, hunk ->
|
||||
val header = HUNK_HEADER.find(headers.getOrElse(index) { "" })
|
||||
var leftNumber = header?.groupValues?.get(1)?.toIntOrNull() ?: 1
|
||||
var rightNumber = header?.groupValues?.get(2)?.toIntOrNull() ?: 1
|
||||
entries += DiffEntry(null, null, hunkLabel = header?.groupValues?.get(3)?.trim().orEmpty())
|
||||
val headerLine = headers.getOrElse(index) { "" }
|
||||
val header = HUNK_HEADER.find(headerLine)
|
||||
// A header this parser cannot read gets no line numbers rather than numbers counted
|
||||
// from 1, which would look right and be wrong. Its raw text stands in for the label.
|
||||
var leftNumber = header?.groupValues?.get(1)?.toIntOrNull()
|
||||
var rightNumber = header?.groupValues?.get(2)?.toIntOrNull()
|
||||
val label = header?.groupValues?.get(3)?.trim() ?: headerLine.trim()
|
||||
entries += DiffEntry(null, null, hunkLabel = label)
|
||||
hunkToRows(hunk).forEach { row ->
|
||||
entries += DiffEntry(
|
||||
left = row.left,
|
||||
right = row.right,
|
||||
leftNumber = if (row.left != null) leftNumber++ else null,
|
||||
rightNumber = if (row.right != null) rightNumber++ else null,
|
||||
leftNumber = if (row.left != null) leftNumber?.also { leftNumber = it + 1 } else null,
|
||||
rightNumber = if (row.right != null) rightNumber?.also { rightNumber = it + 1 } else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -109,8 +113,9 @@ object UnifiedDiffParser {
|
||||
for (line in section) {
|
||||
when {
|
||||
line.startsWith("@@") -> hunks.add(mutableListOf<DiffLine>())
|
||||
// The file's own "---"/"+++" lines come before its first hunk and are skipped here.
|
||||
// Inside a hunk, "+++x" is an added "++x" and "--- x" a removed "-- x".
|
||||
hunks.isEmpty() -> Unit
|
||||
line.startsWith("+++") || line.startsWith("---") -> Unit
|
||||
line.startsWith("+") -> hunks.last() += DiffLine.Added(line.substring(1))
|
||||
line.startsWith("-") -> hunks.last() += DiffLine.Removed(line.substring(1))
|
||||
line.startsWith(" ") -> hunks.last() += DiffLine.Context(line.substring(1))
|
||||
|
||||
@ -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<RepositoryPath, Job>()
|
||||
|
||||
// The last few files shown, so going back and forth between saved tabs does not fetch each again.
|
||||
private val recentFiles = object : LinkedHashMap<Pair<CodeBrowserTarget, RepositoryPath>, CodeBrowserUiState.File>(8, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Pair<CodeBrowserTarget, RepositoryPath>, CodeBrowserUiState.File>) =
|
||||
size > RECENT_FILES
|
||||
}
|
||||
|
||||
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
||||
|
||||
/**
|
||||
* 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,8 +335,22 @@ class CodeBrowserViewModel(
|
||||
val file = browser.getFile(target.repository, target.ref, path)
|
||||
classifier.classify(file.content, path)
|
||||
}
|
||||
showFile(target, path, classification, siblingEntries, focusSymbol, tree)
|
||||
} catch (failure: Throwable) {
|
||||
handleFailure(target, path, failure, isFile = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFile(
|
||||
target: CodeBrowserTarget,
|
||||
path: RepositoryPath,
|
||||
classification: ContentClassification,
|
||||
siblingEntries: List<TreeEntry>,
|
||||
focusSymbol: String? = null,
|
||||
tree: FileTree? = null,
|
||||
) {
|
||||
val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path)
|
||||
mutableState.value = CodeBrowserUiState.File(
|
||||
val state = CodeBrowserUiState.File(
|
||||
target = target,
|
||||
path = path,
|
||||
content = classification,
|
||||
@ -307,9 +361,18 @@ class CodeBrowserViewModel(
|
||||
// A folder still being fetched would never hear back in the new state.
|
||||
tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries),
|
||||
)
|
||||
} catch (failure: Throwable) {
|
||||
handleFailure(target, path, failure, isFile = true)
|
||||
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<TreeEntry> = try {
|
||||
loadAllPages { page -> browser.listTree(target.repository, target.ref, folder, page, PAGE_SIZE) }
|
||||
.distinctBy(TreeEntry::path)
|
||||
.sortedWith(LISTING_ORDER)
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun handleFailure(
|
||||
@ -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<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<TextLayoutResult>(1) }
|
||||
val wordTaps = if (onWordTapped == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.pointerInput(index, document.lines) {
|
||||
detectQuietTap { position ->
|
||||
val text = layout[0] ?: return@detectQuietTap
|
||||
wordTapped?.invoke(wordAt(document.lines[index], text.getOffsetForPosition(position)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
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<SearchMatch>?,
|
||||
|
||||
@ -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
|
||||
@ -147,6 +160,7 @@ private enum class ViewerLayout {
|
||||
private val CompactHeightBreakpoint = 480.dp
|
||||
private val SidePaneWidth = 288.dp
|
||||
private val TreeIndent = 14.dp
|
||||
private val EdgeTabTouchWidth = 28.dp
|
||||
private val NarrowSidePaneWidth = 220.dp
|
||||
private val NarrowWideBreakpoint = 700.dp
|
||||
|
||||
@ -201,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<String?>(null) }
|
||||
val wordOccurrences = remember(document.lines, selectedWord) {
|
||||
selectedWord?.let { findWordOccurrences(document.lines, it) }.orEmpty()
|
||||
}
|
||||
var currentWord by remember(wordOccurrences) { mutableIntStateOf(-1) }
|
||||
val words = remember(wordOccurrences, currentWord) {
|
||||
if (wordOccurrences.isEmpty()) SearchHighlight.None else SearchHighlight(wordOccurrences.groupBy { it.line }, wordOccurrences.getOrNull(currentWord))
|
||||
}
|
||||
val onWordTapped: (String?) -> Unit = { word -> selectedWord = if (word == null || word == selectedWord) null else word }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
// The sheet is shown from outside the layout below, where the jump is worked out.
|
||||
var jumpForSheet by remember { mutableStateOf<SymbolJump?>(null) }
|
||||
@ -211,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
|
||||
@ -223,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)
|
||||
@ -244,6 +299,7 @@ fun CodeViewerScreen(
|
||||
when {
|
||||
drawerState.isOpen -> scope.launch { drawerState.close() }
|
||||
outlineOpen -> outlineOpen = false
|
||||
selectedWord != null -> selectedWord = null
|
||||
searching -> {
|
||||
searching = false
|
||||
query = ""
|
||||
@ -258,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() }
|
||||
@ -301,6 +359,7 @@ fun CodeViewerScreen(
|
||||
onToggleSideBySide = { sideBySide = !sideBySide },
|
||||
onToggleSync = { syncPanes = !syncPanes },
|
||||
text = text.renderedText,
|
||||
workbench = benchTools,
|
||||
)
|
||||
val location = stringResource(
|
||||
R.string.codeviewer_location,
|
||||
@ -376,8 +435,9 @@ fun CodeViewerScreen(
|
||||
LocationStrip(enclosingSymbols(document.symbols, firstVisibleLine))
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.weight(1f).fillMaxWidth().openFromLeftEdge(enabled = !sidePaneIsShown && drawerState.isClosed) {
|
||||
Modifier.fillMaxSize().openFromLeftEdge(enabled = !sidePaneIsShown && drawerState.isClosed) {
|
||||
scope.launch { drawerState.open() }
|
||||
},
|
||||
) {
|
||||
@ -395,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 ->
|
||||
@ -422,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(
|
||||
@ -434,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(
|
||||
@ -448,6 +519,22 @@ fun CodeViewerScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Says that there is something to draw out from this edge; without it nothing on the
|
||||
// screen does.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -489,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
|
||||
@ -513,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),
|
||||
@ -552,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,
|
||||
@ -643,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)
|
||||
}
|
||||
@ -835,6 +961,67 @@ 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun EdgeTab(modifier: Modifier = Modifier, onOpen: () -> Unit) {
|
||||
val description = stringResource(R.string.codeviewer_edge_tab)
|
||||
Box(
|
||||
modifier
|
||||
.size(width = EdgeTabTouchWidth, height = 72.dp)
|
||||
.clickable(role = Role.Button, onClickLabel = description, onClick = onOpen)
|
||||
.semantics { contentDescription = description },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = 14.dp, height = 56.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.85f),
|
||||
RoundedCornerShape(topEnd = 12.dp, bottomEnd = 12.dp),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UBIcon(R.drawable.ic_ub_chevron_right, null, size = 14.dp, tint = MaterialTheme.colorScheme.onPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header and its source, side by side, header on the left whichever of the two was opened. The
|
||||
* bar between them is dragged to give one side more of the width.
|
||||
@ -853,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,
|
||||
) {
|
||||
@ -867,6 +1057,7 @@ private fun PairPanes(
|
||||
CodePane(
|
||||
document, listState, preferences.wordWrap, preferences.fontSizeSp,
|
||||
search = highlight, focusedLine = focusedLine, onFunctionTapped = onFunctionTapped,
|
||||
words = words, onWordTapped = onWordTapped,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -876,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())
|
||||
@ -891,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())
|
||||
@ -904,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)
|
||||
|
||||
@ -912,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))
|
||||
@ -1476,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
|
||||
|
||||
@ -58,3 +58,43 @@ fun counterpartOf(path: RepositoryPath, siblings: List<RepositoryPath>): 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<String>, word: String): List<SearchMatch> {
|
||||
if (word.isEmpty()) return emptyList()
|
||||
val occurrences = mutableListOf<SearchMatch>()
|
||||
lines.forEachIndexed { index, line ->
|
||||
var from = 0
|
||||
while (true) {
|
||||
val start = line.indexOf(word, from)
|
||||
if (start < 0) break
|
||||
val end = start + word.length
|
||||
val before = start == 0 || !line[start - 1].isWordCharacter()
|
||||
val after = end == line.length || !line[end].isWordCharacter()
|
||||
if (before && after) occurrences += SearchMatch(index, start, end)
|
||||
from = start + 1
|
||||
}
|
||||
}
|
||||
return occurrences
|
||||
}
|
||||
|
||||
private fun Char.isWordCharacter(): Boolean = this == '_' || isLetterOrDigit()
|
||||
|
||||
@ -0,0 +1,250 @@
|
||||
package com.ubisam.ubgitea.feature.compare
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
|
||||
/**
|
||||
* The saved tabs and the comparison, as the code screens use them. Handed down from the activity
|
||||
* rather than through every screen in between; null where there is no session.
|
||||
*/
|
||||
@Stable
|
||||
class CodeWorkbench(
|
||||
val tabs: List<SavedCodeTab>,
|
||||
val pending: PendingCompare?,
|
||||
val fetching: Boolean,
|
||||
val onToggleTab: (SavedCodeTab) -> Unit,
|
||||
val onPin: (ComparedFile, CompareSide) -> Unit,
|
||||
val onCompareWith: (ComparedFile) -> Unit,
|
||||
val onClearPin: () -> Unit,
|
||||
// A file as it is at a commit: fetched, then pinned on the side given, or compared when null.
|
||||
val onUseCommitFile: (commitId: String, shortId: String, path: String, side: CompareSide?) -> Unit,
|
||||
)
|
||||
|
||||
val LocalCodeWorkbench = staticCompositionLocalOf<CodeWorkbench?> { null }
|
||||
|
||||
/**
|
||||
* Wraps a compare button in its menu. With nothing waiting, the menu puts the file on screen on
|
||||
* the left or the right. With a file waiting, it compares with that file first, and can still put
|
||||
* this one in its place.
|
||||
*/
|
||||
@Composable
|
||||
fun CompareMenu(
|
||||
pending: PendingCompare?,
|
||||
onPin: (CompareSide) -> Unit,
|
||||
onCompare: () -> Unit,
|
||||
onClearPin: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
button: @Composable (onOpen: () -> Unit) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
button { open = true }
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
if (pending != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.compare_with_pinned, pending.file.fileName), fontWeight = FontWeight.Bold) },
|
||||
leadingIcon = { UBIcon(R.drawable.ic_ub_compare, null, size = 20.dp) },
|
||||
onClick = {
|
||||
open = false
|
||||
onCompare()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.compare_put_left)) },
|
||||
onClick = {
|
||||
open = false
|
||||
onPin(CompareSide.LEFT)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.compare_put_right)) },
|
||||
onClick = {
|
||||
open = false
|
||||
onPin(CompareSide.RIGHT)
|
||||
},
|
||||
)
|
||||
if (pending != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.compare_unpin)) },
|
||||
onClick = {
|
||||
open = false
|
||||
onClearPin()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The strip between the screen and the bottom tabs: the file waiting to be compared, and the
|
||||
* saved tabs. Nothing is drawn when there is neither.
|
||||
*/
|
||||
@Composable
|
||||
fun CodeTabStrip(
|
||||
tabs: List<SavedCodeTab>,
|
||||
current: SavedCodeTab?,
|
||||
pending: PendingCompare?,
|
||||
fetching: Boolean,
|
||||
onOpen: (SavedCodeTab) -> Unit,
|
||||
onClose: (SavedCodeTab) -> Unit,
|
||||
onClearPin: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (tabs.isEmpty() && pending == null && !fetching) return
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
if (pending != null || fetching) PendingRow(pending, fetching, onClearPin)
|
||||
if (tabs.isNotEmpty()) {
|
||||
val label = stringResource(R.string.workbench_tabs)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.semantics { contentDescription = label }
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
tabs.forEach { tab -> TabChip(tab, tab == current, { onOpen(tab) }, { onClose(tab) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingRow(pending: PendingCompare?, fetching: Boolean, onClearPin: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(start = 12.dp, end = 4.dp)
|
||||
.heightIn(min = 40.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (fetching) {
|
||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
} else {
|
||||
UBIcon(R.drawable.ic_ub_compare, null, size = 18.dp, tint = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
if (pending != null) {
|
||||
val side = stringResource(if (pending.side == CompareSide.LEFT) R.string.compare_side_left else R.string.compare_side_right)
|
||||
Text(
|
||||
stringResource(R.string.compare_pending, pending.file.fileName, side),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(if (fetching) R.string.compare_fetching else R.string.compare_pending_hint),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (pending != null) {
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(RoundedCornerShape(20.dp)).clickable(role = Role.Button, onClick = onClearPin),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UBIcon(R.drawable.ic_ub_close, stringResource(R.string.compare_unpin), size = 18.dp, tint = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabChip(tab: SavedCodeTab, isCurrent: Boolean, onOpen: () -> Unit, onClose: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val openLabel = stringResource(R.string.workbench_tab_open, tab.fileName, tab.name)
|
||||
val currentLabel = stringResource(R.string.workbench_tab_current)
|
||||
Row(
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (isCurrent) colors.primaryContainer else colors.surfaceVariant)
|
||||
.border(1.dp, if (isCurrent) colors.primary else colors.outlineVariant, RoundedCornerShape(10.dp))
|
||||
.heightIn(min = 40.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
Modifier.clickable(role = Role.Tab, onClick = onOpen)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = if (isCurrent) "$openLabel, $currentLabel" else openLabel
|
||||
selected = isCurrent
|
||||
}
|
||||
.padding(start = 10.dp, end = 2.dp, top = 3.dp, bottom = 3.dp)
|
||||
.widthIn(max = 168.dp),
|
||||
) {
|
||||
Text(
|
||||
tab.fileName,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (isCurrent) colors.onPrimaryContainer else colors.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
"${tab.name} · ${tab.ref}",
|
||||
fontSize = 10.sp,
|
||||
color = if (isCurrent) colors.onPrimaryContainer else colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier.size(36.dp).clip(RoundedCornerShape(18.dp)).clickable(role = Role.Button, onClick = onClose),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UBIcon(R.drawable.ic_ub_close, stringResource(R.string.workbench_tab_close, tab.fileName), size = 16.dp, tint = colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,238 @@
|
||||
package com.ubisam.ubgitea.feature.compare
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ubisam.ubgitea.core.auth.android.AuthSession
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.SourceContentClassifier
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* A code file kept as a tab: where it is, never what is in it. Its fields are joined with U+001F
|
||||
* for storage, which owner, repository, ref and path can never contain.
|
||||
*/
|
||||
data class SavedCodeTab(
|
||||
val owner: String,
|
||||
val name: String,
|
||||
val refKind: String,
|
||||
val ref: String,
|
||||
val path: String,
|
||||
) {
|
||||
val fileName: String get() = path.substringAfterLast('/')
|
||||
|
||||
fun encode(): String = listOf(owner, name, refKind, ref, path).joinToString(SEPARATOR)
|
||||
|
||||
companion object {
|
||||
const val MAX_TABS = 5
|
||||
private const val SEPARATOR = "\u001F"
|
||||
|
||||
fun decodeOrNull(encoded: String): SavedCodeTab? {
|
||||
val parts = encoded.split(SEPARATOR)
|
||||
if (parts.size != 5 || parts.any(String::isEmpty)) return null
|
||||
return SavedCodeTab(parts[0], parts[1], parts[2], parts[3], parts[4])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One side of a comparison: a text file as it is at one ref or commit. */
|
||||
data class ComparedFile(
|
||||
val repository: RepositoryCoordinates,
|
||||
// The branch, tag or short commit id shown under the file's name.
|
||||
val refLabel: String,
|
||||
val path: String,
|
||||
val text: String,
|
||||
val isTruncated: Boolean = false,
|
||||
) {
|
||||
val fileName: String get() = path.substringAfterLast('/')
|
||||
|
||||
fun isSameFileAs(other: ComparedFile): Boolean =
|
||||
repository == other.repository && refLabel == other.refLabel && path == other.path
|
||||
}
|
||||
|
||||
enum class CompareSide { LEFT, RIGHT }
|
||||
|
||||
/** The first file chosen for a comparison, waiting for the second. */
|
||||
data class PendingCompare(val file: ComparedFile, val side: CompareSide)
|
||||
|
||||
/** Two files and how their lines line up. */
|
||||
class CompareSession(
|
||||
val left: ComparedFile,
|
||||
val right: ComparedFile,
|
||||
val leftLines: List<String>,
|
||||
val rightLines: List<String>,
|
||||
val rows: List<CompareRow>,
|
||||
) {
|
||||
val differences: List<Int> = differenceStarts(rows)
|
||||
}
|
||||
|
||||
sealed interface CompareUiState {
|
||||
data object None : CompareUiState
|
||||
data class Working(val left: ComparedFile, val right: ComparedFile) : CompareUiState
|
||||
data class Ready(val session: CompareSession) : CompareUiState
|
||||
}
|
||||
|
||||
/** Something to tell the reader once, in passing. */
|
||||
enum class WorkbenchMessage { TABS_FULL, FILE_UNAVAILABLE, FILE_NOT_TEXT, SAME_FILE }
|
||||
|
||||
data class CodeWorkbenchState(
|
||||
val tabs: List<SavedCodeTab> = emptyList(),
|
||||
val pending: PendingCompare? = null,
|
||||
// A commit's file being fetched to become one side of a comparison.
|
||||
val fetching: Boolean = false,
|
||||
val compare: CompareUiState = CompareUiState.None,
|
||||
val message: WorkbenchMessage? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the code screens share across repositories: the files kept as tabs, and a comparison
|
||||
* between two files from anywhere -- two refs, two commits, or two places in the code.
|
||||
*/
|
||||
class CodeWorkbenchViewModel(
|
||||
private val browser: GiteaRepositoryBrowser,
|
||||
private val settings: AppSettingsStore,
|
||||
private val classifier: SourceContentClassifier = SourceContentClassifier(
|
||||
maxInputBytes = 2_000_000,
|
||||
maxRenderedCharacters = 400_000,
|
||||
maxRenderedLines = 20_000,
|
||||
),
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ViewModel() {
|
||||
private val mutableState = MutableStateFlow(
|
||||
CodeWorkbenchState(tabs = settings.savedCodeTabs().mapNotNull(SavedCodeTab::decodeOrNull).take(SavedCodeTab.MAX_TABS)),
|
||||
)
|
||||
val state: StateFlow<CodeWorkbenchState> = mutableState.asStateFlow()
|
||||
private var fetch: Job? = null
|
||||
private var comparing: Job? = null
|
||||
|
||||
/** Keeps [tab] as a tab, or lets it go if it already is one. A sixth is refused, not swapped in. */
|
||||
fun toggleTab(tab: SavedCodeTab) {
|
||||
val tabs = mutableState.value.tabs
|
||||
when {
|
||||
tab in tabs -> saveTabs(tabs - tab)
|
||||
tabs.size >= SavedCodeTab.MAX_TABS -> mutableState.update { it.copy(message = WorkbenchMessage.TABS_FULL) }
|
||||
else -> saveTabs(tabs + tab)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTab(tab: SavedCodeTab) = saveTabs(mutableState.value.tabs - tab)
|
||||
|
||||
/** Puts [file] on one side, to be compared with the next file chosen. */
|
||||
fun pin(file: ComparedFile, side: CompareSide) {
|
||||
mutableState.update { it.copy(pending = PendingCompare(file, side)) }
|
||||
}
|
||||
|
||||
fun clearPin() {
|
||||
fetch?.cancel()
|
||||
mutableState.update { it.copy(pending = null, fetching = false) }
|
||||
}
|
||||
|
||||
/** Compares [file] with the one waiting on the other side. */
|
||||
fun compareWith(file: ComparedFile) {
|
||||
val pending = mutableState.value.pending ?: return
|
||||
if (pending.file.isSameFileAs(file)) {
|
||||
mutableState.update { it.copy(message = WorkbenchMessage.SAME_FILE) }
|
||||
return
|
||||
}
|
||||
val (left, right) = if (pending.side == CompareSide.LEFT) pending.file to file else file to pending.file
|
||||
mutableState.update { it.copy(pending = null) }
|
||||
start(left, right)
|
||||
}
|
||||
|
||||
/** Fetches a file as it is at a commit, then pins it or compares it with the waiting one. */
|
||||
fun useCommitFile(repository: RepositoryCoordinates, commitId: String, shortId: String, path: String, side: CompareSide?) {
|
||||
fetch?.cancel()
|
||||
mutableState.update { it.copy(fetching = true) }
|
||||
fetch = viewModelScope.launch {
|
||||
val result = try {
|
||||
withContext(defaultDispatcher) {
|
||||
val repositoryPath = RepositoryPath.of(path)
|
||||
classifier.classify(browser.getFile(repository, RepositoryRef.of(commitId), repositoryPath).content, repositoryPath)
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
null
|
||||
}
|
||||
mutableState.update { it.copy(fetching = false) }
|
||||
when (result) {
|
||||
null -> mutableState.update { it.copy(message = WorkbenchMessage.FILE_UNAVAILABLE) }
|
||||
!is ContentClassification.Text -> mutableState.update { it.copy(message = WorkbenchMessage.FILE_NOT_TEXT) }
|
||||
else -> {
|
||||
val file = ComparedFile(repository, shortId, path, result.renderedText, result.isTruncated)
|
||||
if (side != null) pin(file, side) else compareWith(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun swapSides() {
|
||||
val session = (mutableState.value.compare as? CompareUiState.Ready)?.session ?: return
|
||||
start(session.right, session.left)
|
||||
}
|
||||
|
||||
fun closeCompare() {
|
||||
comparing?.cancel()
|
||||
mutableState.update { it.copy(compare = CompareUiState.None) }
|
||||
}
|
||||
|
||||
fun messageShown() {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
}
|
||||
|
||||
private fun start(left: ComparedFile, right: ComparedFile) {
|
||||
comparing?.cancel()
|
||||
mutableState.update { it.copy(compare = CompareUiState.Working(left, right)) }
|
||||
comparing = viewModelScope.launch {
|
||||
val session = withContext(defaultDispatcher) {
|
||||
val leftLines = left.text.toLines()
|
||||
val rightLines = right.text.toLines()
|
||||
CompareSession(left, right, leftLines, rightLines, compareLines(leftLines, rightLines))
|
||||
}
|
||||
mutableState.update { it.copy(compare = CompareUiState.Ready(session)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveTabs(tabs: List<SavedCodeTab>) {
|
||||
mutableState.update { it.copy(tabs = tabs) }
|
||||
settings.setSavedCodeTabs(tabs.map(SavedCodeTab::encode))
|
||||
}
|
||||
|
||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
require(modelClass.isAssignableFrom(CodeWorkbenchViewModel::class.java))
|
||||
val settings = AppSettingsSession.store(application)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return CodeWorkbenchViewModel(
|
||||
GiteaRepositoryBrowserApi(
|
||||
UBGiteaOAuthDefaults.config,
|
||||
OkHttpTransport(),
|
||||
AuthSession.tokenProvider(application),
|
||||
reactiveAuthRetryEnabled = { settings.isReactiveAuthRetryEnabled() },
|
||||
),
|
||||
settings,
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lines as the code viewer splits them: a Windows "\r" is not part of a line.
|
||||
internal fun String.toLines(): List<String> = split("\n").map { it.removeSuffix("\r") }
|
||||
@ -0,0 +1,314 @@
|
||||
package com.ubisam.ubgitea.feature.compare
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.DisableSelection
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLine
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
||||
import com.ubisam.ubgitea.ui.components.CautionBanner
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Two files side by side, the way a merge tool shows them: lines that match sit level, a changed
|
||||
* line is amber on both sides, a line only one side has is red on the left or green on the right
|
||||
* with a gap opposite. On a narrow window the two sides are interleaved in one column instead.
|
||||
*/
|
||||
@Composable
|
||||
fun CompareScreen(
|
||||
state: CompareUiState,
|
||||
fontSizeSp: Int,
|
||||
onSwap: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(onBack = onClose)
|
||||
val (left, right) = when (state) {
|
||||
is CompareUiState.Ready -> state.session.left to state.session.right
|
||||
is CompareUiState.Working -> state.left to state.right
|
||||
CompareUiState.None -> return
|
||||
}
|
||||
val session = (state as? CompareUiState.Ready)?.session
|
||||
val listState = remember(session) { LazyListState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun jump(direction: Int) {
|
||||
val differences = session?.differences ?: return
|
||||
val current = listState.firstVisibleItemIndex + CONTEXT_ROWS
|
||||
val target = if (direction > 0) differences.firstOrNull { it > current } else differences.lastOrNull { it < current }
|
||||
if (target != null) scope.launch { listState.animateScrollToItem((target - CONTEXT_ROWS).coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding()) {
|
||||
ScreenTopBar(
|
||||
title = if (left.fileName == right.fileName) left.fileName else "${left.fileName} ↔ ${right.fileName}",
|
||||
subtitle = "${left.refLabel} ↔ ${right.refLabel}",
|
||||
monospaceTitle = true,
|
||||
onBack = onClose,
|
||||
actions = {
|
||||
IconButton(onClick = onSwap, enabled = session != null) {
|
||||
UBIcon(R.drawable.ic_ub_compare, stringResource(R.string.compare_swap), size = 22.dp)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (left.isTruncated || right.isTruncated) CautionBanner(stringResource(R.string.compare_truncated))
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val sideBySide = maxWidth >= WideLayoutBreakpoint
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (sideBySide) SideHeaders(left, right)
|
||||
when {
|
||||
session == null -> CenteredLoading(stringResource(R.string.compare_working))
|
||||
session.differences.isEmpty() -> CenteredMessage(stringResource(R.string.compare_identical))
|
||||
else -> CompareList(session, fontSizeSp, sideBySide, listState)
|
||||
}
|
||||
}
|
||||
}
|
||||
val position by remember(session) {
|
||||
derivedStateOf {
|
||||
val current = listState.firstVisibleItemIndex + CONTEXT_ROWS
|
||||
session?.differences?.count { it <= current } ?: 0
|
||||
}
|
||||
}
|
||||
CompareToolbar(
|
||||
summary = session?.let {
|
||||
if (it.differences.isEmpty()) null else stringResource(R.string.compare_summary, it.differences.size)
|
||||
},
|
||||
position = position,
|
||||
canJump = !session?.differences.isNullOrEmpty(),
|
||||
onPrevious = { jump(-1) },
|
||||
onNext = { jump(1) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SideHeaders(left: ComparedFile, right: ComparedFile) {
|
||||
Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant).height(IntrinsicSize.Min)) {
|
||||
SideHeader(R.string.compare_side_left, left, Modifier.weight(1f))
|
||||
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
|
||||
SideHeader(R.string.compare_side_right, right, Modifier.weight(1f))
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SideHeader(@StringRes side: Int, file: ComparedFile, modifier: Modifier) {
|
||||
Column(modifier.padding(horizontal = 12.dp, vertical = 6.dp)) {
|
||||
Text(
|
||||
stringResource(side) + " · " + file.path,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.compare_side_label, file.repository.name, file.refLabel),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompareList(session: CompareSession, fontSizeSp: Int, sideBySide: Boolean, listState: LazyListState) {
|
||||
val colors = rememberCodeSyntaxColors()
|
||||
val language = remember(session.left.path) { languageOf(session.left.path) }
|
||||
val style = remember(fontSizeSp, colors.text) {
|
||||
TextStyle(fontFamily = FontFamily.Monospace, fontSize = fontSizeSp.sp, lineHeight = (fontSizeSp * 1.5f).sp, color = colors.text)
|
||||
}
|
||||
val measurer = rememberTextMeasurer()
|
||||
val characterWidthPx = remember(style) { measurer.measure("0", style).size.width.toFloat() }
|
||||
val digits = maxOf(session.leftLines.size, session.rightLines.size).toString().length
|
||||
val numberWidth = with(LocalDensity.current) { (characterWidthPx * digits).toDp() } + 18.dp
|
||||
val cell = CellStyle(style, characterWidthPx, numberWidth, language)
|
||||
|
||||
SelectionContainer {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(session.rows.size, key = { it }) { index ->
|
||||
val row = session.rows[index]
|
||||
val leftText = row.left?.let(session.leftLines::get)
|
||||
val rightText = row.right?.let(session.rightLines::get)
|
||||
if (sideBySide) {
|
||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
CompareCell(leftText, row.left, row.kind, isLeft = true, cell, Modifier.weight(1f).fillMaxHeight())
|
||||
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
|
||||
CompareCell(rightText, row.right, row.kind, isLeft = false, cell, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (row.kind == CompareRowKind.SAME) {
|
||||
CompareCell(leftText, row.left, row.kind, isLeft = true, cell, Modifier.fillMaxWidth())
|
||||
} else {
|
||||
if (leftText != null) CompareCell(leftText, row.left, row.kind, isLeft = true, cell, Modifier.fillMaxWidth())
|
||||
if (rightText != null) CompareCell(rightText, row.right, row.kind, isLeft = false, cell, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CellStyle(val text: TextStyle, val characterWidthPx: Float, val numberWidth: Dp, val language: SyntaxLanguage)
|
||||
|
||||
@Composable
|
||||
private fun CompareCell(text: String?, index: Int?, kind: CompareRowKind, isLeft: Boolean, cell: CellStyle, modifier: Modifier) {
|
||||
if (text == null || index == null) {
|
||||
// The other side has a line this side lacks.
|
||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||
return
|
||||
}
|
||||
val syntax = rememberCodeSyntaxColors()
|
||||
val background = when (kind) {
|
||||
CompareRowKind.SAME -> Color.Transparent
|
||||
CompareRowKind.CHANGED -> UBGiteaTheme.colors.warningContainer
|
||||
CompareRowKind.LEFT_ONLY -> MaterialTheme.colorScheme.errorContainer
|
||||
CompareRowKind.RIGHT_ONLY -> UBGiteaTheme.colors.successContainer
|
||||
}
|
||||
val (sign, signColor, signLabel) = when {
|
||||
kind == CompareRowKind.SAME -> Triple("", Color.Transparent, null)
|
||||
isLeft -> Triple("−", MaterialTheme.colorScheme.error, stringResource(R.string.compare_side_left))
|
||||
else -> Triple("+", UBGiteaTheme.colors.success, stringResource(R.string.compare_side_right))
|
||||
}
|
||||
val shown: AnnotatedString = remember(text, cell.language, syntax) { highlightLine(text, cell.language, syntax) }
|
||||
val indent = text.takeWhile { it == ' ' || it == '\t' }.sumOf { if (it == '\t') 4 else 1 }
|
||||
val hanging = with(LocalDensity.current) { (cell.characterWidthPx * (indent + 4)).toSp() }
|
||||
Row(modifier.background(background), verticalAlignment = Alignment.Top) {
|
||||
DisableSelection {
|
||||
Text(
|
||||
(index + 1).toString(),
|
||||
style = cell.text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.width(cell.numberWidth).padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
sign,
|
||||
style = cell.text,
|
||||
color = signColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.width(16.dp).semantics { if (signLabel != null) contentDescription = signLabel },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
shown,
|
||||
style = cell.text.copy(textIndent = TextIndent(restLine = hanging)),
|
||||
softWrap = true,
|
||||
modifier = Modifier.weight(1f).padding(end = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompareToolbar(summary: String?, position: Int, canJump: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column(Modifier.navigationBarsPadding()) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CompareTool(R.drawable.ic_ub_chevron_up, R.string.compare_previous, canJump, Modifier.weight(1f), onPrevious)
|
||||
Text(
|
||||
if (summary == null) "" else "$position / $summary",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1.4f),
|
||||
)
|
||||
CompareTool(R.drawable.ic_ub_chevron_down, R.string.compare_next, canJump, Modifier.weight(1f), onNext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompareTool(@DrawableRes icon: Int, @StringRes label: Int, enabled: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||
.heightIn(min = 52.dp)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Box(Modifier.height(30.dp), contentAlignment = Alignment.Center) { UBIcon(icon, null, size = 22.dp, tint = color) }
|
||||
Text(stringResource(label), fontSize = 11.sp, fontWeight = FontWeight.Medium, color = color, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun languageOf(path: String): SyntaxLanguage =
|
||||
try {
|
||||
syntaxLanguageForPath(RepositoryPath.of(path))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
SyntaxLanguage.PLAIN
|
||||
}
|
||||
|
||||
private const val CONTEXT_ROWS = 2
|
||||
144
app/src/main/java/com/ubisam/ubgitea/feature/compare/LineDiff.kt
Normal file
144
app/src/main/java/com/ubisam/ubgitea/feature/compare/LineDiff.kt
Normal file
@ -0,0 +1,144 @@
|
||||
package com.ubisam.ubgitea.feature.compare
|
||||
|
||||
/** How one row of a two-column comparison differs between its sides. */
|
||||
enum class CompareRowKind {
|
||||
/** The same line on both sides. */
|
||||
SAME,
|
||||
|
||||
/** A line on each side, and they differ. */
|
||||
CHANGED,
|
||||
|
||||
/** A line only on the left; the right is blank. */
|
||||
LEFT_ONLY,
|
||||
|
||||
/** A line only on the right; the left is blank. */
|
||||
RIGHT_ONLY,
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of a merge-tool style comparison: the left file's line beside the right file's, or a
|
||||
* blank on the side that has none, so the lines that match stay level. Line numbers are zero-based
|
||||
* indexes into each side's lines, null on a blank side.
|
||||
*/
|
||||
data class CompareRow(val kind: CompareRowKind, val left: Int?, val right: Int?)
|
||||
|
||||
/**
|
||||
* Lines the two sides share are found with Myers' algorithm, after the common start and end are set
|
||||
* aside. Between two shared lines, removed and added lines are paired in order into changed rows;
|
||||
* whichever side has more keeps the rest to itself.
|
||||
*
|
||||
* When the files differ in more than [maxEdits] lines the search stops and the differing middle is
|
||||
* shown as one changed block. Each step of the search keeps a copy of its frontier, so memory grows
|
||||
* with the square of the edit count, and an unbounded search on two unrelated large files could take
|
||||
* all of it.
|
||||
*/
|
||||
fun compareLines(left: List<String>, right: List<String>, maxEdits: Int = DEFAULT_MAX_EDITS): List<CompareRow> {
|
||||
var prefix = 0
|
||||
while (prefix < left.size && prefix < right.size && left[prefix] == right[prefix]) prefix++
|
||||
var suffix = 0
|
||||
while (
|
||||
suffix < left.size - prefix && suffix < right.size - prefix &&
|
||||
left[left.size - 1 - suffix] == right[right.size - 1 - suffix]
|
||||
) suffix++
|
||||
|
||||
val rows = ArrayList<CompareRow>(maxOf(left.size, right.size) + 16)
|
||||
repeat(prefix) { rows += CompareRow(CompareRowKind.SAME, it, it) }
|
||||
val leftMiddle = left.subList(prefix, left.size - suffix)
|
||||
val rightMiddle = right.subList(prefix, right.size - suffix)
|
||||
val shared = sharedLines(leftMiddle, rightMiddle, maxEdits)
|
||||
|
||||
var l = 0
|
||||
var r = 0
|
||||
for ((sharedLeft, sharedRight) in shared + (leftMiddle.size to rightMiddle.size)) {
|
||||
addBlock(rows, prefix + l, prefix + sharedLeft, prefix + r, prefix + sharedRight)
|
||||
if (sharedLeft < leftMiddle.size) rows += CompareRow(CompareRowKind.SAME, prefix + sharedLeft, prefix + sharedRight)
|
||||
l = sharedLeft + 1
|
||||
r = sharedRight + 1
|
||||
}
|
||||
repeat(suffix) {
|
||||
rows += CompareRow(CompareRowKind.SAME, left.size - suffix + it, right.size - suffix + it)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The index of the first row of every run of differing rows: where "next difference" lands. */
|
||||
fun differenceStarts(rows: List<CompareRow>): List<Int> =
|
||||
rows.indices.filter { rows[it].kind != CompareRowKind.SAME && (it == 0 || rows[it - 1].kind == CompareRowKind.SAME) }
|
||||
|
||||
private fun addBlock(rows: MutableList<CompareRow>, leftFrom: Int, leftTo: Int, rightFrom: Int, rightTo: Int) {
|
||||
val leftCount = leftTo - leftFrom
|
||||
val rightCount = rightTo - rightFrom
|
||||
for (i in 0 until maxOf(leftCount, rightCount)) {
|
||||
rows += when {
|
||||
i < leftCount && i < rightCount -> CompareRow(CompareRowKind.CHANGED, leftFrom + i, rightFrom + i)
|
||||
i < leftCount -> CompareRow(CompareRowKind.LEFT_ONLY, leftFrom + i, null)
|
||||
else -> CompareRow(CompareRowKind.RIGHT_ONLY, null, rightFrom + i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pairs (left index, right index) of lines kept in a shortest edit script from [a] to [b], in
|
||||
* order. Empty when nothing is shared or when more than [maxEdits] edits would be needed.
|
||||
*/
|
||||
private fun sharedLines(a: List<String>, b: List<String>, maxEdits: Int): List<Pair<Int, Int>> {
|
||||
val n = a.size
|
||||
val m = b.size
|
||||
if (n == 0 || m == 0) return emptyList()
|
||||
val max = minOf(n + m, maxEdits)
|
||||
val offset = max + 1
|
||||
val v = IntArray(2 * max + 3)
|
||||
// trace[d] is v for diagonals -d..d after step d.
|
||||
val trace = ArrayList<IntArray>()
|
||||
var found = -1
|
||||
search@ for (d in 0..max) {
|
||||
for (k in -d..d step 2) {
|
||||
var x = if (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) {
|
||||
v[offset + k + 1]
|
||||
} else {
|
||||
v[offset + k - 1] + 1
|
||||
}
|
||||
var y = x - k
|
||||
while (x < n && y < m && a[x] == b[y]) {
|
||||
x++
|
||||
y++
|
||||
}
|
||||
v[offset + k] = x
|
||||
if (x >= n && y >= m) {
|
||||
trace += v.copyOfRange(offset - d, offset + d + 1)
|
||||
found = d
|
||||
break@search
|
||||
}
|
||||
}
|
||||
trace += v.copyOfRange(offset - d, offset + d + 1)
|
||||
}
|
||||
if (found < 0) return emptyList()
|
||||
|
||||
val pairs = ArrayList<Pair<Int, Int>>()
|
||||
var x = n
|
||||
var y = m
|
||||
for (d in found downTo 1) {
|
||||
val previous = trace[d - 1]
|
||||
fun at(k: Int) = previous[k + d - 1]
|
||||
val k = x - y
|
||||
val previousK = if (k == -d || (k != d && at(k - 1) < at(k + 1))) k + 1 else k - 1
|
||||
val previousX = at(previousK)
|
||||
val previousY = previousX - previousK
|
||||
while (x > previousX && y > previousY) {
|
||||
x--
|
||||
y--
|
||||
pairs += x to y
|
||||
}
|
||||
x = previousX
|
||||
y = previousY
|
||||
}
|
||||
while (x > 0 && y > 0) {
|
||||
x--
|
||||
y--
|
||||
pairs += x to y
|
||||
}
|
||||
pairs.reverse()
|
||||
return pairs
|
||||
}
|
||||
|
||||
private const val DEFAULT_MAX_EDITS = 3000
|
||||
@ -62,6 +62,8 @@ import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLine
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -185,6 +185,11 @@ class RepositoryListViewModel(
|
||||
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
|
||||
private var operation: Job? = null
|
||||
private val rowBranchJobs = mutableMapOf<RepositoryCoordinates, Job>()
|
||||
// Repositories whose star/unstar request is still on its way. A second tap on the same row is
|
||||
// ignored until the first request settles: the tap flips the star at once, so without this a
|
||||
// quick double tap sent a star and an unstar together, and whichever reached the server last
|
||||
// won -- not necessarily the one the screen ended up showing.
|
||||
private val favoriteRequestsInFlight = mutableSetOf<RepositoryCoordinates>()
|
||||
private var activeAccountName: String? = null
|
||||
private var hasAuthenticatedSession = false
|
||||
private var repositoryContent: RepositoryListUiState.Content? = null
|
||||
@ -317,6 +322,7 @@ class RepositoryListViewModel(
|
||||
|
||||
fun toggleFavorite(repository: RepositoryRowUiModel) {
|
||||
val content = mutableState.value as? RepositoryListUiState.Content ?: return
|
||||
if (!favoriteRequestsInFlight.add(repository.coordinates)) return
|
||||
val mode = content.mode
|
||||
// Once this session has confirmed a star succeeded (favoriteAction == Starred), a repeat
|
||||
// tap on the ALL list now un-stars instead of starring again -- a real toggle, matching
|
||||
@ -404,6 +410,8 @@ class RepositoryListViewModel(
|
||||
updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.Failed) }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
favoriteRequestsInFlight -= repository.coordinates
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -423,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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -1,14 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- App icon background layer: a solid fill in the same sky blue used as this app's Material 3
|
||||
primary color (ui/theme/Color.kt's SkyPrimaryLight), so the home-screen icon and the in-app
|
||||
theme read as the same deliberate color rather than drifting apart after the sky-blue redesign
|
||||
(this used to match the earlier "Gitea green" theme's primary before that changed). -->
|
||||
<!-- App icon, back layer (TASK-061): the bright sky blue of the app (BrandSky in ui/theme/Color.kt) running to its deeper primary, with two faint circles so the flat color has some depth -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="14"
|
||||
android:startY="8"
|
||||
android:endX="94"
|
||||
android:endY="100">
|
||||
<item android:offset="0" android:color="#FF4DB0FF" />
|
||||
<item android:offset="0.55" android:color="#FF2F98F9" />
|
||||
<item android:offset="1" android:color="#FF0A6FD8" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#0369A1"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
android:fillColor="#1FFFFFFF"
|
||||
android:pathData="M58,88 a34,34 0 1,0 68,0 a34,34 0 1,0 -68,0 Z" />
|
||||
<path
|
||||
android:fillColor="#14FFFFFF"
|
||||
android:pathData="M-14,16 a26,26 0 1,0 52,0 a26,26 0 1,0 -52,0 Z" />
|
||||
</vector>
|
||||
|
||||
@ -1,35 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A simple git-branch glyph (trunk node, a straight line up to a main-branch node, a curved line
|
||||
out to a second branch node), reading immediately as "a git tool" without reproducing Gitea's
|
||||
own trademarked logo. Every coordinate stays within the adaptive-icon safe zone (the centered
|
||||
66dp-diameter circle on this 108dp canvas), since the system mask crops outside it on some
|
||||
launchers. This app has no icon library (see CLAUDE.md's established convention), so this is
|
||||
hand-drawn geometry, circles for nodes and stroked paths for the connecting branch lines,
|
||||
matching the same plain-vector approach already used for the notification small icon. -->
|
||||
<!-- App icon, front layer (TASK-061): a branch that leaves the trunk, its commits drawn as rings. It says "Git" without using Gitea's own logo. Everything stays inside the 66dp safe circle of the 108dp canvas, which is all that some launchers show -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Drawn large, then brought inside the safe circle. -->
|
||||
<group
|
||||
android:pivotX="54"
|
||||
android:pivotY="54"
|
||||
android:scaleX="0.85"
|
||||
android:scaleY="0.85"
|
||||
android:translateX="-2">
|
||||
<path
|
||||
android:pathData="M40,78 L40,30"
|
||||
android:pathData="M41,74 L41,34"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="6"
|
||||
android:strokeWidth="6.5"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000" />
|
||||
<path
|
||||
android:pathData="M40,78 C40,60 74,64 74,46"
|
||||
android:pathData="M41,74 C41,57 72,61 72,45"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="6"
|
||||
android:strokeWidth="6.5"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000" />
|
||||
<!-- Each node is a ring: the outer circle with the inner one cut out of it. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M33,78 A7,7 0 1,0 47,78 A7,7 0 1,0 33,78 Z" />
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M31.5,76 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M36.5,76 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M33,30 A7,7 0 1,0 47,30 A7,7 0 1,0 33,30 Z" />
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M31.5,32 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M36.5,32 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M67,46 A7,7 0 1,0 81,46 A7,7 0 1,0 67,46 Z" />
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M62.5,43 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M67.5,43 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@ -1,31 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android 13+ themed-icon layer. The system re-tints this to match the device wallpaper theme,
|
||||
so (like the notification small icon) only the shape/alpha matters, not this fill color.
|
||||
Same glyph as ic_launcher_foreground.xml. -->
|
||||
<!-- App icon, themed layer (Android 13+): the same shape as the front layer. The system colors it, so only the shape matters -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Drawn large, then brought inside the safe circle. -->
|
||||
<group
|
||||
android:pivotX="54"
|
||||
android:pivotY="54"
|
||||
android:scaleX="0.85"
|
||||
android:scaleY="0.85"
|
||||
android:translateX="-2">
|
||||
<path
|
||||
android:pathData="M40,78 L40,30"
|
||||
android:pathData="M41,74 L41,34"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeWidth="6"
|
||||
android:strokeWidth="6.5"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000" />
|
||||
<path
|
||||
android:pathData="M40,78 C40,60 74,64 74,46"
|
||||
android:pathData="M41,74 C41,57 72,61 72,45"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeWidth="6"
|
||||
android:strokeWidth="6.5"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000" />
|
||||
<!-- Each node is a ring: the outer circle with the inner one cut out of it. -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M33,78 A7,7 0 1,0 47,78 A7,7 0 1,0 33,78 Z" />
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M31.5,76 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M36.5,76 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M33,30 A7,7 0 1,0 47,30 A7,7 0 1,0 33,30 Z" />
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M31.5,32 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M36.5,32 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M67,46 A7,7 0 1,0 81,46 A7,7 0 1,0 67,46 Z" />
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M62.5,43 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 Z M67.5,43 a4.5,4.5 0 1,0 9,0 a4.5,4.5 0 1,0 -9,0 Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
14
app/src/main/res/drawable/ic_ub_bookmark.xml
Normal file
14
app/src/main/res/drawable/ic_ub_bookmark.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6.5,3.5h11v17l-5.5,-4 -5.5,4z" />
|
||||
</vector>
|
||||
13
app/src/main/res/drawable/ic_ub_bookmark_filled.xml
Normal file
13
app/src/main/res/drawable/ic_ub_bookmark_filled.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6.5,3.5h11v17l-5.5,-4 -5.5,4z" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_compare.xml
Normal file
14
app/src/main/res/drawable/ic_ub_compare.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M4,4.5h6v15h-6zM14,4.5h6v15h-6zM6,8.5h2M6,12h2M16,8.5h2M16,15.5h2" />
|
||||
</vector>
|
||||
@ -69,6 +69,7 @@
|
||||
<string name="codeviewer_files_title">폴더 구조</string>
|
||||
<string name="codeviewer_symbols_title">클래스 · 함수</string>
|
||||
<string name="codeviewer_symbols_empty">이 파일에는 표시할 클래스나 함수가 없습니다.</string>
|
||||
<string name="codeviewer_edge_tab">폴더 구조와 클래스 · 함수 열기</string>
|
||||
<string name="codeviewer_pane_swap">위아래 바꾸기</string>
|
||||
<string name="codeviewer_folder_open">펼쳐짐</string>
|
||||
<string name="codeviewer_folder_closed">접힘</string>
|
||||
|
||||
42
app/src/main/res/values/strings_compare.xml
Normal file
42
app/src/main/res/values/strings_compare.xml
Normal file
@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Saved code tabs (TASK-065) -->
|
||||
<string name="workbench_tabs">저장한 탭</string>
|
||||
<string name="workbench_tab_open">%1$s 열기 · %2$s</string>
|
||||
<string name="workbench_tab_close">%1$s 탭 닫기</string>
|
||||
<string name="workbench_tab_current">지금 보는 파일</string>
|
||||
<string name="workbench_tabs_full">탭은 %1$d개까지 저장할 수 있습니다. 하나를 닫고 다시 저장하세요.</string>
|
||||
<string name="codeviewer_tool_save_tab">탭 저장</string>
|
||||
<string name="codeviewer_tool_compare">비교</string>
|
||||
|
||||
<!-- Comparing two files -->
|
||||
<string name="compare_put_left">이 파일을 왼쪽에 두기</string>
|
||||
<string name="compare_put_right">이 파일을 오른쪽에 두기</string>
|
||||
<string name="compare_with_pinned">%1$s와 비교하기</string>
|
||||
<string name="compare_unpin">비교 대기 취소</string>
|
||||
<string name="compare_pending">비교 대기 · %1$s (%2$s)</string>
|
||||
<string name="compare_pending_hint">다른 파일에서 비교를 누르세요</string>
|
||||
<string name="compare_side_left">왼쪽</string>
|
||||
<string name="compare_side_right">오른쪽</string>
|
||||
<string name="compare_fetching">커밋의 파일을 불러오는 중입니다.</string>
|
||||
<string name="compare_file_unavailable">이 커밋의 파일을 불러오지 못했습니다. 삭제된 파일은 비교할 수 없습니다.</string>
|
||||
<string name="compare_file_not_text">텍스트 파일만 비교할 수 있습니다.</string>
|
||||
<string name="compare_same_file">같은 파일입니다. 다른 커밋이나 다른 파일을 고르세요.</string>
|
||||
<string name="compare_working">두 파일을 맞춰 보는 중입니다.</string>
|
||||
<string name="compare_summary">다른 곳 %1$d군데</string>
|
||||
<string name="compare_identical">두 파일이 같습니다.</string>
|
||||
<string name="compare_previous">이전 차이</string>
|
||||
<string name="compare_next">다음 차이</string>
|
||||
<string name="compare_swap">좌우 바꾸기</string>
|
||||
<string name="compare_truncated">파일이 길어 앞부분만 비교합니다.</string>
|
||||
<string name="compare_side_label">%1$s · %2$s</string>
|
||||
|
||||
<!-- Highlighting a word in the code -->
|
||||
<string name="codeviewer_word_count">%1$s · %2$d곳</string>
|
||||
<string name="codeviewer_word_previous">이전 위치</string>
|
||||
<string name="codeviewer_word_next">다음 위치</string>
|
||||
<string name="codeviewer_word_clear">표시 지우기</string>
|
||||
|
||||
<!-- Side pane width -->
|
||||
<string name="codeviewer_side_pane_handle">옆 창 너비 조절. 좌우로 끌어 조절하고, 두 번 누르면 기본 너비로 되돌립니다.</string>
|
||||
</resources>
|
||||
15
app/src/main/res/values/strings_update.xml
Normal file
15
app/src/main/res/values/strings_update.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="update_section">앱 정보</string>
|
||||
<string name="update_installed_version">현재 버전 %1$s</string>
|
||||
<string name="update_check">업데이트 확인</string>
|
||||
<string name="update_checking">확인하는 중…</string>
|
||||
<string name="update_up_to_date">최신 버전입니다.</string>
|
||||
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
|
||||
<string name="update_available">새 버전 %1$s을(를) 받을 수 있습니다.</string>
|
||||
<string name="update_download">다운로드 페이지 열기</string>
|
||||
<string name="update_dialog_title">새 버전이 있습니다</string>
|
||||
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n다운로드 페이지에서 비밀번호를 입력해 받은 뒤 설치하세요.</string>
|
||||
<string name="update_dialog_download">다운로드</string>
|
||||
<string name="update_dialog_later">나중에</string>
|
||||
</resources>
|
||||
@ -0,0 +1,92 @@
|
||||
package com.ubisam.ubgitea.core.update
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppUpdateTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||
|
||||
@Test
|
||||
fun `a version file is read`() {
|
||||
assertEquals(
|
||||
AppRelease(3, "0.3.0", "탭 저장"),
|
||||
parseAppRelease("""{"versionCode": 3, "versionName": " 0.3.0 ", "notes": "탭 저장"}"""),
|
||||
)
|
||||
assertEquals(AppRelease(3, "0.3.0"), parseAppRelease("""{"versionCode": 3, "versionName": "0.3.0", "notes": ""}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a broken or incomplete version file is no release`() {
|
||||
assertNull(parseAppRelease("<html>404</html>"))
|
||||
assertNull(parseAppRelease("""{"versionName": "0.3.0"}"""))
|
||||
assertNull(parseAppRelease("""{"versionCode": "three", "versionName": "0.3.0"}"""))
|
||||
assertNull(parseAppRelease("""{"versionCode": 0, "versionName": "0.3.0"}"""))
|
||||
assertNull(parseAppRelease("""{"versionCode": 3}"""))
|
||||
assertNull(parseAppRelease("[]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a newer build is offered at launch`() = runTest(dispatcher) {
|
||||
val release = AppRelease(3, "0.3.0")
|
||||
val viewModel = AppUpdateViewModel({ release }, installedVersionCode = 2, installedVersionName = "0.2.0")
|
||||
viewModel.checkAtLaunch()
|
||||
advanceUntilIdle()
|
||||
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||
assertEquals(release, viewModel.state.value.prompt)
|
||||
viewModel.dismissPrompt()
|
||||
assertNull(viewModel.state.value.prompt)
|
||||
assertEquals(release, viewModel.state.value.available)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the launch check runs once`() = runTest(dispatcher) {
|
||||
var calls = 0
|
||||
val viewModel = AppUpdateViewModel({ calls++; AppRelease(3, "0.3.0") }, 2, "0.2.0")
|
||||
viewModel.checkAtLaunch()
|
||||
advanceUntilIdle()
|
||||
viewModel.dismissPrompt()
|
||||
viewModel.checkAtLaunch()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, calls)
|
||||
assertNull(viewModel.state.value.prompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same or an older build is up to date`() = runTest(dispatcher) {
|
||||
val viewModel = AppUpdateViewModel({ AppRelease(2, "0.2.0") }, 2, "0.2.0")
|
||||
viewModel.checkNow()
|
||||
advanceUntilIdle()
|
||||
assertEquals(UpdateCheck.UP_TO_DATE, viewModel.state.value.check)
|
||||
assertNull(viewModel.state.value.available)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a check by hand finds a newer build without the launch prompt`() = runTest(dispatcher) {
|
||||
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0")
|
||||
viewModel.checkNow()
|
||||
advanceUntilIdle()
|
||||
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||
assertNull(viewModel.state.value.prompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable version file is a failed check`() = runTest(dispatcher) {
|
||||
val viewModel = AppUpdateViewModel({ null }, 2, "0.2.0")
|
||||
viewModel.checkNow()
|
||||
advanceUntilIdle()
|
||||
assertEquals(UpdateCheck.FAILED, viewModel.state.value.check)
|
||||
}
|
||||
}
|
||||
@ -216,4 +216,43 @@ class UnifiedDiffParserTest {
|
||||
assertNull(added.leftNumber)
|
||||
assertEquals(2, added.rightNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lines whose own text starts with -- or ++ are kept inside a hunk`() {
|
||||
val patch = """
|
||||
diff --git a/query.sql b/query.sql
|
||||
--- a/query.sql
|
||||
+++ b/query.sql
|
||||
@@ -1,2 +1,2 @@
|
||||
--- old comment
|
||||
+++i;
|
||||
SELECT 1;
|
||||
""".trimIndent()
|
||||
|
||||
val rows = UnifiedDiffParser.parseFileEntries(patch, "query.sql").drop(1)
|
||||
|
||||
assertEquals(2, rows.size)
|
||||
assertEquals(DiffLine.Removed("-- old comment"), rows[0].left)
|
||||
assertEquals(DiffLine.Added("++i;"), rows[0].right)
|
||||
assertEquals(2, rows[1].leftNumber)
|
||||
assertEquals(2, rows[1].rightNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hunk header that cannot be read leaves its rows unnumbered instead of counting from 1`() {
|
||||
val patch = """
|
||||
diff --git a/foo.kt b/foo.kt
|
||||
--- a/foo.kt
|
||||
+++ b/foo.kt
|
||||
@@ something unexpected @@
|
||||
unchanged
|
||||
+added
|
||||
""".trimIndent()
|
||||
|
||||
val entries = UnifiedDiffParser.parseFileEntries(patch, "foo.kt")
|
||||
|
||||
assertEquals("@@ something unexpected @@", entries[0].hunkLabel)
|
||||
assertTrue(entries.drop(1).all { it.leftNumber == null && it.rightNumber == null })
|
||||
assertEquals(2, entries.drop(1).size)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
package com.ubisam.ubgitea.feature.compare
|
||||
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||
import com.ubisam.ubgitea.domain.model.CommitSummary
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import com.ubisam.ubgitea.domain.model.SourceContent
|
||||
import com.ubisam.ubgitea.domain.model.SourceFile
|
||||
import com.ubisam.ubgitea.domain.model.TagSummary
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CodeWorkbenchViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val repository = RepositoryCoordinates.of("owner", "repo")
|
||||
|
||||
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||
|
||||
@Test
|
||||
fun `tabs are kept, toggled off, and a sixth is refused`() {
|
||||
val settings = FakeSettings()
|
||||
val viewModel = CodeWorkbenchViewModel(FakeBrowser(), settings, defaultDispatcher = dispatcher)
|
||||
val tabs = (1..6).map { tab("src/F$it.kt") }
|
||||
tabs.take(5).forEach(viewModel::toggleTab)
|
||||
assertEquals(5, viewModel.state.value.tabs.size)
|
||||
|
||||
viewModel.toggleTab(tabs[5])
|
||||
assertEquals(tabs.take(5), viewModel.state.value.tabs)
|
||||
assertEquals(WorkbenchMessage.TABS_FULL, viewModel.state.value.message)
|
||||
|
||||
viewModel.toggleTab(tabs[0])
|
||||
assertEquals(tabs.subList(1, 5), viewModel.state.value.tabs)
|
||||
assertEquals(tabs.subList(1, 5).map(SavedCodeTab::encode), settings.tabs)
|
||||
|
||||
val reopened = CodeWorkbenchViewModel(FakeBrowser(), settings, defaultDispatcher = dispatcher)
|
||||
assertEquals(tabs.subList(1, 5), reopened.state.value.tabs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tab survives encoding, and damaged entries are dropped`() {
|
||||
val tab = tab("dir/a b.kt")
|
||||
assertEquals(tab, SavedCodeTab.decodeOrNull(tab.encode()))
|
||||
assertNull(SavedCodeTab.decodeOrNull("only\u001Ftwo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pinned side decides which file is on the left`() = runTest(dispatcher.scheduler) {
|
||||
val viewModel = CodeWorkbenchViewModel(FakeBrowser(), FakeSettings(), defaultDispatcher = dispatcher)
|
||||
val mine = file("main", "a\nb")
|
||||
val theirs = file("dev", "a\nc")
|
||||
viewModel.pin(mine, CompareSide.RIGHT)
|
||||
viewModel.compareWith(theirs)
|
||||
advanceUntilIdle()
|
||||
|
||||
val session = (viewModel.state.value.compare as CompareUiState.Ready).session
|
||||
assertEquals("dev", session.left.refLabel)
|
||||
assertEquals("main", session.right.refLabel)
|
||||
assertEquals(listOf(1), session.differences)
|
||||
assertNull(viewModel.state.value.pending)
|
||||
|
||||
viewModel.swapSides()
|
||||
advanceUntilIdle()
|
||||
assertEquals("main", (viewModel.state.value.compare as CompareUiState.Ready).session.left.refLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comparing a file with itself is refused`() {
|
||||
val viewModel = CodeWorkbenchViewModel(FakeBrowser(), FakeSettings(), defaultDispatcher = dispatcher)
|
||||
viewModel.pin(file("main", "a"), CompareSide.LEFT)
|
||||
viewModel.compareWith(file("main", "a"))
|
||||
assertEquals(WorkbenchMessage.SAME_FILE, viewModel.state.value.message)
|
||||
assertTrue(viewModel.state.value.compare is CompareUiState.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a commit's file is fetched at that commit and compared`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser()
|
||||
val viewModel = CodeWorkbenchViewModel(browser, FakeSettings(), defaultDispatcher = dispatcher)
|
||||
viewModel.pin(file("main", "x"), CompareSide.LEFT)
|
||||
viewModel.useCommitFile(repository, "abcdef0123", "abcdef0", "src/F.kt", side = null)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("abcdef0123"), browser.refs)
|
||||
val session = (viewModel.state.value.compare as CompareUiState.Ready).session
|
||||
assertEquals("abcdef0", session.right.refLabel)
|
||||
assertEquals(listOf("at abcdef0123"), session.rightLines)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a commit's file that cannot be fetched says so and keeps the pin`() = runTest(dispatcher.scheduler) {
|
||||
val viewModel = CodeWorkbenchViewModel(FakeBrowser(failure = ApiFailure.NotFound()), FakeSettings(), defaultDispatcher = dispatcher)
|
||||
viewModel.pin(file("main", "x"), CompareSide.LEFT)
|
||||
viewModel.useCommitFile(repository, "abc", "abc", "src/F.kt", side = null)
|
||||
advanceUntilIdle()
|
||||
assertEquals(WorkbenchMessage.FILE_UNAVAILABLE, viewModel.state.value.message)
|
||||
assertEquals("main", viewModel.state.value.pending?.file?.refLabel)
|
||||
}
|
||||
|
||||
private fun tab(path: String) = SavedCodeTab("owner", "repo", "BRANCH", "main", path)
|
||||
|
||||
private fun file(ref: String, text: String) = ComparedFile(repository, ref, "src/F.kt", text)
|
||||
|
||||
private class FakeSettings : AppSettingsStore {
|
||||
var tabs: List<String> = emptyList()
|
||||
override fun isReactiveAuthRetryEnabled() = false
|
||||
override fun setReactiveAuthRetryEnabled(enabled: Boolean) = Unit
|
||||
override fun isFavoritePushNotificationsEnabled() = false
|
||||
override fun setFavoritePushNotificationsEnabled(enabled: Boolean) = Unit
|
||||
override fun savedCodeTabs(): List<String> = tabs
|
||||
override fun setSavedCodeTabs(tabs: List<String>) {
|
||||
this.tabs = tabs
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeBrowser(private val failure: Throwable? = null) : GiteaRepositoryBrowser {
|
||||
val refs = mutableListOf<String>()
|
||||
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> = Page.of(emptyList(), null)
|
||||
override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> = Page.of(emptyList(), null)
|
||||
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> = Page.of(emptyList(), null)
|
||||
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> =
|
||||
Page.of(emptyList(), null)
|
||||
override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile {
|
||||
refs += ref.value
|
||||
failure?.let { throw it }
|
||||
return SourceFile(path, ref, SourceContent.from("at ${ref.value}".encodeToByteArray()), "text/plain")
|
||||
}
|
||||
override suspend fun listCommits(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath?, page: Int, pageSize: Int): Page<CommitSummary> =
|
||||
Page.of(emptyList(), null)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package com.ubisam.ubgitea.feature.compare
|
||||
|
||||
import com.ubisam.ubgitea.feature.compare.CompareRowKind.CHANGED
|
||||
import com.ubisam.ubgitea.feature.compare.CompareRowKind.LEFT_ONLY
|
||||
import com.ubisam.ubgitea.feature.compare.CompareRowKind.RIGHT_ONLY
|
||||
import com.ubisam.ubgitea.feature.compare.CompareRowKind.SAME
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LineDiffTest {
|
||||
@Test
|
||||
fun identicalFilesAreAllSame() {
|
||||
val lines = listOf("a", "b", "c")
|
||||
assertEquals(
|
||||
listOf(CompareRow(SAME, 0, 0), CompareRow(SAME, 1, 1), CompareRow(SAME, 2, 2)),
|
||||
compareLines(lines, lines),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aChangedLineIsPairedWithItsReplacement() {
|
||||
val rows = compareLines(listOf("a", "b", "c"), listOf("a", "x", "c"))
|
||||
assertEquals(listOf(CompareRow(SAME, 0, 0), CompareRow(CHANGED, 1, 1), CompareRow(SAME, 2, 2)), rows)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInsertedLineLeavesABlankOnTheLeft() {
|
||||
val rows = compareLines(listOf("a", "c"), listOf("a", "b", "c"))
|
||||
assertEquals(listOf(CompareRow(SAME, 0, 0), CompareRow(RIGHT_ONLY, null, 1), CompareRow(SAME, 1, 2)), rows)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRemovedLineLeavesABlankOnTheRight() {
|
||||
val rows = compareLines(listOf("a", "b", "c"), listOf("a", "c"))
|
||||
assertEquals(listOf(CompareRow(SAME, 0, 0), CompareRow(LEFT_ONLY, 1, null), CompareRow(SAME, 2, 1)), rows)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedLinesInTheMiddleStayLevel() {
|
||||
val left = listOf("1", "x", "keep", "y", "2")
|
||||
val right = listOf("1", "keep", "z", "w", "2")
|
||||
val rows = compareLines(left, right)
|
||||
val keep = rows.single { it.kind == SAME && it.left == 2 }
|
||||
assertEquals(1, keep.right)
|
||||
assertEveryLineAppearsOnceInOrder(rows, left.size, right.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySides() {
|
||||
assertEquals(listOf(CompareRow(RIGHT_ONLY, null, 0)), compareLines(emptyList(), listOf("a")))
|
||||
assertEquals(listOf(CompareRow(LEFT_ONLY, 0, null)), compareLines(listOf("a"), emptyList()))
|
||||
assertEquals(emptyList<CompareRow>(), compareLines(emptyList(), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tooManyEditsFallsBackToOneChangedBlock() {
|
||||
val left = (0 until 50).map { "L$it" } + "end"
|
||||
val right = (0 until 40).map { "R$it" } + "end"
|
||||
val rows = compareLines(left, right, maxEdits = 10)
|
||||
assertEquals(40, rows.count { it.kind == CHANGED })
|
||||
assertEquals(10, rows.count { it.kind == LEFT_ONLY })
|
||||
assertEquals(CompareRow(SAME, 50, 40), rows.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun randomEditsKeepEveryLineInOrder() {
|
||||
val random = java.util.Random(7)
|
||||
repeat(200) {
|
||||
val left = List(random.nextInt(30)) { "l${random.nextInt(6)}" }
|
||||
val right = List(random.nextInt(30)) { "l${random.nextInt(6)}" }
|
||||
val rows = compareLines(left, right)
|
||||
assertEveryLineAppearsOnceInOrder(rows, left.size, right.size)
|
||||
rows.filter { it.kind == SAME }.forEach { assertEquals(left[it.left!!], right[it.right!!]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differenceStartsMarksTheFirstRowOfEachRun() {
|
||||
val rows = listOf(
|
||||
CompareRow(SAME, 0, 0),
|
||||
CompareRow(CHANGED, 1, 1),
|
||||
CompareRow(LEFT_ONLY, 2, null),
|
||||
CompareRow(SAME, 3, 2),
|
||||
CompareRow(RIGHT_ONLY, null, 3),
|
||||
)
|
||||
assertEquals(listOf(1, 4), differenceStarts(rows))
|
||||
}
|
||||
|
||||
private fun assertEveryLineAppearsOnceInOrder(rows: List<CompareRow>, leftSize: Int, rightSize: Int) {
|
||||
assertEquals((0 until leftSize).toList(), rows.mapNotNull { it.left })
|
||||
assertEquals((0 until rightSize).toList(), rows.mapNotNull { it.right })
|
||||
}
|
||||
}
|
||||
@ -172,6 +172,36 @@ class RepositoryListViewModelTest {
|
||||
assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests)
|
||||
}
|
||||
|
||||
@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"))))
|
||||
@ -461,6 +491,30 @@ class RepositoryListViewModelTest {
|
||||
assertEquals(listOf(RepositoryCoordinates.of("owner", "repo")), browser.starredCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second tap while the first star request is still on its way is ignored`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo", starsCount = 2))))
|
||||
val viewModel = viewModel(browser)
|
||||
advanceUntilIdle()
|
||||
|
||||
val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
|
||||
viewModel.toggleFavorite(row)
|
||||
val afterFirstTap = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
|
||||
viewModel.toggleFavorite(afterFirstTap)
|
||||
advanceUntilIdle()
|
||||
|
||||
val settled = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
|
||||
assertEquals(FavoriteActionUiState.Starred, settled.favoriteAction)
|
||||
assertEquals(3, settled.starsCount)
|
||||
assertEquals(listOf(RepositoryCoordinates.of("owner", "repo")), browser.starredCalls)
|
||||
assertEquals(emptyList<RepositoryCoordinates>(), browser.unstarredCalls)
|
||||
|
||||
// Once the first request has settled, the next tap goes through as usual.
|
||||
viewModel.toggleFavorite(settled)
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(RepositoryCoordinates.of("owner", "repo")), browser.unstarredCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a repeat tap on an already-starred row from the all-repositories list un-stars it`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo", starsCount = 4))))
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@ -1410,3 +1410,84 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- Checks and results: the required checks pass; 437 unit tests, 0 failures; lint 0 errors.
|
||||
- Live verification: installed on the SM-F966N; not seen on a screen by the implementer.
|
||||
- Next owner: the product owner, to confirm on the device.
|
||||
|
||||
## 2026-09-28 (continued) — TASK-061: a tab on the left edge, and a new app icon
|
||||
|
||||
- Request: the human product owner asked for a mark showing that the side pane can be drawn out
|
||||
from the left edge of the code, and for a more fitting app icon.
|
||||
- What changed: see `tasks/061-edge-tab-and-app-icon.md`.
|
||||
- Checks and results: the required checks pass; 437 unit tests, 0 failures; lint 0 errors.
|
||||
- Live verification: installed on the SM-F966N; neither change was seen on the device by the
|
||||
implementer.
|
||||
- Next owner: the product owner, to confirm on the device.
|
||||
|
||||
## 2026-09-28 (continued) — TASK-062: a batch file that syncs the main folder
|
||||
|
||||
- Request: the human product owner asked for a batch file for the sync they run by hand.
|
||||
- What changed: `tools/sync-main-folder.bat`. Fast-forward only; tracked changes stop it; files
|
||||
in the way are stashed, never deleted; `master` is moved to the same commit.
|
||||
- Verification: run three times against a throwaway clone (files in the way, already up to
|
||||
date, uncommitted change). Not run against the main folder, which is the owner's to run.
|
||||
- Correction, same day: the first version failed when the owner ran it. Its Korean text was read
|
||||
by `cmd` as commands, and the files it stashed were still in the folder afterwards, so the
|
||||
merge was refused. Why the stash left them there was not found; it did not happen in a copy.
|
||||
The logic now lives in `tools/sync-main-folder.ps1` (UTF-8 with a byte order mark), moves the
|
||||
files in the way into `.git/sync-backup/` itself, and the batch file is plain ASCII. Run four
|
||||
ways against a throwaway clone, including a Korean file name, from a code page 949 console.
|
||||
|
||||
## 2026-09-29 — TASK-063: task records brought up to date
|
||||
|
||||
- Request: after a full code review, the human product owner said Codex is not used, every
|
||||
redesign item installed on their Z Fold7 is confirmed, the release-hardening leftovers are on
|
||||
hold, and biometric reauthentication is out.
|
||||
- What changed: no task waits on a Codex review any more; TASK-045 and TASK-049 to TASK-061 are
|
||||
marked confirmed; TASK-016's two open items are on hold; `docs/product.md` records no biometric
|
||||
reauthentication and has no open product decisions left. Owner/reviewer history is unchanged.
|
||||
|
||||
## 2026-09-29 (continued) — TASK-064: star double tap, dropped diff lines
|
||||
|
||||
- Found by the full review. A quick double tap on a star sent a star and an unstar together, and
|
||||
whichever reached the NAS last won; a second tap is now ignored until the first request settles.
|
||||
- Found while fixing the diff parser: a changed line whose own text starts with `--` or `++` was
|
||||
dropped from the diff, because the parser skipped every `---`/`+++` line, not only the file's
|
||||
own header lines. SQL/Lua comments, Markdown `---` and a `++i;` at the start of a line were
|
||||
affected, and the line numbers below them were off. A hunk header the parser cannot read now
|
||||
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.
|
||||
|
||||
## 2026-10-12 — TASK-067: source and APK on the team's Gitea
|
||||
|
||||
- Request: the human product owner named the upload address and asked for the APK to be
|
||||
downloadable from there.
|
||||
- What changed: TASK-063/064 merged with TASK-065/066; version 0.2.1 (versionCode 3), since the
|
||||
build differs from the 0.2.0 on the download page; `origin` added; source pushed as `main`; the
|
||||
APK pushed alone on an `apk` branch as `UBGitea.apk`.
|
||||
- The repository is public and the product owner chose to publish as is. The in-app update check
|
||||
still reads the download page's `version.json` (versionCode 2), so 0.2.1 is not offered there.
|
||||
- Verification: the five required checks pass on the merged branch; 471 unit tests, 0 failures.
|
||||
|
||||
@ -60,7 +60,8 @@ The first release is read-oriented. Creating, merging, deleting, or administerin
|
||||
- Repositories are cached for offline browsing at the list level only (branches/files inside a repository are not cached); see TASK-044
|
||||
- Comparing branches and commits was removed from the history screen in TASK-024
|
||||
- The team mainly reads C# and C++; source viewing is tuned for those first (see TASK-049, TASK-053, TASK-054)
|
||||
- No biometric reauthentication; the app relies on the device lock and the OAuth session (decided 2026-09-29)
|
||||
|
||||
## Open product decisions
|
||||
|
||||
- Whether biometric reauthentication is required
|
||||
- None
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-007: View commit history and changes
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review
|
||||
Status: implemented; all required checks pass; no review pending (Codex retired, 2026-09-29)
|
||||
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-008: Browse pull requests and issues
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review
|
||||
Status: implemented; all required checks pass; no review pending (Codex retired, 2026-09-29)
|
||||
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-009: Implement history and diff API contracts
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review and contract freeze
|
||||
Status: implemented; all required checks pass; no review pending (Codex retired, 2026-09-29)
|
||||
Owner: Claude (human product owner directed Claude to implement this directly rather than wait for Codex, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-010: Implement pull-request and issue API contracts
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review and contract freeze
|
||||
Status: implemented; all required checks pass; no review pending (Codex retired, 2026-09-29)
|
||||
Owner: Claude (ownership transferred by human product owner)
|
||||
Reviewer: Codex
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-013: Typed application navigation
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review
|
||||
Status: implemented; all required checks pass; no review pending (Codex retired, 2026-09-29)
|
||||
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-014: Browse organizations and filter repositories by organization
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review
|
||||
Status: implemented; all required checks pass; no review pending (Codex retired, 2026-09-29)
|
||||
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-015: Read-only notifications
|
||||
|
||||
Status: implemented, pending Codex review
|
||||
Status: implemented; no review pending (Codex retired, 2026-09-29)
|
||||
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
@ -71,4 +71,4 @@ A signed-in user can see their Gitea notifications and open the related reposito
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex review.
|
||||
None recorded. No review is pending: Codex was retired from the project (2026-09-29).
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-016: Release hardening
|
||||
|
||||
Status: partially implemented — device-dependent items explicitly blocked, see Acceptance criteria
|
||||
Status: partially implemented — the two open items (R8 minification, on-device regression of every authenticated screen) are on hold by the human product owner's decision (2026-09-29)
|
||||
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
@ -64,4 +64,4 @@ The parts of release hardening that can be verified without an authenticated Git
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review, with the two explicitly blocked acceptance criteria above flagged for whoever picks this up next.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29), with the two explicitly blocked acceptance criteria above flagged for whoever picks this up next.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-017: Sort repositories by recent push, page the list, and show the last-pushed branch
|
||||
|
||||
Status: implemented; all required checks pass; pending Codex review
|
||||
Status: implemented; all required checks pass; no review pending (Codex retired, 2026-09-29)
|
||||
Owner: Claude (human product owner priority-1 request, 2026-08-26)
|
||||
Reviewer: Codex
|
||||
|
||||
|
||||
@ -45,4 +45,4 @@ Reproduced live on the human product owner's authenticated device via wireless `
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -64,4 +64,4 @@ The repository reference screen was the *only* entry point that reached `Route.C
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -79,4 +79,4 @@ Plus seven more follow-up fixes/changes found or requested during the same live-
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -88,4 +88,4 @@ not be worked around by moving login into an in-app WebView.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -244,4 +244,4 @@ the Files/History tab to change underneath it.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -134,4 +134,4 @@ previous/next, since Gitea's endpoints for those don't report a total count —
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -168,4 +168,4 @@ OAuth `refresh_token` grant is specifically designed to do without ever re-promp
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -160,4 +160,4 @@ primary language and Gitea star count, with a tappable star toggle wired to the
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -145,4 +145,4 @@ timeout to answer.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -82,4 +82,4 @@ subject of this session's earlier gesture-conflict fixes).
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -141,4 +141,4 @@ exceptions to the read-only invariant.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -123,4 +123,4 @@ starts; the update is only rolled back if the request actually turns out to have
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -81,4 +81,4 @@ None identified.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -224,4 +224,4 @@ why it's the default branch specifically, not necessarily the branch that was ac
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -98,4 +98,4 @@ any repository that's genuinely already starred server-side.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -156,4 +156,4 @@ Settings screen so neither one silently changes existing behavior for everyone:
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -130,4 +130,4 @@ notifications, settings) in both light and dark mode:
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -77,4 +77,4 @@ None outstanding for this task.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -110,4 +110,4 @@ None outstanding for this task.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -101,4 +101,4 @@ the work-items screens' badge.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -69,4 +69,4 @@ None outstanding for this task.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -117,4 +117,4 @@ for a live view.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
# TASK-045: Sky blue + white redesign, UbiSam logo
|
||||
|
||||
Status: implemented; all required checks pass; awaiting live device confirmation (wireless adb
|
||||
pairing is currently broken on the human product owner's PC -- see Known limitations)
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: Codex
|
||||
|
||||
@ -106,4 +105,4 @@ product owner shared.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet -- pending Codex/human review.
|
||||
None yet -- no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -105,4 +105,4 @@ therefore a pure win: Gitea skips work the app already threw away, with no loss
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet -- pending Codex/human review.
|
||||
None yet -- no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -110,4 +110,4 @@ product owner's choice between real trade-offs (see Known limitations).
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet — pending Codex/human review.
|
||||
None yet — no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -134,4 +134,4 @@ notes' numbered account).
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet -- pending Codex/human review.
|
||||
None yet -- no Codex review pending (Codex retired, 2026-09-29).
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-049: C/C++ syntax highlighting and multi-line comment coloring
|
||||
|
||||
Status: implemented; see Evidence for check results; device confirmation pending
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-050: App shell: four tabs, bright sky theme, real logo
|
||||
|
||||
Status: implemented; required checks pass; not yet confirmed on a device
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-051: Home: team activity
|
||||
|
||||
Status: implemented; required checks pass; not yet confirmed on a device
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-052: Work tab: issues and pull requests across repositories
|
||||
|
||||
Status: implemented; required checks pass; not yet confirmed on a device
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-053: Code viewer: full screen, search, outline, Visual Studio colors
|
||||
|
||||
Status: implemented; required checks pass; not yet confirmed on a device
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-054: C++ header/source pairs and unfolded-window layouts
|
||||
|
||||
Status: implemented; required checks pass; not yet confirmed on a device
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-055: First device feedback on the redesign
|
||||
|
||||
Status: implemented; required checks pass; installed on the product owner's Z Fold7, each item awaiting their confirmation
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-056: Reading gestures, a resizable pair, and fast directory listing
|
||||
|
||||
Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-057: Side pane redesign, folder tree, and the unfolded Home
|
||||
|
||||
Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-058: The outline was empty for C++ written on Windows
|
||||
|
||||
Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-059: From a function in the header to its definition in the source
|
||||
|
||||
Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-060: Tapping a function's name in the code
|
||||
|
||||
Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
|
||||
60
tasks/061-edge-tab-and-app-icon.md
Normal file
60
tasks/061-edge-tab-and-app-icon.md
Normal file
@ -0,0 +1,60 @@
|
||||
# TASK-061: A tab on the left edge, and a new app icon
|
||||
|
||||
Status: implemented; required checks pass; confirmed by the human product owner on their Z Fold7 (2026-09-29)
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Outcome
|
||||
|
||||
Requested by the human product owner, 2026-09-28: nothing on the code screen said that the side
|
||||
pane could be drawn out from the left edge, and the app icon should be changed to suit the app.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included:
|
||||
1. A small tab at the middle of the code's left edge, with an arrow. It marks where the pane is
|
||||
drawn out from, and tapping it opens the pane as well. It is not shown where the pane is
|
||||
already beside the code.
|
||||
2. App icon: the background is the app's bright sky blue running to its deeper primary (it was
|
||||
a flat darker blue from before the redesign); the branch glyph's commits are drawn as rings.
|
||||
The themed (monochrome) layer has the same shape.
|
||||
- Excluded: using the UbiSam logo in the icon. It is a wordmark, and unreadable at icon size.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt`
|
||||
- `app/src/main/res/drawable/ic_launcher_*.xml`
|
||||
- `app/src/main/res/values/strings_codebrowser.xml`
|
||||
- `tasks/`, `docs/`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The tab has a spoken description and a touch area wider than what is drawn.
|
||||
- [x] The icon's glyph stays inside the 66dp safe circle of the adaptive icon.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
No additional concerns.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- Required checks pass: 437 unit tests, 0 failures; lint 0 errors.
|
||||
- Installed on the SM-F966N over wireless `adb`.
|
||||
- The icon was looked at only as a rough drawing made on a PC from the same coordinates, before
|
||||
the glyph was made smaller to fit the safe circle; not on a launcher. The tab has not been seen
|
||||
on a screen by the implementer.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The tab covers a strip 14dp wide and 56dp tall of the line numbers behind it.
|
||||
- A launcher may keep showing the old icon until it refreshes its cache.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet.
|
||||
53
tasks/063-record-cleanup-after-review.md
Normal file
53
tasks/063-record-cleanup-after-review.md
Normal file
@ -0,0 +1,53 @@
|
||||
# TASK-063: Bring the task records up to date after the full review
|
||||
|
||||
Status: complete
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Outcome
|
||||
|
||||
The task files say what is actually open. Nothing waits on a Codex review, the redesign tasks the
|
||||
product owner has confirmed on their device say so, the release-hardening leftovers are on hold,
|
||||
and biometric reauthentication is decided.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included: status lines and review-finding lines in `tasks/`, the Codex sentence in `AGENTS.md`,
|
||||
product decisions in `docs/product.md`, the development log.
|
||||
- Excluded: owner/reviewer history. Tasks Codex owned or reviewed keep saying so.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `tasks/`
|
||||
- `AGENTS.md`
|
||||
- `docs/product.md`
|
||||
- `docs/development-log.md`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] No task status or review-finding line waits on Codex. Codex is not used (product owner,
|
||||
2026-09-29).
|
||||
- [x] TASK-045 and TASK-049 to TASK-061 are marked confirmed on the product owner's Z Fold7
|
||||
(product owner, 2026-09-29).
|
||||
- [x] TASK-016's two open items (R8 minification, on-device regression of every authenticated
|
||||
screen) are marked on hold (product owner, 2026-09-29).
|
||||
- [x] `docs/product.md` records that there is no biometric reauthentication, and has no open
|
||||
product decisions left.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
No additional concerns.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
grep -rn "pending Codex" tasks AGENTS.md -> only "no Codex review pending" remains
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- The grep above finds no remaining wait on Codex.
|
||||
|
||||
## Review findings
|
||||
|
||||
None.
|
||||
59
tasks/064-review-fixes-star-double-tap-and-diff-lines.md
Normal file
59
tasks/064-review-fixes-star-double-tap-and-diff-lines.md
Normal file
@ -0,0 +1,59 @@
|
||||
# TASK-064: Fixes from the full review — star double tap, diff lines
|
||||
|
||||
Status: implemented; required checks pass; not yet confirmed on a device
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Outcome
|
||||
|
||||
1. A quick double tap on a repository's star no longer sends a star and an unstar together.
|
||||
2. A changed line whose own text starts with `--` or `++` (an SQL or Lua comment, a Markdown
|
||||
`---`, a C++ `++i;` at the start of a line) shows in the diff. Before, it was dropped, and the
|
||||
line numbers under it were off by one per dropped line.
|
||||
3. A hunk header the parser cannot read leaves its rows unnumbered, instead of numbering them from
|
||||
1.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included: `RepositoryListViewModel.toggleFavorite`, `UnifiedDiffParser`.
|
||||
- Excluded: anything else the review looked at.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/domain/history/UnifiedDiffParser.kt`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/domain/history/UnifiedDiffParserTest.kt`
|
||||
- `tasks/064-review-fixes-star-double-tap-and-diff-lines.md`
|
||||
- `docs/development-log.md`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] While a star or unstar request for a repository is on its way, another tap on that
|
||||
repository's star does nothing. After it settles, the next tap works as before.
|
||||
- [x] Inside a hunk, `+++x` is an added `++x` and `--- x` is a removed `-- x`. The file's own
|
||||
`---`/`+++` lines, which come before the first hunk, are still skipped.
|
||||
- [x] A hunk header that does not match `@@ -a[,b] +c[,d] @@` gives rows with no line numbers, and
|
||||
its raw text is the hunk label.
|
||||
- [x] Unit tests cover all three.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
No additional concerns.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
See the development log entry for TASK-064.
|
||||
|
||||
## Review findings
|
||||
|
||||
Found by the full code review of 2026-09-29. Other reported candidates were checked against the
|
||||
code and dropped: the offline cache cannot show another account's list (every logout path clears
|
||||
it), state updates run on the main thread without a suspension between read and write, and the
|
||||
highlighter's patterns cannot match an empty string.
|
||||
76
tasks/065-code-tabs-compare-and-reading-aids.md
Normal file
76
tasks/065-code-tabs-compare-and-reading-aids.md
Normal file
@ -0,0 +1,76 @@
|
||||
# TASK-065: Saved code tabs, comparing two files, and reading aids
|
||||
|
||||
Status: done
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Outcome
|
||||
|
||||
Requested by the human product owner, 2026-10-07:
|
||||
|
||||
1. Code files can be kept as tabs. Saving a tab puts a strip between the screen and the bottom
|
||||
tabs; each saved file stays there, at most five, and tapping one goes back to it.
|
||||
2. Two files can be compared side by side like a merge tool: the current file is placed on the
|
||||
left or right, then another file (from another commit or another place) is opened and the
|
||||
same button pressed again to compare the two.
|
||||
3. On the unfolded screen the side pane beside the code can be resized by dragging; near its
|
||||
default width it snaps to the default like a magnet.
|
||||
4. Tapping a word in the code marks every occurrence of the same name.
|
||||
5. Pressing the Repositories tab again while inside a repository returns to the repository list
|
||||
page that was being viewed; pressing it again there goes to the first page.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included: the five items above.
|
||||
- Excluded: editing or merging files; comparing directories; three-way comparison.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `app/src/main/java/com/ubisam/ubgitea/`
|
||||
- `app/src/main/res/values/`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/`
|
||||
- `tasks/`, `docs/`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The code viewer has a "save tab" button; a saved file shows as a chip in a strip above the
|
||||
bottom tabs (and above the viewer's toolbar when reading full screen). Tapping a chip opens
|
||||
that file in its repository and ref; its close mark removes it. Tabs survive a restart.
|
||||
- [ ] A sixth tab is refused with a message; nothing is evicted silently.
|
||||
- [ ] The compare button asks whether the current file goes left or right, then a banner shows
|
||||
the waiting file. Pressing compare on another file (in the code viewer or a commit's file
|
||||
diff) opens a two-column view where matching lines stay level, differing lines are
|
||||
coloured, and there are next/previous difference and swap-sides actions.
|
||||
- [ ] The line comparison is a pure function with unit tests, and gives up gracefully on files
|
||||
that differ too much.
|
||||
- [ ] The side pane width is dragged with a handle, kept between launches, snaps to the default
|
||||
within 16dp, and resets on double tap.
|
||||
- [ ] Tapping an identifier highlights all whole-word occurrences in a colour distinct from
|
||||
search hits; tapping it again or tapping a non-word clears it.
|
||||
- [ ] Repositories tab reselect: inside a repository → the list page last viewed; on the list
|
||||
with page > 1 → page 1. Other tabs behave as before.
|
||||
- [ ] All new text is in string resources; new controls have spoken labels.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
No additional concerns. Saved tabs hold only owner, repository name, ref and path; no tokens.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- 2026-10-07: the required checks pass; 461 unit tests, 0 failures; lint 0 errors.
|
||||
- New tests: `LineDiffTest`, `CodeWorkbenchViewModelTest`, `openFile` in
|
||||
`CodeBrowserViewModelTest`, `reselect` in `RepositoryListViewModelTest`, `snapTo` in
|
||||
`SplitTest`, `wordAt` and `findWordOccurrences` in `CodeSearchTest`, and a file route and an
|
||||
unrestored comparison in `RouteEncodingTest`.
|
||||
- Live verification: none. No device was connected; the tab strip, the compare screen, the
|
||||
drag handle's snap and the word marks were not seen on a screen by the implementer.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet.
|
||||
51
tasks/066-download-page-and-update-check.md
Normal file
51
tasks/066-download-page-and-update-check.md
Normal file
@ -0,0 +1,51 @@
|
||||
# TASK-066: download page and in-app update check
|
||||
|
||||
Status: done
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Request
|
||||
|
||||
The human product owner asked for a web link to download the app, protected by a download
|
||||
password they chose, and for the app to offer the newest version when it is not on it. They chose
|
||||
to host it in a folder of the existing `choimy0204.github.io` site.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `https://choimy0204.github.io/ubgitea/` serves a page that asks for the password and only then
|
||||
gives the APK. The APK on the host is encrypted (AES-256-GCM, key from PBKDF2-SHA256), so its
|
||||
address alone gives nothing. The password is not in this repository.
|
||||
- `version.json` beside it names the newest versionCode, versionName and notes.
|
||||
- The app reads it once at launch; when it is newer than the installed build, a dialog offers the
|
||||
download page. Settings shows the installed version, a check button, and the download page when
|
||||
something newer is out. No Gitea token is sent to that host.
|
||||
- `tools/publish-download.py` builds the page from an APK; versionCode is raised for every build
|
||||
published.
|
||||
|
||||
## Writable paths
|
||||
|
||||
`app/build.gradle.kts`, `app/src/main/java/com/ubisam/ubgitea/core/update/`, `MainActivity.kt`,
|
||||
`feature/settings/SettingsScreen.kt`, `res/values/strings_update.xml`, `app/src/test/.../core/update/`,
|
||||
`tools/publish-download.py`, `docs/development-log.md`, this file.
|
||||
|
||||
## Checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- 2026-10-07: the required checks pass; 468 unit tests, 0 failures; lint 0 errors.
|
||||
- The page's decryption was run with Node's WebCrypto (the browser's API): the right password
|
||||
gives a file byte-identical to `app-debug.apk`, a wrong one is refused.
|
||||
- Published version 0.2.0 (versionCode 2), the debug build, to `choimy0204.github.io/ubgitea/`.
|
||||
- Not seen: the page opened on a phone, the install over an existing copy, the in-app dialog.
|
||||
|
||||
## Review findings
|
||||
|
||||
- 2026-10-07, product owner: a wrong password still downloaded. In a desktop browser a wrong
|
||||
password got nothing, but the page fetched the encrypted APK before checking the password,
|
||||
and a phone browser can keep that fetch as a download (still encrypted, not installable). The
|
||||
page now opens a small sealed check with the password first and fetches nothing when it is
|
||||
wrong; tried in the browser: a wrong password made no request, the right one downloaded.
|
||||
52
tasks/067-publish-to-team-gitea.md
Normal file
52
tasks/067-publish-to-team-gitea.md
Normal file
@ -0,0 +1,52 @@
|
||||
# TASK-067: Publish the source and an installable APK to the team's Gitea
|
||||
|
||||
Status: implemented; pushed; download link not yet opened on a device
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Outcome
|
||||
|
||||
The repository lives at
|
||||
`https://gitea.ubisampaju.synology.me/RobotAutomationDevelopment-Dept/Ubisam-MobileGit.git`, and
|
||||
the APK can be downloaded from a fixed link on its `apk` branch.
|
||||
|
||||
## Scope
|
||||
|
||||
- Included: merging TASK-063/064 with TASK-065/066, version 0.2.1 (versionCode 3), the `origin`
|
||||
remote, a `main` branch with the source, an `apk` branch holding only `UBGitea.apk`.
|
||||
- Excluded: the password-protected download page and its `version.json`, which stay as they are.
|
||||
|
||||
## Writable paths
|
||||
|
||||
- `app/build.gradle.kts`
|
||||
- `tasks/067-publish-to-team-gitea.md`
|
||||
- `docs/development-log.md`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The merged branch passes the required checks.
|
||||
- [x] The upload address is not written in any tracked file except this task and the log; a
|
||||
change of address needs only `git remote set-url origin <new address>`.
|
||||
- [x] `main` on the remote is the merged source; `apk` holds only the APK, so the source history
|
||||
carries no binaries.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
The repository is public on the team's Gitea: anyone who can reach the server can read the source
|
||||
and download the APK without logging in. The product owner chose this (2026-10-12). No secrets are
|
||||
tracked; `local.properties` and signing keys are not in the repository. The APK is signed with
|
||||
this PC's debug key.
|
||||
|
||||
## Required checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
See the development log entry for TASK-067.
|
||||
|
||||
## Review findings
|
||||
|
||||
None.
|
||||
187
tools/publish-download.py
Normal file
187
tools/publish-download.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""Builds the password-protected download page for one APK (TASK-066).
|
||||
|
||||
The APK is encrypted with AES-256-GCM under a key derived from the password with PBKDF2-SHA256,
|
||||
so the files can sit on a public static host: the page asks for the password and decrypts in the
|
||||
browser with WebCrypto. version.json beside it is plain; the app reads it to know a newer build
|
||||
is out.
|
||||
|
||||
python tools/publish-download.py <apk> <out-dir> --notes "what changed"
|
||||
|
||||
The password is read from UBGITEA_DOWNLOAD_PASSWORD, or asked for. It is never written anywhere.
|
||||
versionCode and versionName are read from app/build.gradle.kts.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import getpass
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
ITERATIONS = 310_000
|
||||
ENCRYPTED_NAME = "ubgitea.apk.enc"
|
||||
CHECK_TEXT = b"UBGitea"
|
||||
|
||||
|
||||
def gradle_version(root: Path):
|
||||
text = (root / "app" / "build.gradle.kts").read_text(encoding="utf-8")
|
||||
code = re.search(r"versionCode\s*=\s*(\d+)", text)
|
||||
name = re.search(r'versionName\s*=\s*"([^"]+)"', text)
|
||||
if not code or not name:
|
||||
sys.exit("versionCode / versionName not found in app/build.gradle.kts")
|
||||
return int(code.group(1)), name.group(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("apk", type=Path)
|
||||
parser.add_argument("out", type=Path)
|
||||
parser.add_argument("--notes", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
password = os.environ.get("UBGITEA_DOWNLOAD_PASSWORD") or getpass.getpass("Download password: ")
|
||||
if not password:
|
||||
sys.exit("A password is required.")
|
||||
code, name = gradle_version(Path(__file__).resolve().parent.parent)
|
||||
|
||||
data = args.apk.read_bytes()
|
||||
salt = os.urandom(16)
|
||||
iv = os.urandom(12)
|
||||
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, ITERATIONS, 32)
|
||||
sealed = AESGCM(key).encrypt(iv, data, None)
|
||||
# A few sealed bytes the page opens first: a wrong password is refused before the APK is
|
||||
# fetched at all, so nothing is downloaded with it.
|
||||
check_iv = os.urandom(12)
|
||||
check = AESGCM(key).encrypt(check_iv, CHECK_TEXT, None)
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
(args.out / ENCRYPTED_NAME).write_bytes(sealed)
|
||||
release = {"versionCode": code, "versionName": name, "notes": args.notes}
|
||||
(args.out / "version.json").write_text(json.dumps(release, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
meta = {
|
||||
"salt": base64.b64encode(salt).decode(),
|
||||
"iv": base64.b64encode(iv).decode(),
|
||||
"iterations": ITERATIONS,
|
||||
"check": base64.b64encode(check).decode(),
|
||||
"checkIv": base64.b64encode(check_iv).decode(),
|
||||
"file": ENCRYPTED_NAME,
|
||||
"fileName": f"UBGitea-{name}.apk",
|
||||
"size": len(data),
|
||||
}
|
||||
page = PAGE.replace("{{META}}", json.dumps(meta)) \
|
||||
.replace("{{VERSION}}", html.escape(name)) \
|
||||
.replace("{{NOTES}}", html.escape(args.notes)) \
|
||||
.replace("{{SIZE}}", f"{len(data) / 1024 / 1024:.1f} MB")
|
||||
(args.out / "index.html").write_text(page, encoding="utf-8")
|
||||
print(f"UBGitea {name} (versionCode {code}) -> {args.out}")
|
||||
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>UBGitea 다운로드</title>
|
||||
<style>
|
||||
:root { --bg: #f4f7fc; --card: #ffffff; --ink: #14213d; --muted: #5b6b86; --line: #d8e1f0;
|
||||
--blue: #1f5fd6; --blue-ink: #ffffff; --bad: #c62828; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #0d1524; --card: #16213a; --ink: #e8eefb; --muted: #9fb0cf; --line: #2a3a5c;
|
||||
--blue: #6d9cff; --blue-ink: #0d1524; --bad: #ff8a80; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
main { width: 100%; max-width: 420px; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 20px; padding: 28px 24px; }
|
||||
h1 { margin: 0 0 4px; font-size: 24px; }
|
||||
.version { color: var(--blue); font-weight: 600; }
|
||||
p { color: var(--muted); line-height: 1.5; margin: 8px 0; }
|
||||
.notes { white-space: pre-line; color: var(--ink); background: var(--bg); border-radius: 12px;
|
||||
padding: 12px 14px; margin: 16px 0; }
|
||||
.notes:empty { display: none; }
|
||||
label { display: block; font-weight: 600; margin: 20px 0 8px; }
|
||||
input { width: 100%; font-size: 17px; padding: 14px; border-radius: 12px; border: 1px solid var(--line);
|
||||
background: var(--card); color: var(--ink); }
|
||||
button { width: 100%; margin-top: 12px; font-size: 17px; font-weight: 600; padding: 14px; border: 0;
|
||||
border-radius: 12px; background: var(--blue); color: var(--blue-ink); cursor: pointer; }
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
#status { min-height: 1.5em; margin-top: 12px; }
|
||||
#status.bad { color: var(--bad); }
|
||||
ol { color: var(--muted); padding-left: 20px; line-height: 1.6; margin: 20px 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>UBGitea</h1>
|
||||
<div class="version">버전 {{VERSION}} · {{SIZE}}</div>
|
||||
<div class="notes">{{NOTES}}</div>
|
||||
<form id="form">
|
||||
<label for="password">다운로드 비밀번호</label>
|
||||
<input id="password" type="password" autocomplete="off" required>
|
||||
<button id="go" type="submit">APK 다운로드</button>
|
||||
</form>
|
||||
<p id="status" role="status" aria-live="polite"></p>
|
||||
<ol>
|
||||
<li>받은 APK 파일을 엽니다.</li>
|
||||
<li>"출처를 알 수 없는 앱" 설치를 이 브라우저에 허용합니다.</li>
|
||||
<li>설치(업데이트)를 누릅니다. 로그인과 설정은 그대로 남습니다.</li>
|
||||
</ol>
|
||||
</main>
|
||||
<script>
|
||||
const META = {{META}};
|
||||
const status = document.getElementById("status");
|
||||
const go = document.getElementById("go");
|
||||
const b64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
||||
function say(text, bad) { status.textContent = text; status.className = bad ? "bad" : ""; }
|
||||
document.getElementById("form").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
go.disabled = true;
|
||||
try {
|
||||
say("비밀번호 확인 중…");
|
||||
const password = new TextEncoder().encode(document.getElementById("password").value);
|
||||
const base = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveKey"]);
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: b64(META.salt), iterations: META.iterations, hash: "SHA-256" },
|
||||
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
|
||||
try {
|
||||
await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.checkIv) }, key, b64(META.check));
|
||||
} catch (e) {
|
||||
say("비밀번호가 맞지 않습니다.", true);
|
||||
return;
|
||||
}
|
||||
say("파일을 받는 중…");
|
||||
const response = await fetch(META.file, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("fetch");
|
||||
const sealed = await response.arrayBuffer();
|
||||
say("여는 중…");
|
||||
const apk = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.iv) }, key, sealed);
|
||||
const url = URL.createObjectURL(new Blob([apk], { type: "application/vnd.android.package-archive" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = META.fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
say("다운로드를 시작했습니다. 받은 파일을 열어 설치하세요.");
|
||||
} catch (e) {
|
||||
say("파일을 받지 못했습니다. 잠시 뒤 다시 시도하세요.", true);
|
||||
} finally {
|
||||
go.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
tools/sync-main-folder.bat
Normal file
16
tools/sync-main-folder.bat
Normal file
@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
rem Brings the main folder up to the branch the work is done on (TASK-062).
|
||||
rem
|
||||
rem sync-main-folder.bat [branch] [folder]
|
||||
rem
|
||||
rem The work is done by sync-main-folder.ps1, next to this file. This file has no text other than
|
||||
rem plain ASCII on purpose: cmd reads a batch file in the system's code page, and Korean text in
|
||||
rem it was read as commands.
|
||||
setlocal
|
||||
set "ARGS="
|
||||
if not "%~1"=="" set ARGS=-Branch "%~1"
|
||||
if not "%~2"=="" set ARGS=%ARGS% -Main "%~2"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sync-main-folder.ps1" %ARGS%
|
||||
set "RESULT=%ERRORLEVEL%"
|
||||
pause
|
||||
exit /b %RESULT%
|
||||
123
tools/sync-main-folder.ps1
Normal file
123
tools/sync-main-folder.ps1
Normal file
@ -0,0 +1,123 @@
|
||||
# Brings the main folder up to the branch the work is done on (TASK-062).
|
||||
# Run it through sync-main-folder.bat.
|
||||
#
|
||||
# It only ever moves forward (fast-forward), and it never deletes: a file that is in the way is
|
||||
# moved into .git\sync-backup\<time>\ and the place is named on screen.
|
||||
#
|
||||
# This file is saved as UTF-8 with a byte order mark. Windows PowerShell reads a script without
|
||||
# one in the system's own code page, and the Korean messages would come out garbled.
|
||||
param(
|
||||
[string]$Branch = 'claude/app-features-purpose-c45235',
|
||||
[string]$Main = 'D:\Android\UBGitea'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
# File names come from git as UTF-8; without this a Korean file name is not found again.
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Stop-Sync([string]$message) {
|
||||
Write-Host ''
|
||||
Write-Host "[중단] $message" -ForegroundColor Yellow
|
||||
Write-Host ''
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Invoke-Git {
|
||||
# Quietly: what matters to the caller is whether it worked.
|
||||
& git -C $Main -c core.quotepath=false @args 2>$null
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' 메인 폴더 동기화'
|
||||
Write-Host " 대상 폴더 : $Main"
|
||||
Write-Host " 가져올 작업 : $Branch"
|
||||
Write-Host ''
|
||||
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Stop-Sync 'git 을 찾을 수 없습니다. Git 이 설치되어 있는지 확인해 주세요.' }
|
||||
if (-not (Test-Path (Join-Path $Main '.git'))) { Stop-Sync "$Main 은 Git 저장소가 아닙니다." }
|
||||
Invoke-Git rev-parse --verify --quiet "$Branch^{commit}" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Stop-Sync "$Branch 브랜치가 없습니다." }
|
||||
|
||||
# A change to a tracked file is someone's work in progress. It is not touched.
|
||||
Invoke-Git diff --quiet
|
||||
$changed = $LASTEXITCODE -ne 0
|
||||
Invoke-Git diff --cached --quiet
|
||||
if ($changed -or $LASTEXITCODE -ne 0) {
|
||||
Write-Host '[중단] 메인 폴더에 저장하지 않은 수정이 있습니다. 아무것도 바꾸지 않았습니다.' -ForegroundColor Yellow
|
||||
Write-Host ' 아래 파일을 커밋하거나 되돌린 뒤 다시 실행해 주세요.'
|
||||
Write-Host ''
|
||||
Invoke-Git status --short --untracked-files=no
|
||||
Write-Host ''
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Moving forward is only possible from a commit the branch already contains.
|
||||
Invoke-Git merge-base --is-ancestor HEAD $Branch
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Stop-Sync '메인 폴더에 이 작업에 없는 커밋이 있어, 앞으로만 이동하는 방식으로는 합칠 수 없습니다. 아무것도 바뀌지 않았습니다.'
|
||||
}
|
||||
|
||||
# A file lying in the folder, unknown to git, that the branch has a file of its own for. Git will
|
||||
# not write over it, rightly; so it is moved aside first.
|
||||
$blocking = @(Invoke-Git ls-files --others --exclude-standard | Where-Object {
|
||||
Invoke-Git cat-file -e "${Branch}:$_"
|
||||
$LASTEXITCODE -eq 0
|
||||
})
|
||||
$backup = $null
|
||||
if ($blocking.Count -gt 0) {
|
||||
$backup = Join-Path $Main (Join-Path '.git\sync-backup' (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||
Write-Host "[1/3] 길을 막는 파일 $($blocking.Count)개를 옮겨 둡니다."
|
||||
$moved = @()
|
||||
foreach ($path in $blocking) {
|
||||
$from = Join-Path $Main $path
|
||||
$to = Join-Path $backup $path
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $to) -ErrorAction Stop | Out-Null
|
||||
Move-Item -LiteralPath $from -Destination $to -ErrorAction Stop
|
||||
$moved += $path
|
||||
Write-Host " $path"
|
||||
} catch {
|
||||
# All or nothing: put back what was already moved.
|
||||
foreach ($done in $moved) {
|
||||
Move-Item -LiteralPath (Join-Path $backup $done) -Destination (Join-Path $Main $done) -ErrorAction SilentlyContinue
|
||||
}
|
||||
Stop-Sync "$path 을(를) 옮길 수 없습니다. 이 파일을 연 프로그램(PowerPoint, Android Studio 등)을 닫고 다시 실행해 주세요. 아무것도 바뀌지 않았습니다."
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Host '[1/3] 길을 막는 파일이 없습니다.'
|
||||
}
|
||||
|
||||
Write-Host '[2/3] 최신 작업으로 이동합니다.'
|
||||
$output = & git -C $Main merge --ff-only $Branch 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
foreach ($done in $blocking) {
|
||||
Move-Item -LiteralPath (Join-Path $backup $done) -Destination (Join-Path $Main $done) -ErrorAction SilentlyContinue
|
||||
}
|
||||
$output | ForEach-Object { Write-Host " $_" }
|
||||
Stop-Sync '이동하지 못했습니다. 옮겨 둔 파일은 제자리로 돌려놓았습니다.'
|
||||
}
|
||||
|
||||
$current = Invoke-Git branch --show-current
|
||||
if ($current -eq 'master') {
|
||||
Write-Host ' 지금 master 에 있으므로 master 도 함께 이동했습니다.'
|
||||
} else {
|
||||
Invoke-Git branch -f master $Branch | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host ' [주의] master 를 옮기지 못했습니다. 다른 폴더에서 master 를 열어 둔 경우입니다.' -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host ' master 도 같은 위치로 맞췄습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host '[3/3] 결과'
|
||||
Invoke-Git log --oneline -3 | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ''
|
||||
if ($backup) {
|
||||
Write-Host " 옮겨 둔 예전 파일: $backup"
|
||||
Write-Host ' 같은 파일의 최신 버전이 들어왔으므로 보통은 필요 없습니다.'
|
||||
Write-Host ''
|
||||
}
|
||||
Write-Host ' 완료되었습니다.' -ForegroundColor Green
|
||||
Write-Host ''
|
||||
exit 0
|
||||
Loading…
x
Reference in New Issue
Block a user