diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index acc3c6d6..da156173 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -3,12 +3,12 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; // Added useRef import just in case it was missed, though likely already present +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react'; import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidStatefulModelInfo, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName, hasDownloadButtonsOnModelsProviderNames, subTextMdOfProviderName } from '../../../../common/voidSettingsTypes.js' import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js' import { VoidButtonBgDarken, VoidCustomDropdownBox, VoidInputBox2, VoidSimpleInputBox, VoidSwitch } from '../util/inputs.js' import { useAccessor, useIsDark, useIsOptedOut, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js' -import { X, RefreshCw, Loader2, Check, Asterisk, Plus } from 'lucide-react' +import { X, RefreshCw, Loader2, Check, Asterisk, Plus, GripVertical } from 'lucide-react' import { URI } from '../../../../../../../base/common/uri.js' import { ModelDropdown } from './ModelDropdown.js' import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js' @@ -390,6 +390,56 @@ export const ModelDump = ({ filteredProviders }: { filteredProviders?: ProviderN const [modelName, setModelName] = useState(''); const [errorString, setErrorString] = useState(''); + // Drag-and-drop state for reordering CUSTOM models within a provider. + // Default/autodetected models aren't draggable because their order is regenerated + // on provider refresh (see _modelsWithSwappedInNewModels). + // The list is rendered in "preview order": while dragging, the source row physically + // moves to its prospective drop position in the DOM (by mutating modelDump), so there's + // no placeholder/collapse animation and nothing visually changes on release. + const [dragSource, setDragSource] = useState<{ providerName: ProviderName; modelName: string } | null>(null); + const [dropTarget, setDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null); + // After a drop, hold drag state until the reordered settingsState arrives, then clear + // synchronously (before paint) so we never show an intermediate frame. + const awaitingDropCommitRef = useRef(false); + useLayoutEffect(() => { + if (awaitingDropCommitRef.current) { + awaitingDropCommitRef.current = false; + setDragSource(null); + setDropTarget(null); + } + }, [settingsState]); + + // Ref to the outer list container — the positioning context for the ghost. + const listContainerRef = useRef(null); + // Vertical-only drag ghost. Rendered via React (so it inherits theme styles), but + // positioned imperatively so `ondrag` doesn't trigger a re-render on every event. + const ghostElRef = useRef(null); + const ghostMetricsRef = useRef<{ + offsetY: number; left: number; width: number; height: number; + initialTop: number; minTop: number; maxTop: number; containerTop: number; + // Viewport rect of each custom row captured at dragStart — used as a stable + // threshold for the swap trigger (so thresholds don't move when the preview + // reorder shifts rows around mid-drag). + originalRowRects: Map; + }>({ + offsetY: 0, left: 0, width: 0, height: 0, + initialTop: 0, minTop: 0, maxTop: 0, containerTop: 0, + originalRowRects: new Map(), + }); + // When dragSource becomes set, apply the captured position to the just-mounted ghost + // before paint so the first frame shows it at the correct spot. Positions are stored + // in container-relative coords (see onDragStart). + useLayoutEffect(() => { + if (dragSource && ghostElRef.current) { + const m = ghostMetricsRef.current; + const el = ghostElRef.current; + el.style.left = `${m.left}px`; + el.style.top = `${m.initialTop}px`; + el.style.width = `${m.width}px`; + el.style.height = `${m.height}px`; + } + }, [dragSource]); + // a dump of all the enabled providers' models const modelDump: (VoidStatefulModelInfo & { providerName: ProviderName, providerEnabled: boolean })[] = [] @@ -407,6 +457,20 @@ export const ModelDump = ({ filteredProviders }: { filteredProviders?: ProviderN return Number(b.providerEnabled) - Number(a.providerEnabled) }) + // Preview reorder: while dragging with a chosen drop target, move the source row + // to its prospective position in `modelDump` so the list previews the final order + // in real time. On release nothing visually changes because it's already there. + if (dragSource && dropTarget) { + const srcIdx = modelDump.findIndex(m => m.providerName === dragSource.providerName && m.modelName === dragSource.modelName) + const tgtIdx = modelDump.findIndex(m => `${m.providerName}::${m.modelName}` === dropTarget.key) + if (srcIdx !== -1 && tgtIdx !== -1 && srcIdx !== tgtIdx) { + const [src] = modelDump.splice(srcIdx, 1) + const newTgtIdx = modelDump.findIndex(m => `${m.providerName}::${m.modelName}` === dropTarget.key) + const insertAt = dropTarget.position === 'before' ? newTgtIdx : newTgtIdx + 1 + modelDump.splice(insertAt, 0, src) + } + } + // Add model handler const handleAddModel = () => { if (!userChosenProviderName) { @@ -435,7 +499,25 @@ export const ModelDump = ({ filteredProviders }: { filteredProviders?: ProviderN setErrorString(''); }; - return
+ // Container-level drop handler: catches drops that aren't directly over a specific + // row target (the current dropTarget state is the source of truth for where to land). + const onContainerDragOver = dragSource ? (e: React.DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + } : undefined + const onContainerDrop = dragSource ? (e: React.DragEvent) => { + e.preventDefault() + if (dragSource && dropTarget) { + const targetModelName = dropTarget.key.split('::').slice(1).join('::') + settingsStateService.reorderCustomModel(dragSource.providerName, dragSource.modelName, targetModelName, dropTarget.position) + awaitingDropCommitRef.current = true + } else { + setDragSource(null) + setDropTarget(null) + } + } : undefined + + return
{modelDump.map((m, i) => { const { isHidden, type, modelName, providerName, providerEnabled } = m @@ -461,14 +543,141 @@ export const ModelDump = ({ filteredProviders }: { filteredProviders?: ProviderN const hasOverrides = !!settingsState.overridesOfModel?.[providerName]?.[modelName] + const isCustom = type === 'custom' + const rowKey = `${providerName}::${modelName}` + const isValidDropTarget = + !!dragSource && + isCustom && + dragSource.providerName === providerName && + dragSource.modelName !== modelName + const isBeingDragged = !!dragSource && dragSource.providerName === providerName && dragSource.modelName === modelName + return
{ + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', modelName) + + // Suppress the native drag ghost — we render our own vertical-only ghost. + const invisible = document.createElement('div') + invisible.style.cssText = 'width:1px;height:1px;position:fixed;top:-1000px;opacity:0;pointer-events:none;' + document.body.appendChild(invisible) + e.dataTransfer.setDragImage(invisible, 0, 0) + setTimeout(() => { invisible.remove() }, 0) + + const rect = e.currentTarget.getBoundingClientRect() + + // The ghost is `position: absolute` inside listContainerRef (which is + // `position: relative`), so all coords we store/apply must be + // container-relative — not viewport-relative. + const container = listContainerRef.current + const containerRect = container?.getBoundingClientRect() + const cLeft = containerRect?.left ?? 0 + const cTop = containerRect?.top ?? 0 + + // Clamp vertical travel to the first/last custom row of THIS provider. + // Also capture each custom row's original viewport rect (stable thresholds + // independent of preview reorders), keyed by model name. + let minTop = rect.top - cTop + let maxTop = rect.top - cTop + const originalRowRects = new Map() + if (container) { + const rows = Array.from(container.querySelectorAll(`[data-row-provider="${providerName}"]`)) + if (rows.length > 0) { + const firstR = rows[0].getBoundingClientRect() + const lastR = rows[rows.length - 1].getBoundingClientRect() + minTop = firstR.top - cTop + maxTop = (lastR.bottom - cTop) - rect.height + } + for (const row of rows) { + const mn = row.querySelector('[data-row-model-name]')?.dataset.rowModelName + if (!mn) continue + const r = row.getBoundingClientRect() + originalRowRects.set(mn, { top: r.top, bottom: r.bottom }) + } + } + + ghostMetricsRef.current = { + offsetY: e.clientY - rect.top, + left: rect.left - cLeft, + width: rect.width, + height: rect.height, + initialTop: rect.top - cTop, + minTop, + maxTop, + containerTop: cTop, + originalRowRects, + } + + setDragSource({ providerName, modelName }) + } : undefined} + onDrag={isCustom ? (e) => { + // clientY is 0 on the terminal dragend event — ignore that. + const el = ghostElRef.current + const mm = ghostMetricsRef.current + if (el && e.clientY > 0) { + const desired = (e.clientY - mm.offsetY) - mm.containerTop + const clamped = Math.max(mm.minTop, Math.min(mm.maxTop, desired)) + el.style.top = `${clamped}px` + el.style.left = `${mm.left}px` // lock X + } + } : undefined} + onDragEnd={() => { + // If a drop is being committed, leave drag state alone — the layout effect + // on settingsState clears it on the same render that shows the new order. + if (awaitingDropCommitRef.current) return + setDragSource(null); setDropTarget(null); + }} + onDragOver={isValidDropTarget ? (e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + // Ghost center vs target's ORIGINAL rect → stable threshold so "undo swap" + // and "do swap" trigger at the same visual point (entry edge). + const mm = ghostMetricsRef.current + const ghostCenterVp = (e.clientY - mm.offsetY) + mm.height / 2 + const origRect = mm.originalRowRects.get(modelName) ?? (() => { + const r = e.currentTarget.getBoundingClientRect() + return { top: r.top, bottom: r.bottom } + })() + // Direction from ORIGINAL order (modelDump is unmutated by preview reorder here). + const srcIdxOrig = modelDump.findIndex(mm => mm.providerName === providerName && mm.modelName === dragSource!.modelName) + const tgtIdxOrig = modelDump.findIndex(mm => mm.providerName === providerName && mm.modelName === modelName) + const isTargetBelow = tgtIdxOrig > srcIdxOrig + const threshold = isTargetBelow ? origRect.top : origRect.bottom + const position: 'before' | 'after' = ghostCenterVp < threshold ? 'before' : 'after' + if (dropTarget?.key !== rowKey || dropTarget?.position !== position) { + setDropTarget({ key: rowKey, position }) + } + } : undefined} + onDrop={isValidDropTarget ? (e) => { + e.preventDefault() + if (dragSource && dropTarget?.key === rowKey) { + settingsStateService.reorderCustomModel(providerName, dragSource.modelName, modelName, dropTarget.position) + awaitingDropCommitRef.current = true + } else { + setDragSource(null) + setDropTarget(null) + } + } : undefined} className={`flex items-center justify-between gap-4 hover:bg-black/10 dark:hover:bg-gray-300/10 py-1 px-3 rounded-sm overflow-hidden cursor-default truncate group + ${isCustom ? 'select-none' : ''} + ${isBeingDragged ? 'opacity-60' : ''} `} > {/* left part is width:full */}
{isNewProviderName ? providerTitle : ''} - {modelName} + {/* Drag handle (visual cue only; whole row is draggable) */} + + {isCustom && !dragSource ? ( + + ) : null} + + {modelName}
{/* right part is anything that fits */} @@ -521,6 +730,27 @@ export const ModelDump = ({ filteredProviders }: { filteredProviders?: ProviderN
})} + {/* Vertical-only drag ghost. Mirrors the row's layout + theme classes so it + looks like the dragged row. Position is set in a useLayoutEffect on + dragSource change, and updated imperatively via ghostElRef in `onDrag`. */} + {dragSource && (() => { + const src = modelDump.find(x => x.providerName === dragSource.providerName && x.modelName === dragSource.modelName) + if (!src) return null + return ( +
+ + {displayInfoOfProviderName(dragSource.providerName).title} + + {/* Empty slot matching the row's grip-handle column so the model name lines up. */} + + {dragSource.modelName} +
+ ) + })()} + {/* Add Model Section */} {showCheckmark ? (
diff --git a/src/vs/workbench/contrib/void/common/voidSettingsService.ts b/src/vs/workbench/contrib/void/common/voidSettingsService.ts index 3e0c2295..ea5b72eb 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsService.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsService.ts @@ -75,6 +75,7 @@ export interface IVoidSettingsService { toggleModelHidden(providerName: ProviderName, modelName: string): void; addModel(providerName: ProviderName, modelName: string): void; deleteModel(providerName: ProviderName, modelName: string): boolean; + reorderCustomModel(providerName: ProviderName, modelName: string, targetModelName: string, position: 'before' | 'after'): boolean; addMCPUserStateOfNames(userStateOfName: MCPUserStateOfName): Promise; removeMCPUserStateOfNames(serverNames: string[]): Promise; @@ -560,6 +561,35 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { return true } + // Reorders a custom model so that it sits immediately before/after `targetModelName`. + // Both source and target must be custom models of the same provider. Non-custom + // models (default/autodetected) are not moved because their order is managed by + // _modelsWithSwappedInNewModels and gets reset on provider refresh. + reorderCustomModel(providerName: ProviderName, modelName: string, targetModelName: string, position: 'before' | 'after'): boolean { + if (modelName === targetModelName) return false + + const { models } = this.state.settingsOfProvider[providerName] + const fromIdx = models.findIndex(m => m.modelName === modelName) + const toIdx = models.findIndex(m => m.modelName === targetModelName) + if (fromIdx === -1 || toIdx === -1) return false + if (models[fromIdx].type !== 'custom' || models[toIdx].type !== 'custom') return false + + const moving = models[fromIdx] + const without = [...models.slice(0, fromIdx), ...models.slice(fromIdx + 1)] + // targetModelName's index shifts left by one if it was after fromIdx, so recompute + const targetAfterRemoval = without.findIndex(m => m.modelName === targetModelName) + const insertAt = position === 'before' ? targetAfterRemoval : targetAfterRemoval + 1 + const newModels = [ + ...without.slice(0, insertAt), + moving, + ...without.slice(insertAt), + ] + this.setSettingOfProvider(providerName, 'models', newModels) + + this._metricsService.capture('Reorder Custom Model', { providerName, modelName, targetModelName, position }) + return true + } + // MCP Server State private _setMCPUserStateOfName = async (newStates: MCPUserStateOfName) => { const newState: VoidSettingsState = {