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:
parent
aa23f356d9
commit
023fa0b728
@ -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),
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
@ -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.
|
||||
|
||||
57
tasks/060-tap-a-function-in-the-code.md
Normal file
57
tasks/060-tap-a-function-in-the-code.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user