prospective file adding

This commit is contained in:
Mathew Pareles 2025-01-16 02:20:32 -08:00
parent 9756ab6d16
commit e4fd6d05a4
4 changed files with 213 additions and 107 deletions

View file

@ -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 && (
<div
className='flex items-center flex-wrap gap-0.5 text-left relative'
>
{selections.map((selection, i) => {
<div className='flex items-center flex-wrap gap-0.5 text-left relative'>
const isThisSelectionOpened = !!(selection.selectionStr && selectionIsOpened[i])
const isThisSelectionAFile = selection.selectionStr === null
{allSelections.map((selection, i) => {
return <div key={i} // container for `selectionSummary` and `selectionText`
className={`${isThisSelectionOpened ? 'w-full' : ''}`}
const isThisSelectionOpened = !!(selection.selectionStr && selectionIsOpened[i])
const isThisSelectionAFile = selection.selectionStr === null
const isThisSelectionProspective = i > selections.length - 1
return <div key={i} // container for `selectionSummary` and `selectionText`
className={`${isThisSelectionOpened ? 'w-full' : ''}`}
>
{/* selection summary */}
<div // container for delete button
className='flex items-center gap-0.5'
>
{/* selection summary */}
<div // container for delete button
className='flex items-center gap-0.5'
>
<div // styled summary box
className={`flex items-center gap-0.5 relative
<div // styled summary box
className={`flex items-center gap-0.5 relative
rounded-md px-1
w-fit h-fit
select-none
bg-void-bg-3 hover:brightness-95
${isThisSelectionProspective ? 'bg-void-1' : 'bg-void-bg-3 hover:brightness-95'}
text-void-fg-1 text-xs text-nowrap
border rounded-xs ${isClearHovered ? 'border-void-border-1' : 'border-void-border-2'} hover:border-void-border-1
transition-all duration-150`}
onClick={() => {
// 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
});
}
}}
>
<span>
{/* file name */}
{getBasename(selection.fileURI.fsPath)}
{/* selection range */}
{!isThisSelectionAFile ? ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})` : ''}
</span>
onClick={() => {
if (isThisSelectionProspective) { // add prospective selection to selections
if (type !== 'staging') return; // (never)
setSelections([...selections, selection as CodeStagingSelection])
{/* X button */}
{type === 'staging' &&
<span
className='cursor-pointer hover:brightness-95 rounded-md z-1'
onClick={(e) => {
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)])
}}
>
<IconX size={16} className="p-[2px] stroke-[3]" />
</span>}
} 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
});
}
}}
>
<span>
{/* file name */}
{getBasename(selection.fileURI.fsPath)}
{/* selection range */}
{!isThisSelectionAFile ? ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})` : ''}
</span>
{/* X button */}
{type === 'staging' &&
<span
className='cursor-pointer hover:brightness-95 rounded-md z-1'
onClick={(e) => {
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)])
}}
>
<IconX size={16} className="p-[2px] stroke-[3]" />
</span>}
</div>
</div>
{/* clear all selections button */}
{type !== 'staging' || selections.length === 0 || i !== selections.length - 1
? null
: <div key={i} className={`flex items-center gap-0.5 ${isThisSelectionOpened ? 'w-full' : ''}`}>
<div
className='rounded-md'
onMouseEnter={() => setIsClearHovered(true)}
onMouseLeave={() => setIsClearHovered(false)}
>
<Delete
size={16}
className={`stroke-[1]
{/* clear all selections button */}
{type !== 'staging' || allSelections.length === 0 || i !== allSelections.length - 1
? null
: <div key={i} className={`flex items-center gap-0.5 ${isThisSelectionOpened ? 'w-full' : ''}`}>
<div
className='rounded-md'
onMouseEnter={() => setIsClearHovered(true)}
onMouseLeave={() => setIsClearHovered(false)}
>
<Delete
size={16}
className={`stroke-[1]
stroke-void-fg-1
fill-void-bg-3
opacity-40
@ -359,35 +378,35 @@ export const SelectedFiles = (
transition-all duration-150
cursor-pointer
`}
onClick={() => { setStaging([]) }}
/>
</div>
onClick={() => { setSelections([]) }}
/>
</div>
}
</div>
{/* selection text */}
{isThisSelectionOpened &&
<div
className='w-full px-1 rounded-sm border-vscode-editor-border'
onClick={(e) => {
e.stopPropagation(); // don't focus input box
}}
>
<BlockCode
initValue={selection.selectionStr!}
language={filenameToVscodeLanguage(selection.fileURI.path)}
maxHeight={200}
showScrollbars={true}
/>
</div>
}
</div>
{/* selection text */}
{isThisSelectionOpened &&
<div
className='w-full px-1 rounded-sm border-vscode-editor-border'
onClick={(e) => {
e.stopPropagation(); // don't focus input box
}}
>
<BlockCode
initValue={selection.selectionStr!}
language={filenameToVscodeLanguage(selection.fileURI.path)}
maxHeight={200}
showScrollbars={true}
/>
</div>
}
</div>
})}
})}
</div>
)
</div>
)
}
@ -407,7 +426,7 @@ const ChatBubble = ({ chatMessage, isLoading }: {
if (role === 'user') {
chatbubbleContents = <>
<SelectedFiles type='past' selections={chatMessage.selections} />
<SelectedFiles type='past' selections={chatMessage.selections || []} />
{chatMessage.displayContent}
{/* {!isEditMode ? chatMessage.displayContent : <></>} */}
@ -604,9 +623,8 @@ export const SidebarChat = () => {
{/* top row */}
<>
{/* selections */}
{(selections && selections.length !== 0) &&
<SelectedFiles type='staging' selections={selections} setStaging={chatThreadsService.setStaging.bind(chatThreadsService)} />
}
<SelectedFiles type='staging' selections={selections || []} setSelections={chatThreadsService.setStaging.bind(chatThreadsService)} />
{/* error message */}
{latestError === undefined ? null :

View file

@ -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(() => {

View file

@ -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() }))
}
}

View file

@ -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<VoidUriState>): void;
onDidChangeState: Event<void>;
}
export const IVoidUriStateService = createDecorator<IVoidUriStateService>('voidUriStateService');
class VoidUriStateService extends Disposable implements IVoidUriStateService {
_serviceBrand: undefined;
static readonly ID = 'voidUriStateService';
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
// state
state: VoidUriState
constructor(
) {
super()
// initial state
this.state = { currentUri: undefined }
}
setState(newState: Partial<VoidUriState>) {
this.state = { ...this.state, ...newState }
this._onDidChangeState.fire()
}
}
registerSingleton(IVoidUriStateService, VoidUriStateService, InstantiationType.Eager);