Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/shared/src/constants/symbols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
type Symbol = {
mark: string
label: string
description: string
regexp: RegExp
exactMatch: (text: string) => boolean
getValue: (text: string) => string
markedValue: (text: string) => string
}

export function captureParts(...patterns: RegExp[]): RegExp {
const sources = patterns.map((pattern) => pattern.source)

return new RegExp(`(${sources.join('|')})`, 'g')
}

export const SYMBOLS = {
File: {
mark: '@File:',
label: '@File',
description: 'Reference a file',
regexp: /@File:[^\s]+/,
exactMatch: (text: string): boolean => {
return new RegExp(`^${SYMBOLS.File.regexp.source}$`).test(text)
},
getValue: (text: string): string => {
return text.slice(SYMBOLS.File.mark.length)
},
markedValue: (text: string): string => {
return `${SYMBOLS.File.mark.slice(1)}${text} `
}
}
} as Record<string, Symbol>
60 changes: 56 additions & 4 deletions packages/ui/src/components/editor/ChatInput/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import styles from './ChatInput.module.scss'
import TextareaAutosize from 'react-textarea-autosize'
import cn from 'classnames'
import { Icon } from '../Icon'
import { captureParts, SYMBOLS } from '@shared/constants/symbols'

type Props = {
value: string
Expand All @@ -16,7 +17,10 @@ type Props = {
is_in_code_completions_mode: boolean
has_active_selection: boolean
has_active_editor: boolean
on_caret_position_change: (caret_position: number) => void
on_caret_position_change: (
caret_position: number,
from_selection: boolean
) => void
is_web_mode: boolean
on_search_click: () => void
on_at_sign_click: () => void
Expand All @@ -41,6 +45,7 @@ type Props = {
}
caret_position_to_set?: number
on_caret_position_set?: () => void
is_focused?: Date
}

const format_token_count = (count?: number) => {
Expand Down Expand Up @@ -81,15 +86,33 @@ export const ChatInput: React.FC<Props> = (props) => {
}
}, [props.value])

useEffect(() => {
if (props.is_focused) {
textarea_ref.current?.focus()
}
}, [props.is_focused])

const get_highlighted_text = (text: string) => {
if (props.is_in_code_completions_mode) {
return <span>{text}</span>
}

const regex =
/(@Selection|@Changes:[^\s,;:.!?]+(?:\/[^\s,;:.!?]+)?|@SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+"|`[^`]+`)/g
const regex = captureParts(
SYMBOLS.File.regexp,
/@Selection/,
/@Changes:[^\s,;:.!?]+(?:\/[^\s,;:.!?]+)?/,
/@SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+"/,
/`[^`]+`/
)
const parts = text.split(regex)
return parts.map((part, index) => {
if (SYMBOLS.File.exactMatch(part)) {
return (
<span key={index} className={styles['selection-keyword']}>
{part}
</span>
)
}
if (part == '@Selection') {
return (
<span
Expand Down Expand Up @@ -133,7 +156,7 @@ export const ChatInput: React.FC<Props> = (props) => {
const handle_select = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const textarea = e.currentTarget
const caret_position = textarea.selectionStart
props.on_caret_position_change(caret_position)
props.on_caret_position_change(caret_position, true)
}

const handle_input_change = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
Expand Down Expand Up @@ -243,6 +266,35 @@ export const ChatInput: React.FC<Props> = (props) => {
props.on_change('')
}
}
} else if (e.key == 'Backspace') {
const textarea = e.currentTarget
const start = textarea.selectionStart
const end = textarea.selectionEnd

// a text is highlighted
if (start != end) {
return
}

const textBeforeCursor = textarea.value.slice(0, start)
const fileSymbolRegex = new RegExp(
`${SYMBOLS.File.regexp.source}\\s$`,
'g'
)
const fileSymbolMatch = textBeforeCursor.match(fileSymbolRegex)

if (fileSymbolMatch) {
e.preventDefault()

const symbolToRemove = fileSymbolMatch[0]
const startOfSymbol = start - symbolToRemove.length
const textAfterCursor = textarea.value.slice(start)

props.on_change(
textBeforeCursor.slice(0, startOfSymbol) + textAfterCursor
)
props.on_caret_position_change(startOfSymbol, false)
}
} else if (props.value) {
set_is_history_enabled(false)
}
Expand Down
1 change: 1 addition & 0 deletions packages/vscode/src/constants/state-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const PINNED_HISTORY_CODE_COMPLETIONS_STATE_KEY =
'pinned-history-code-completions'
export const PINNED_HISTORY_NO_CONTEXT_STATE_KEY = 'pinned-history-no-context'

export const RECENT_FILES_STORAGE_KEY = 'recent-files'
export interface HistoryEntry {
text: string
createdAt: number
Expand Down
4 changes: 4 additions & 0 deletions packages/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
feedback_command,
apply_context_from_clipboard_command,
} from './commands'
import { RecentFileManager } from './services/recent-files-manager'

// Store WebSocketServer instance at module level
let websocket_server_instance: WebSocketManager | null = null
Expand Down Expand Up @@ -96,7 +97,10 @@ export async function activate(context: vscode.ExtensionContext) {
)
}

const recentFileManager = new RecentFileManager(context)

context.subscriptions.push(
recentFileManager.setupListener(workspace_provider),
open_file_from_workspace_command(open_editors_provider),
apply_chat_response_command(context),
...code_completion_commands(
Expand Down
183 changes: 183 additions & 0 deletions packages/vscode/src/services/recent-files-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import * as vscode from 'vscode'
import * as fs from 'fs'
import * as path from 'path'
import { RECENT_FILES_STORAGE_KEY } from '@/constants/state-keys'
import { WorkspaceProvider } from '@/context/providers/workspace-provider'

const MAX_RECENT_FILES = 50
const DEBOUNCE_DELAY = 300
const REMOVE_BUTTON_TOOLTIP = 'Remove from Recent Files'

type QuickPickItemFile = vscode.QuickPickItem & {
path: string
uri: vscode.Uri
}

// Helper function to create a QuickPickItem for a file
function createFileQuickPickItem(
fileUri: vscode.Uri,
workspaceRoot: string,
withRemoveButton: boolean = true
): QuickPickItemFile {
const relativePath = path.relative(workspaceRoot, fileUri.fsPath)
const buttons: vscode.QuickInputButton[] = []

if (withRemoveButton) {
buttons.push({
iconPath: new vscode.ThemeIcon('close'),
tooltip: REMOVE_BUTTON_TOOLTIP
})
}

return {
label: path.basename(fileUri.fsPath),
description: path.dirname(relativePath),
path: relativePath,
uri: fileUri,
buttons
}
}

function isSearching(value: string): boolean {
return value.length > 0
}

export class RecentFileManager {
private context: vscode.ExtensionContext

constructor(context: vscode.ExtensionContext) {
this.context = context
}

public setupListener(
workspace_provider: WorkspaceProvider
): vscode.Disposable {
function isIgnored(file_path: string): boolean {
const workspace_root =
workspace_provider.get_workspace_root_for_file(file_path)

return workspace_root
? workspace_provider.is_excluded(
path.relative(workspace_root, file_path)
)
: true
}

return vscode.workspace.onDidOpenTextDocument((document) => {
if (document.uri.scheme === 'file' && !isIgnored(document.uri.path)) {
this.addFile(document.uri)
}
})
}

public getRecentFiles(): string[] {
return this.context.workspaceState.get<string[]>(
RECENT_FILES_STORAGE_KEY,
[]
)
}

public addFile(uri: vscode.Uri): void {
const filePath = uri.fsPath
let recentFiles = this.getRecentFiles().filter((p) => p !== filePath)
recentFiles.unshift(filePath)

if (recentFiles.length > MAX_RECENT_FILES) {
recentFiles = recentFiles.slice(0, MAX_RECENT_FILES)
}
this.context.workspaceState.update(RECENT_FILES_STORAGE_KEY, recentFiles)
}

public removeFile(uri: vscode.Uri): void {
const filePath = uri.fsPath
let recentFiles = this.getRecentFiles().filter((p) => p !== filePath)

this.context.workspaceState.update(RECENT_FILES_STORAGE_KEY, recentFiles)
}

public async showFilePicker(workspace_root: string) {
return new Promise<string | undefined>((resolve) => {
const quickPick = vscode.window.createQuickPick<QuickPickItemFile>()
quickPick.placeholder = 'Search for a file by name'

let debounceTimeout: NodeJS.Timeout

const recentFiles = this.getRecentFiles()
.map((p) => createFileQuickPickItem(vscode.Uri.file(p), workspace_root))
.filter((item) => fs.existsSync(item.uri.fsPath))

// Initially, show only recent files
quickPick.items = recentFiles

quickPick.onDidChangeValue((value) => {
clearTimeout(debounceTimeout)

// When user clears the input, show recent files again
if (!isSearching(value)) {
quickPick.busy = false
quickPick.items = recentFiles
return
}

quickPick.busy = true // Show loading indicator

// Debounce the search to avoid excessive API calls
debounceTimeout = setTimeout(async () => {
const query = `**/*${value}*`
const searchResults = await vscode.workspace.findFiles(
query,
undefined,
100
)

if (searchResults) {
quickPick.items = searchResults.map((uri) =>
createFileQuickPickItem(
uri,
workspace_root,
this.getRecentFiles().includes(uri.fsPath)
)
)
}
quickPick.busy = false
}, DEBOUNCE_DELAY)
})

quickPick.onDidTriggerItemButton((e) => {
this.removeFile(e.item.uri)

if (isSearching(quickPick.value)) {
quickPick.items = quickPick.items.map((item) => {
if (item.path === e.item.path) {
item.buttons = []
}

return item
})
} else {
quickPick.items = quickPick.items.filter(
(item) => item.path !== e.item.path
)
}
})

quickPick.onDidAccept(() => {
const selectedFile = quickPick.selectedItems[0]
if (selectedFile) {
resolve(selectedFile.path)
this.addFile(selectedFile.uri)
}

quickPick.hide()
})

quickPick.onDidHide(() => {
clearTimeout(debounceTimeout)
quickPick.dispose()
resolve(undefined) // Resolve with undefined if the user dismisses the picker
})

quickPick.show()
})
}
}
22 changes: 22 additions & 0 deletions packages/vscode/src/utils/at-sign-quick-pick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import * as path from 'path'
import * as fs from 'fs'
import { SAVED_CONTEXTS_STATE_KEY } from '../constants/state-keys'
import { SavedContext } from '../types/context'
import { RecentFileManager } from '@/services/recent-files-manager'
import { SYMBOLS } from '@shared/constants/symbols'

export async function at_sign_quick_pick(
context: vscode.ExtensionContext,
is_code_completions_mode = false
): Promise<string | undefined> {
let items = [
{
label: SYMBOLS.File.label,
description: SYMBOLS.File.description
},
{
label: '@Selection',
description: 'Text selection of the active editor'
Expand Down Expand Up @@ -38,6 +44,22 @@ export async function at_sign_quick_pick(
if (!selected) {
return
}
if (selected.label == SYMBOLS.File.label) {
const workspace_folders = vscode.workspace.workspaceFolders
if (!workspace_folders || workspace_folders.length == 0) {
vscode.window.showErrorMessage('No workspace folders found')
return
}
const workspace_root = workspace_folders[0].uri.fsPath
const recentFileManager = new RecentFileManager(context)
const selectedFile = await recentFileManager.showFilePicker(workspace_root)

if (selectedFile) {
return SYMBOLS.File.markedValue(selectedFile)
} else {
return await at_sign_quick_pick(context)
}
}

if (selected.label == '@Selection') {
return 'Selection '
Expand Down
Loading