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
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.collection.MutableIntIntMap
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.hardware.display.DisplayManagerCompat
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
Expand Down Expand Up @@ -117,7 +117,6 @@ import com.itsaky.androidide.preferences.internal.BuildPreferences
import com.itsaky.androidide.preferences.internal.GeneralPreferences
import com.itsaky.androidide.projects.IProjectManager
import com.itsaky.androidide.projects.ProjectManagerImpl
import com.itsaky.androidide.resources.R as ResR
import com.itsaky.androidide.services.debug.DebuggerService
import com.itsaky.androidide.tasks.cancelIfActive
import com.itsaky.androidide.ui.CodeEditorView
Expand Down Expand Up @@ -792,6 +791,8 @@ abstract class BaseEditorActivity :
closeKeyboard()
// Dismiss autocomplete and other editor windows
provideCurrentEditor()?.editor?.ensureWindowsDismissed()
// Reflect files changed while the drawer was closed (e.g. by a plugin).
getFileTreeFragment()?.refreshExpandedNodes()
}
}

Expand Down Expand Up @@ -1137,14 +1138,23 @@ abstract class BaseEditorActivity :

open fun getFileTreeFragment(): FileTreeFragment? {
if (filesTreeFragment == null) {
filesTreeFragment =
supportFragmentManager.findFragmentByTag(
FileTreeFragment.TAG,
) as FileTreeFragment?
// File tree is a nav destination in the sidebar's NavHostFragment, so it
// lives in a nested child FragmentManager — search recursively.
filesTreeFragment = findFileTreeFragment(supportFragmentManager)
}
return filesTreeFragment
}

private fun findFileTreeFragment(manager: FragmentManager): FileTreeFragment? {
for (fragment in manager.fragments) {
if (fragment is FileTreeFragment) {
return fragment
}
findFileTreeFragment(fragment.childFragmentManager)?.let { return it }
}
return null
}

Comment on lines +1141 to +1157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether filesTreeFragment is ever cleared/invalidated
rg -n 'filesTreeFragment' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

Repository: appdevforall/CodeOnTheGo

Length of output: 401


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== around filesTreeFragment clear =="
sed -n '450,500p' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

echo
echo "== around getFileTreeFragment =="
sed -n '1128,1160p' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

echo
echo "== lifecycle methods mentioning fragment cache =="
rg -n 'onDestroy|onCreate|onStop|onStart|filesTreeFragment = null|filesTreeFragment' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

Repository: appdevforall/CodeOnTheGo

Length of output: 3215


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== filesTreeFragment usages =="
rg -n 'filesTreeFragment|getFileTreeFragment\(' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

echo
echo "== onDestroy / preDestroy path =="
sed -n '950,990p' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

echo
echo "== fragment-related navigation / host setup nearby =="
sed -n '600,760p' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

Repository: appdevforall/CodeOnTheGo

Length of output: 6572


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== setupContainers / sidebar nav host setup =="
sed -n '760,900p' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

echo
echo "== file tree fragment definitions / navigation references =="
rg -n 'FileTreeFragment|NavHostFragment|nav host|fragmentManager|childFragmentManager|navigate\(' app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt

Repository: appdevforall/CodeOnTheGo

Length of output: 5107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== FileTreeFragment outline =="
ast-grep outline app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt --view expanded

echo
echo "== FileTreeFragment lifecycle / methods =="
rg -n 'onDestroyView|binding == null|refreshExpandedNodes|saveTreeState|listProjectFiles|Fragment' app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt

echo
echo "== relevant slices =="
sed -n '1,260p' app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt

Repository: appdevforall/CodeOnTheGo

Length of output: 12920


Invalidate filesTreeFragment when the sidebar fragment is recreated. preDestroy() only clears the cache during activity teardown, so navigating away from and back to the NavHost destination can leave getFileTreeFragment() returning a detached instance and make refreshExpandedNodes() / listProjectFiles() no-ops.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt`
around lines 1141 - 1157, The cached filesTreeFragment is only cleared in
preDestroy(), so it can become stale when the sidebar NavHost destination is
recreated. Update getFileTreeFragment() to detect and discard a detached or
non-added FileTreeFragment before returning it, and repopulate the cache by
calling findFileTreeFragment(supportFragmentManager) again; keep preDestroy() as
the teardown cleanup. Make sure refreshExpandedNodes() and listProjectFiles()
always use the refreshed fragment instance.

fun doSetStatus(
text: CharSequence,
@GravityInt gravity: Int = Gravity.CENTER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.itsaky.androidide.dnd.FileDragResult
import com.itsaky.androidide.dnd.FileDragStarter
import com.itsaky.androidide.eventbus.events.filetree.FileClickEvent
import com.itsaky.androidide.eventbus.events.filetree.FileLongClickEvent
import com.itsaky.androidide.eventbus.events.filetree.PluginFilesChangedEvent
import com.itsaky.androidide.events.CollapseTreeNodeRequestEvent
import com.itsaky.androidide.events.ExpandTreeNodeRequestEvent
import com.itsaky.androidide.events.ListProjectFilesRequestEvent
Expand Down Expand Up @@ -66,6 +67,15 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener,
private var binding: LayoutEditorFileTreeBinding? = null
private var fileTreeView: AndroidTreeView? = null

// Root of the current tree; used to walk expanded nodes on refresh.
private var treeRoot: TreeNode? = null

private val pluginRefreshRunnable = Runnable {
if (isVisible && context != null) {
refreshExpandedNodes()
}
}

private val viewModel by viewModels<FileTreeViewModel>(ownerProducer = { requireActivity() })
private val fileDragStarter by lazy(LazyThreadSafetyMode.NONE) {
FileDragStarter(requireContext())
Expand Down Expand Up @@ -112,10 +122,12 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener,
super.onDestroyView()
EventBus.getDefault().unregister(this)

binding?.root?.removeCallbacks(pluginRefreshRunnable)
saveTreeState()

binding = null
fileTreeView = null
treeRoot = null
_dropController = null
}

Expand Down Expand Up @@ -271,6 +283,18 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener,
listProjectFiles()
}

@Suppress("unused", "UNUSED_PARAMETER")
@Subscribe(threadMode = MAIN)
fun onPluginFilesChanged(event: PluginFilesChangedEvent) {
if (!isVisible || context == null) {
return
}
val root = binding?.root ?: return
// Debounce bursts of writes into a single refresh.
root.removeCallbacks(pluginRefreshRunnable)
root.postDelayed(pluginRefreshRunnable, PLUGIN_REFRESH_DEBOUNCE_MS)
}

@Suppress("unused")
@Subscribe(threadMode = MAIN)
fun onGetExpandTreeNodeRequest(event: ExpandTreeNodeRequestEvent) {
Expand Down Expand Up @@ -309,6 +333,7 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener,
val projectRoot = TreeNode.root(projectDir)
projectRoot.viewHolder = FileTreeViewHolder(context, externalDropHandler)
rootNode.addChild(projectRoot, false)
treeRoot = rootNode

binding!!.horizontalCroll.visibility = View.GONE
binding!!.horizontalCroll.visibility = View.VISIBLE
Expand All @@ -334,6 +359,39 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener,
}
}

/**
* Re-lists expanded folders whose on-disk contents changed, so plugin-created
* files appear without a manual refresh.
*/
fun refreshExpandedNodes() {
if (binding == null || context == null) {
return
}
val root = treeRoot ?: return
refreshChangedDirs(root)
}

private fun refreshChangedDirs(node: TreeNode) {
node.children.forEach { child ->
val file = child.value ?: return@forEach
if (!file.isDirectory || !child.isExpanded) {
return@forEach
}

refreshChangedDirs(child)

if (childrenDifferFromDisk(child)) {
listNode(child) { expandNode(child, false) }
}
}
}

private fun childrenDifferFromDisk(node: TreeNode): Boolean {
val onDisk = node.value.listFiles()?.map { it.name }?.toHashSet() ?: hashSetOf()
val displayed = node.children.mapNotNull { it.value?.name }.toHashSet()
return onDisk != displayed
}

Comment on lines +374 to +394

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recursion order loses expanded state of descendants when a directory is re-listed.

refreshChangedDirs recurses into child (line 381) before checking childrenDifferFromDisk (line 383). When the check is true, listNode(child) clears child.children and rebuilds from disk, destroying the expanded subnodes the recursion just visited. Result: expanded grandchildren are collapsed after a plugin refresh, and the recursion work on them is wasted.

Check-and-reload before descending, or reload first then recurse into the fresh children.

🐛 Proposed fix: reload before recursing
 private fun refreshChangedDirs(node: TreeNode) {
     node.children.forEach { child ->
         val file = child.value ?: return@forEach
         if (!file.isDirectory || !child.isExpanded) {
             return@forEach
         }

-        refreshChangedDirs(child)
-
         if (childrenDifferFromDisk(child)) {
             listNode(child) { expandNode(child, false) }
         }
+
+        // Recurse into (possibly refreshed) children after reload so we
+        // visit the current on-disk subtree and preserve expanded state.
+        refreshChangedDirs(child)
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun refreshChangedDirs(node: TreeNode) {
node.children.forEach { child ->
val file = child.value ?: return@forEach
if (!file.isDirectory || !child.isExpanded) {
return@forEach
}
refreshChangedDirs(child)
if (childrenDifferFromDisk(child)) {
listNode(child) { expandNode(child, false) }
}
}
}
private fun childrenDifferFromDisk(node: TreeNode): Boolean {
val onDisk = node.value.listFiles()?.map { it.name }?.toHashSet() ?: hashSetOf()
val displayed = node.children.mapNotNull { it.value?.name }.toHashSet()
return onDisk != displayed
}
private fun refreshChangedDirs(node: TreeNode) {
node.children.forEach { child ->
val file = child.value ?: return@forEach
if (!file.isDirectory || !child.isExpanded) {
return@forEach
}
if (childrenDifferFromDisk(child)) {
listNode(child) { expandNode(child, false) }
}
// Recurse into (possibly refreshed) children after reload so we
// visit the current on-disk subtree and preserve expanded state.
refreshChangedDirs(child)
}
}
private fun childrenDifferFromDisk(node: TreeNode): Boolean {
val onDisk = node.value.listFiles()?.map { it.name }?.toHashSet() ?: hashSetOf()
val displayed = node.children.mapNotNull { it.value?.name }.toHashSet()
return onDisk != displayed
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt`
around lines 374 - 394, `refreshChangedDirs` is recursing into expanded children
before reloading directories whose contents differ from disk, which causes
`listNode(child)` to rebuild the node and discard any expanded descendants just
visited. Update the flow in `refreshChangedDirs` so the
`childrenDifferFromDisk(child)` check happens before descending, or reload the
node first and then recurse into the refreshed children; use `listNode` and
`expandNode` together with `childrenDifferFromDisk` to preserve expanded state.

private fun createTreeView(node: TreeNode): AndroidTreeView? {
return if (context == null) {
null
Expand Down Expand Up @@ -409,6 +467,9 @@ class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener,
// Should be same as defined in layout/activity_layouteditor.xml
const val TAG = "editor.fileTree"

// Debounce window for coalescing write bursts into one refresh.
private const val PLUGIN_REFRESH_DEBOUNCE_MS = 250L

@JvmStatic
fun newInstance(): FileTreeFragment {
return FileTreeFragment()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@ data class FileClickEvent(val file: File) : Event()
* @author Akash Yadav
*/
class FileLongClickEvent(val file: File) : Event()

/**
* Event dispatched when a plugin creates or deletes a file via the IDE file service,
* so the file tree can refresh immediately.
*
* @param file The file that was created or deleted.
*/
class PluginFilesChangedEvent(val file: File) : Event()
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.itsaky.androidide.plugins.manager.services

import com.itsaky.androidide.eventbus.events.filetree.PluginFilesChangedEvent
import com.itsaky.androidide.plugins.PluginPermission
import com.itsaky.androidide.plugins.services.IdeFileService
import org.greenrobot.eventbus.EventBus
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
Expand Down Expand Up @@ -40,6 +42,7 @@ class IdeFileServiceImpl(
return try {
file.parentFile?.mkdirs()
file.writeText(content)
notifyFilesChanged(file)
true
} catch (e: Exception) {
false
Expand All @@ -53,6 +56,7 @@ class IdeFileServiceImpl(
return try {
file.parentFile?.mkdirs()
file.appendText(content)
notifyFilesChanged(file)
true
} catch (e: Exception) {
false
Expand Down Expand Up @@ -107,6 +111,7 @@ class IdeFileServiceImpl(
return try {
file.parentFile?.mkdirs()
file.writeBytes(data)
notifyFilesChanged(file)
true
} catch (e: Exception) {
false
Expand All @@ -119,11 +124,13 @@ class IdeFileServiceImpl(

return try {
file.parentFile?.mkdirs()
BufferedInputStream(input).use { buffered ->
val written = BufferedInputStream(input).use { buffered ->
FileOutputStream(file).use { output ->
copyInterruptible(buffered, output)
}
}
notifyFilesChanged(file)
written
} catch (e: Exception) {
FAILED_WRITE
}
Expand All @@ -135,12 +142,17 @@ class IdeFileServiceImpl(

return try {
if (!file.exists()) {
true
} else if (file.isDirectory) {
return true
}
val deleted = if (file.isDirectory) {
file.deleteRecursively()
} else {
file.delete()
}
if (deleted) {
notifyFilesChanged(file)
}
deleted
} catch (e: Exception) {
false
}
Expand Down Expand Up @@ -180,6 +192,11 @@ class IdeFileServiceImpl(
return total
}

private fun notifyFilesChanged(file: File) {
// Refresh the file tree; guarded so a missing EventBus can't break the write action .
runCatching { EventBus.getDefault().post(PluginFilesChangedEvent(file)) }
}

private fun ensureWritePermission() {
if (!hasAnyWritePermission()) {
throw SecurityException(
Expand Down
Loading