package com.calsignlabs.apde.vcs; import android.content.Context; import android.content.Intent; import android.os.Bundle; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.Toolbar; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.style.ForegroundColorSpan; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.BaseAdapter; import android.widget.ListView; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import com.calsignlabs.apde.APDE; import com.calsignlabs.apde.R; import com.calsignlabs.apde.SettingsActivity; import org.eclipse.jgit.diff.DiffEntry; import org.eclipse.jgit.diff.DiffFormatter; import org.eclipse.jgit.errors.IncorrectObjectTypeException; import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.treewalk.AbstractTreeIterator; import org.eclipse.jgit.treewalk.CanonicalTreeParser; import org.eclipse.jgit.treewalk.EmptyTreeIterator; import org.eclipse.jgit.treewalk.FileTreeIterator; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; public class GitHistoryActivity extends AppCompatActivity { private GitRepository repo; private ArrayList<RevCommit> commits; private ArrayList<CharSequence> commitMessages; private CommitListFragment commitListFragment; private CommitDiffFragment commitDiffFragment; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_git_history); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); toolbar.setBackgroundColor(getResources().getColor(R.color.bar_overlay)); setSupportActionBar(toolbar); getSupportActionBar().setHomeButtonEnabled(true); getSupportActionBar().setDisplayHomeAsUpEnabled(true); getWindow().getDecorView().setBackgroundColor(getResources().getColor(R.color.activity_background)); //Get a list of commits repo = new GitRepository(((APDE) getApplicationContext()).getSketchLocation()); //TODO Implement infinite scrolling with dynamic loading for sketches with lots of commits commits = repo.getRecentCommits(-1); commitMessages = repo.getRecentCommitMessages(commits, 72); commitMessages.add(0, ((APDE) getApplication()).getString(R.string.git_history_local_changes)); commitMessages.add(((APDE) getApplication()).getString(R.string.git_history_empty_repository)); repo.close(); if (savedInstanceState != null) { commitListFragment = (CommitListFragment) getSupportFragmentManager().getFragment(savedInstanceState, "commitList"); commitDiffFragment = (CommitDiffFragment) getSupportFragmentManager().getFragment(savedInstanceState, "commitDiff"); } if (getResources().getBoolean(R.bool.tablet_multi_pane)) { if (commitListFragment == null) { commitListFragment = new CommitListFragment(); loadFragment(commitListFragment, R.id.git_history_commit_list_frame, false); } if (commitDiffFragment == null) { commitDiffFragment = new CommitDiffFragment(); loadFragment(commitDiffFragment, R.id.git_history_commit_diff_frame, false); } } else { if (commitListFragment == null) { commitListFragment = new CommitListFragment(); loadFragment(commitListFragment, R.id.git_history_frame, false); } } } public boolean isMultiPane() { return getResources().getBoolean(R.bool.tablet_multi_pane); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); //We are currently retaining each fragment's instance across configuration changes... //...is this what we really want? // //The commit list works without it, but the commit diff needs it commitListFragment.setRetainInstance(true); getSupportFragmentManager().putFragment(outState, "commitList", commitListFragment); if (commitDiffFragment != null && commitDiffFragment.isAdded()) { commitDiffFragment.setRetainInstance(true); getSupportFragmentManager().putFragment(outState, "commitDiff", commitDiffFragment); } } @Override public void onStart() { super.onStart(); } protected void loadFragment(Fragment fragment, int id, boolean transition) { FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); transaction.replace(id, fragment); if (transition) { transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN); transaction.addToBackStack(null); } transaction.commit(); } protected ArrayList<CharSequence> getCommitMessages() { return commitMessages; } private Thread loadThread; protected void selectCommit(final int num) { if (num == commitMessages.size() - 1) { //You can't select the last item, "Empty Repository" return; } if (isMultiPane()) { commitListFragment.selectItem(num); } diffCommits(num, num + 1, num > 0 ? commits.get(num - 1) : null); } protected void diffCommits(int a, int b, final RevCommit commit) { if (!(isMultiPane())) { commitDiffFragment = new CommitDiffFragment(); loadFragment(commitDiffFragment, R.id.git_history_frame, true); } final int commitNum = Math.min(a, b); final int parentNum = Math.max(a, b); if (loadThread != null) { loadThread.interrupt(); } loadThread = new Thread() { @Override public void run() { repo.open(); AbstractTreeIterator commitTreeIterator; AbstractTreeIterator parentTreeIterator; if (commitNum == 0) { //Local changes commitTreeIterator = new FileTreeIterator(repo.getGit().getRepository()); } else { //Some commit commitTreeIterator = createTreeIterator(commits.get(commitNum - 1).getTree()); } if (parentNum == commits.size() + 1) { //Empty repository parentTreeIterator = new EmptyTreeIterator(); } else { //Some commit parentTreeIterator = createTreeIterator(commits.get(parentNum - 1).getTree()); } ByteArrayOutputStream out = new ByteArrayOutputStream(); DiffFormatter formatter = repo.getDiffFormatter(out); final ArrayList<CommitDiff> commitDiffs = new ArrayList<CommitDiff>(); try { List<DiffEntry> diffEntries; diffEntries = formatter.scan(parentTreeIterator, commitTreeIterator); for (DiffEntry diffEntry : diffEntries) { String changeType = diffEntry.getChangeType().name().toUpperCase(); //If we're deleting, at least show a path that's useful String path; if (changeType.equalsIgnoreCase("delete")) { path = diffEntry.getOldPath(); } else if (changeType.equalsIgnoreCase("rename")) { path = diffEntry.getOldPath() + " → " + diffEntry.getNewPath(); } else { path = diffEntry.getNewPath(); } formatter.format(diffEntry); Spannable diffText = cleanDiffText(out.toString()); commitDiffs.add(new CommitDiff(changeType, path, diffText)); out.reset(); } } catch (IOException e) { e.printStackTrace(); } finally { try { out.close(); } catch (IOException e) { e.printStackTrace(); } } repo.close(); runOnUiThread(new Runnable() { @Override public void run() { try { getSupportFragmentManager().executePendingTransactions(); commitDiffFragment.setProgressVisibility(false); commitDiffFragment.setCommitDiffs(commit, commitDiffs); } catch (NullPointerException e) { //This happens when the user has already navigated away from this screen //It took too long to load or something //Either way, we can't have the app crashing for no reason this long after leaving the screen... } } }); } }; loadThread.start(); runOnUiThread(new Runnable() { @Override public void run() { getSupportFragmentManager().executePendingTransactions(); commitDiffFragment.setProgressVisibility(true); } }); } private CanonicalTreeParser createTreeIterator(RevTree tree) { CanonicalTreeParser treeParser = new CanonicalTreeParser(); ObjectReader objectReader = repo.getGit().getRepository().newObjectReader(); try { treeParser.reset(objectReader, tree.getId()); } catch (IncorrectObjectTypeException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { objectReader.release(); } return treeParser; } private Spannable cleanDiffText(String diffText) { String[] lines = diffText.split("\\r?\\n"); SpannableStringBuilder output = new SpannableStringBuilder(); boolean pastHeader = false; for (String line : lines) { if (!pastHeader) { //Filter out unwanted header text if (line.startsWith("diff --git")) continue; if (line.startsWith("index")) continue; if (line.startsWith("+++")) continue; if (line.startsWith("---")) continue; if (line.startsWith("old mode")) continue; if (line.startsWith("new mode")) continue; if (line.startsWith("new file mode")) continue; if (line.startsWith("deleted file mode")) continue; if (line.startsWith("similarity index")) continue; if (line.startsWith("rename from")) continue; if (line.startsWith("rename to")) continue; if (line.startsWith("copy from")) continue; if (line.startsWith("copy to")) continue; if (line.startsWith("dissimilarity index")) continue; } pastHeader = true; int outputLength = output.length(); int lineLength = line.length(); //We can remove the "+" and "-" characters from the beginning of the line if we want to //Currently opting not to for two reasons: // - People aren't used to it // - Blank lines aren't highlighted properly if (line.startsWith("+")) { output.append(line); output.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.git_diff_plus)), outputLength, outputLength + lineLength, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else if (line.startsWith("-")) { output.append(line); output.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.git_diff_minus)), outputLength, outputLength + lineLength, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else if (line.startsWith("@@") && line.endsWith("@@")) { output.append(line); output.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.git_diff_hunk_header)), outputLength, outputLength + lineLength, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else if (line.equals("\\ No newline at end of file")) { output.append(line); output.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.git_diff_no_newline)), outputLength, outputLength + lineLength, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else if (line.startsWith(" ")) { //Context lines output.append(line); } else { output.append(line); } output.append('\n'); } //Trim off the trailing newline return output.length() > 0 ? output.delete(output.length() - 1, output.length()) : output; } protected class CommitDiff { public String changeType; public String path; public Spannable diffText; public CommitDiff(String changeType, String path, Spannable diffText) { this.changeType = changeType; this.path = path; this.diffText = diffText; } } @Override public boolean onCreateOptionsMenu(Menu menu) { //Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.activity_git_history, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { switch(item.getItemId()) { case android.R.id.home: if (getSupportFragmentManager().getBackStackEntryCount() > 0) { getSupportFragmentManager().popBackStack(); } else { finish(); } return true; case R.id.action_settings: launchSettings(); return true; default: return super.onOptionsItemSelected(item); } } private void launchSettings() { startActivity(new Intent(this, SettingsActivity.class)); } public static class CommitListFragment extends Fragment { private boolean loaded = false; private int selectedItem = -1; private int[] selection = {-1, -1}; private ActionMode actionMode; private View rootView; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { // StackOverflow: http://stackoverflow.com/a/23533575 if (rootView == null) { rootView = inflater.inflate(R.layout.fragment_git_history_commit, container, false); } return rootView; } @Override public void onDestroyView() { // StackOverflow: http://stackoverflow.com/a/23533575 if (rootView.getParent() != null) { ((ViewGroup) rootView.getParent()).removeView(rootView); } super.onDestroyView(); } @Override public void onStart() { super.onStart(); loadCommitList(); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (getView() != null) { final ListView commitList = (ListView) getView().findViewById(R.id.git_history_commit_list); if (commitList != null) { outState.putInt("listIndex", commitList.getFirstVisiblePosition()); View v = commitList.getChildAt(0); outState.putInt("listTop", v == null ? 0 : v.getTop()); outState.putInt("selectedItem", selectedItem); } } } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); if (getView() != null && savedInstanceState != null) { final ListView commitList = (ListView) getView().findViewById(R.id.git_history_commit_list); if (commitList != null) { commitList.setSelectionFromTop(savedInstanceState.getInt("listIndex", 0), savedInstanceState.getInt("listTop", 0)); selectItem(savedInstanceState.getInt("selectedItem", -1)); } } } public void loadCommitList() { if (!loaded) { final ListView commitList = (ListView) getView().findViewById(R.id.git_history_commit_list); final ArrayList<CharSequence> commitMessages = ((GitHistoryActivity) getActivity()).getCommitMessages(); commitList.setAdapter(new BaseAdapter() { @Override public int getCount() { return commitMessages.size(); } @Override public Object getItem(int position) { return commitMessages.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { //Let's see if we can convert the old view - otherwise inflate a new one if (convertView == null) { LayoutInflater inflater = (LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); convertView = inflater.inflate(R.layout.git_history_commit_list_item, parent, false); } convertView.setBackgroundColor(getResources().getColor(selectedItem == position ? R.color.holo_select : android.R.color.transparent)); if (selectedItem != position) { for (int item : selection) { if (position == item) { convertView.setBackgroundColor(getResources().getColor(R.color.holo_select_light)); } } } CharSequence message = commitMessages.get(position); ((TextView) convertView.findViewById(R.id.git_history_commit_list_item_text)).setText(message); return convertView; } }); //If there aren't any commits, let the user know if (commitList.getCount() <= 0) { getView().findViewById(R.id.git_history_empty).setVisibility(View.VISIBLE); } else { getView().findViewById(R.id.git_history_empty).setVisibility(View.GONE); } commitList.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parentAdapter, View view, int position, long id) { if (actionMode == null) { ((GitHistoryActivity) getActivity()).selectCommit(position); } else { handleActionModeTouch(position); } } }); commitList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { @Override public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { handleActionModeTouch(position); return true; } }); loaded = true; } } private void handleActionModeTouch(int position) { //If this item has already been selected, then un-select it //If there are less than two items selected, then select this item //If there are already two items selected, then tell the user that they can't select another item if (selection[0] == position) { selection[0] = -1; } else if (selection[1] == position) { selection[1] = -1; } else if (selection[0] == -1) { selection[0] = position; } else if (selection[1] == -1) { selection[1] = position; } else { Toast.makeText(getActivity(), R.string.git_diff_error_two_items_required, Toast.LENGTH_SHORT).show(); } selectItems(); if ((selection[0] != -1 || selection[1] != -1) && actionMode == null) { //We need to start the CAB actionMode = ((GitHistoryActivity) getActivity()).startSupportActionMode(new ActionMode.Callback() { @Override public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { actionMode.getMenuInflater().inflate(R.menu.git_history_commit_list_actions, menu); return true; } @Override public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { return false; } @Override public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { switch (menuItem.getItemId()) { case R.id.menu_git_diff: //Need two commits for a diff... if (selection[0] != -1 && selection[1] != -1) { ((GitHistoryActivity) getActivity()).diffCommits(selection[0], selection[1], null); actionMode.finish(); } else { Toast.makeText(getActivity(), R.string.git_diff_error_two_items_required, Toast.LENGTH_SHORT).show(); } return true; } return false; } @Override public void onDestroyActionMode(ActionMode actionMode) { selection[0] = -1; selection[1] = -1; //If we're navigating away, then we can't be messing with the non-existant view... if (getView() != null) { selectItems(); } CommitListFragment.this.actionMode = null; } }); } else if (selection[0] == -1 && selection[1] == -1 && actionMode != null) { //We need to close the CAB actionMode.finish(); } } public void selectItem(int num) { final ListView commitList = (ListView) getView().findViewById(R.id.git_history_commit_list); selectedItem = num; int selection = num - commitList.getFirstVisiblePosition(); //Keep the selected commit on screen... with a little bit of breathing room if (num < commitList.getFirstVisiblePosition() + 2) { commitList.setSelection(num == 0 ? num : num - 1); } else if (num > commitList.getLastVisiblePosition() - 2) { commitList.setSelection(num == commitList.getCount() - 1 ? num : num + 1); } for (int i = 0; i < commitList.getCount(); i ++) { View child = commitList.getChildAt(i); if (child != null) { child.setBackgroundColor(selection == i ? getResources().getColor(R.color.holo_select) : getResources().getColor(android.R.color.transparent)); } } } public void selectItems() { final ListView commitList = (ListView) getView().findViewById(R.id.git_history_commit_list); for (int i = 0; i < commitList.getCount(); i ++) { View child = commitList.getChildAt(i); if (child != null) { child.setBackgroundColor(getResources().getColor(android.R.color.transparent)); } } for (int item : selection) { int index = item - commitList.getFirstVisiblePosition(); View child = commitList.getChildAt(index); if (child != null) { child.setBackgroundColor(getResources().getColor(R.color.holo_select_light)); } } } } public static class CommitDiffFragment extends Fragment { private View rootView; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { //http://stackoverflow.com/a/23533575 if (rootView == null) { rootView = inflater.inflate(R.layout.fragment_git_history_diff, container, false); } else { ((ViewGroup) rootView.getParent()).removeView(rootView); } return rootView; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); } @Override public void onStart() { super.onStart(); } public void setCommitDiffs(RevCommit commit, final ArrayList<CommitDiff> commitDiffs) { final ListView diffList = (ListView) getView().findViewById(R.id.git_history_diff_list); diffList.setAdapter(new BaseAdapter() { @Override public int getCount() { return commitDiffs.size(); } @Override public Object getItem(int position) { return commitDiffs.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { //Let's see if we can convert the old view - otherwise inflate a new one if(convertView == null) { LayoutInflater inflater = (LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); convertView = inflater.inflate(R.layout.git_history_diff_item, parent, false); } CommitDiff commitDiff = commitDiffs.get(position); ((TextView) convertView.findViewById(R.id.git_history_diff_item_change_type)).setText(commitDiff.changeType); ((TextView) convertView.findViewById(R.id.git_history_diff_item_path)).setText(commitDiff.path); ((TextView) convertView.findViewById(R.id.git_history_diff_item_diff_text)).setText(commitDiff.diffText); return convertView; } }); View diffHeader = getView().findViewById(R.id.git_history_diff_header); if (commit != null) { PersonIdent commitAuthor = commit.getAuthorIdent(); String name = commitAuthor.getName(); String email = commitAuthor.getEmailAddress(); String author; if (name.equals("") && email.equals("")) { author = ((APDE) getActivity().getApplication()).getString(R.string.git_diff_commit_no_author); } else if (name.equals("")) { author = "<" + email + ">"; } else if (email.equals("")) { author = name; } else { author = name + " <" + email + ">"; } String timestamp = commit.getCommitterIdent().getWhen().toString(); final String shortMessage = GitRepository.ellipsizeCommitMessage(commit, 72); final String fullMessage = commit.getFullMessage(); final TextView messageView = ((TextView) getView().findViewById(R.id.git_history_diff_commit_message)); final TextView authorView = ((TextView) getView().findViewById(R.id.git_history_diff_commit_author)); final TextView timestampView = ((TextView) getView().findViewById(R.id.git_history_diff_commit_timestamp)); messageView.setText(shortMessage); authorView.setText(author); timestampView.setText(timestamp); diffHeader.setVisibility(View.VISIBLE); if (!shortMessage.equals(fullMessage)) { //Allow the user to toggle between the full message and the shortened message View.OnClickListener messageToggleListener = new View.OnClickListener() { private boolean full = false; @Override public void onClick(View view) { full = !full; messageView.setText(full ? fullMessage : shortMessage); } }; //Detect touches anywhere in the header diffHeader.setOnClickListener(messageToggleListener); messageView.setOnClickListener(messageToggleListener); authorView.setOnClickListener(messageToggleListener); timestampView.setOnClickListener(messageToggleListener); } } else { diffHeader.setVisibility(View.GONE); } //If there aren't any changes, let the user know if(diffList.getCount() <= 0) { getView().findViewById(R.id.git_diff_empty).setVisibility(View.VISIBLE); } else { getView().findViewById(R.id.git_diff_empty).setVisibility(View.GONE); } } public void setProgressVisibility(boolean visible) { ProgressBar progress = (ProgressBar) getView().findViewById(R.id.git_diff_progress_bar); progress.setVisibility(visible ? View.VISIBLE : View.GONE); } } }