Search in files

This commit is contained in:
M M Arif
2025-03-12 22:56:38 +05:00
parent 01f2a2ef7c
commit 9bd97ff973
7 changed files with 495 additions and 39 deletions

View File

@@ -24,6 +24,7 @@
<activity
android:name=".activities.FileViewActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|density|screenLayout|keyboard|keyboardHidden|navigation"
android:windowSoftInputMode="adjustResize"
android:theme="@style/AppTheme.NoActionBar"/>
<activity
android:name=".activities.CreateFileActivity"

View File

@@ -8,14 +8,18 @@ import android.graphics.Bitmap;
import android.graphics.Typeface;
import android.os.Bundle;
import android.text.method.ScrollingMovementMethod;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.ImageButton;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.NotificationCompat;
import com.vdurmont.emoji.EmojiParser;
import java.io.IOException;
@@ -32,8 +36,10 @@ import org.mian.gitnex.fragments.BottomSheetFileViewerFragment;
import org.mian.gitnex.helpers.AlertDialogs;
import org.mian.gitnex.helpers.AppUtil;
import org.mian.gitnex.helpers.Constants;
import org.mian.gitnex.helpers.FileContentSearcher;
import org.mian.gitnex.helpers.Images;
import org.mian.gitnex.helpers.Markdown;
import org.mian.gitnex.helpers.SnackBar;
import org.mian.gitnex.helpers.Toasty;
import org.mian.gitnex.helpers.contexts.RepositoryContext;
import org.mian.gitnex.notifications.Notifications;
@@ -49,6 +55,12 @@ public class FileViewActivity extends BaseActivity implements BottomSheetListene
private ActivityFileViewBinding binding;
private ContentsResponse file;
private RepositoryContext repository;
private FileContentSearcher searcher;
private String fileContent;
private ImageButton prevButton;
private ImageButton nextButton;
private boolean buttonsAdded = false;
private String lastQuery = "";
ActivityResultLauncher<Intent> activityResultLauncher =
registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
@@ -209,6 +221,7 @@ public class FileViewActivity extends BaseActivity implements BottomSheetListene
binding.toolbarTitle.setMovementMethod(new ScrollingMovementMethod());
binding.toolbarTitle.setText(file.getPath());
searcher = new FileContentSearcher(binding.contentScrollContainer, binding.markdown);
getSingleFileContents(
repository.getOwner(),
repository.getName(),
@@ -281,7 +294,7 @@ public class FileViewActivity extends BaseActivity implements BottomSheetListene
}
processable = true;
String text = responseBody.string();
fileContent = responseBody.string();
runOnUiThread(
() -> {
@@ -289,16 +302,15 @@ public class FileViewActivity extends BaseActivity implements BottomSheetListene
View.GONE);
binding.contents.setContent(
text, fileExtension);
fileContent, fileExtension);
if (renderMd) {
Markdown.render(
ctx,
getApplicationContext(),
EmojiParser.parseToUnicode(
text),
fileContent),
binding.markdown,
repository);
binding.contents.setVisibility(
View.GONE);
binding.markdownFrame.setVisibility(
@@ -309,6 +321,7 @@ public class FileViewActivity extends BaseActivity implements BottomSheetListene
binding.contents.setVisibility(
View.VISIBLE);
}
invalidateOptionsMenu();
});
break;
}
@@ -395,67 +408,237 @@ public class FileViewActivity extends BaseActivity implements BottomSheetListene
thread.start();
}
private void performSearch(String query, boolean isSubmit) {
if (fileContent != null && processable && !renderMd) {
searcher.search(
fileContent,
FilenameUtils.getExtension(file.getPath()),
query,
binding.contents,
binding.markdown,
renderMd);
int matches = searcher.getMatchCount();
if (isSubmit || !query.equals(lastQuery)) {
if (matches > 0) {
SnackBar.success(
this,
binding.getRoot(),
getString(R.string.search_matches_found, matches));
} else {
SnackBar.warning(
this, binding.getRoot(), getString(R.string.search_no_matches));
}
lastQuery = query;
}
}
}
@Override
public boolean onCreateOptionsMenu(@NonNull Menu menu) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.generic_nav_dotted_menu, menu);
inflater.inflate(R.menu.markdown_switcher, menu);
inflater.inflate(R.menu.search_menu, menu);
MenuItem markdownItem = menu.findItem(R.id.markdown);
MenuItem searchItem = menu.findItem(R.id.action_search);
if (!FilenameUtils.getExtension(file.getName()).equalsIgnoreCase("md")) {
markdownItem.setVisible(false);
}
searchItem.setVisible(!renderMd);
menu.getItem(0).setVisible(false);
int iconsColor = getAttrColor(this, R.attr.iconsColor);
binding.toolbar.setTitleTextColor(iconsColor);
for (int i = 0; i < menu.size(); i++) {
MenuItem item = menu.getItem(i);
if (item.getIcon() != null) {
item.getIcon().setTint(iconsColor);
}
}
SearchView searchView = (SearchView) searchItem.getActionView();
if (searchView != null) {
searchView.setOnQueryTextListener(
new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
performSearch(query, true);
return true;
}
@Override
public boolean onQueryTextChange(String newText) {
performSearch(newText, false);
return true;
}
});
if (prevButton == null) {
prevButton = new ImageButton(FileViewActivity.this);
prevButton.setImageResource(R.drawable.ic_arrow_up);
prevButton.setBackground(null);
prevButton.setPadding(12, 12, 24, 12);
prevButton.setColorFilter(iconsColor);
prevButton.setMinimumWidth(48);
prevButton.setMinimumHeight(48);
prevButton.setOnClickListener(
v -> searcher.previousMatch(binding.contents, binding.markdown, renderMd));
}
if (nextButton == null) {
nextButton = new ImageButton(FileViewActivity.this);
nextButton.setImageResource(R.drawable.ic_arrow_down);
nextButton.setBackground(null);
nextButton.setPadding(12, 12, 12, 12);
nextButton.setColorFilter(iconsColor);
nextButton.setMinimumWidth(48);
nextButton.setMinimumHeight(48);
nextButton.setOnClickListener(
v -> searcher.nextMatch(binding.contents, binding.markdown, renderMd));
}
int maxWidth = (int) (getResources().getDisplayMetrics().widthPixels * 0.6f);
searchView.setMaxWidth(maxWidth);
searchView.setOnSearchClickListener(
v -> {
if (!buttonsAdded) {
Toolbar.LayoutParams prevParams =
new Toolbar.LayoutParams(
Toolbar.LayoutParams.WRAP_CONTENT,
Toolbar.LayoutParams.WRAP_CONTENT);
prevParams.gravity = Gravity.END;
prevParams.setMargins(0, 0, 4, 0);
Toolbar.LayoutParams nextParams =
new Toolbar.LayoutParams(
Toolbar.LayoutParams.WRAP_CONTENT,
Toolbar.LayoutParams.WRAP_CONTENT);
nextParams.gravity = Gravity.END;
nextParams.setMargins(0, 0, 8, 0);
binding.toolbar.removeView(prevButton);
binding.toolbar.removeView(nextButton);
binding.toolbar.addView(prevButton, prevParams);
binding.toolbar.addView(nextButton, nextParams);
buttonsAdded = true;
if (FilenameUtils.getExtension(file.getName()).equalsIgnoreCase("md")) {
markdownItem.setVisible(false);
}
}
});
searchItem.setOnActionExpandListener(
new MenuItem.OnActionExpandListener() {
@Override
public boolean onMenuItemActionExpand(@NonNull MenuItem item) {
return true;
}
@Override
public boolean onMenuItemActionCollapse(@NonNull MenuItem item) {
if (fileContent != null) {
searcher.search(
fileContent,
FilenameUtils.getExtension(file.getPath()),
"",
binding.contents,
binding.markdown,
renderMd);
}
binding.toolbar.removeView(prevButton);
binding.toolbar.removeView(nextButton);
buttonsAdded = false;
lastQuery = "";
if (FilenameUtils.getExtension(file.getName()).equalsIgnoreCase("md")) {
markdownItem.setVisible(true);
}
searchItem.setVisible(!renderMd);
invalidateOptionsMenu();
return true;
}
});
searchView.setOnCloseListener(
() -> {
if (fileContent != null) {
searcher.search(
fileContent,
FilenameUtils.getExtension(file.getPath()),
"",
binding.contents,
binding.markdown,
renderMd);
}
binding.toolbar.removeView(prevButton);
binding.toolbar.removeView(nextButton);
buttonsAdded = false;
lastQuery = "";
if (FilenameUtils.getExtension(file.getName()).equalsIgnoreCase("md")) {
markdownItem.setVisible(true);
}
searchItem.setVisible(!renderMd);
invalidateOptionsMenu();
return false;
});
}
return true;
}
private void toggleMarkdown() {
if (!renderMd) {
if (binding.markdown.getAdapter() == null) {
Markdown.render(
ctx, EmojiParser.parseToUnicode(fileContent), binding.markdown, repository);
}
binding.contents.setVisibility(View.GONE);
binding.markdownFrame.setVisibility(View.VISIBLE);
renderMd = true;
} else {
binding.markdownFrame.setVisibility(View.GONE);
binding.contents.setVisibility(View.VISIBLE);
renderMd = false;
if (fileContent != null) {
binding.contents.setContent(
fileContent, FilenameUtils.getExtension(file.getPath()));
}
}
invalidateOptionsMenu();
}
private int getAttrColor(Context context, int attr) {
TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(attr, outValue, true);
return outValue.data;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == android.R.id.home) {
finish();
return true;
} else if (id == R.id.genericMenu) {
BottomSheetFileViewerFragment bottomSheet = new BottomSheetFileViewerFragment();
Bundle opts = repository.getBundle();
opts.putBoolean("editable", processable);
bottomSheet.setArguments(opts);
bottomSheet.show(getSupportFragmentManager(), "fileViewerBottomSheet");
return true;
} else if (id == R.id.markdown) {
if (!renderMd) {
if (binding.markdown.getAdapter() == null) {
Markdown.render(
ctx,
EmojiParser.parseToUnicode(binding.contents.getContent()),
binding.markdown,
repository);
}
binding.contents.setVisibility(View.GONE);
binding.markdownFrame.setVisibility(View.VISIBLE);
renderMd = true;
} else {
binding.markdownFrame.setVisibility(View.GONE);
binding.contents.setVisibility(View.VISIBLE);
renderMd = false;
}
toggleMarkdown();
return true;
} else {
return super.onOptionsItemSelected(item);
}
return super.onOptionsItemSelected(item);
}
@Override

View File

@@ -0,0 +1,195 @@
package org.mian.gitnex.helpers;
import android.content.Context;
import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.BackgroundColorSpan;
import android.util.TypedValue;
import android.view.ViewTreeObserver;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
import androidx.core.widget.NestedScrollView;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.mian.gitnex.R;
import org.mian.gitnex.views.SyntaxHighlightedArea;
/**
* @author mmarif
*/
public class FileContentSearcher {
private final List<int[]> matchPositions = new ArrayList<>();
private int currentMatchIndex = -1;
private String content;
private String fileExtension;
private final NestedScrollView scrollView;
private final RecyclerView recyclerView;
public FileContentSearcher(NestedScrollView scrollView, RecyclerView recyclerView) {
this.scrollView = scrollView;
this.recyclerView = recyclerView;
}
public void search(
String content,
String fileExtension,
String query,
SyntaxHighlightedArea codeView,
RecyclerView markdownView,
boolean isMarkdown) {
this.content = content;
this.fileExtension = fileExtension;
matchPositions.clear();
currentMatchIndex = -1;
if (query == null || query.trim().isEmpty()) {
clearHighlights(codeView, markdownView, isMarkdown);
return;
}
Pattern pattern = Pattern.compile(Pattern.quote(query), Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(content);
while (matcher.find()) {
matchPositions.add(new int[] {matcher.start(), matcher.end()});
}
if (!matchPositions.isEmpty()) {
currentMatchIndex = 0;
highlightMatches(codeView, markdownView, isMarkdown);
scrollToMatch(codeView, markdownView, isMarkdown, currentMatchIndex);
} else {
clearHighlights(codeView, markdownView, isMarkdown);
}
}
public void nextMatch(
SyntaxHighlightedArea codeView, RecyclerView markdownView, boolean isMarkdown) {
if (matchPositions.isEmpty() || currentMatchIndex == -1) return;
currentMatchIndex = (currentMatchIndex + 1) % matchPositions.size();
highlightMatches(codeView, markdownView, isMarkdown);
scrollToMatch(codeView, markdownView, isMarkdown, currentMatchIndex);
}
public void previousMatch(
SyntaxHighlightedArea codeView, RecyclerView markdownView, boolean isMarkdown) {
if (matchPositions.isEmpty() || currentMatchIndex == -1) return;
currentMatchIndex = (currentMatchIndex - 1 + matchPositions.size()) % matchPositions.size();
highlightMatches(codeView, markdownView, isMarkdown);
scrollToMatch(codeView, markdownView, isMarkdown, currentMatchIndex);
}
public int getMatchCount() {
return matchPositions.size();
}
private void highlightMatches(
SyntaxHighlightedArea codeView, RecyclerView markdownView, boolean isMarkdown) {
SpannableString spannable = new SpannableString(content);
int searchHighlightColor = ContextCompat.getColor(codeView.getContext(), R.color.darkGreen);
int selectedHighlightColor =
ContextCompat.getColor(codeView.getContext(), R.color.iconPrMergedColor);
for (int i = 0; i < matchPositions.size(); i++) {
int[] pos = matchPositions.get(i);
int color = (i == currentMatchIndex) ? selectedHighlightColor : searchHighlightColor;
spannable.setSpan(
new BackgroundColorSpan(color),
pos[0],
pos[1],
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (isMarkdown && markdownView != null) {
Markdown.renderWithHighlights(codeView.getContext(), spannable, markdownView, null);
} else {
codeView.setSpannable(spannable);
}
}
private void scrollToMatch(
SyntaxHighlightedArea codeView,
RecyclerView markdownView,
boolean isMarkdown,
int index) {
if (index < 0 || index >= matchPositions.size()) return;
int[] pos = matchPositions.get(index);
if (isMarkdown && recyclerView != null) {
Context context = codeView.getContext();
int avgCharHeight =
(int)
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
14,
context.getResources().getDisplayMetrics());
int charsPerLine = recyclerView.getWidth() / (avgCharHeight / 2);
int scrollY =
(pos[0] / (charsPerLine > 0 ? charsPerLine : 1)) * (int) (avgCharHeight * 1.2);
recyclerView.smoothScrollToPosition(scrollY / avgCharHeight);
} else if (scrollView != null) {
TextView sourceView = codeView.getSourceView();
ViewTreeObserver vto = sourceView.getViewTreeObserver();
vto.addOnGlobalLayoutListener(
new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
sourceView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
Layout layout = sourceView.getLayout();
if (layout != null) {
int line = layout.getLineForOffset(pos[0]);
int y = layout.getLineTop(line);
int contentHeight = sourceView.getHeight();
int scrollViewHeight = scrollView.getHeight();
int maxScroll = Math.max(0, contentHeight - scrollViewHeight);
int adjustedY = Math.min(y, maxScroll);
if (contentHeight > scrollViewHeight) {
scrollView.requestLayout();
scrollView.post(() -> scrollView.smoothScrollTo(0, adjustedY));
}
} else {
int lineHeight = sourceView.getLineHeight();
int lines = content.substring(0, pos[0]).split("\n").length - 1;
int fallbackY = lines * lineHeight;
int contentHeight = sourceView.getHeight();
int scrollViewHeight = scrollView.getHeight();
int maxScroll = Math.max(0, contentHeight - scrollViewHeight);
int adjustedFallbackY = Math.min(fallbackY, maxScroll);
if (contentHeight > scrollViewHeight) {
scrollView.post(
() -> scrollView.smoothScrollTo(0, adjustedFallbackY));
}
}
}
});
}
}
private void clearHighlights(
SyntaxHighlightedArea codeView, RecyclerView markdownView, boolean isMarkdown) {
if (isMarkdown && markdownView != null) {
Markdown.renderWithHighlights(
codeView.getContext(), new SpannableString(content), markdownView, null);
} else {
codeView.setContent(content, fileExtension);
}
}
}

View File

@@ -6,8 +6,10 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.Typeface;
import android.text.Spannable;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -162,6 +164,21 @@ public class Markdown {
}
}
public static void renderWithHighlights(
Context context,
Spannable spannable,
RecyclerView recyclerView,
RepositoryContext repository) {
try {
RecyclerViewRenderer renderer = rvRendererPool.claim(OBJECT_POOL_CLAIM_TIMEOUT);
if (renderer != null) {
renderer.setParameters(context, spannable, recyclerView, repository);
executorService.execute(renderer);
}
} catch (InterruptedException ignored) {
}
}
private static class Renderer implements Runnable, Poolable {
private final Slot slot;
@@ -305,6 +322,7 @@ public class Markdown {
private Context context;
private String markdown;
private Spannable spannable;
private RecyclerView recyclerView;
private MarkwonAdapter adapter;
private RepositoryContext repository;
@@ -532,6 +550,21 @@ public class Markdown {
.build();
}
public void setParameters(
Context context,
Spannable spannable,
RecyclerView recyclerView,
RepositoryContext repository) {
this.context = context;
this.spannable = spannable;
this.markdown = spannable.toString();
this.recyclerView = recyclerView;
this.repository = repository;
if (linkPostProcessor != null) {
linkPostProcessor.repository = repository;
}
}
public void setParameters(
Context context,
String markdown,
@@ -577,9 +610,38 @@ public class Markdown {
// separate ScrollViews
}
});
localReference.setAdapter(localAdapter);
localAdapter.setMarkdown(markwon, localMd);
if (spannable != null) {
TextView tempTextView = new TextView(context);
tempTextView.setText(spannable);
tempTextView.setLayoutParams(
new RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT));
RecyclerView.Adapter<RecyclerView.ViewHolder> customAdapter =
new RecyclerView.Adapter<>() {
@NonNull @Override
public RecyclerView.ViewHolder onCreateViewHolder(
@NonNull ViewGroup parent, int viewType) {
return new RecyclerView.ViewHolder(tempTextView) {};
}
@Override
public void onBindViewHolder(
@NonNull RecyclerView.ViewHolder holder,
int position) {}
@Override
public int getItemCount() {
return 1;
}
};
localReference.setAdapter(customAdapter);
} else {
localAdapter.setMarkdown(markwon, localMd);
localReference.setAdapter(localAdapter);
}
localAdapter.notifyDataSetChanged();
});

View File

@@ -6,6 +6,7 @@ import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.text.Spannable;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
@@ -148,6 +149,16 @@ public class SyntaxHighlightedArea extends LinearLayout {
}
}
public void setSpannable(@NonNull Spannable spannable) {
sourceView.setText(spannable);
long lineCount = AppUtil.getLineCount(spannable.toString());
linesView.setLineCount(lineCount);
}
public TextView getSourceView() {
return sourceView;
}
private Activity getActivity() {
return (Activity) getContext();
}

View File

@@ -59,6 +59,7 @@
app:indicatorColor="?attr/progressIndicatorColor"/>
<androidx.core.widget.NestedScrollView
android:id="@+id/contentScrollContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior"

View File

@@ -283,7 +283,7 @@
<string name="selectBranchError">Select a branch for release</string>
<string name="alertDialogTokenRevokedTitle">Authorization Error</string>
<string name="alertDialogTokenRevokedMessage">It seems that the Access Token is revoked OR your are not allowed to see these contents.\n\nIn case of revoked Token, please add the account again</string>
<string name="alertDialogTokenRevokedMessage">It seems that the Access Token is revoked OR you are not allowed to see these contents.\n\nIn case of revoked Token, please add the account again</string>
<string name="labelDeleteMessage">Do you really want to delete this label?</string>
<!-- org tabbed layout str -->
@@ -469,7 +469,7 @@
<string name="branch_created">Branch created successfully</string>
<string name="branch_error_archive_mirror">Cannot create branch: Repository is archived or a mirror</string>
<string name="branch_error_ref_not_found">Reference does not exist</string>
<string name="branch_error_exists">"Branch %1$s already exists</string>
<string name="branch_error_exists">Branch %1$s already exists</string>
<string name="branch_error_repo_locked">Repository is locked</string>
<string name="create_branch">Create Branch</string>
@@ -942,4 +942,7 @@
<string name="attachment">Attachment</string>
<string name="attachments">Attachments</string>
<string name="attachmentsSaveError">An issue was created but cannot process attachments at this time. Check the server logs for more details.</string>
<string name="search_matches_found">%d matches found</string>
<string name="search_no_matches">No matches found</string>
</resources>