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>
This commit is contained in:
최민영 2026-10-07 20:38:55 +09:00
parent 8bf6e7ad95
commit 54530096e0
28 changed files with 2126 additions and 39 deletions

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,10 +19,12 @@ 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
@ -37,6 +40,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 +100,9 @@ class MainActivity : ComponentActivity() {
private val workViewModel: WorkViewModel by viewModels {
WorkViewModel.Factory(application)
}
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
CodeWorkbenchViewModel.Factory(application)
}
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
// whether to actually show a prompt, this call is safe to make on every launch. Must be
@ -117,6 +133,7 @@ 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()
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
mutableStateOf(listOf<Route>(Route.Home))
@ -147,8 +164,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 +179,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 +197,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 +247,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 +320,44 @@ class MainActivity : ComponentActivity() {
}
UBGiteaTheme(darkTheme = darkTheme) {
CompositionLocalProvider(LocalCodeWorkbench provides workbench) {
AppShell(
selectedTab = currentTab,
showNavigation = showNavigation,
unreadNotifications = unreadNotifications,
onTabSelected = { tab ->
// The Repositories tab pressed again steps back toward the list: from a
// repository's code to its branches' list page, from there to the first page.
val reselected = tab == AppTab.REPOSITORIES && currentTab == AppTab.REPOSITORIES
if (currentRoute is Route.BranchDetail) {
codeBrowserViewModel.close()
historyViewModel.close()
}
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
selectTab(tab)
when {
!reselected -> selectTab(tab)
currentRoute == Route.Repositories -> viewModel.reselect()
else -> {
if (currentRoute == Route.Compare) workbenchViewModel.closeCompare()
selectTab(tab)
viewModel.backToRepositories()
}
}
},
strip = if (showStrip) {
{
CodeTabStrip(
tabs = workbenchState.tabs,
current = currentCodeTab,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onOpen = ::openTab,
onClose = workbenchViewModel::removeTab,
onClearPin = workbenchViewModel::clearPin,
)
}
} else {
null
},
) { isWide ->
// Back on any tab but Home goes Home; back on Home leaves the app.
@ -419,8 +547,15 @@ 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)
}

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

@ -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,23 +335,46 @@ class CodeBrowserViewModel(
val file = browser.getFile(target.repository, target.ref, path)
classifier.classify(file.content, path)
}
val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path)
mutableState.value = CodeBrowserUiState.File(
target = target,
path = path,
content = classification,
siblingImagePaths = siblingFiles.filter { it.hasImageExtension() },
siblingEntries = siblingEntries,
counterpartPath = counterpartOf(path, siblingFiles),
focusSymbol = focusSymbol,
// A folder still being fetched would never hear back in the new state.
tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries),
)
showFile(target, path, classification, siblingEntries, focusSymbol, tree)
} catch (failure: Throwable) {
handleFailure(target, path, failure, isFile = true)
}
}
private fun showFile(
target: CodeBrowserTarget,
path: RepositoryPath,
classification: ContentClassification,
siblingEntries: List<TreeEntry>,
focusSymbol: String? = null,
tree: FileTree? = null,
) {
val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path)
val state = CodeBrowserUiState.File(
target = target,
path = path,
content = classification,
siblingImagePaths = siblingFiles.filter { it.hasImageExtension() },
siblingEntries = siblingEntries,
counterpartPath = counterpartOf(path, siblingFiles),
focusSymbol = focusSymbol,
// A folder still being fetched would never hear back in the new state.
tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries),
)
mutableState.value = state
recentFiles[target to path] = state.copy(focusSymbol = null)
}
// The folder a saved tab's file is in. Without it the file still opens, only with nothing beside it.
private suspend fun listFolderOrNothing(target: CodeBrowserTarget, folder: RepositoryPath): List<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(
target: CodeBrowserTarget,
path: RepositoryPath,
@ -374,6 +437,7 @@ class CodeBrowserViewModel(
private companion object {
const val PAGE_SIZE = 100
const val MAX_PAGES = 100
const val RECENT_FILES = 6
val LISTING_ORDER = compareBy<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
@ -202,6 +215,19 @@ fun CodeViewerScreen(
var syncPanes by rememberSaveable { mutableStateOf(true) }
// The share of the width the left pane of a header/source pair takes.
var splitFraction by rememberSaveable { mutableFloatStateOf(DEFAULT_SPLIT) }
// The width of the side pane on a wide window, in dp, when the reader has dragged it away
// from the default; kept between files and launches.
var sidePaneDp by remember { mutableStateOf(settings.codeSidePaneWidthDp()?.toFloat()) }
// A word tapped in the code, marked wherever it appears, in both panes of a pair (TASK-065).
var selectedWord by remember(state.path) { mutableStateOf<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) }
@ -212,6 +238,28 @@ fun CodeViewerScreen(
val otherText = ((state.counterpart as? CounterpartUiState.Loaded)?.content as? ContentClassification.Text)?.renderedText
val otherDocument = if (counterpart != null && otherText != null) rememberCodeDocument(counterpart, otherText) else null
val otherState = remember(counterpart) { LazyListState() }
val otherWords = remember(otherDocument?.lines, selectedWord) {
val word = selectedWord
val occurrences = if (otherDocument == null || word == null) emptyList() else findWordOccurrences(otherDocument.lines, word)
if (occurrences.isEmpty()) SearchHighlight.None else SearchHighlight(occurrences.groupBy { it.line }, null)
}
// Keeping this file as a tab, and comparing it with another, where the activity offers them.
val workbench = LocalCodeWorkbench.current
val benchTools = workbench?.let { bench ->
val target = state.target
val tab = SavedCodeTab(target.repository.owner, target.repository.name, target.kind.name, target.ref.value, state.path.value)
val refLabel = if (target.kind == CodeReferenceKind.COMMIT) target.ref.value.take(SHORT_COMMIT_LENGTH) else target.ref.value
val file = ComparedFile(target.repository, refLabel, state.path.value, text.renderedText, text.isTruncated)
WorkbenchTools(
isSaved = tab in bench.tabs,
pending = bench.pending,
onToggleSave = { bench.onToggleTab(tab) },
onPin = { side -> bench.onPin(file, side) },
onCompare = { bench.onCompareWith(file) },
onClearPin = bench.onClearPin,
)
}
fun goToLine(line: Int) {
focusedLine = line
@ -224,6 +272,12 @@ fun CodeViewerScreen(
val match = otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) } ?: return
scope.launch { otherState.scrollToItem(match.line) }
}
fun goToWord(index: Int) {
if (wordOccurrences.isEmpty()) return
currentWord = index.mod(wordOccurrences.size)
val line = wordOccurrences[currentWord].line
scope.launch { listState.scrollToItem((line - SEARCH_CONTEXT_LINES).coerceAtLeast(0)) }
}
fun goToMatch(index: Int) {
if (matches.isEmpty()) return
currentMatch = index.mod(matches.size)
@ -245,6 +299,7 @@ fun CodeViewerScreen(
when {
drawerState.isOpen -> scope.launch { drawerState.close() }
outlineOpen -> outlineOpen = false
selectedWord != null -> selectedWord = null
searching -> {
searching = false
query = ""
@ -259,7 +314,9 @@ fun CodeViewerScreen(
maxWidth >= WideLayoutBreakpoint -> ViewerLayout.WIDE
else -> ViewerLayout.PHONE
}
val sidePaneWidth = if (maxWidth < NarrowWideBreakpoint) NarrowSidePaneWidth else SidePaneWidth
val defaultSidePane = if (maxWidth < NarrowWideBreakpoint) NarrowSidePaneWidth else SidePaneWidth
val widestSidePane = (maxWidth * MAX_SIDE_PANE_FRACTION).coerceAtLeast(MinSidePaneWidth)
val sidePaneWidth = (sidePaneDp?.dp ?: defaultSidePane).coerceIn(MinSidePaneWidth, widestSidePane)
val counterpartPath = state.counterpartPath
val showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide
if (showsPair) LaunchedEffect(state.path, counterpartPath) { onLoadCounterpart() }
@ -302,6 +359,7 @@ fun CodeViewerScreen(
onToggleSideBySide = { sideBySide = !sideBySide },
onToggleSync = { syncPanes = !syncPanes },
text = text.renderedText,
workbench = benchTools,
)
val location = stringResource(
R.string.codeviewer_location,
@ -397,6 +455,9 @@ fun CodeViewerScreen(
counterpartPath = counterpartPath!!,
splitFraction = splitFraction,
onSplitChange = { splitFraction = it },
words = words,
otherWords = otherWords,
onWordTapped = onWordTapped,
// Beside each other, a function tapped in one pane is shown in the other.
onFunctionTapped = { symbol ->
otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) }?.let { match ->
@ -424,7 +485,13 @@ fun CodeViewerScreen(
onSymbolSelected = { goToSymbol(it) },
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
)
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
SidePaneHandle(
width = sidePaneWidth,
defaultWidth = defaultSidePane,
widest = widestSidePane,
onWidthChange = { sidePaneDp = it?.value },
onSettled = { settings.setCodeSidePaneWidthDp(sidePaneDp?.roundToInt()) },
)
}
Box(Modifier.weight(1f).fillMaxHeight()) {
CodePane(
@ -436,8 +503,10 @@ fun CodeViewerScreen(
focusedLine = focusedLine,
contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp),
onFunctionTapped = jump?.onJump,
words = words,
onWordTapped = onWordTapped,
)
if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching) {
if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching && selectedWord == null) {
val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD }
if (method != null) {
JumpToCounterpart(
@ -455,6 +524,16 @@ fun CodeViewerScreen(
if (!sidePaneIsShown) {
EdgeTab(Modifier.align(Alignment.CenterStart)) { scope.launch { drawerState.open() } }
}
selectedWord?.let { word ->
WordBar(
word = word,
count = wordOccurrences.size,
onPrevious = { goToWord(if (currentWord < 0) firstMatchFrom(wordOccurrences, listState.firstVisibleItemIndex) - 1 else currentWord - 1) },
onNext = { goToWord(if (currentWord < 0) firstMatchFrom(wordOccurrences, listState.firstVisibleItemIndex) else currentWord + 1) },
onClear = { selectedWord = null },
modifier = Modifier.align(Alignment.BottomCenter).padding(horizontal = 16.dp, vertical = 12.dp),
)
}
}
if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools)
}
@ -497,6 +576,17 @@ private class ViewerTools(
val onToggleSideBySide: () -> Unit,
val onToggleSync: () -> Unit,
val text: String,
// Null where there is nowhere to keep tabs or compare files.
val workbench: WorkbenchTools?,
)
private class WorkbenchTools(
val isSaved: Boolean,
val pending: PendingCompare?,
val onToggleSave: () -> Unit,
val onPin: (CompareSide) -> Unit,
val onCompare: () -> Unit,
val onClearPin: () -> Unit,
)
@Composable
@ -521,6 +611,16 @@ private fun Toolbar(tools: ViewerTools) {
CopyButton(tools.text, Modifier.weight(1f)) { onCopy ->
ToolButton(R.drawable.ic_ub_copy, R.string.codeviewer_tool_copy, false, onClick = onCopy)
}
tools.workbench?.let { bench ->
ToolButton(
if (bench.isSaved) R.drawable.ic_ub_bookmark_filled else R.drawable.ic_ub_bookmark,
R.string.codeviewer_tool_save_tab, bench.isSaved, Modifier.weight(1f),
isToggle = true, onClick = bench.onToggleSave,
)
CompareMenu(bench.pending, bench.onPin, bench.onCompare, bench.onClearPin, Modifier.weight(1f)) { onOpen ->
ToolButton(R.drawable.ic_ub_compare, R.string.codeviewer_tool_compare, bench.pending != null, onClick = onOpen)
}
}
if (tools.canPair) {
ToolButton(
R.drawable.ic_ub_split, R.string.codeviewer_tool_side_by_side, tools.sideBySide, Modifier.weight(1f),
@ -560,7 +660,7 @@ private fun ToolButton(
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Box(
Modifier.size(width = 52.dp, height = 30.dp)
Modifier.widthIn(max = 52.dp).fillMaxWidth().height(30.dp)
.clip(CircleShape)
.background(if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
@ -651,6 +751,24 @@ private fun LandscapeBar(document: CodeDocument, currentLine: Int, onBack: () ->
FontSizeButton(tools) { onOpen ->
IconButton(onClick = onOpen) { UBIcon(R.drawable.ic_ub_text_size, stringResource(R.string.codeviewer_tool_font_size)) }
}
tools.workbench?.let { bench ->
IconButton(onClick = bench.onToggleSave) {
UBIcon(
if (bench.isSaved) R.drawable.ic_ub_bookmark_filled else R.drawable.ic_ub_bookmark,
stringResource(R.string.codeviewer_tool_save_tab),
tint = if (bench.isSaved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
CompareMenu(bench.pending, bench.onPin, bench.onCompare, bench.onClearPin) { onOpen ->
IconButton(onClick = onOpen) {
UBIcon(
R.drawable.ic_ub_compare,
stringResource(R.string.codeviewer_tool_compare),
tint = if (bench.pending != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
@ -843,6 +961,39 @@ private fun JumpToCounterpart(counterpart: RepositoryPath, modifier: Modifier =
}
}
/** The word tapped in the code: how often it appears, a way through those places, and a way out. */
@Composable
private fun WordBar(word: String, count: Int, onPrevious: () -> Unit, onNext: () -> Unit, onClear: () -> Unit, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
shape = CircleShape,
shadowElevation = 6.dp,
modifier = modifier.widthIn(max = 420.dp),
) {
Row(Modifier.heightIn(min = 48.dp).padding(start = 18.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.codeviewer_word_count, word, count),
style = MaterialTheme.typography.labelLarge,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false).semantics { liveRegion = LiveRegionMode.Polite },
)
IconButton(onClick = onPrevious, enabled = count > 0) {
UBIcon(R.drawable.ic_ub_chevron_up, stringResource(R.string.codeviewer_word_previous), size = 20.dp)
}
IconButton(onClick = onNext, enabled = count > 0) {
UBIcon(R.drawable.ic_ub_chevron_down, stringResource(R.string.codeviewer_word_next), size = 20.dp)
}
IconButton(onClick = onClear) {
UBIcon(R.drawable.ic_ub_close, stringResource(R.string.codeviewer_word_clear), size = 18.dp)
}
}
}
}
/**
* A small tab on the left edge, where the side pane is drawn out from. It can be tapped as well:
* a swipe from the edge is not available to everyone.
@ -889,6 +1040,9 @@ private fun PairPanes(
counterpartPath: RepositoryPath,
splitFraction: Float,
onSplitChange: (Float) -> Unit,
words: SearchHighlight,
otherWords: SearchHighlight,
onWordTapped: (String?) -> Unit,
onFunctionTapped: (CodeSymbol) -> Unit,
onOtherFunctionTapped: (CodeSymbol) -> Unit,
) {
@ -903,6 +1057,7 @@ private fun PairPanes(
CodePane(
document, listState, preferences.wordWrap, preferences.fontSizeSp,
search = highlight, focusedLine = focusedLine, onFunctionTapped = onFunctionTapped,
words = words, onWordTapped = onWordTapped,
)
}
}
@ -912,7 +1067,10 @@ private fun PairPanes(
when {
otherDocument != null -> {
PaneHeader(name, counterpartPath, otherDocument.lines.size, enclosingSymbols(otherDocument.symbols, otherState.firstVisibleItemIndex))
CodePane(otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp, onFunctionTapped = onOtherFunctionTapped)
CodePane(
otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp,
onFunctionTapped = onOtherFunctionTapped, words = otherWords, onWordTapped = onWordTapped,
)
}
else -> {
PaneHeader(name, counterpartPath, null, emptyList())
@ -927,12 +1085,20 @@ private fun PairPanes(
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
// The latest value, for a drag that outlives the composition it started in.
val fraction by rememberUpdatedState(splitFraction)
val snapPx = with(LocalDensity.current) { SnapDistance.toPx() }
val haptics = LocalHapticFeedback.current
// Where the finger has taken the split, before it is snapped to the middle.
var dragged by remember { mutableFloatStateOf(splitFraction) }
Row(Modifier.fillMaxSize()) {
(if (currentIsHeader) current else other)(Modifier.weight(splitFraction).fillMaxHeight())
SplitHandle(
onDrag = { deltaPx -> onSplitChange(clampSplit(fraction + deltaPx / widthPx)) },
onDragStart = { dragged = splitFraction },
onDrag = { deltaPx ->
dragged = clampSplit(dragged + deltaPx / widthPx)
val snapped = snapTo(dragged, DEFAULT_SPLIT, snapPx / widthPx)
if (snapped == DEFAULT_SPLIT && splitFraction != DEFAULT_SPLIT) haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onSplitChange(snapped)
},
onReset = { onSplitChange(DEFAULT_SPLIT) },
)
(if (currentIsHeader) other else current)(Modifier.weight(1f - splitFraction).fillMaxHeight())
@ -940,6 +1106,41 @@ private fun PairPanes(
}
}
/**
* [value], or [target] when it is within [threshold] of it: a default that a drag sticks to like a
* magnet, so it is easy to find again.
*/
internal fun snapTo(value: Float, target: Float, threshold: Float): Float = if (abs(value - target) <= threshold) target else value
/**
* The handle between the side pane and the code on a wide window. Dragging sets the pane's width
* between [MinSidePaneWidth] and [widest]; near [defaultWidth] it snaps there with a tick, and the
* default is reported as null. A double tap goes back to the default. [onSettled] is called when a
* drag ends, to keep the width.
*/
@Composable
private fun SidePaneHandle(width: Dp, defaultWidth: Dp, widest: Dp, onWidthChange: (Dp?) -> Unit, onSettled: () -> Unit) {
val density = LocalDensity.current.density
val haptics = LocalHapticFeedback.current
var dragged by remember { mutableFloatStateOf(width.value) }
SplitHandle(
description = stringResource(R.string.codeviewer_side_pane_handle),
onDragStart = { dragged = width.value },
onDrag = { deltaPx ->
dragged = (dragged + deltaPx / density).coerceIn(MinSidePaneWidth.value, widest.value)
val snapped = snapTo(dragged, defaultWidth.value, SnapDistance.value)
val atDefault = snapped == defaultWidth.value
if (atDefault && width != defaultWidth) haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onWidthChange(if (atDefault) null else snapped.dp)
},
onDragEnd = onSettled,
onReset = {
onWidthChange(null)
onSettled()
},
)
}
/** Keeps either pane from being dragged down to nothing. */
internal fun clampSplit(fraction: Float): Float = fraction.coerceIn(MIN_SPLIT, 1f - MIN_SPLIT)
@ -948,20 +1149,34 @@ internal fun clampSplit(fraction: Float): Float = fraction.coerceIn(MIN_SPLIT, 1
* middle, inside a strip a finger can land on. Dragging moves the split; a double tap evens it.
*/
@Composable
private fun SplitHandle(onDrag: (Float) -> Unit, onReset: () -> Unit) {
val description = stringResource(R.string.codeviewer_split_handle)
private fun SplitHandle(
onDrag: (Float) -> Unit,
onReset: () -> Unit,
description: String = stringResource(R.string.codeviewer_split_handle),
onDragStart: () -> Unit = {},
onDragEnd: () -> Unit = {},
) {
// The latest callbacks, for a gesture that outlives the composition it started in.
val drag by rememberUpdatedState(onDrag)
val reset by rememberUpdatedState(onReset)
val start by rememberUpdatedState(onDragStart)
val end by rememberUpdatedState(onDragEnd)
Box(
Modifier.width(SplitHandleWidth)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surface)
.semantics { contentDescription = description }
.pointerInput(Unit) {
detectHorizontalDragGestures { change, dragAmount ->
detectHorizontalDragGestures(
onDragStart = { start() },
onDragEnd = { end() },
onDragCancel = { end() },
) { change, dragAmount ->
change.consume()
onDrag(dragAmount)
drag(dragAmount)
}
}
.pointerInput(Unit) { detectTapGestures(onDoubleTap = { onReset() }) },
.pointerInput(Unit) { detectTapGestures(onDoubleTap = { reset() }) },
contentAlignment = Alignment.Center,
) {
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
@ -1512,6 +1727,11 @@ private fun OutlineSheet(
private const val DEFAULT_SPLIT = 0.5f
private const val MIN_SPLIT = 0.2f
private const val MAX_SIDE_PANE_FRACTION = 0.6f
private val MinSidePaneWidth = 160.dp
// How near a drag has to come to a default width to stick to it.
private val SnapDistance = 16.dp
private const val SHORT_COMMIT_LENGTH = 7
private const val DRAWER_WIDTH_FRACTION = 0.85f
private val MaxDrawerWidth = 320.dp
private val SplitHandleWidth = 24.dp

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

@ -423,6 +423,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

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

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

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

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

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

View File

@ -1434,3 +1434,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl
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-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.

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.