diff --git a/platform/extensions/src/com/intellij/openapi/extensions/LoadingOrder.kt b/platform/extensions/src/com/intellij/openapi/extensions/LoadingOrder.kt index 8fcc5dafbcc46..19308374dcc19 100644 --- a/platform/extensions/src/com/intellij/openapi/extensions/LoadingOrder.kt +++ b/platform/extensions/src/com/intellij/openapi/extensions/LoadingOrder.kt @@ -250,4 +250,4 @@ class LoadingOrder { val orderId: String? val order: LoadingOrder } -} \ No newline at end of file +} diff --git a/platform/platform-impl/src/com/intellij/openapi/wm/impl/status/IdeStatusBarImpl.kt b/platform/platform-impl/src/com/intellij/openapi/wm/impl/status/IdeStatusBarImpl.kt index c6a516e05a599..0865b1dff24aa 100644 --- a/platform/platform-impl/src/com/intellij/openapi/wm/impl/status/IdeStatusBarImpl.kt +++ b/platform/platform-impl/src/com/intellij/openapi/wm/impl/status/IdeStatusBarImpl.kt @@ -61,6 +61,8 @@ import com.intellij.openapi.wm.WidgetPresentationFactory import com.intellij.openapi.wm.ex.ProgressIndicatorEx import com.intellij.openapi.wm.ex.StatusBarEx import com.intellij.openapi.wm.impl.status.TextPanel.WithIconAndArrows +import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetSettings +import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetUserMove import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetsActionGroup import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetsManager import com.intellij.openapi.wm.impl.status.widget.WidgetPresentationWrapper @@ -127,6 +129,9 @@ import java.awt.GridBagLayout import java.awt.Insets import java.awt.LayoutManager import java.awt.Point +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException import java.awt.event.MouseEvent import java.util.function.Supplier import javax.accessibility.Accessible @@ -139,6 +144,7 @@ import javax.swing.JLabel import javax.swing.JPanel import javax.swing.SwingUtilities import javax.swing.ToolTipManager +import javax.swing.TransferHandler import javax.swing.UIManager import javax.swing.border.Border import javax.swing.border.CompoundBorder @@ -199,6 +205,134 @@ open class IdeStatusBarImpl @Internal constructor( internal val HOVERED_WIDGET_ID: DataKey = DataKey.create("HOVERED_WIDGET_ID") const val NAVBAR_WIDGET_KEY: String = "NavBar" + + private val WIDGET_FLAVOR = DataFlavor(DataFlavor.javaJVMLocalObjectMimeType + ";class=javax.swing.JComponent") + } + + private class WidgetTransferable(val component: JComponent) : Transferable { + override fun getTransferDataFlavors(): Array = + arrayOf(WIDGET_FLAVOR) + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = + flavor == WIDGET_FLAVOR + + override fun getTransferData(flavor: DataFlavor): Any = + if (isDataFlavorSupported(flavor)) return component + else throw UnsupportedFlavorException(flavor) + } + + private inner class WidgetTransferHandler : TransferHandler() { + override fun getSourceActions(c: JComponent): Int = + MOVE + + override fun createTransferable(c: JComponent): Transferable = + WidgetTransferable(c) + + override fun canImport(support: TransferSupport): Boolean = + support.isDataFlavorSupported(WIDGET_FLAVOR) && + support.isDrop + + override fun importData(support: TransferSupport): Boolean { + try { + if (!canImport(support)) return false + + val sourceWidgetId = (support.transferable.getTransferData(WIDGET_FLAVOR) as JComponent) + .let { ClientProperty.get(it, WIDGET_ID) } + ?: return false + + val targetWidgetId = (support.component as? JComponent) + ?.let { findWidgetComponent(it) } + ?.let { ClientProperty.get(it, WIDGET_ID) } + ?: return false + + if (sourceWidgetId == targetWidgetId) return false + + reorderWidgets(sourceWidgetId, targetWidgetId) + + return true + } + catch (e: Exception) { // it appears escaping exceptions are swallowed + LOG.error("Unexpected error when reordering widget", e) + throw e + } + } + } + + /** + * Traverse the components in case the drop was somehow not on the widget itself. + * + * Most likely not needed. + */ + private fun findWidgetComponent(component: Component): JComponent? { + var current: Component? = component + while (current != null) { + (current as? JComponent) + ?.let { ClientProperty.get(current, WIDGET_ID) } + ?.let { return current } + + if (current === rightPanel) break + current = current.parent + } + return null + } + + class WidgetSorter( + private val moves: MutableList = + StatusBarWidgetSettings.getInstance().getUserMoves().toMutableList(), + private val persist: (List) -> Unit = + StatusBarWidgetSettings.getInstance()::setUserMoves + ) { + + fun reorder(sourceWidgetId: String, targetWidgetId: String) { + moves.removeIf { it.source == sourceWidgetId } + moves += StatusBarWidgetUserMove(sourceWidgetId, targetWidgetId) + persist(moves) + } + + fun sortWidgets(sorted: MutableList) { + LoadingOrder.sortByLoadingOrder(sorted) + + LOG.debug("Working list after sort: $sorted") + + LOG.debug("User moves: $moves") + for (move in moves) { + applyUserMove(sorted, move) + } + + LOG.debug("Working list after applying user moves: $sorted") + } + + private fun applyUserMove( + widgets: MutableList, + move: StatusBarWidgetUserMove + ) { + val sourceIndex = widgets.indexOfFirst { it.orderId == move.source } + val targetIndex = widgets.indexOfFirst { it.orderId == move.target } + + if (sourceIndex == -1 || targetIndex == -1) return + + val item = widgets.removeAt(sourceIndex) + val insertIndex = + if (sourceIndex < targetIndex) targetIndex - 1 else targetIndex + + widgets.add(insertIndex, item) + } + } + + private val widgetSorter = WidgetSorter() + + private fun reorderWidgets(sourceWidgetId: String, targetWidgetId: String) { + val sourceBean = widgetRegistry.get(sourceWidgetId) ?: return + val targetBean = widgetRegistry.get(targetWidgetId) ?: return + + if (sourceBean.position != Position.RIGHT) return + if (targetBean.position != Position.RIGHT) return + + widgetSorter.reorder(sourceWidgetId, targetWidgetId) + + sortRightWidgets() + rightPanel.revalidate() + rightPanel.repaint() } override fun findChild(c: Component): StatusBar { @@ -467,10 +601,14 @@ open class IdeStatusBarImpl @Internal constructor( get() = it.id override val order: LoadingOrder get() = it.order + override fun toString(): String { + return "Virtual(id=${it.id}, order=${it.order})" + } } } - LoadingOrder.sortByLoadingOrder(sorted) + widgetSorter.sortWidgets(sorted) + for ((index, item) in sorted.withIndex()) { rightPanelLayout.setConstraints((item as? WidgetBean ?: continue).component, GridBagConstraints().apply { gridx = index @@ -498,6 +636,21 @@ open class IdeStatusBarImpl @Internal constructor( if (bean.position == Position.LEFT && panel.componentCount == 0) { bean.component.border = if (SystemInfoRt.isMac) JBUI.Borders.empty(2, 0, 2, 4) else JBUI.Borders.empty() } + + // Enable drag and drop for right panel widgets + if (bean.position == Position.RIGHT) { + bean.component.transferHandler = WidgetTransferHandler() + + // For generic JComponents, we need to manually trigger drag via MouseMotionListener + // since setDragEnabled() is only available on specific components (JList, JTable, etc.) + bean.component.addMouseMotionListener(object : java.awt.event.MouseMotionAdapter() { + override fun mouseDragged(e: MouseEvent) { + val handler = bean.component.transferHandler + handler?.exportAsDrag(bean.component, e, TransferHandler.MOVE) + } + }) + } + panel.add(bean.component) panel.revalidate() } @@ -902,6 +1055,7 @@ private fun configurePresentationComponent(presentation: WidgetPresentation, pan internal fun wrap(widget: StatusBarWidget): JComponent { val result = if (widget is CustomStatusBarWidget) { return wrapCustomStatusBarWidget(widget) + .also { ClientProperty.put(it, WIDGET_ID, widget.ID()) } } else { createComponentByWidgetPresentation(widget) diff --git a/platform/platform-impl/src/com/intellij/openapi/wm/impl/status/widget/StatusBarWidgetSettings.kt b/platform/platform-impl/src/com/intellij/openapi/wm/impl/status/widget/StatusBarWidgetSettings.kt index 6158c73bf2b03..75afb9fc6ad3e 100644 --- a/platform/platform-impl/src/com/intellij/openapi/wm/impl/status/widget/StatusBarWidgetSettings.kt +++ b/platform/platform-impl/src/com/intellij/openapi/wm/impl/status/widget/StatusBarWidgetSettings.kt @@ -28,16 +28,35 @@ class StatusBarWidgetSettings : SerializablePersistentStateComponent) { + updateState { state -> + state.copy(userMoves = userMoves.associate { it.source to it.target }) + } + } + + fun getUserMoves(): List { + return state.userMoves.map { StatusBarWidgetUserMove(it.key, it.value) } + } } @Internal -data class StatusBarState(@JvmField val widgets: Map = emptyMap()) +data class StatusBarState( + @JvmField val widgets: Map = emptyMap(), + @JvmField val userMoves: Map = emptyMap(), +) + +@Internal +data class StatusBarWidgetUserMove( + val source: String, + val target: String, +) diff --git a/platform/platform-tests/testSrc/com/intellij/openapi/wm/impl/status/WidgetSorterTest.kt b/platform/platform-tests/testSrc/com/intellij/openapi/wm/impl/status/WidgetSorterTest.kt new file mode 100644 index 0000000000000..c81a8beb89107 --- /dev/null +++ b/platform/platform-tests/testSrc/com/intellij/openapi/wm/impl/status/WidgetSorterTest.kt @@ -0,0 +1,160 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.openapi.wm.impl.status + +import com.intellij.openapi.extensions.LoadingOrder +import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetUserMove +import org.junit.Assert.assertEquals +import org.junit.Test + +class WidgetSorterTest { + + private data class TestOrderable( + override val orderId: String, + override val order: LoadingOrder + ) : LoadingOrder.Orderable { + override fun toString(): String = "Widget(id=$orderId, order=$order)" + } + + @Test + fun `should sort widgets by loading order`() { + val widgets = mutableListOf( + TestOrderable("LineSeparator", LoadingOrder.after("Position")), + TestOrderable("Position", LoadingOrder.ANY), + TestOrderable("Encoding", LoadingOrder.after("LineSeparator")) + ) + + val sorter = IdeStatusBarImpl.WidgetSorter(mutableListOf()) { } + sorter.sortWidgets(widgets.cast()) + + assertEquals("Position", widgets[0].orderId) + assertEquals("LineSeparator", widgets[1].orderId) + assertEquals("Encoding", widgets[2].orderId) + } + + @Test + fun `should apply user moves after loading order sort`() { + val widgets = mutableListOf( + TestOrderable("Position", LoadingOrder.ANY), + TestOrderable("LineSeparator", LoadingOrder.after("Position")), + TestOrderable("Encoding", LoadingOrder.after("LineSeparator")) + ) + + // Move Encoding before Position + val moves = mutableListOf(StatusBarWidgetUserMove("Encoding", "Position")) + val sorter = IdeStatusBarImpl.WidgetSorter(moves) { } + sorter.sortWidgets(widgets.cast()) + + assertEquals("Encoding", widgets[0].orderId) + assertEquals("Position", widgets[1].orderId) + assertEquals("LineSeparator", widgets[2].orderId) + } + + @Test + fun `should handle multiple user moves`() { + val widgets = mutableListOf( + TestOrderable("A", LoadingOrder.ANY), + TestOrderable("B", LoadingOrder.ANY), + TestOrderable("C", LoadingOrder.ANY) + ) + + // Initial order A, B, C + // Move C to A -> C, A, B + // Move B to C -> B, C, A + val moves = mutableListOf( + StatusBarWidgetUserMove("C", "A"), + StatusBarWidgetUserMove("B", "C") + ) + val sorter = IdeStatusBarImpl.WidgetSorter(moves) { } + sorter.sortWidgets(widgets.cast()) + + assertEquals("B", widgets[0].orderId) + assertEquals("C", widgets[1].orderId) + assertEquals("A", widgets[2].orderId) + } + + @Test + fun `should handle complex dependencies from example`() { + // This is actual Widget configuration taken from running instance, below as `widgets` + // [Widget(id=Position, order=ANY, position=RIGHT), + // Widget(id=LanguageServiceStatusBarWidget, order=after Position, after AIAssistant, before LineSeparator, position=RIGHT), + // Widget(id=LineSeparator, order=after Position, position=RIGHT), + // Widget(id=Encoding, order=after LineSeparator, position=RIGHT), + // Widget(id=PowerSaveMode, order=after Encoding, position=RIGHT), + // Widget(id=InsertOverwrite, order=after PowerSaveMode, position=RIGHT), + // Widget(id=CodeStyleStatusBarWidget, order=after InsertOverwrite, position=RIGHT), + // Widget(id=JSONSchemaSelector, order=after CodeStyleStatusBarWidget, before ReadOnlyAttribute, position=RIGHT), + // Widget(id=largeFileEncodingWidget, order=after PowerSaveMode, position=RIGHT), + // Widget(id=ReadOnlyAttribute, order=after InsertOverwrite, position=RIGHT), + // Widget(id=FatalError, order=after Notifications, position=RIGHT)] + + val widgets = mutableListOf( + TestOrderable("Position", LoadingOrder.ANY), + TestOrderable("LanguageServiceStatusBarWidget", LoadingOrder.readOrder("after Position, before LineSeparator")), + TestOrderable("LineSeparator", LoadingOrder.after("Position")), + TestOrderable("Encoding", LoadingOrder.after("LineSeparator")), + TestOrderable("PowerSaveMode", LoadingOrder.after("Encoding")), + TestOrderable("InsertOverwrite", LoadingOrder.after("PowerSaveMode")), + TestOrderable("CodeStyleStatusBarWidget", LoadingOrder.after("InsertOverwrite")), + TestOrderable("JSONSchemaSelector", LoadingOrder.readOrder("after CodeStyleStatusBarWidget, before ReadOnlyAttribute")), + TestOrderable("largeFileEncodingWidget", LoadingOrder.after("PowerSaveMode")), + TestOrderable("ReadOnlyAttribute", LoadingOrder.after("InsertOverwrite")), + TestOrderable("FatalError", LoadingOrder.after("Notifications")), // Notifications is missing + TestOrderable("Notifications", LoadingOrder.ANY) // Added to satisfy FatalError dependency if we want to test it + ) + + val sorter = IdeStatusBarImpl.WidgetSorter(mutableListOf()) { } + sorter.sortWidgets(widgets.cast()) + + // Verify some key orderings + assertBefore(widgets, "Position", "LanguageServiceStatusBarWidget") + assertBefore(widgets, "LanguageServiceStatusBarWidget", "LineSeparator") + assertBefore(widgets, "LineSeparator", "Encoding") + assertBefore(widgets, "Encoding", "PowerSaveMode") + assertBefore(widgets, "PowerSaveMode", "InsertOverwrite") + assertBefore(widgets, "InsertOverwrite", "CodeStyleStatusBarWidget") + assertBefore(widgets, "CodeStyleStatusBarWidget", "JSONSchemaSelector") + assertBefore(widgets, "JSONSchemaSelector", "ReadOnlyAttribute") + assertBefore(widgets, "PowerSaveMode", "largeFileEncodingWidget") + assertBefore(widgets, "Notifications", "FatalError") + } + + @Test + fun `should persist moves when reordering`() { + var persistedMoves: List? = null + val sorter = IdeStatusBarImpl.WidgetSorter(mutableListOf()) { persistedMoves = it } + + sorter.reorder("A", "B") + + assertEquals(1, persistedMoves?.size) + assertEquals("A", persistedMoves?.get(0)?.source) + assertEquals("B", persistedMoves?.get(0)?.target) + } + + @Test + fun `should replace existing move for the same source widget`() { + var persistedMoves: List? = null + val initialMoves = mutableListOf(StatusBarWidgetUserMove("A", "B")) + val sorter = IdeStatusBarImpl.WidgetSorter(initialMoves) { persistedMoves = it } + + sorter.reorder("A", "C") + + assertEquals(1, persistedMoves?.size) + assertEquals("A", persistedMoves?.get(0)?.source) + assertEquals("C", persistedMoves?.get(0)?.target) + } + + private fun assertBefore(widgets: List, beforeId: String, afterId: String) { + val beforeIndex = widgets.indexOfFirst { it.orderId == beforeId } + val afterIndex = widgets.indexOfFirst { it.orderId == afterId } + + if (beforeIndex == -1) throw AssertionError("Widget $beforeId not found") + if (afterIndex == -1) throw AssertionError("Widget $afterId not found") + + if (beforeIndex >= afterIndex) { + throw AssertionError("Expected $beforeId to be before $afterId, but was at $beforeIndex and $afterIndex respectively. List: ${widgets.map { it.orderId }}") + } + } + + @Suppress("UNCHECKED_CAST") + private fun MutableList.cast(): MutableList = this as MutableList +}