diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 0c573adb4d..661e1d7faf 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -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 @@ -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 @@ -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() } } @@ -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 + } + fun doSetStatus( text: CharSequence, @GravityInt gravity: Int = Gravity.CENTER, diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt index 271edc6afd..f62ded1c71 100755 --- a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt @@ -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 @@ -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(ownerProducer = { requireActivity() }) private val fileDragStarter by lazy(LazyThreadSafetyMode.NONE) { FileDragStarter(requireContext()) @@ -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 } @@ -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) { @@ -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 @@ -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 + } + private fun createTreeView(node: TreeNode): AndroidTreeView? { return if (context == null) { null @@ -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() diff --git a/eventbus-events/src/main/java/com/itsaky/androidide/eventbus/events/filetree/FileTreeEvents.kt b/eventbus-events/src/main/java/com/itsaky/androidide/eventbus/events/filetree/FileTreeEvents.kt index 96801a8d68..5d1c031b67 100644 --- a/eventbus-events/src/main/java/com/itsaky/androidide/eventbus/events/filetree/FileTreeEvents.kt +++ b/eventbus-events/src/main/java/com/itsaky/androidide/eventbus/events/filetree/FileTreeEvents.kt @@ -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() diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt index 925cae4858..03928b1876 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt @@ -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 @@ -40,6 +42,7 @@ class IdeFileServiceImpl( return try { file.parentFile?.mkdirs() file.writeText(content) + notifyFilesChanged(file) true } catch (e: Exception) { false @@ -53,6 +56,7 @@ class IdeFileServiceImpl( return try { file.parentFile?.mkdirs() file.appendText(content) + notifyFilesChanged(file) true } catch (e: Exception) { false @@ -107,6 +111,7 @@ class IdeFileServiceImpl( return try { file.parentFile?.mkdirs() file.writeBytes(data) + notifyFilesChanged(file) true } catch (e: Exception) { false @@ -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 } @@ -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 } @@ -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(