Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 70 additions & 40 deletions app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,22 @@
import com.itsaky.androidide.models.SearchResult;
import com.itsaky.androidide.tasks.TaskExecutor;
import com.itsaky.androidide.ui.CodeEditorView;
import com.itsaky.androidide.utils.FileIOUtils;
import com.itsaky.androidide.utils.FileUtils;
import com.itsaky.androidide.utils.FlashbarActivityUtilsKt;
import com.itsaky.androidide.utils.FlashbarUtilsKt;
import com.itsaky.androidide.utils.LSPUtils;
import io.github.rosemoe.sora.lang.diagnostic.DiagnosticsContainer;
import io.github.rosemoe.sora.text.Content;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import kotlin.Unit;
import org.slf4j.Logger;
Expand Down Expand Up @@ -107,6 +106,9 @@ public static void shutdown() {

private final Map<File, List<DiagnosticItem>> diagnostics = new HashMap<>();

/** Identifies the most recent {@link #showLocations(List)} request; older ones must not publish. */
private final AtomicInteger showLocationsRequest = new AtomicInteger();

protected EditorHandlerActivity activity;

private IDELanguageClientImpl(EditorHandlerActivity provider) {
Expand Down Expand Up @@ -271,56 +273,74 @@ public void showLocations(List<Location> locations) {
return;
}

boolean error = locations == null || locations.isEmpty();
activity.handleSearchResultVisibility(error);
// Claims the panel for this request. The publish below is asynchronous, so without this a slow
// request that started first would land last and overwrite the newer search the user is looking at.
final int request = showLocationsRequest.incrementAndGet();

boolean error = locations == null || locations.isEmpty();
if (error) {
activity.handleSearchResultVisibility(true);
activity
.setSearchResultAdapter(
new SearchListAdapter(Collections.emptyMap(), this::noOp, this::noOp));
return;
}

final Map<File, List<SearchResult>> results = new HashMap<>();
for (int i = 0; i < locations.size(); i++) {
try {
final Location loc = locations.get(i);
if (loc == null) {
continue;
}
// Group by file first. Reads then cost one pass per file instead of one full read per hit, which
// is what this used to do - and it did it on this thread. See SearchResultGrouping.
final Map<File, List<Location>> byFile = new LinkedHashMap<>();
for (final Location loc : locations) {
if (loc == null) {
continue;
}
byFile.computeIfAbsent(loc.getFile().toFile(), f -> new ArrayList<>()).add(loc);
}

final File file = loc.getFile().toFile();
if (!file.exists() || !file.isFile()) {
continue;
// A file with an open editor is resolved here, on the UI thread: its Content is live UI state
// that a background thread must not touch, and pulling a few lines out of it is substring work
// with no I/O. Everything else is read off this thread below.
final Map<File, List<SearchResult>> fromEditors = new HashMap<>();
final Map<File, List<Location>> onDisk = new LinkedHashMap<>();
for (final Map.Entry<File, List<Location>> entry : byFile.entrySet()) {
final var frag = findEditorByFile(entry.getKey());
if (frag != null && frag.getEditor() != null) {
final List<SearchResult> rows = SearchResultGrouping.INSTANCE.resultsFor(
entry.getKey(), entry.getValue(), frag.getEditor().getText());
if (!rows.isEmpty()) {
fromEditors.put(entry.getKey(), rows);
}
var frag = findEditorByFile(file);
Content content;
if (frag != null && frag.getEditor() != null) {
content = frag.getEditor().getText();
} else {
content = new Content(FileIOUtils.readFile2String(file));
}
final List<SearchResult> matches = results.containsKey(file) ? results.get(file) : new ArrayList<>();
Objects.requireNonNull(matches)
.add(
new SearchResult(
loc.getRange(),
file,
content.getLineString(loc.getRange().getStart().getLine()),
content
.subContent(
loc.getRange().getStart().getLine(),
loc.getRange().getStart().getColumn(),
loc.getRange().getEnd().getLine(),
loc.getRange().getEnd().getColumn())
.toString()));
results.put(file, matches);
} catch (Throwable th) {
LOG.error("Failed to show file location", th);
} else {
onDisk.put(entry.getKey(), entry.getValue());
}
}

activity.handleSearchResults(results);
if (onDisk.isEmpty()) {
publishLocations(fromEditors);
return;
}

// Some other search may publish (and bump the generation) while the read is in flight; capture it
// here so this request does not overwrite whatever replaced it.
final int generation = activity.getEditorViewModel().getCurrentSearchGeneration();

TaskExecutor.executeAsyncProvideError(
() -> SearchResultGrouping.INSTANCE.readFromDisk(onDisk),
(result, throwable) -> {
if (!canUseActivity()
|| request != showLocationsRequest.get()
|| generation != activity.getEditorViewModel().getCurrentSearchGeneration()) {
// Superseded, or the activity went away. Leave the panel to whoever owns it now: this
// request's results would be an answer to a question no longer on screen.
return;
}
final Map<File, List<SearchResult>> merged = new HashMap<>(fromEditors);
if (result != null) {
merged.putAll(result);
} else {
LOG.error("Failed to read search result files", throwable);
}
publishLocations(merged);
});
}

private Boolean applyActionEdits(@Nullable final IDEEditor editor, final CodeActionItem action) {
Expand Down Expand Up @@ -476,4 +496,14 @@ private List<DiagnosticGroup> mapAsGroup(Map<File, List<DiagnosticItem>> map) {
private Unit noOp(final Object obj) {
return Unit.INSTANCE;
}

/**
* Shows {@code results} in the search panel.
*
* Visibility and rows are committed together: a publish that never happens - superseded, or the activity recreated mid-read - must not leave the panel open with the "no results" placeholder hidden over the previous query's rows.
*/
private void publishLocations(final Map<File, List<SearchResult>> results) {
activity.handleSearchResultVisibility(results.isEmpty());
activity.handleSearchResults(results);
}
}
143 changes: 143 additions & 0 deletions app/src/main/java/com/itsaky/androidide/lsp/SearchResultGrouping.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.itsaky.androidide.lsp

import com.itsaky.androidide.models.Location
import com.itsaky.androidide.models.SearchResult
import io.github.rosemoe.sora.text.Content
import org.slf4j.LoggerFactory
import java.io.BufferedReader
import java.io.File

/**
* Builds the search-results panel's rows for a set of [Location]s.
*
* Exists because the panel used to read every result file **in full, once per hit, on the main
* thread**: a file with twelve usages was read and materialised twelve times. Find usages made that a
* real cost rather than a latent one.
*
* A row needs only two short strings per hit - the hit's line, and the matched text - so nothing here
* retains a file's contents. Reads are one sequential pass per file, and peak memory is one line rather
* than one file. A per-file content cache would fix the repeated reads but hold every result file's text
* at once, which is the wrong trade on a phone.
*/
internal object SearchResultGrouping {
private val logger = LoggerFactory.getLogger(SearchResultGrouping::class.java)

/**
* Rows for [locations] in [file], built from already-available [lines] (0-based line number to text).
*
* A location whose lines are not all present is dropped: a stale location can point past the end of
* a file that has since been edited, and a row referring to a line that no longer exists is worse
* than no row.
*/
fun resultsFor(
file: File,
locations: List<Location>,
lines: Map<Int, String>,
): List<SearchResult> =
locations.mapNotNull { location ->
val range = location.range
val startLine = lines[range.start.line] ?: return@mapNotNull null
val match = matchedText(range.start.line, range.start.column, range.end.line, range.end.column, lines)
if (match == null) {
logger.debug("Dropping stale search result in {}", file.name)
return@mapNotNull null
}
SearchResult(range, file, startLine, match)
}

/** Rows for [locations] in [file], read from the live editor buffer [content]. */
fun resultsFor(
file: File,
locations: List<Location>,
content: Content,
): List<SearchResult> {
val lines =
linesNeededBy(locations)
.filter { it >= 0 && it < content.lineCount }
.associateWith { content.getLineString(it) }

return resultsFor(file, locations, lines)
}

/** Rows for every file in [byFile], reading each file exactly once. */
fun readFromDisk(byFile: Map<File, List<Location>>): Map<File, List<SearchResult>> =
byFile
.mapValues { (file, locations) -> resultsFor(file, locations, readLines(file, linesNeededBy(locations))) }
.filterValues { it.isNotEmpty() }

/** Every 0-based line number whose text [locations] need. */
fun linesNeededBy(locations: List<Location>): Set<Int> =
locations
.flatMapTo(mutableSetOf()) { location ->
location.range.start.line..location.range.end.line
}

/**
* The text of just the [wanted] lines of [file], in one sequential pass.
*
* Stops as soon as the last wanted line has been seen, and never holds more than the current line,
* so a hit near the top of a large file does not read the rest of it. Missing lines - a file shorter
* than the location claims, or an unreadable file - are simply absent from the result.
*/
fun readLines(
file: File,
wanted: Set<Int>,
): Map<Int, String> {
if (wanted.isEmpty()) {
return emptyMap()
}

val last = wanted.max()
val lines = HashMap<Int, String>(wanted.size)
return try {
file.bufferedReader().use { reader ->
reader.collectLines(wanted, last, lines)
}
lines
} catch (e: Exception) {
// A result file that has been deleted or is unreadable drops its rows, which is what the
// previous implementation did too by way of an exists() check per hit.
logger.debug("Could not read search result file {}", file, e)
lines
}
}

private fun BufferedReader.collectLines(
wanted: Set<Int>,
last: Int,
into: MutableMap<Int, String>,
) {
var number = 0
while (number <= last) {
val line = readLine() ?: return
if (number in wanted) {
into[number] = line
}
number++
}
}

/** The text covered by the range, or null when any line it spans is missing. */
private fun matchedText(
startLine: Int,
startColumn: Int,
endLine: Int,
endColumn: Int,
lines: Map<Int, String>,
): String? {
val first = lines[startLine] ?: return null
if (startLine == endLine) {
val from = startColumn.coerceIn(0, first.length)
return first.substring(from, endColumn.coerceIn(from, first.length))
}

return buildString {
append(first.substring(startColumn.coerceIn(0, first.length)))
for (line in (startLine + 1) until endLine) {
append('\n').append(lines[line] ?: return null)
}
val lastLine = lines[endLine] ?: return null
append('\n').append(lastLine.substring(0, endColumn.coerceIn(0, lastLine.length)))
}
}
}
Loading
Loading