mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
feat: opened tabs and selection in the @ menu
This commit is contained in:
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
|
|||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { listFiles } from "../../services/glob/list-files"
|
import { listFiles } from "../../services/glob/list-files"
|
||||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||||
|
import { toRelativePath } from "../../utils/path"
|
||||||
|
|
||||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||||
const MAX_INITIAL_FILES = 1_000
|
const MAX_INITIAL_FILES = 1_000
|
||||||
@@ -48,6 +49,52 @@ class WorkspaceTracker {
|
|||||||
)
|
)
|
||||||
|
|
||||||
this.disposables.push(watcher)
|
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() {
|
private workspaceDidUpdate() {
|
||||||
@@ -59,12 +106,13 @@ class WorkspaceTracker {
|
|||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd))
|
||||||
this.providerRef.deref()?.postMessageToWebview({
|
this.providerRef.deref()?.postMessageToWebview({
|
||||||
type: "workspaceUpdated",
|
type: "workspaceUpdated",
|
||||||
filePaths: Array.from(this.filePaths).map((file) => {
|
filePaths: relativeFilePaths,
|
||||||
const relativePath = path.relative(cwd, file).toPosix()
|
openedTabs: this.getOpenedTabsInfo(),
|
||||||
return file.endsWith("/") ? relativePath + "/" : relativePath
|
activeSelection: this.getActiveSelectionInfo(),
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
this.updateTimer = null
|
this.updateTimer = null
|
||||||
}, 300) // Debounce for 300ms
|
}, 300) // Debounce for 300ms
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ export interface ExtensionMessage {
|
|||||||
lmStudioModels?: string[]
|
lmStudioModels?: string[]
|
||||||
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
|
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
|
||||||
filePaths?: string[]
|
filePaths?: string[]
|
||||||
|
openedTabs?: Array<{
|
||||||
|
label: string
|
||||||
|
isActive: boolean
|
||||||
|
path?: string
|
||||||
|
}>
|
||||||
|
activeSelection?: {
|
||||||
|
file: string
|
||||||
|
selection: {
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
}
|
||||||
|
} | null
|
||||||
partialMessage?: ClineMessage
|
partialMessage?: ClineMessage
|
||||||
glamaModels?: Record<string, ModelInfo>
|
glamaModels?: Record<string, ModelInfo>
|
||||||
openRouterModels?: Record<string, ModelInfo>
|
openRouterModels?: Record<string, ModelInfo>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const { filePaths, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState()
|
const { filePaths, openedTabs, activeSelection, currentApiConfigName, listApiConfigMeta, customModes } =
|
||||||
|
useExtensionState()
|
||||||
const [gitCommits, setGitCommits] = useState<any[]>([])
|
const [gitCommits, setGitCommits] = useState<any[]>([])
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
return () => window.removeEventListener("message", messageHandler)
|
return () => window.removeEventListener("message", messageHandler)
|
||||||
}, [setInputValue])
|
}, [setInputValue])
|
||||||
|
|
||||||
|
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||||
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
||||||
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
||||||
const [showContextMenu, setShowContextMenu] = useState(false)
|
const [showContextMenu, setShowContextMenu] = useState(false)
|
||||||
@@ -135,17 +137,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
}, [inputValue, textAreaDisabled, setInputValue])
|
}, [inputValue, textAreaDisabled, setInputValue])
|
||||||
|
|
||||||
const queryItems = useMemo(() => {
|
const queryItems = useMemo(() => {
|
||||||
return [
|
const items = [
|
||||||
{ type: ContextMenuOptionType.Problems, value: "problems" },
|
{ type: ContextMenuOptionType.Problems, value: "problems" },
|
||||||
...gitCommits,
|
...gitCommits,
|
||||||
|
// Add opened tabs
|
||||||
|
...openedTabs
|
||||||
|
.filter((tab) => tab.path)
|
||||||
|
.map((tab) => ({
|
||||||
|
type: ContextMenuOptionType.OpenedFile,
|
||||||
|
value: "/" + tab.path,
|
||||||
|
})),
|
||||||
|
|
||||||
|
// Add regular file paths
|
||||||
...filePaths
|
...filePaths
|
||||||
.map((file) => "/" + file)
|
.map((file) => "/" + file)
|
||||||
|
.filter((path) => !openedTabs.some((tab) => tab.path && "/" + tab.path === path)) // Filter out paths that are already in openedTabs
|
||||||
.map((path) => ({
|
.map((path) => ({
|
||||||
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
|
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
|
||||||
value: path,
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
return <span>Git Commits</span>
|
return <span>Git Commits</span>
|
||||||
}
|
}
|
||||||
case ContextMenuOptionType.File:
|
case ContextMenuOptionType.File:
|
||||||
|
case ContextMenuOptionType.OpenedFile:
|
||||||
case ContextMenuOptionType.Folder:
|
case ContextMenuOptionType.Folder:
|
||||||
if (option.value) {
|
if (option.value) {
|
||||||
return (
|
return (
|
||||||
@@ -100,6 +101,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
|
|
||||||
const getIconForOption = (option: ContextMenuQueryItem): string => {
|
const getIconForOption = (option: ContextMenuQueryItem): string => {
|
||||||
switch (option.type) {
|
switch (option.type) {
|
||||||
|
case ContextMenuOptionType.OpenedFile:
|
||||||
|
return "star-full"
|
||||||
case ContextMenuOptionType.File:
|
case ContextMenuOptionType.File:
|
||||||
return "file"
|
return "file"
|
||||||
case ContextMenuOptionType.Folder:
|
case ContextMenuOptionType.Folder:
|
||||||
@@ -194,6 +197,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
{(option.type === ContextMenuOptionType.Problems ||
|
{(option.type === ContextMenuOptionType.Problems ||
|
||||||
((option.type === ContextMenuOptionType.File ||
|
((option.type === ContextMenuOptionType.File ||
|
||||||
option.type === ContextMenuOptionType.Folder ||
|
option.type === ContextMenuOptionType.Folder ||
|
||||||
|
option.type === ContextMenuOptionType.OpenedFile ||
|
||||||
option.type === ContextMenuOptionType.Git) &&
|
option.type === ContextMenuOptionType.Git) &&
|
||||||
option.value)) && (
|
option.value)) && (
|
||||||
<i
|
<i
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||||||
openAiModels: string[]
|
openAiModels: string[]
|
||||||
mcpServers: McpServer[]
|
mcpServers: McpServer[]
|
||||||
filePaths: string[]
|
filePaths: string[]
|
||||||
|
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
|
||||||
|
activeSelection: {
|
||||||
|
file: string
|
||||||
|
selection: { startLine: number; endLine: number }
|
||||||
|
} | null
|
||||||
setApiConfiguration: (config: ApiConfiguration) => void
|
setApiConfiguration: (config: ApiConfiguration) => void
|
||||||
setCustomInstructions: (value?: string) => void
|
setCustomInstructions: (value?: string) => void
|
||||||
setAlwaysAllowReadOnly: (value: boolean) => void
|
setAlwaysAllowReadOnly: (value: boolean) => void
|
||||||
@@ -116,6 +121,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||||||
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
|
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
|
||||||
[glamaDefaultModelId]: glamaDefaultModelInfo,
|
[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>>({
|
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
|
||||||
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
|
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
|
||||||
})
|
})
|
||||||
@@ -176,7 +186,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "workspaceUpdated": {
|
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
|
break
|
||||||
}
|
}
|
||||||
case "partialMessage": {
|
case "partialMessage": {
|
||||||
@@ -243,6 +259,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||||||
openAiModels,
|
openAiModels,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
filePaths,
|
filePaths,
|
||||||
|
openedTabs,
|
||||||
|
activeSelection,
|
||||||
soundVolume: state.soundVolume,
|
soundVolume: state.soundVolume,
|
||||||
fuzzyMatchThreshold: state.fuzzyMatchThreshold,
|
fuzzyMatchThreshold: state.fuzzyMatchThreshold,
|
||||||
writeDelayMs: state.writeDelayMs,
|
writeDelayMs: state.writeDelayMs,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function removeMention(text: string, position: number): { newText: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum ContextMenuOptionType {
|
export enum ContextMenuOptionType {
|
||||||
|
OpenedFile = "openedFile",
|
||||||
File = "file",
|
File = "file",
|
||||||
Folder = "folder",
|
Folder = "folder",
|
||||||
Problems = "problems",
|
Problems = "problems",
|
||||||
@@ -80,8 +81,14 @@ export function getContextMenuOptions(
|
|||||||
if (query === "") {
|
if (query === "") {
|
||||||
if (selectedType === ContextMenuOptionType.File) {
|
if (selectedType === ContextMenuOptionType.File) {
|
||||||
const files = queryItems
|
const files = queryItems
|
||||||
.filter((item) => item.type === ContextMenuOptionType.File)
|
.filter(
|
||||||
.map((item) => ({ type: ContextMenuOptionType.File, value: item.value }))
|
(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 }]
|
return files.length > 0 ? files : [{ type: ContextMenuOptionType.NoResults }]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +132,12 @@ export function getContextMenuOptions(
|
|||||||
}
|
}
|
||||||
if (query.startsWith("http")) {
|
if (query.startsWith("http")) {
|
||||||
suggestions.push({ type: ContextMenuOptionType.URL, value: query })
|
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
|
// Add exact SHA matches to suggestions
|
||||||
|
|||||||
Reference in New Issue
Block a user