Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.itsaky.androidide.app.IDEApplication.Companion.getPluginManager
import com.itsaky.androidide.fragments.DiagnosticsListFragment
import com.itsaky.androidide.fragments.SearchResultFragment
import com.itsaky.androidide.fragments.debug.DebuggerFragment
import com.itsaky.androidide.fragments.git.GitBottomSheetFragment
import com.itsaky.androidide.fragments.output.AppLogFragment
import com.itsaky.androidide.fragments.output.BuildOutputFragment
import com.itsaky.androidide.fragments.output.IDELogFragment
Expand Down Expand Up @@ -53,6 +54,7 @@ class EditorBottomSheetTabAdapter(
const val TAB_SEARCH_RESULTS = 4
const val TAB_DEBUGGER = 5
const val TAB_AGENT = 6
const val TAB_GIT = 7
}

private val allTabs =
Expand Down Expand Up @@ -120,6 +122,14 @@ class EditorBottomSheetTabAdapter(
tooltipTag = TooltipTag.PROJECT_AGENT,
),
)

add(
Tab(
title = fragmentActivity.getString(R.string.git_title),
fragmentClass = GitBottomSheetFragment::class.java,
itemId = TAB_GIT,
),
)
}
}

Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/com/itsaky/androidide/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import com.itsaky.androidide.agent.GeminiMacroProcessor
import com.itsaky.androidide.agent.viewmodel.ChatViewModel
import com.itsaky.androidide.analytics.AnalyticsManager
import com.itsaky.androidide.analytics.IAnalyticsManager
import org.koin.androidx.viewmodel.dsl.viewModel
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
import org.koin.dsl.module
import org.koin.core.module.dsl.viewModel

val coreModule =
module {
Expand All @@ -19,4 +20,7 @@ val coreModule =
viewModel {
ChatViewModel()
}
viewModel {
GitBottomSheetViewModel()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.itsaky.androidide.fragments.git

import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.itsaky.androidide.R
import com.itsaky.androidide.databinding.FragmentGitBottomSheetBinding
import com.itsaky.androidide.fragments.git.adapter.GitFileChangeAdapter
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import androidx.fragment.app.activityViewModels

class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {

private val viewModel: GitBottomSheetViewModel by activityViewModels()
private lateinit var adapter: GitFileChangeAdapter

private var _binding: FragmentGitBottomSheetBinding? = null
private val binding get() = _binding!!

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentGitBottomSheetBinding.bind(view)

adapter = GitFileChangeAdapter(onFileClicked = { change ->
// Show diff in a dialog when changed file is clicked
val dialog = GitDiffViewerDialog.newInstance(change.path)
dialog.show(childFragmentManager, "GitDiffViewerDialog")
})

binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter

viewLifecycleOwner.lifecycleScope.launch {
viewModel.gitStatus.collectLatest { status ->
val allChanges = status.staged + status.unstaged + status.untracked + status.conflicted

if (allChanges.isEmpty()) {
binding.emptyView.visibility = View.VISIBLE
binding.recyclerView.visibility = View.GONE
} else {
binding.emptyView.visibility = View.GONE
binding.recyclerView.visibility = View.VISIBLE
adapter.submitList(allChanges)
}
}
}
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.itsaky.androidide.fragments.git

import android.graphics.Canvas
import android.graphics.Paint
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
import android.text.style.ForegroundColorSpan
import android.text.style.LineBackgroundSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.R
import com.itsaky.androidide.databinding.DialogGitDiffBinding
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File

class GitDiffViewerDialog : DialogFragment() {

private val viewModel: GitBottomSheetViewModel by activityViewModels()

private var filePath: String = ""

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.Theme_AndroidIDE)
filePath = arguments?.getString(ARG_FILE_PATH) ?: getString(R.string.diff_viewer)
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.dialog_git_diff, container, false)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

val binding = DialogGitDiffBinding.bind(view)

binding.diffTitle.text = filePath
binding.diffText.text = getString(R.string.diff_loading)

viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val repo = viewModel.currentRepository
val diff = if (repo != null && repo.rootDir.exists()) {
val file = File(repo.rootDir, filePath)
repo.getDiff(file)
} else {
null
} ?: getString(R.string.unable_to_load_diff)

withContext(Dispatchers.Main) {
binding.diffText.text = applyDiffFormatting(diff)
}
}

binding.btnClose.setOnClickListener {
dismiss()
}
}

private fun applyDiffFormatting(diff: String): SpannableStringBuilder {
val builder = SpannableStringBuilder()
val lines = diff.split("\n")

// Find the index of the first diff chunk (starts with @@)
val firstChunkIndex = lines.indexOfFirst { it.startsWith("@@") }
val startIndex = if (firstChunkIndex != -1) firstChunkIndex else 0

val context = requireContext()
val colorAdd = getColor(context, R.color.git_diff_add_text)
val bgAdd = getColor(context, R.color.git_diff_add_bg)
val colorDel = getColor(context, R.color.git_diff_del_text)
val bgDel = getColor(context, R.color.git_diff_del_bg)
val colorHeader = getColor(context, R.color.git_diff_header_text)

for (i in startIndex until lines.size) {
val line = lines[i]
val startIdx = builder.length
when {
line.startsWith("+") && !line.startsWith("+++") -> {
val formattedLine = line.replaceFirst("+", "+ ")
builder.append(formattedLine).append("\n")
val endIdx = builder.length

builder.setSpan(ForegroundColorSpan(colorAdd), startIdx, endIdx,
SPAN_EXCLUSIVE_EXCLUSIVE
)
builder.setSpan(FullWidthBackgroundColorSpan(bgAdd), startIdx, endIdx,
SPAN_EXCLUSIVE_EXCLUSIVE
)
}
line.startsWith("-") && !line.startsWith("---") -> {
val formattedLine = line.replaceFirst("-", "- ")
builder.append(formattedLine).append("\n")
val endIdx = builder.length

builder.setSpan(ForegroundColorSpan(colorDel), startIdx, endIdx,
SPAN_EXCLUSIVE_EXCLUSIVE
)
builder.setSpan(FullWidthBackgroundColorSpan(bgDel), startIdx, endIdx,
SPAN_EXCLUSIVE_EXCLUSIVE
)
}
line.startsWith("@@") -> {
builder.append(line).append("\n")
val endIdx = builder.length

builder.setSpan(ForegroundColorSpan(colorHeader), startIdx, endIdx,
SPAN_EXCLUSIVE_EXCLUSIVE
)
}
else -> {
val formattedLine = if (line.startsWith(" ")) " $line" else line
builder.append(formattedLine).append("\n")
}
}
}

return builder
}

private class FullWidthBackgroundColorSpan(private val color: Int) : LineBackgroundSpan {
override fun drawBackground(
canvas: Canvas, paint: Paint,
left: Int, right: Int, top: Int, baseline: Int, bottom: Int,
text: CharSequence, start: Int, end: Int, lineNumber: Int
) {
val oldColor = paint.color
paint.color = color
canvas.drawRect(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat(), paint)
paint.color = oldColor
}
}

override fun onStart() {
super.onStart()
dialog?.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}

companion object {
private const val ARG_FILE_PATH = "arg_file_path"

fun newInstance(filePath: String): GitDiffViewerDialog {
return GitDiffViewerDialog().apply {
arguments = Bundle().apply {
putString(ARG_FILE_PATH, filePath)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.itsaky.androidide.fragments.git.adapter

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.itsaky.androidide.R
import com.itsaky.androidide.databinding.ItemGitFileChangeBinding
import com.itsaky.androidide.git.core.models.ChangeType
import com.itsaky.androidide.git.core.models.FileChange

class GitFileChangeAdapter(
private val onFileClicked: (FileChange) -> Unit
) : ListAdapter<FileChange, GitFileChangeAdapter.ViewHolder>(DiffCallback()) {

// Keep track of which files are selected to be committed
val selectedFiles = mutableSetOf<String>()

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemGitFileChangeBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val change = getItem(position)
holder.bind(change)
}

inner class ViewHolder(private val binding: ItemGitFileChangeBinding) : RecyclerView.ViewHolder(binding.root) {

init {
itemView.setOnClickListener {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onFileClicked(getItem(pos))
}
}

binding.checkbox.setOnCheckedChangeListener { _, isChecked ->
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
val change = getItem(pos)
if (isChecked) {
selectedFiles.add(change.path)
} else {
selectedFiles.remove(change.path)
}
}
}
}

fun bind(change: FileChange) {
binding.filePath.text = change.path

binding.checkbox.isChecked = selectedFiles.contains(change.path)

val (imageRes, descRes) = when (change.type) {
ChangeType.ADDED -> R.drawable.ic_file_added to R.string.desc_file_added
ChangeType.MODIFIED -> R.drawable.ic_file_modified to R.string.desc_file_modified
ChangeType.DELETED -> R.drawable.ic_file_deleted to R.string.desc_file_deleted
ChangeType.UNTRACKED -> R.drawable.ic_file_added to R.string.desc_file_untracked
ChangeType.RENAMED -> R.drawable.ic_file_renamed to R.string.desc_file_renamed
ChangeType.CONFLICTED -> R.drawable.ic_file_conflicted to R.string.desc_file_conflicted
}
binding.statusIcon.apply {
setImageResource(imageRes)
contentDescription = binding.root.context.getString(descRes)
}
}
}

class DiffCallback : DiffUtil.ItemCallback<FileChange>() {
override fun areItemsTheSame(oldItem: FileChange, newItem: FileChange): Boolean {
return oldItem.path == newItem.path
}

override fun areContentsTheSame(oldItem: FileChange, newItem: FileChange): Boolean {
return oldItem == newItem
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class BottomSheetViewModel : ViewModel() {
const val TAB_SEARCH_RESULT = EditorBottomSheetTabAdapter.TAB_SEARCH_RESULTS
const val TAB_DEBUGGER = EditorBottomSheetTabAdapter.TAB_DEBUGGER
const val TAB_AGENT = EditorBottomSheetTabAdapter.TAB_AGENT
const val TAB_GIT = EditorBottomSheetTabAdapter.TAB_GIT
}

@Keep
Expand All @@ -42,6 +43,7 @@ class BottomSheetViewModel : ViewModel() {
TAB_SEARCH_RESULT,
TAB_DEBUGGER,
TAB_AGENT,
TAB_GIT,
)
@Retention(AnnotationRetention.SOURCE)
annotation class TabDef
Expand Down
Loading