diff --git a/src/vs/workbench/contrib/void/browser/react/build.js b/src/vs/workbench/contrib/void/browser/react/build.js index 26b5bc37..9507aa59 100755 --- a/src/vs/workbench/contrib/void/browser/react/build.js +++ b/src/vs/workbench/contrib/void/browser/react/build.js @@ -74,7 +74,7 @@ function saveStylesFile() { } catch (err) { console.error('[scope-tailwind] Error saving styles.css:', err); } - }, 4000); + }, 6000); } const args = process.argv.slice(2); diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx index bc68e66f..856049ea 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx @@ -274,7 +274,7 @@ export const useApplyButtonHTML = ({ codeStr, applyBoxId, uri }: { codeStr: stri null ) - const statusIndicatorHTML = + const statusIndicatorHTML = return { statusIndicatorHTML, diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/VoidCommandBar.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-editor-widgets-tsx/VoidCommandBar.tsx similarity index 100% rename from src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/VoidCommandBar.tsx rename to src/vs/workbench/contrib/void/browser/react/src/void-editor-widgets-tsx/VoidCommandBar.tsx diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-editor-widgets-tsx/VoidSelectionHelper.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-editor-widgets-tsx/VoidSelectionHelper.tsx new file mode 100644 index 00000000..1e6e89ba --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/void-editor-widgets-tsx/VoidSelectionHelper.tsx @@ -0,0 +1,145 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + + +import { useAccessor, useIsDark, useSettingsState } from '../util/services.js'; + +import '../styles.css' +import { VOID_CTRL_K_ACTION_ID, VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js'; +import { Circle, MoreVertical } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { VoidSelectionHelperProps } from '../../../../../../contrib/void/browser/voidSelectionHelperWidget.js'; +import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js'; + + +export const VoidSelectionHelperMain = (props: VoidSelectionHelperProps) => { + + const isDark = useIsDark() + + return
+ +
+} + + + +const VoidSelectionHelper = ({ rerenderKey }: VoidSelectionHelperProps) => { + + + const accessor = useAccessor() + const keybindingService = accessor.get('IKeybindingService') + const commandService = accessor.get('ICommandService') + + const ctrlLKeybind = keybindingService.lookupKeybinding(VOID_CTRL_L_ACTION_ID) + const ctrlKKeybind = keybindingService.lookupKeybinding(VOID_CTRL_K_ACTION_ID) + + const dividerHTML =
+ + const [reactRerenderCount, setReactRerenderKey] = useState(rerenderKey) + const [clickState, setClickState] = useState<'init' | 'clickedOption' | 'clickedMore'>('init') + + // rerender when the key changes + if (reactRerenderCount !== rerenderKey) { + setReactRerenderKey(rerenderKey) + setClickState('init') + } + // useEffect(() => { + // }, [rerenderKey, reactRerenderCount, setReactRerenderKey, setClickState]) + + // if the user selected an option, close + if (clickState === 'clickedOption') { + return null + } + + const defaultHTML = <> + {ctrlLKeybind && +
{ + commandService.executeCommand(VOID_CTRL_L_ACTION_ID) + setClickState('clickedOption'); + }} + > + Add to Chat + + {ctrlLKeybind.getLabel()} + +
+ } + {ctrlLKeybind && ctrlKKeybind && + dividerHTML + } + {ctrlKKeybind && +
{ + commandService.executeCommand(VOID_CTRL_K_ACTION_ID) + setClickState('clickedOption'); + }} + > + Edit Inline + + {ctrlKKeybind.getLabel()} + +
+ } + + {dividerHTML} + +
{ + setClickState('clickedMore'); + }} + > + +
+ + + + const moreOptionsHTML = <> +
{ + commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID); + setClickState('clickedOption'); + }} + > + Disable Suggestions? +
+ + + return
+ {clickState === 'init' ? defaultHTML + : clickState === 'clickedMore' ? moreOptionsHTML + : <> + } +
+} diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/index.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-editor-widgets-tsx/index.tsx similarity index 77% rename from src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/index.tsx rename to src/vs/workbench/contrib/void/browser/react/src/void-editor-widgets-tsx/index.tsx index 5b185788..61663502 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-command-bar-tsx/index.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-editor-widgets-tsx/index.tsx @@ -5,5 +5,9 @@ import { mountFnGenerator } from '../util/mountFnGenerator.js' import { VoidCommandBarMain } from './VoidCommandBar.js' +import { VoidSelectionHelperMain } from './VoidSelectionHelper.js' export const mountVoidCommandBar = mountFnGenerator(VoidCommandBarMain) + +export const mountVoidSelectionHelper = mountFnGenerator(VoidSelectionHelperMain) + 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 3b97463a..92c5dffc 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 @@ -534,9 +534,26 @@ export const FeaturesTab = () => { + +
+

Editor

+
{`Settings that control the visibility of suggestions and widgets in the code editor.`}
+ +
+ {/* Auto Accept Switch */} +
+ voidSettingsService.setGlobalSetting('showInlineSuggestions', newVal)} + /> + {voidSettingsState.globalSettings.showInlineSuggestions ? 'Show suggestions on select' : 'Show suggestions on select'} +
+
+ diff --git a/src/vs/workbench/contrib/void/browser/react/tsup.config.js b/src/vs/workbench/contrib/void/browser/react/tsup.config.js index ab3bd525..82dae1d9 100644 --- a/src/vs/workbench/contrib/void/browser/react/tsup.config.js +++ b/src/vs/workbench/contrib/void/browser/react/tsup.config.js @@ -7,7 +7,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ entry: [ - './src2/void-command-bar-tsx/index.tsx', + './src2/void-editor-widgets-tsx/index.tsx', './src2/sidebar-tsx/index.tsx', './src2/void-settings-tsx/index.tsx', './src2/quick-edit-tsx/index.tsx', diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index 503981c6..b997dc11 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -175,7 +175,7 @@ registerAction2(class extends Action2 { super({ id: VOID_CTRL_L_ACTION_ID, f1: true, - title: localize2('voidCtrlL', 'Void: Add Select to Chat'), + title: localize2('voidCtrlL', 'Void: Add Selection to Chat'), keybinding: { primary: KeyMod.CtrlCmd | KeyCode.KeyL, weight: KeybindingWeight.VoidExtension diff --git a/src/vs/workbench/contrib/void/browser/void.contribution.ts b/src/vs/workbench/contrib/void/browser/void.contribution.ts index 9054450b..f0dcc477 100644 --- a/src/vs/workbench/contrib/void/browser/void.contribution.ts +++ b/src/vs/workbench/contrib/void/browser/void.contribution.ts @@ -46,6 +46,9 @@ import './metricsPollService.js' // helper services import './helperServices/consistentItemService.js' +// register selection helper +import './voidSelectionHelperWidget.js' + // ---------- common (unclear if these actually need to be imported, because they're already imported wherever they're used) ---------- // llmMessage diff --git a/src/vs/workbench/contrib/void/browser/voidCommandBarService.ts b/src/vs/workbench/contrib/void/browser/voidCommandBarService.ts index 7711068d..c7780301 100644 --- a/src/vs/workbench/contrib/void/browser/voidCommandBarService.ts +++ b/src/vs/workbench/contrib/void/browser/voidCommandBarService.ts @@ -11,7 +11,7 @@ import { Widget } from '../../../../base/browser/ui/widget.js'; import { IOverlayWidget, ICodeEditor, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; -import { mountVoidCommandBar } from './react/out/void-command-bar-tsx/index.js' +import { mountVoidCommandBar } from './react/out/void-editor-widgets-tsx/index.js' import { deepClone } from '../../../../base/common/objects.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IEditCodeService } from './editCodeServiceInterface.js'; diff --git a/src/vs/workbench/contrib/void/browser/voidSelectionHelperWidget.ts b/src/vs/workbench/contrib/void/browser/voidSelectionHelperWidget.ts new file mode 100644 index 00000000..b3cd5ead --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/voidSelectionHelperWidget.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; +import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; +import { ICursorSelectionChangedEvent } from '../../../../editor/common/cursorEvents.js'; +import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; +import { Selection } from '../../../../editor/common/core/selection.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import * as dom from '../../../../base/browser/dom.js'; +import { mountVoidSelectionHelper } from './react/out/void-editor-widgets-tsx/index.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IVoidSettingsService } from '../common/voidSettingsService.js'; + + +const minDistanceFromRightPx = 400; +const minDistanceFromLeftPx = 60; + + +export type VoidSelectionHelperProps = { + rerenderKey: number // alternates between 0 and 1 +} + + +export class SelectionHelperContribution extends Disposable implements IEditorContribution, IOverlayWidget { + public static readonly ID = 'editor.contrib.voidSelectionHelper'; + // react + private _rootHTML: HTMLElement; + private _rerender: (props?: any) => void = () => { }; + private _rerenderKey: number = 0; + private _reactComponentDisposable: IDisposable | null = null; + + // internal + private _isVisible = false; + private _showScheduler: RunOnceScheduler; + private _lastSelection: Selection | null = null; + + constructor( + private readonly _editor: ICodeEditor, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IVoidSettingsService private readonly _voidSettingsService: IVoidSettingsService + ) { + super(); + + // Create the container element for React component + const { root, content } = dom.h('div@root', [ + dom.h('div@content', []) + ]); + + // Set styles for container + root.style.position = 'absolute'; + root.style.display = 'none'; // Start hidden + + // Initialize React component + this._instantiationService.invokeFunction(accessor => { + if (this._reactComponentDisposable) { + this._reactComponentDisposable.dispose(); + } + const res = mountVoidSelectionHelper(content, accessor); + if (!res) return; + + this._reactComponentDisposable = res; + this._rerender = res.rerender; + + this._register(this._reactComponentDisposable); + + + }); + + this._rootHTML = root; + + // Register as overlay widget + this._editor.addOverlayWidget(this); + + // Use scheduler to debounce showing widget + this._showScheduler = new RunOnceScheduler(() => { + if (this._lastSelection) { + this._showHelperForSelection(this._lastSelection); + } + }, 50); + + // Register event listeners + this._register(this._editor.onDidChangeCursorSelection(e => this._onSelectionChange(e))); + + // Add a flag to track if mouse is over the widget + let isMouseOverWidget = false; + this._rootHTML.addEventListener('mouseenter', () => { + isMouseOverWidget = true; + }); + this._rootHTML.addEventListener('mouseleave', () => { + isMouseOverWidget = false; + }); + + // Only hide helper when text editor loses focus and mouse is not over the widget + this._register(this._editor.onDidBlurEditorText(() => { + if (!isMouseOverWidget) { + this._hideHelper(); + } + })); + + this._register(this._editor.onDidScrollChange(() => this._updatePositionIfVisible())); + this._register(this._editor.onDidLayoutChange(() => this._updatePositionIfVisible())); + } + + // IOverlayWidget implementation + public getId(): string { + return SelectionHelperContribution.ID; + } + + public getDomNode(): HTMLElement { + return this._rootHTML; + } + + public getPosition(): IOverlayWidgetPosition | null { + return null; // We position manually + } + + private _onSelectionChange(e: ICursorSelectionChangedEvent): void { + if (!this._editor.hasModel()) { + return; + } + + const selection = this._editor.getSelection(); + + if (!selection || selection.isEmpty()) { + this._hideHelper(); + return; + } + + // Get selection text to check if it's worth showing the helper + const text = this._editor.getModel()!.getValueInRange(selection); + if (text.length < 3) { + this._hideHelper(); + return; + } + + // Store selection + this._lastSelection = new Selection( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn + ); + + this._showScheduler.schedule(); + } + + // Update the _showHelperForSelection method to work with the React component + private _showHelperForSelection(selection: Selection): void { + if (!this._editor.hasModel()) { + return; + } + + const model = this._editor.getModel()!; + + // Calculate the middle line of the selection + const startLine = selection.startLineNumber; + const endLine = selection.endLineNumber; + const middleLineNumber = Math.floor(startLine + (endLine - startLine) / 2); + + // Get the content of the middle line + const lineContent = model.getLineContent(middleLineNumber); + + // Find the position at the end of the middle line + const endOfLinePos = this._editor.getScrolledVisiblePosition({ + lineNumber: middleLineNumber, + column: lineContent.length + 1 // +1 because columns are 1-based + }); + + if (!endOfLinePos) { + this._hideHelper(); + return; + } + + // Calculate right edge of visible editor area + const editorWidth = this._editor.getLayoutInfo().width; + + // Position the helper element at the end of the middle line but ensure it's visible + const xPosition = Math.max(Math.min(endOfLinePos.left, editorWidth - minDistanceFromRightPx), minDistanceFromLeftPx); + + // Update the React component position + this._rootHTML.style.left = `${xPosition}px`; + this._rootHTML.style.top = `${endOfLinePos.top}px`; + this._rootHTML.style.display = 'flex'; // Show the container + + this._isVisible = true; + + // rerender + const enabled = this._voidSettingsService.state.globalSettings.showInlineSuggestions + && this._editor.hasTextFocus() // needed since VS Code counts unfocused selections as selections, which causes this to rerender when it shouldnt (bad ux) + + if (enabled) { + this._rerender({ rerenderKey: this._rerenderKey } satisfies VoidSelectionHelperProps) + this._rerenderKey = (this._rerenderKey + 1) % 2; + // this._reactComponentRerender(); + } + + } + + private _hideHelper(): void { + this._rootHTML.style.display = 'none'; + this._isVisible = false; + this._lastSelection = null; + } + + private _updatePositionIfVisible(): void { + if (!this._isVisible || !this._lastSelection || !this._editor.hasModel()) { + return; + } + + this._showHelperForSelection(this._lastSelection); + } + + override dispose(): void { + this._hideHelper(); + if (this._reactComponentDisposable) { + this._reactComponentDisposable.dispose(); + } + this._editor.removeOverlayWidget(this); + this._showScheduler.dispose(); + super.dispose(); + } +} + +// Register the contribution +registerEditorContribution(SelectionHelperContribution.ID, SelectionHelperContribution, EditorContributionInstantiation.Eager); diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index acfe57f0..a056a84a 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -389,6 +389,7 @@ export type GlobalSettings = { enableFastApply: boolean; chatMode: ChatMode; autoApprove: boolean; + showInlineSuggestions: boolean; } export const defaultGlobalSettings: GlobalSettings = { @@ -399,6 +400,7 @@ export const defaultGlobalSettings: GlobalSettings = { enableFastApply: true, chatMode: 'agent', autoApprove: false, + showInlineSuggestions: true, } export type GlobalSettingName = keyof GlobalSettings