diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 06a1c4ae..e534a324 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -6,7 +6,7 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, useCallback, useEffect, useRef, useState } from 'react'; -import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState } from '../util/services.js'; +import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState } from '../util/services.js'; import { ChatMessage, CodeSelection, CodeStagingSelection } from '../../../chatThreadService.js'; import { BlockCode } from '../markdown/BlockCode.js'; @@ -259,9 +259,9 @@ const getBasename = (pathStr: string) => { } export const SelectedFiles = ( - { type, selections, setStaging }: - | { type: 'past', selections: CodeSelection[] | null; setStaging?: undefined } - | { type: 'staging', selections: CodeStagingSelection[] | null; setStaging: ((files: CodeStagingSelection[]) => void) } + { type, selections, setSelections }: + | { type: 'past', selections: CodeSelection[]; setSelections?: undefined } + | { type: 'staging', selections: CodeStagingSelection[]; setSelections: ((newSelections: CodeStagingSelection[]) => void) } ) => { // index -> isOpened @@ -273,85 +273,104 @@ export const SelectedFiles = ( const accessor = useAccessor() const commandService = accessor.get('ICommandService') + const { currentUri } = useUriState() + + let prospectiveSelections: CodeStagingSelection[] = [] + if ( // add a prospective file if type === 'staging' and if the user is in a file, and if the file is not selected yet + type === 'staging' + && currentUri + && !selections.find(s => s.range === null && s.fileURI.fsPath === currentUri.fsPath) + ) { + prospectiveSelections = [{ + type: 'File', + fileURI: currentUri, + selectionStr: null, + range: null, + }] + } + + const allSelections = [...selections, ...prospectiveSelections] + return ( - !!selections && selections.length !== 0 && ( -
- {selections.map((selection, i) => { +
- const isThisSelectionOpened = !!(selection.selectionStr && selectionIsOpened[i]) - const isThisSelectionAFile = selection.selectionStr === null + {allSelections.map((selection, i) => { - return
selections.length - 1 + + return
+ {/* selection summary */} +
- {/* selection summary */} -
-
{ - // open the file if it is a file - if (isThisSelectionAFile) { - commandService.executeCommand('vscode.open', selection.fileURI, { - preview: true, - // preserveFocus: false, - }); - } else { - // open the selection if it is a text-selection - setSelectionIsOpened(s => { - const newS = [...s] - newS[i] = !newS[i] - return newS - }); - } - }} - > - - {/* file name */} - {getBasename(selection.fileURI.fsPath)} - {/* selection range */} - {!isThisSelectionAFile ? ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})` : ''} - + onClick={() => { + if (isThisSelectionProspective) { // add prospective selection to selections + if (type !== 'staging') return; // (never) + setSelections([...selections, selection as CodeStagingSelection]) - {/* X button */} - {type === 'staging' && - { - e.stopPropagation(); // don't open/close selection - if (type !== 'staging') return; - setStaging([...selections.slice(0, i), ...selections.slice(i + 1)]) - setSelectionIsOpened(o => [...o.slice(0, i), ...o.slice(i + 1)]) - }} - > - - } + } else if (isThisSelectionAFile) { // open files + commandService.executeCommand('vscode.open', selection.fileURI, { + preview: true, + // preserveFocus: false, + }); + } else { // show text + setSelectionIsOpened(s => { + const newS = [...s] + newS[i] = !newS[i] + return newS + }); + } + }} + > + + {/* file name */} + {getBasename(selection.fileURI.fsPath)} + {/* selection range */} + {!isThisSelectionAFile ? ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})` : ''} + + + {/* X button */} + {type === 'staging' && + { + e.stopPropagation(); // don't open/close selection + if (type !== 'staging') return; + setSelections([...selections.slice(0, i), ...selections.slice(i + 1)]) + setSelectionIsOpened(o => [...o.slice(0, i), ...o.slice(i + 1)]) + }} + > + + } -
+
- {/* clear all selections button */} - {type !== 'staging' || selections.length === 0 || i !== selections.length - 1 - ? null - :
-
setIsClearHovered(true)} - onMouseLeave={() => setIsClearHovered(false)} - > - +
setIsClearHovered(true)} + onMouseLeave={() => setIsClearHovered(false)} + > + { setStaging([]) }} - /> -
+ onClick={() => { setSelections([]) }} + />
- } -
- {/* selection text */} - {isThisSelectionOpened && -
{ - e.stopPropagation(); // don't focus input box - }} - > -
}
+ {/* selection text */} + {isThisSelectionOpened && +
{ + e.stopPropagation(); // don't focus input box + }} + > + +
+ } +
- })} + })} -
- ) +
+ ) } @@ -407,7 +426,7 @@ const ChatBubble = ({ chatMessage, isLoading }: { if (role === 'user') { chatbubbleContents = <> - + {chatMessage.displayContent} {/* {!isEditMode ? chatMessage.displayContent : <>} */} @@ -604,9 +623,8 @@ export const SidebarChat = () => { {/* top row */} <> {/* selections */} - {(selections && selections.length !== 0) && - - } + + {/* error message */} {latestError === undefined ? null : diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx index b5613ab6..b15cd2a6 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx @@ -10,6 +10,7 @@ import { IDisposable } from '../../../../../../../base/common/lifecycle.js' import { VoidSidebarState } from '../../../sidebarStateService.js' import { VoidSettingsState } from '../../../../../../../platform/void/common/voidSettingsService.js' import { ColorScheme } from '../../../../../../../platform/theme/common/theme.js' +import { VoidUriState } from '../../../voidUriStateService.js'; import { VoidQuickEditState } from '../../../quickEditStateService.js' import { RefreshModelStateOfProvider } from '../../../../../../../platform/void/common/refreshModelService.js' @@ -28,6 +29,7 @@ import { ILLMMessageService } from '../../../../../../../platform/void/common/ll import { IRefreshModelService } from '../../../../../../../platform/void/common/refreshModelService.js'; import { IVoidSettingsService } from '../../../../../../../platform/void/common/voidSettingsService.js'; import { IInlineDiffsService } from '../../../inlineDiffsService.js'; +import { IVoidUriStateService } from '../../../voidUriStateService.js'; import { IQuickEditStateService } from '../../../quickEditStateService.js'; import { ISidebarStateService } from '../../../sidebarStateService.js'; import { IChatThreadService } from '../../../chatThreadService.js'; @@ -47,10 +49,14 @@ import { IPathService } from '../../../../../../../workbench/services/path/commo import { IMetricsService } from '../../../../../../../platform/void/common/metricsService.js' + // normally to do this you'd use a useEffect that calls .onDidChangeState(), but useEffect mounts too late and misses initial state changes // even if React hasn't mounted yet, the variables are always updated to the latest state. // React listens by adding a setState function to these listeners. +let uriState: VoidUriState +const uriStateListeners: Set<(s: VoidUriState) => void> = new Set() + let quickEditState: VoidQuickEditState const quickEditStateListeners: Set<(s: VoidQuickEditState) => void> = new Set() @@ -90,6 +96,7 @@ export const _registerServices = (accessor: ServicesAccessor) => { _registerAccessor(accessor) const stateServices = { + uriStateService: accessor.get(IVoidUriStateService), quickEditStateService: accessor.get(IQuickEditStateService), sidebarStateService: accessor.get(ISidebarStateService), chatThreadsStateService: accessor.get(IChatThreadService), @@ -99,7 +106,15 @@ export const _registerServices = (accessor: ServicesAccessor) => { inlineDiffsService: accessor.get(IInlineDiffsService), } - const { sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, inlineDiffsService } = stateServices + const { uriStateService, sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, inlineDiffsService } = stateServices + + uriState = uriStateService.state + disposables.push( + uriStateService.onDidChangeState(() => { + uriState = uriStateService.state + uriStateListeners.forEach(l => l(uriState)) + }) + ) quickEditState = quickEditStateService.state disposables.push( @@ -178,6 +193,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => { IRefreshModelService: accessor.get(IRefreshModelService), IVoidSettingsService: accessor.get(IVoidSettingsService), IInlineDiffsService: accessor.get(IInlineDiffsService), + IVoidUriStateService: accessor.get(IVoidUriStateService), IQuickEditStateService: accessor.get(IQuickEditStateService), ISidebarStateService: accessor.get(ISidebarStateService), IChatThreadService: accessor.get(IChatThreadService), @@ -224,6 +240,16 @@ export const useAccessor = () => { // -- state of services -- +export const useUriState = () => { + const [s, ss] = useState(uriState) + useEffect(() => { + ss(uriState) + uriStateListeners.add(ss) + return () => { uriStateListeners.delete(ss) } + }, [ss]) + return s +} + export const useQuickEditState = () => { const [s, ss] = useState(quickEditState) useEffect(() => { diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index 3b260158..5787fc4d 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -26,10 +26,9 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { URI } from '../../../../base/common/uri.js'; import { localize2 } from '../../../../nls.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; - +import { IVoidUriStateService } from './voidUriStateService.js'; // ---------- Register commands and keybindings ---------- @@ -241,7 +240,7 @@ registerAction2(class extends Action2 { export class TabSwitchListener extends Disposable { constructor( - onSwitchTab: (uri: URI) => void, + onSwitchTab: () => void, @ICodeEditorService private readonly _editorService: ICodeEditorService, ) { super() @@ -250,7 +249,7 @@ export class TabSwitchListener extends Disposable { const addTabSwitchListeners = (editor: ICodeEditor) => { this._register(editor.onDidChangeModel(e => { if (e.newModelUrl?.scheme !== 'file') return - onSwitchTab(e.newModelUrl) + onSwitchTab() })) } @@ -270,8 +269,10 @@ class TabSwitchContribution extends Disposable implements IWorkbenchContribution constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, - @ICommandService private readonly commandService: ICommandService, @IViewsService private readonly viewsService: IViewsService, + @IVoidUriStateService private readonly uriStateService: IVoidUriStateService, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + // @ICommandService private readonly commandService: ICommandService, ) { super() @@ -281,18 +282,22 @@ class TabSwitchContribution extends Disposable implements IWorkbenchContribution sidebarIsVisible = e.visible })) - const addCurrentFileIfVisible = () => { - if (sidebarIsVisible) - this.commandService.executeCommand(VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID) + const onSwitchTab = () => { // update state + if (sidebarIsVisible) { + const currentUri = this.codeEditorService.getActiveCodeEditor()?.getModel()?.uri + if (!currentUri) return; + this.uriStateService.setState({ currentUri }) + // this.commandService.executeCommand(VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID) + } } // when sidebar becomes visible, add current file this._register(this.viewsService.onDidChangeViewVisibility(e => { sidebarIsVisible = e.visible })) // run on current tab if it exists, and listen for tab switches and visibility changes - addCurrentFileIfVisible() - this._register(this.viewsService.onDidChangeViewVisibility(() => { addCurrentFileIfVisible() })) - this._register(this.instantiationService.createInstance(TabSwitchListener, () => { addCurrentFileIfVisible() })) + onSwitchTab() + this._register(this.viewsService.onDidChangeViewVisibility(() => { onSwitchTab() })) + this._register(this.instantiationService.createInstance(TabSwitchListener, () => { onSwitchTab() })) } } diff --git a/src/vs/workbench/contrib/void/browser/voidUriStateService.ts b/src/vs/workbench/contrib/void/browser/voidUriStateService.ts new file mode 100644 index 00000000..1a89c29b --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/voidUriStateService.ts @@ -0,0 +1,57 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + + + +// service that manages state +export type VoidUriState = { + currentUri?: URI +} + +export interface IVoidUriStateService { + readonly _serviceBrand: undefined; + + readonly state: VoidUriState; // readonly to the user + setState(newState: Partial): void; + onDidChangeState: Event; +} + +export const IVoidUriStateService = createDecorator('voidUriStateService'); +class VoidUriStateService extends Disposable implements IVoidUriStateService { + _serviceBrand: undefined; + + static readonly ID = 'voidUriStateService'; + + private readonly _onDidChangeState = new Emitter(); + readonly onDidChangeState: Event = this._onDidChangeState.event; + + + // state + state: VoidUriState + + constructor( + ) { + super() + + // initial state + this.state = { currentUri: undefined } + } + + setState(newState: Partial) { + + this.state = { ...this.state, ...newState } + this._onDidChangeState.fire() + } + + +} + +registerSingleton(IVoidUriStateService, VoidUriStateService, InstantiationType.Eager);