mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-20 11:16:06 +00:00
fix(ui): make right panel drag reorder work
Replace the native HTML drag handlers on right-panel tabs and Status sections with the same solid-dnd sortable primitives used by the main instance tabs. This keeps direct drag-to-reorder interaction reliable across the panel customization UI while preserving the existing persisted order model. Validation: npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/registry.test.ts; npm run typecheck --workspace @codenomad/ui; git diff --check.
This commit is contained in:
parent
137b8c00ab
commit
6bcc52567d
3 changed files with 124 additions and 84 deletions
|
|
@ -12,6 +12,14 @@ import {
|
|||
} from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
type DragEvent as SolidDndDragEvent,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||
|
|
@ -82,6 +90,32 @@ function RightPanelTabFallback() {
|
|||
return <div class="flex-1 min-h-0" />
|
||||
}
|
||||
|
||||
interface SortableRightPanelTabProps {
|
||||
tab: RightPanelTabModule
|
||||
active: boolean
|
||||
label: string
|
||||
dragTitle: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
const SortableRightPanelTab: Component<SortableRightPanelTabProps> = (props) => {
|
||||
const sortable = createSortable(props.tab.id)
|
||||
return (
|
||||
<div ref={sortable} class={`right-panel-tab-draggable ${sortable.isActiveDraggable ? "right-panel-tab-draggable-active" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={`right-panel-tab ${props.active ? "right-panel-tab-active" : "right-panel-tab-inactive"}`}
|
||||
aria-selected={props.active}
|
||||
title={props.dragTitle}
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
<span class="tab-label">{props.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RightPanelProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
|
|
@ -118,7 +152,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
const [rightPanelCustomization, setRightPanelCustomization] = createSignal<RightPanelCustomization>(
|
||||
parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)),
|
||||
)
|
||||
const [draggedTabId, setDraggedTabId] = createSignal<string | null>(null)
|
||||
|
||||
const [browserPath, setBrowserPath] = createSignal(".")
|
||||
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
|
||||
|
|
@ -689,8 +722,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
}))
|
||||
}
|
||||
|
||||
const tabClass = (tab: RightPanelTab) =>
|
||||
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
|
||||
const handleTabDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => {
|
||||
if (!droppable) return
|
||||
moveTab(String(draggable.id), String(droppable.id))
|
||||
}
|
||||
|
||||
const rightPanelModules = createMemo<RightPanelModule[]>(() => [
|
||||
{
|
||||
|
|
@ -883,38 +918,23 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
|
||||
<For each={visibleRightPanelTabs()}>
|
||||
{(tab) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass(tab.id)}
|
||||
aria-selected={rightPanelTab() === tab.id}
|
||||
title={props.t("instanceShell.rightPanel.customize.dragToReorder")}
|
||||
onClick={() => setRightPanelTab(tab.id)}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setDraggedTabId(tab.id)
|
||||
event.dataTransfer?.setData("text/plain", tab.id)
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!draggedTabId() || draggedTabId() === tab.id) return
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move"
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
const sourceId = event.dataTransfer?.getData("text/plain") || draggedTabId()
|
||||
if (sourceId) moveTab(sourceId, tab.id)
|
||||
setDraggedTabId(null)
|
||||
}}
|
||||
onDragEnd={() => setDraggedTabId(null)}
|
||||
>
|
||||
<span class="tab-label">{props.t(tab.labelKey)}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleTabDragEnd}>
|
||||
<DragDropSensors>
|
||||
<SortableProvider ids={visibleRightPanelTabs().map((tab) => tab.id)}>
|
||||
<For each={visibleRightPanelTabs()}>
|
||||
{(tab) => (
|
||||
<SortableRightPanelTab
|
||||
tab={tab}
|
||||
active={rightPanelTab() === tab.id}
|
||||
label={props.t(tab.labelKey)}
|
||||
dragTitle={props.t("instanceShell.rightPanel.customize.dragToReorder")}
|
||||
onSelect={() => setRightPanelTab(tab.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</DragDropSensors>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
|
||||
<div class="tab-strip-spacer" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { For, Show, createMemo, createSignal, type Accessor, type Component } from "solid-js"
|
||||
import { For, Show, createMemo, type Accessor, type Component } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
type DragEvent as SolidDndDragEvent,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
import Switch from "@suid/material/Switch"
|
||||
|
|
@ -41,9 +49,42 @@ interface StatusTabProps {
|
|||
extraSections?: readonly RightPanelSectionModule[]
|
||||
}
|
||||
|
||||
interface SortableStatusSectionProps {
|
||||
section: RightPanelSectionModule
|
||||
expanded: boolean
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
}
|
||||
|
||||
const SortableStatusSection: Component<SortableStatusSectionProps> = (props) => {
|
||||
const sortable = createSortable(props.section.id)
|
||||
return (
|
||||
<div ref={sortable} class={`right-panel-section-draggable ${sortable.isActiveDraggable ? "right-panel-section-draggable-active" : ""}`}>
|
||||
<Accordion.Item value={props.section.id} class="right-panel-accordion-item">
|
||||
<Accordion.Header class="right-panel-accordion-header-row">
|
||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||
<span class="section-left">
|
||||
<GripVertical class="h-3.5 w-3.5 text-tertiary" aria-hidden="true" />
|
||||
<span class="section-label">{props.t(props.section.labelKey)}</span>
|
||||
</span>
|
||||
<ChevronDown class={`right-panel-accordion-chevron ${props.expanded ? "right-panel-accordion-chevron-expanded" : ""}`} />
|
||||
</Accordion.Trigger>
|
||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(props.section.tooltipKey)}>
|
||||
<Info class="section-info-icon" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="section-info-tooltip">{props.t(props.section.tooltipKey)}</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="right-panel-accordion-content">{props.section.render()}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||
const [draggedSectionId, setDraggedSectionId] = createSignal<string | null>(null)
|
||||
|
||||
const renderYoloModeSection = () => {
|
||||
const session = props.activeSession()
|
||||
|
|
@ -259,6 +300,11 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||
props.onCustomizationChange((current) => ({ ...current, statusSectionOrder: next }))
|
||||
}
|
||||
|
||||
const handleSectionDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => {
|
||||
if (!droppable) return
|
||||
moveSection(String(draggable.id), String(droppable.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="status-tab-container">
|
||||
<Show when={props.activeSession()}>
|
||||
|
|
@ -274,53 +320,15 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||
value={props.expandedItems()}
|
||||
onChange={props.onExpandedItemsChange}
|
||||
>
|
||||
<For each={statusSections()}>
|
||||
{(section) => (
|
||||
<Accordion.Item
|
||||
value={section.id}
|
||||
class="right-panel-accordion-item"
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setDraggedSectionId(section.id)
|
||||
event.dataTransfer?.setData("text/plain", section.id)
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!draggedSectionId() || draggedSectionId() === section.id) return
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move"
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
const sourceId = event.dataTransfer?.getData("text/plain") || draggedSectionId()
|
||||
if (sourceId) moveSection(sourceId, section.id)
|
||||
setDraggedSectionId(null)
|
||||
}}
|
||||
onDragEnd={() => setDraggedSectionId(null)}
|
||||
>
|
||||
<Accordion.Header class="right-panel-accordion-header-row">
|
||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||
<span class="section-left">
|
||||
<GripVertical class="h-3.5 w-3.5 text-tertiary" aria-hidden="true" />
|
||||
<span class="section-label">{props.t(section.labelKey)}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
|
||||
<Info class="section-info-icon" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleSectionDragEnd}>
|
||||
<DragDropSensors>
|
||||
<SortableProvider ids={statusSections().map((section) => section.id)}>
|
||||
<For each={statusSections()}>
|
||||
{(section) => <SortableStatusSection section={section} expanded={isSectionExpanded(section.id)} t={props.t} />}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</DragDropSensors>
|
||||
</DragDropProvider>
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
.right-panel-tab-draggable {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.right-panel-tab-draggable-active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.right-panel-tab:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
|
|
@ -769,6 +777,10 @@
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.right-panel-section-draggable-active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.right-panel-customization-popover {
|
||||
position: absolute;
|
||||
top: 2.75rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue