From 064dc4e52fbdc37d87eccc05f00930ce042f7e19 Mon Sep 17 00:00:00 2001 From: loup Date: Fri, 31 Jan 2025 09:16:43 +0100 Subject: [PATCH] feat: opened tabs and selection in the @ menu --- .../workspace/WorkspaceTracker.ts | 56 +++++++++++++++++-- src/shared/ExtensionMessage.ts | 12 ++++ src/utils/path.ts | 5 ++ .../src/components/chat/ChatTextArea.tsx | 27 ++++++++- .../src/components/chat/ContextMenu.tsx | 4 ++ .../src/context/ExtensionStateContext.tsx | 20 ++++++- webview-ui/src/utils/context-mentions.ts | 17 +++++- 7 files changed, 131 insertions(+), 10 deletions(-) diff --git a/src/integrations/workspace/WorkspaceTracker.ts b/src/integrations/workspace/WorkspaceTracker.ts index 550de84..49d5708 100644 --- a/src/integrations/workspace/WorkspaceTracker.ts +++ b/src/integrations/workspace/WorkspaceTracker.ts @@ -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 diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 4be5ed3..4108937 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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 openRouterModels?: Record diff --git a/src/utils/path.ts b/src/utils/path.ts index b61eb38..a15d4e0 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -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 +} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 99c75df..96a9f9c 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -50,7 +50,8 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState() + const { filePaths, openedTabs, activeSelection, currentApiConfigName, listApiConfigMeta, customModes } = + useExtensionState() const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) @@ -89,6 +90,7 @@ const ChatTextArea = forwardRef( return () => window.removeEventListener("message", messageHandler) }, [setInputValue]) + const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) const [showContextMenu, setShowContextMenu] = useState(false) @@ -135,17 +137,36 @@ const ChatTextArea = forwardRef( }, [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) => { diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index b945813..2d12f1a 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -74,6 +74,7 @@ const ContextMenu: React.FC = ({ return Git Commits } case ContextMenuOptionType.File: + case ContextMenuOptionType.OpenedFile: case ContextMenuOptionType.Folder: if (option.value) { return ( @@ -100,6 +101,8 @@ const ContextMenu: React.FC = ({ 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 = ({ {(option.type === ContextMenuOptionType.Problems || ((option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder || + option.type === ContextMenuOptionType.OpenedFile || option.type === ContextMenuOptionType.Git) && option.value)) && ( + 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>({ [glamaDefaultModelId]: glamaDefaultModelInfo, }) + const [openedTabs, setOpenedTabs] = useState>([]) + const [activeSelection, setActiveSelection] = useState<{ + file: string + selection: { startLine: number; endLine: number } + } | null>(null) const [openRouterModels, setOpenRouterModels] = useState>({ [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, diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 1772383..aa517d0 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -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