mirror of
https://github.com/voideditor/void
synced 2026-05-24 09:58:23 +00:00
prospective file adding
This commit is contained in:
parent
9756ab6d16
commit
e4fd6d05a4
4 changed files with 213 additions and 107 deletions
|
|
@ -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 :
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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() }))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
57
src/vs/workbench/contrib/void/browser/voidUriStateService.ts
Normal file
57
src/vs/workbench/contrib/void/browser/voidUriStateService.ts
Normal 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);
|
||||
Loading…
Reference in a new issue