feat: opened tabs and selection in the @ menu

This commit is contained in:
loup
2025-01-31 09:16:43 +01:00
committed by Matt Rubens
parent 90ba9e18e1
commit 064dc4e52f
7 changed files with 131 additions and 10 deletions

View File

@@ -2,6 +2,7 @@ import * as vscode from "vscode"
import * as path from "path"
import { listFiles } from "../../services/glob/list-files"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { toRelativePath } from "../../utils/path"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const MAX_INITIAL_FILES = 1_000
@@ -48,6 +49,52 @@ class WorkspaceTracker {
)
this.disposables.push(watcher)
// Listen for tab changes
this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate()))
// Listen for editor/selection changes
this.disposables.push(vscode.window.onDidChangeActiveTextEditor(() => this.workspaceDidUpdate()))
this.disposables.push(vscode.window.onDidChangeTextEditorSelection(() => this.workspaceDidUpdate()))
/*
An event that is emitted when a workspace folder is added or removed.
**Note:** this event will not fire if the first workspace folder is added, removed or changed,
because in that case the currently executing extensions (including the one that listens to this
event) will be terminated and restarted so that the (deprecated) `rootPath` property is updated
to point to the first workspace folder.
*/
// In other words, we don't have to worry about the root workspace folder ([0]) changing since the extension will be restarted and our cwd will be updated to reflect the new workspace folder. (We don't care about non root workspace folders, since cline will only be working within the root folder cwd)
// this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this)))
}
private getOpenedTabsInfo() {
return vscode.window.tabGroups.all.flatMap((group) =>
group.tabs
.filter((tab) => tab.input instanceof vscode.TabInputText)
.map((tab) => {
const path = (tab.input as vscode.TabInputText).uri.fsPath
return {
label: tab.label,
isActive: tab.isActive,
path: toRelativePath(path, cwd || ""),
}
}),
)
}
private getActiveSelectionInfo() {
const editor = vscode.window.activeTextEditor
if (!editor) return null
if (editor.selection.isEmpty) return null
return {
file: toRelativePath(editor.document.uri.fsPath, cwd || ""),
selection: {
startLine: editor.selection.start.line,
endLine: editor.selection.end.line,
},
}
}
private workspaceDidUpdate() {
@@ -59,12 +106,13 @@ class WorkspaceTracker {
if (!cwd) {
return
}
const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd))
this.providerRef.deref()?.postMessageToWebview({
type: "workspaceUpdated",
filePaths: Array.from(this.filePaths).map((file) => {
const relativePath = path.relative(cwd, file).toPosix()
return file.endsWith("/") ? relativePath + "/" : relativePath
}),
filePaths: relativeFilePaths,
openedTabs: this.getOpenedTabsInfo(),
activeSelection: this.getActiveSelectionInfo(),
})
this.updateTimer = null
}, 300) // Debounce for 300ms

View File

@@ -57,6 +57,18 @@ export interface ExtensionMessage {
lmStudioModels?: string[]
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
filePaths?: string[]
openedTabs?: Array<{
label: string
isActive: boolean
path?: string
}>
activeSelection?: {
file: string
selection: {
startLine: number
endLine: number
}
} | null
partialMessage?: ClineMessage
glamaModels?: Record<string, ModelInfo>
openRouterModels?: Record<string, ModelInfo>

View File

@@ -99,3 +99,8 @@ export function getReadablePath(cwd: string, relPath?: string): string {
}
}
}
export const toRelativePath = (filePath: string, cwd: string) => {
const relativePath = path.relative(cwd, filePath).toPosix()
return filePath.endsWith("/") ? relativePath + "/" : relativePath
}

View File

@@ -50,7 +50,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
const { filePaths, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState()
const { filePaths, openedTabs, activeSelection, currentApiConfigName, listApiConfigMeta, customModes } =
useExtensionState()
const [gitCommits, setGitCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false)
@@ -89,6 +90,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return () => window.removeEventListener("message", messageHandler)
}, [setInputValue])
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
const [showContextMenu, setShowContextMenu] = useState(false)
@@ -135,17 +137,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, [inputValue, textAreaDisabled, setInputValue])
const queryItems = useMemo(() => {
return [
const items = [
{ type: ContextMenuOptionType.Problems, value: "problems" },
...gitCommits,
// Add opened tabs
...openedTabs
.filter((tab) => tab.path)
.map((tab) => ({
type: ContextMenuOptionType.OpenedFile,
value: "/" + tab.path,
})),
// Add regular file paths
...filePaths
.map((file) => "/" + file)
.filter((path) => !openedTabs.some((tab) => tab.path && "/" + tab.path === path)) // Filter out paths that are already in openedTabs
.map((path) => ({
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
value: path,
})),
]
}, [filePaths, gitCommits])
if (activeSelection) {
items.unshift({
type: ContextMenuOptionType.OpenedFile,
value: `/${activeSelection.file}:${activeSelection.selection.startLine + 1}-${activeSelection.selection.endLine + 1}`,
})
}
return items
}, [filePaths, openedTabs, activeSelection])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {

View File

@@ -74,6 +74,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return <span>Git Commits</span>
}
case ContextMenuOptionType.File:
case ContextMenuOptionType.OpenedFile:
case ContextMenuOptionType.Folder:
if (option.value) {
return (
@@ -100,6 +101,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
const getIconForOption = (option: ContextMenuQueryItem): string => {
switch (option.type) {
case ContextMenuOptionType.OpenedFile:
return "star-full"
case ContextMenuOptionType.File:
return "file"
case ContextMenuOptionType.Folder:
@@ -194,6 +197,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
{(option.type === ContextMenuOptionType.Problems ||
((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.OpenedFile ||
option.type === ContextMenuOptionType.Git) &&
option.value)) && (
<i

View File

@@ -27,6 +27,11 @@ export interface ExtensionStateContextType extends ExtensionState {
openAiModels: string[]
mcpServers: McpServer[]
filePaths: string[]
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
activeSelection: {
file: string
selection: { startLine: number; endLine: number }
} | null
setApiConfiguration: (config: ApiConfiguration) => void
setCustomInstructions: (value?: string) => void
setAlwaysAllowReadOnly: (value: boolean) => void
@@ -116,6 +121,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
[glamaDefaultModelId]: glamaDefaultModelInfo,
})
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
const [activeSelection, setActiveSelection] = useState<{
file: string
selection: { startLine: number; endLine: number }
} | null>(null)
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
@@ -176,7 +186,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
break
}
case "workspaceUpdated": {
setFilePaths(message.filePaths ?? [])
const paths = message.filePaths ?? []
const tabs = message.openedTabs ?? []
const selection = message.activeSelection ?? null
setFilePaths(paths)
setOpenedTabs(tabs)
setActiveSelection(selection)
break
}
case "partialMessage": {
@@ -243,6 +259,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
openAiModels,
mcpServers,
filePaths,
openedTabs,
activeSelection,
soundVolume: state.soundVolume,
fuzzyMatchThreshold: state.fuzzyMatchThreshold,
writeDelayMs: state.writeDelayMs,

View File

@@ -48,6 +48,7 @@ export function removeMention(text: string, position: number): { newText: string
}
export enum ContextMenuOptionType {
OpenedFile = "openedFile",
File = "file",
Folder = "folder",
Problems = "problems",
@@ -80,8 +81,14 @@ export function getContextMenuOptions(
if (query === "") {
if (selectedType === ContextMenuOptionType.File) {
const files = queryItems
.filter((item) => item.type === ContextMenuOptionType.File)
.map((item) => ({ type: ContextMenuOptionType.File, value: item.value }))
.filter(
(item) =>
item.type === ContextMenuOptionType.File || item.type === ContextMenuOptionType.OpenedFile,
)
.map((item) => ({
type: item.type,
value: item.value,
}))
return files.length > 0 ? files : [{ type: ContextMenuOptionType.NoResults }]
}
@@ -125,6 +132,12 @@ export function getContextMenuOptions(
}
if (query.startsWith("http")) {
suggestions.push({ type: ContextMenuOptionType.URL, value: query })
} else {
suggestions.push(
...queryItems
.filter((item) => item.type !== ContextMenuOptionType.OpenedFile)
.filter((item) => item.value?.toLowerCase().includes(lowerQuery)),
)
}
// Add exact SHA matches to suggestions