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.mutableIntStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString 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.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString 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.SyntaxLanguage
import com.ubisam.ubgitea.feature.codebrowser.content.extractSymbols import com.ubisam.ubgitea.feature.codebrowser.content.extractSymbols
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines 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.rememberCodeSyntaxColors
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
@ -131,8 +137,15 @@ fun CodePane(
search: SearchHighlight = SearchHighlight.None, search: SearchHighlight = SearchHighlight.None,
focusedLine: Int? = null, focusedLine: Int? = null,
contentPadding: PaddingValues = PaddingValues(vertical = 8.dp), 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 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) { val style = remember(fontSizeSp, colors.text) {
TextStyle( TextStyle(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@ -165,8 +178,26 @@ fun CodePane(
LazyColumn(state = listState, contentPadding = contentPadding, modifier = Modifier.fillMaxSize()) { LazyColumn(state = listState, contentPadding = contentPadding, modifier = Modifier.fillMaxSize()) {
items(document.lines.size, key = { it }) { index -> items(document.lines.size, key = { it }) { index ->
val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) } val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) }
val shown = remember(line, search.byLine[index], search.current) { val function = functionsByLine[index]
withSearchHits(line, search.byLine[index], search.current, hit) 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( Row(
Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent), Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent),

View File

@ -395,6 +395,15 @@ fun CodeViewerScreen(
counterpartPath = counterpartPath!!, counterpartPath = counterpartPath!!,
splitFraction = splitFraction, splitFraction = splitFraction,
onSplitChange = { splitFraction = it }, 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 -> { else -> {
if (layout == ViewerLayout.WIDE) { if (layout == ViewerLayout.WIDE) {
@ -424,6 +433,7 @@ fun CodeViewerScreen(
search = highlight, search = highlight,
focusedLine = focusedLine, focusedLine = focusedLine,
contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp), contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp),
onFunctionTapped = jump?.onJump,
) )
if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching) { if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching) {
val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD } val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD }
@ -843,6 +853,8 @@ private fun PairPanes(
counterpartPath: RepositoryPath, counterpartPath: RepositoryPath,
splitFraction: Float, splitFraction: Float,
onSplitChange: (Float) -> Unit, onSplitChange: (Float) -> Unit,
onFunctionTapped: (CodeSymbol) -> Unit,
onOtherFunctionTapped: (CodeSymbol) -> Unit,
) { ) {
if (syncPanes && otherDocument != null) { if (syncPanes && otherDocument != null) {
FollowSymbols(listState, document, otherState, otherDocument) FollowSymbols(listState, document, otherState, otherDocument)
@ -852,7 +864,10 @@ private fun PairPanes(
val current: @Composable (Modifier) -> Unit = { modifier -> val current: @Composable (Modifier) -> Unit = { modifier ->
Column(modifier) { Column(modifier) {
PaneHeader(document.name, state.path, document.lines.size, enclosingSymbols(document.symbols, listState.firstVisibleItemIndex)) 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 -> val other: @Composable (Modifier) -> Unit = { modifier ->
@ -861,7 +876,7 @@ private fun PairPanes(
when { when {
otherDocument != null -> { otherDocument != null -> {
PaneHeader(name, counterpartPath, otherDocument.lines.size, enclosingSymbols(otherDocument.symbols, otherState.firstVisibleItemIndex)) 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 -> { else -> {
PaneHeader(name, counterpartPath, null, emptyList()) 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 = fun methodCountOf(symbols: List<CodeSymbol>, container: CodeSymbol): Int =
symbols.count { it.kind == SymbolKind.METHOD && it.line > container.line && it.line <= container.endLine } 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. */ /** The symbols enclosing [line], outermost first: what a "you are here" strip shows. */
fun enclosingSymbols(symbols: List<CodeSymbol>, line: Int): List<CodeSymbol> = fun enclosingSymbols(symbols: List<CodeSymbol>, line: Int): List<CodeSymbol> =
symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth) 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) 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 @Test
fun `a forward declaration is not listed`() { fun `a forward declaration is not listed`() {
val symbols = extractSymbols(listOf("class Foo;"), SyntaxLanguage.CPP) 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. - 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. - 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. - 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.