TASK-060: a function's name in the code is a link to its counterpart

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-09-28 16:24:26 +09:00
parent aa23f356d9
commit 023fa0b728
7 changed files with 153 additions and 4 deletions

View File

@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -36,6 +37,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
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.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -52,6 +56,8 @@ import com.ubisam.ubgitea.feature.codebrowser.content.SearchMatch
import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage
import com.ubisam.ubgitea.feature.codebrowser.content.extractSymbols
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines
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.ui.theme.UBGiteaTheme
@ -131,8 +137,15 @@ fun CodePane(
search: SearchHighlight = SearchHighlight.None,
focusedLine: Int? = null,
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,
) {
val colors = rememberCodeSyntaxColors()
val tapped by rememberUpdatedState(onFunctionTapped)
val linked = onFunctionTapped != null
val functionsByLine = remember(document.symbols, linked) {
if (linked) document.symbols.filter { it.kind == SymbolKind.METHOD }.associateBy(CodeSymbol::line) else emptyMap()
}
val style = remember(fontSizeSp, colors.text) {
TextStyle(
fontFamily = FontFamily.Monospace,
@ -165,8 +178,26 @@ fun CodePane(
LazyColumn(state = listState, contentPadding = contentPadding, modifier = Modifier.fillMaxSize()) {
items(document.lines.size, key = { it }) { index ->
val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) }
val shown = remember(line, search.byLine[index], search.current) {
withSearchHits(line, search.byLine[index], search.current, hit)
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 name = function?.let { nameRangeOf(it, document.lines[index]) }
if (function == null || name == null || name.last >= marked.length) {
marked
} else {
buildAnnotatedString {
append(marked)
// Underlined and otherwise left in its own color: it is still code.
addLink(
LinkAnnotation.Clickable(
tag = "function",
styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)),
) { tapped?.invoke(function) },
name.first,
name.last + 1,
)
}
}
}
Row(
Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent),

View File

@ -395,6 +395,15 @@ fun CodeViewerScreen(
counterpartPath = counterpartPath!!,
splitFraction = splitFraction,
onSplitChange = { splitFraction = it },
// 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 ->
scope.launch { otherState.scrollToItem(match.line) }
}
},
onOtherFunctionTapped = { symbol ->
findSymbolNamed(document.symbols, symbol.name, symbol.kind)?.let { goToLine(it.line) }
},
)
else -> {
if (layout == ViewerLayout.WIDE) {
@ -424,6 +433,7 @@ fun CodeViewerScreen(
search = highlight,
focusedLine = focusedLine,
contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp),
onFunctionTapped = jump?.onJump,
)
if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching) {
val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD }
@ -843,6 +853,8 @@ private fun PairPanes(
counterpartPath: RepositoryPath,
splitFraction: Float,
onSplitChange: (Float) -> Unit,
onFunctionTapped: (CodeSymbol) -> Unit,
onOtherFunctionTapped: (CodeSymbol) -> Unit,
) {
if (syncPanes && otherDocument != null) {
FollowSymbols(listState, document, otherState, otherDocument)
@ -852,7 +864,10 @@ private fun PairPanes(
val current: @Composable (Modifier) -> Unit = { modifier ->
Column(modifier) {
PaneHeader(document.name, state.path, document.lines.size, enclosingSymbols(document.symbols, listState.firstVisibleItemIndex))
CodePane(document, listState, preferences.wordWrap, preferences.fontSizeSp, search = highlight, focusedLine = focusedLine)
CodePane(
document, listState, preferences.wordWrap, preferences.fontSizeSp,
search = highlight, focusedLine = focusedLine, onFunctionTapped = onFunctionTapped,
)
}
}
val other: @Composable (Modifier) -> Unit = { modifier ->
@ -861,7 +876,7 @@ private fun PairPanes(
when {
otherDocument != null -> {
PaneHeader(name, counterpartPath, otherDocument.lines.size, enclosingSymbols(otherDocument.symbols, otherState.firstVisibleItemIndex))
CodePane(otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp)
CodePane(otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp, onFunctionTapped = onOtherFunctionTapped)
}
else -> {
PaneHeader(name, counterpartPath, null, emptyList())

View File

@ -126,6 +126,21 @@ fun outlineTree(symbols: List<CodeSymbol>, collapsed: Set<Int> = emptySet()): Li
fun methodCountOf(symbols: List<CodeSymbol>, container: CodeSymbol): Int =
symbols.count { it.kind == SymbolKind.METHOD && it.line > container.line && it.line <= container.endLine }
/**
* Where the name of [symbol] is written on [line], the line it is declared on, as `[start, end)`;
* null when it cannot be found there. The name is the last occurrence before the parameter list,
* so in `Retry Retry::Make(int n)` it is `Make`, and in `A::A()` the second `A`.
*/
fun nameRangeOf(symbol: CodeSymbol, line: String): IntRange? {
val parenthesis = line.indexOf('(')
if (parenthesis <= 0) return null
val start = line.lastIndexOf(symbol.name, parenthesis - 1)
if (start < 0 || start + symbol.name.length > parenthesis) return null
// Part of a longer word (`GetValue` inside `TryGetValue`) is not the name.
if (start > 0 && (line[start - 1].isLetterOrDigit() || line[start - 1] == '_')) return null
return start until start + symbol.name.length
}
/** The symbols enclosing [line], outermost first: what a "you are here" strip shows. */
fun enclosingSymbols(symbols: List<CodeSymbol>, line: Int): List<CodeSymbol> =
symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth)

View File

@ -157,6 +157,28 @@ class SymbolOutlineTest {
assertEquals(9, symbols.first().endLine)
}
@Test
fun `the name of a function is found where it is written on its line`() {
fun named(line: String): String? {
val symbol = extractSymbols(listOf("class A", "{", line, "};"), SyntaxLanguage.CPP).firstOrNull { it.kind == SymbolKind.METHOD }
?: extractSymbols(listOf(line, "{", "}"), SyntaxLanguage.CPP).first { it.kind == SymbolKind.METHOD }
return nameRangeOf(symbol, line)?.let { line.substring(it.first, it.last + 1) + "@" + it.first }
}
assertEquals("OnBnClickedOk@17", named(" afx_msg void OnBnClickedOk();"))
assertEquals("Make@13", named("Retry Retry::Make(int n)"))
assertEquals("A@3", named("A::A(int a)"))
assertEquals("~A@3", named("A::~A()"))
}
@Test
fun `a name that is only part of a longer word on the line is not the name`() {
val symbol = CodeSymbol("GetValue", "GetValue()", SymbolKind.METHOD, 0, 0, 0)
assertEquals(null, nameRangeOf(symbol, "bool TryGetValue();"))
assertEquals(null, nameRangeOf(symbol, "no parenthesis here"))
}
@Test
fun `a forward declaration is not listed`() {
val symbols = extractSymbols(listOf("class Foo;"), SyntaxLanguage.CPP)

Binary file not shown.

View File

@ -1401,3 +1401,12 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Checks and results: the required checks pass; 435 unit tests, 0 failures; lint 0 errors.
- Live verification: installed on the SM-F966N; not seen on a screen by the implementer.
- Next owner: the product owner, to confirm on the device.
## 2026-09-28 (continued) — TASK-060: tapping a function in the code
- Request: the human product owner asked for the jump of TASK-059 from the code itself.
- What changed: see `tasks/060-tap-a-function-in-the-code.md`. Names are links where a function
is declared or defined, not where it is called.
- Checks and results: the required checks pass; 437 unit tests, 0 failures; lint 0 errors.
- Live verification: installed on the SM-F966N; not seen on a screen by the implementer.
- Next owner: the product owner, to confirm on the device.

View File

@ -0,0 +1,57 @@
# TASK-060: Tapping a function's name in the code
Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation
Owner: Claude
Reviewer: human product owner
## Outcome
TASK-059 put the jump from a header to its source on a button in the outline. The human product
owner asked for it in the code itself.
## Scope
- Included:
- In a file that has a counterpart, the name of a function is underlined on the line that
declares or defines it. Tapping it opens the counterpart at the function of the same name:
`.h` to `.cpp`, and `.cpp` to `.h`.
- Side by side, tapping the name in one pane brings the same function into view in the other.
- Excluded:
- A function's name where it is *called*. Going from a call to a definition needs to know which
file defines it, which means reading the whole repository; this app reads one file at a time.
- Files with no counterpart (C#, a lone `.cpp`): no names are links, as there is nowhere to go.
- Telling overloads apart: the first function of that name is the one shown.
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/`
- `app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/`
- `tasks/`, `docs/`
## Acceptance criteria
- [x] Only the name is the link, not the whole line; the rest of the line can still be selected.
- [x] The name is found after a qualifier (`A::F`), for a constructor (`A::A`) and a destructor.
- [x] A name that is only part of a longer word on the line is not linked.
- [x] Unit tests cover where the name is found.
## Security and privacy
No additional concerns.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
- Required checks pass: 437 unit tests, 0 failures; lint 0 errors.
- Installed on the SM-F966N over wireless `adb`.
- Not seen on a screen by the implementer. In particular, how a tap on a link and a long press to
select text get along inside the selectable code has not been tried on a device.
## Review findings
None yet.