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

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,36 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger

object SidebarSlotManager {
private val builtInItemCount = AtomicInteger(0)
private val reservedPluginSlots = ConcurrentHashMap<String, Int>()

const val MAX_NAVIGATION_RAIL_ITEMS = 12
fun setBuiltInItemCount(count: Int) {
require(count >= 0) { "Built-in item count must not be negative" }
builtInItemCount.set(count)
}

private val builtInItemCount = AtomicInteger(0)
private val reservedPluginSlots = ConcurrentHashMap<String, Int>()
fun getBuiltInItemCount(): Int = builtInItemCount.get()

fun setBuiltInItemCount(count: Int) {
require(count in 0..MAX_NAVIGATION_RAIL_ITEMS) {
"Built-in item count must be between 0 and $MAX_NAVIGATION_RAIL_ITEMS"
}
builtInItemCount.set(count)
}
fun getReservedPluginSlotCount(): Int = reservedPluginSlots.values.sum()

fun getBuiltInItemCount(): Int = builtInItemCount.get()
fun getTotalItemCount(): Int = builtInItemCount.get() + getReservedPluginSlotCount()
Comment thread
Daniel-ADFA marked this conversation as resolved.

fun getReservedPluginSlotCount(): Int = reservedPluginSlots.values.sum()
fun getDeclaredSlots(pluginId: String): Int = reservedPluginSlots[pluginId] ?: 0

fun getTotalItemCount(): Int = builtInItemCount.get() + getReservedPluginSlotCount()
fun reservePluginSlots(
pluginId: String,
count: Int,
) {
if (count <= 0) return
reservedPluginSlots[pluginId] = count
}

fun getAvailableSlotsForPlugins(): Int =
(MAX_NAVIGATION_RAIL_ITEMS - builtInItemCount.get() - getReservedPluginSlotCount())
.coerceAtLeast(0)
fun releasePluginSlots(pluginId: String) {
reservedPluginSlots.remove(pluginId)
}

fun canAddPluginItems(count: Int): Boolean = count <= getAvailableSlotsForPlugins()

fun getDeclaredSlots(pluginId: String): Int = reservedPluginSlots[pluginId] ?: 0

@Throws(SidebarSlotExceededException::class)
fun reservePluginSlots(pluginId: String, count: Int) {
if (count <= 0) return

val available = getAvailableSlotsForPlugins()
if (count > available) {
throw SidebarSlotExceededException(count, available, pluginId)
}
reservedPluginSlots[pluginId] = count
}

fun releasePluginSlots(pluginId: String) {
reservedPluginSlots.remove(pluginId)
}

fun reset() {
builtInItemCount.set(0)
reservedPluginSlots.clear()
}
fun reset() {
builtInItemCount.set(0)
reservedPluginSlots.clear()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.itsaky.androidide.actions

import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Test

class SidebarSlotManagerTest {
@After
fun tearDown() = SidebarSlotManager.reset()

@Test
fun `a plugin can declare more sidebar items than fit beside the built-in ones`() {
SidebarSlotManager.setBuiltInItemCount(7)

SidebarSlotManager.reservePluginSlots("plugin.a", 14)

assertThat(SidebarSlotManager.getDeclaredSlots("plugin.a")).isEqualTo(14)
assertThat(SidebarSlotManager.getTotalItemCount()).isEqualTo(21)
}
}
140 changes: 91 additions & 49 deletions app/src/main/java/com/itsaky/androidide/ui/IdeNavigationRailView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,98 @@ package com.itsaky.androidide.ui

import android.content.Context
import android.util.AttributeSet
import androidx.core.view.isGone
import androidx.core.view.marginTop
import androidx.core.widget.NestedScrollView
import com.google.android.material.navigation.NavigationBarMenuView
import com.google.android.material.navigationrail.NavigationRailView
import com.itsaky.androidide.R

class IdeNavigationRailView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = com.google.android.material.R.attr.navigationRailStyle
) : NavigationRailView(context, attrs, defStyleAttr) {

companion object {
const val MAX_ITEM_COUNT = 12
}

override fun getMaxItemCount(): Int = MAX_ITEM_COUNT

override fun onAttachedToWindow() {
super.onAttachedToWindow()
enableMenuScrolling()
}

private fun enableMenuScrolling() {
post {
val menuView = (0 until childCount)
.map { getChildAt(it) }
.firstOrNull { it is NavigationBarMenuView }
?: return@post

if (menuView.parent is NestedScrollView) return@post

removeView(menuView)

val scroll = NestedScrollView(context).apply {
isVerticalScrollBarEnabled = false
addView(
menuView,
LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT
)
)
}

addView(
scroll,
LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.MATCH_PARENT
)
)
}
}
}
class IdeNavigationRailView
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = com.google.android.material.R.attr.navigationRailStyle,
) : NavigationRailView(context, attrs, defStyleAttr) {
private val menuMarginTop = resources.getDimensionPixelSize(R.dimen.sidebar_rail_menu_margin_top)
private var menuScroll: NestedScrollView? = null

override fun getMaxItemCount(): Int = Int.MAX_VALUE

override fun onAttachedToWindow() {
super.onAttachedToWindow()
enableMenuScrolling()
}

override fun onMeasure(
widthMeasureSpec: Int,
heightMeasureSpec: Int,
) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val scroll = menuScroll ?: return

val menuTop = menuTop()
(scroll.layoutParams as LayoutParams).topMargin = menuTop
scroll.forceLayout()
scroll.measure(
MeasureSpec.makeMeasureSpec(scroll.measuredWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(
(measuredHeight - paddingTop - paddingBottom - menuTop).coerceAtLeast(0),
MeasureSpec.EXACTLY,
),
)
}

override fun onLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int,
) {
super.onLayout(changed, left, top, right, bottom)
val menu = menuScroll?.getChildAt(0) ?: return
menu.offsetTopAndBottom(-menu.top)
}

private fun menuTop(): Int {
val header = headerView?.takeUnless { it.isGone } ?: return menuMarginTop
return header.marginTop + header.measuredHeight + menuMarginTop
}

private fun enableMenuScrolling() {
post {
val menuView =
(0 until childCount)
.map { getChildAt(it) }
.firstOrNull { it is NavigationBarMenuView }
?: return@post

if (menuView.parent is NestedScrollView) return@post

removeView(menuView)

val scroll =
NestedScrollView(context).apply {
isVerticalScrollBarEnabled = false
addView(
menuView,
LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT,
),
)
}

addView(
scroll,
LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.MATCH_PARENT,
),
)
menuScroll = scroll
}
}
}
1 change: 1 addition & 0 deletions app/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@
<dimen name="sidebar_header_logo_size">44dp</dimen>
<dimen name="sidebar_header_logo_margin_top">64dp</dimen>
<dimen name="sidebar_header_logo_padding">6dp</dimen>
<dimen name="sidebar_rail_menu_margin_top">8dp</dimen>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.itsaky.androidide.ui

import android.app.Activity
import android.app.Application
import android.os.Looper
import android.view.ContextThemeWrapper
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.children
import androidx.core.widget.NestedScrollView
import androidx.test.core.app.ApplicationProvider
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class IdeNavigationRailViewTest {
@Test
fun `holds more items than a material navigation rail allows`() {
val context =
ContextThemeWrapper(
ApplicationProvider.getApplicationContext(),
com.google.android.material.R.style.Theme_Material3_DayNight,
)
val rail = IdeNavigationRailView(context)

repeat(ITEM_COUNT) { rail.menu.add(0, it + 1, it, "Item $it") }

assertThat(rail.menu.size()).isEqualTo(ITEM_COUNT)
}

@Test
fun `scrolls to the last item when the rail has a header`() = assertScrollsToLastItem(withHeader = true)

@Test
fun `scrolls to the last item when the rail has no header`() = assertScrollsToLastItem(withHeader = false)

private fun assertScrollsToLastItem(withHeader: Boolean) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val rail =
IdeNavigationRailView(
ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight),
)
if (withHeader) {
rail.addHeaderView(
FrameLayout(rail.context).apply {
addView(View(context), FrameLayout.LayoutParams(1, HEADER_HEIGHT))
},
)
}
repeat(ITEM_COUNT) { rail.menu.add(0, it + 1, it, "Item $it") }
activity.setContentView(rail, ViewGroup.LayoutParams(RAIL_WIDTH, RAIL_HEIGHT))
shadowOf(Looper.getMainLooper()).idle()
rail.measure(
View.MeasureSpec.makeMeasureSpec(RAIL_WIDTH, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(RAIL_HEIGHT, View.MeasureSpec.EXACTLY),
)
rail.layout(0, 0, RAIL_WIDTH, RAIL_HEIGHT)

val scroll = rail.children.filterIsInstance<NestedScrollView>().single()
val menu = scroll.getChildAt(0) as ViewGroup
assertThat(scroll.top).isAtLeast(rail.headerView?.bottom ?: 0)
assertThat(scroll.canScrollVertically(1)).isTrue()

scroll.scrollTo(0, menu.height)

val lastItem = menu.getChildAt(menu.childCount - 1)
assertThat(menu.top + lastItem.bottom - scroll.scrollY).isAtMost(scroll.height)
}

private companion object {
const val ITEM_COUNT = 21
const val HEADER_HEIGHT = 120
const val RAIL_WIDTH = 80
const val RAIL_HEIGHT = 600
}
}
9 changes: 9 additions & 0 deletions docs/PLUGIN_API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]
= diffed from `plugin-api/src` history (predates the dump; symbol-accurate).

### 26.41 — unreleased
- **added — No cap on sidebar items** _(ADFA-4977)_
The sidebar held 12 items: the IDE's seven plus the slots plugins declared with
`plugin.sidebar_items`. A plugin declaring more than the free slots failed to load, and
plugins loaded before the editor counted its own items could overfill the sidebar and
crash the IDE at launch. The sidebar now scrolls, so every declared item is shown.
`IdeSidebarService.getMaxSidebarItems()` and `getAvailableSidebarSlots()` return
`Int.MAX_VALUE`, and `canAddSidebarItems()` returns `true`. A plugin still returns no
more items than it declares. Floor `plugin.min_ide_version` at `26.41` if the plugin
declares more than 5 items, the slots an older IDE leaves free.
- **added — Tool-source groups and health, backend model names, and change listeners** _(ADFA-6278)_ **[verified]**
A consumer such as the agent's chat screen could not tell which tools the agent has, whether
they work, or which model will answer, and was never told when any of that changed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package com.itsaky.androidide.plugins.manager.core

import android.app.Activity
import android.content.Context
import com.itsaky.androidide.actions.SidebarSlotExceededException
import com.itsaky.androidide.actions.SidebarSlotManager
import com.itsaky.androidide.plugins.IPlugin
import com.itsaky.androidide.plugins.PluginContext
Expand Down Expand Up @@ -552,14 +551,7 @@ class PluginManager private constructor(
return Result.failure(SecurityException("plugin failed security validation: ${manifest.id}"))
}

// Validate sidebar slots BEFORE loading plugin code
if (manifest.sidebarItems > 0) {
val available = SidebarSlotManager.getAvailableSlotsForPlugins()
if (manifest.sidebarItems > available) {
return Result.failure(
SidebarSlotExceededException(manifest.sidebarItems, available, manifest.id),
)
}
SidebarSlotManager.reservePluginSlots(manifest.id, manifest.sidebarItems)
reservedSlotsPluginId = manifest.id
}
Expand Down
Loading
Loading