Compare commits

...

10 Commits

Author SHA1 Message Date
4b03c52975 TASK-067: version 0.2.1 for the build published to the team's Gitea
Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:03:53 +09:00
7aa79f15ce Merge TASK-065 and TASK-066 into the reviewed branch
Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:59:35 +09:00
a02a987946 TASK-066: the download page checks the password before fetching
A wrong password fetched the encrypted APK before being refused, and a
phone browser can keep that fetch as a download. A small sealed check is
opened first; nothing is fetched when it fails.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:57:06 +09:00
69556b1e23 TASK-066: download page and in-app update check
- version 0.2.0 (versionCode 2)
- The app reads version.json from the download page once at launch and
  offers the page when a newer build is out; settings shows the version
  and a check button.
- tools/publish-download.py encrypts an APK under the download password
  and writes the page that decrypts it in the browser.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:52:45 +09:00
54530096e0 TASK-065: code tabs, comparing two files, and reading aids
- Up to five code files saved as tabs in a strip above the bottom tabs.
- Compare a file with one from another ref, commit or place; the
  current file goes left or right, then the other is chosen.
- The side pane on a wide screen is dragged to size and snaps back to
  its default; the header/source split snaps to the middle.
- A tapped name is marked everywhere in the file, with previous/next.
- The Repositories tab pressed again goes back to the list page the
  repository came from, then to the first page.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:38:55 +09:00
6a149087c0 TASK-064: ignore a second star tap in flight; keep -- and ++ lines in diffs
Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:25:49 +09:00
f6ac20c8ed TASK-063: task records say what is actually open
No task waits on a Codex review; the redesign tasks are confirmed on the
product owner's Z Fold7; TASK-016's leftovers are on hold; no biometric
reauthentication.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:25:49 +09:00
8bf6e7ad95 TASK-062: sync script moves blocking files itself; batch file is ASCII only
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 17:02:29 +09:00
fb3d059bca TASK-062: batch file that fast-forwards the main folder
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 16:59:24 +09:00
17fa248065 TASK-061: edge tab for the side pane, and a new app icon
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 16:31:27 +09:00
99 changed files with 3380 additions and 132 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
*.bat text eol=crlf
*.ps1 text eol=crlf

View File

@ -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

View File

@ -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"
}

View File

@ -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))

View File

@ -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"

View File

@ -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()
}

View File

@ -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
}
}
}

View File

@ -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))

View File

@ -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() }
}
}

View File

@ -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>?,

View File

@ -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

View File

@ -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()

View File

@ -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)
}
}
}

View File

@ -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") }

View File

@ -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

View 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

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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)) {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View 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>

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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,
)
}
}

View File

@ -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)
}
}

View File

@ -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 })
}
}

View File

@ -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))))

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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).

View File

@ -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.

View File

@ -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

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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).

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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.

View 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.

View 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.

View 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.

View 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.

View 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
View 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()

View 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
View 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