From 90ba9e18e135e926494a4cfc41f7723f66a147dd Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sat, 1 Feb 2025 11:16:21 -0500 Subject: [PATCH 1/5] Remove annoying test --- .../chat/__tests__/AutoApproveMenu.test.tsx | 212 ------------------ 1 file changed, 212 deletions(-) delete mode 100644 webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx diff --git a/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx b/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx deleted file mode 100644 index 8d48a20..0000000 --- a/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { render, fireEvent, screen } from "@testing-library/react" -import { useExtensionState } from "../../../context/ExtensionStateContext" -import AutoApproveMenu from "../AutoApproveMenu" -import { defaultModeSlug, defaultPrompts } from "../../../../../src/shared/modes" -import { experimentDefault } from "../../../../../src/shared/experiments" - -// Mock the ExtensionStateContext hook -jest.mock("../../../context/ExtensionStateContext") - -const mockUseExtensionState = useExtensionState as jest.MockedFunction - -describe("AutoApproveMenu", () => { - const defaultMockState = { - // Required state properties - version: "1.0.0", - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - allowedCommands: [], - soundEnabled: false, - soundVolume: 0.5, - diffEnabled: false, - fuzzyMatchThreshold: 1.0, - preferredLanguage: "English", - writeDelayMs: 1000, - browserViewportSize: "900x600", - screenshotQuality: 75, - terminalOutputLineLimit: 500, - mcpEnabled: true, - requestDelaySeconds: 5, - rateLimitSeconds: 0, - currentApiConfigName: "default", - listApiConfigMeta: [], - mode: defaultModeSlug, - customModePrompts: defaultPrompts, - customSupportPrompts: {}, - enhancementApiConfigId: "", - didHydrateState: true, - showWelcome: false, - theme: {}, - glamaModels: {}, - openRouterModels: {}, - openAiModels: [], - mcpServers: [], - filePaths: [], - experiments: experimentDefault, - customModes: [], - enableMcpServerCreation: false, - - // Auto-approve specific properties - alwaysAllowReadOnly: false, - alwaysAllowWrite: false, - alwaysAllowExecute: false, - alwaysAllowBrowser: false, - alwaysAllowMcp: false, - alwaysApproveResubmit: false, - alwaysAllowModeSwitch: false, - autoApprovalEnabled: false, - - // Required setter functions - setApiConfiguration: jest.fn(), - setCustomInstructions: jest.fn(), - setAlwaysAllowReadOnly: jest.fn(), - setAlwaysAllowWrite: jest.fn(), - setAlwaysAllowExecute: jest.fn(), - setAlwaysAllowBrowser: jest.fn(), - setAlwaysAllowMcp: jest.fn(), - setAlwaysAllowModeSwitch: jest.fn(), - setShowAnnouncement: jest.fn(), - setAllowedCommands: jest.fn(), - setSoundEnabled: jest.fn(), - setSoundVolume: jest.fn(), - setDiffEnabled: jest.fn(), - setBrowserViewportSize: jest.fn(), - setFuzzyMatchThreshold: jest.fn(), - setPreferredLanguage: jest.fn(), - setWriteDelayMs: jest.fn(), - setScreenshotQuality: jest.fn(), - setTerminalOutputLineLimit: jest.fn(), - setMcpEnabled: jest.fn(), - setAlwaysApproveResubmit: jest.fn(), - setRequestDelaySeconds: jest.fn(), - setRateLimitSeconds: jest.fn(), - setCurrentApiConfigName: jest.fn(), - setListApiConfigMeta: jest.fn(), - onUpdateApiConfig: jest.fn(), - setMode: jest.fn(), - setCustomModePrompts: jest.fn(), - setCustomSupportPrompts: jest.fn(), - setEnhancementApiConfigId: jest.fn(), - setAutoApprovalEnabled: jest.fn(), - setExperimentEnabled: jest.fn(), - handleInputChange: jest.fn(), - setCustomModes: jest.fn(), - setEnableMcpServerCreation: jest.fn(), - } - - beforeEach(() => { - mockUseExtensionState.mockReturnValue(defaultMockState) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it("renders with initial collapsed state", () => { - render() - - // Check for main checkbox and label - expect(screen.getByText("Auto-approve:")).toBeInTheDocument() - expect(screen.getByText("None")).toBeInTheDocument() - - // Verify the menu is collapsed (actions not visible) - expect(screen.queryByText("Read files and directories")).not.toBeInTheDocument() - }) - - it("expands menu when clicked", () => { - render() - - // Click to expand - fireEvent.click(screen.getByText("Auto-approve:")) - - // Verify menu items are visible - expect(screen.getByText("Read files and directories")).toBeInTheDocument() - expect(screen.getByText("Edit files")).toBeInTheDocument() - expect(screen.getByText("Execute approved commands")).toBeInTheDocument() - expect(screen.getByText("Use the browser")).toBeInTheDocument() - expect(screen.getByText("Use MCP servers")).toBeInTheDocument() - expect(screen.getByText("Retry failed requests")).toBeInTheDocument() - }) - - it("toggles main auto-approval checkbox", () => { - render() - - const mainCheckbox = screen.getByRole("checkbox") - fireEvent.click(mainCheckbox) - - expect(defaultMockState.setAutoApprovalEnabled).toHaveBeenCalledWith(true) - }) - - it("toggles individual permissions", () => { - render() - - // Expand menu - fireEvent.click(screen.getByText("Auto-approve:")) - - // Click read files checkbox - fireEvent.click(screen.getByText("Read files and directories")) - expect(defaultMockState.setAlwaysAllowReadOnly).toHaveBeenCalledWith(true) - - // Click edit files checkbox - fireEvent.click(screen.getByText("Edit files")) - expect(defaultMockState.setAlwaysAllowWrite).toHaveBeenCalledWith(true) - - // Click execute commands checkbox - fireEvent.click(screen.getByText("Execute approved commands")) - expect(defaultMockState.setAlwaysAllowExecute).toHaveBeenCalledWith(true) - }) - - it("displays enabled actions in summary", () => { - mockUseExtensionState.mockReturnValue({ - ...defaultMockState, - alwaysAllowReadOnly: true, - alwaysAllowWrite: true, - autoApprovalEnabled: true, - }) - - render() - - // Check that enabled actions are shown in summary - expect(screen.getByText("Read, Edit")).toBeInTheDocument() - }) - - it("preserves checkbox states", () => { - // Mock state with some permissions enabled - const mockState = { - ...defaultMockState, - alwaysAllowReadOnly: true, - alwaysAllowWrite: true, - } - - // Update mock to return our state - mockUseExtensionState.mockReturnValue(mockState) - - render() - - // Expand menu - fireEvent.click(screen.getByText("Auto-approve:")) - - // Verify read and edit checkboxes are checked - expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument() - expect(screen.getByLabelText("Edit files")).toBeInTheDocument() - - // Verify the setters haven't been called yet - expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled() - expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled() - - // Collapse menu - fireEvent.click(screen.getByText("Auto-approve:")) - - // Expand again - fireEvent.click(screen.getByText("Auto-approve:")) - - // Verify checkboxes are still present - expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument() - expect(screen.getByLabelText("Edit files")).toBeInTheDocument() - - // Verify the setters still haven't been called - expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled() - expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled() - }) -}) From 064dc4e52fbdc37d87eccc05f00930ce042f7e19 Mon Sep 17 00:00:00 2001 From: loup Date: Fri, 31 Jan 2025 09:16:43 +0100 Subject: [PATCH 2/5] 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 From 1e5a257e52bd00839a5f1ada45c2e5a796b27db8 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sat, 1 Feb 2025 11:45:57 -0500 Subject: [PATCH 3/5] Removed active selection for now --- .../workspace/WorkspaceTracker.ts | 30 ------------------- src/shared/ExtensionMessage.ts | 7 ----- .../src/components/chat/ChatTextArea.tsx | 12 ++------ .../src/context/ExtensionStateContext.tsx | 11 ------- 4 files changed, 2 insertions(+), 58 deletions(-) diff --git a/src/integrations/workspace/WorkspaceTracker.ts b/src/integrations/workspace/WorkspaceTracker.ts index 49d5708..57c7f7f 100644 --- a/src/integrations/workspace/WorkspaceTracker.ts +++ b/src/integrations/workspace/WorkspaceTracker.ts @@ -50,22 +50,7 @@ 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() { @@ -83,20 +68,6 @@ class WorkspaceTracker { ) } - 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() { if (this.updateTimer) { clearTimeout(this.updateTimer) @@ -112,7 +83,6 @@ class WorkspaceTracker { type: "workspaceUpdated", 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 4108937..bee51f6 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -62,13 +62,6 @@ export interface ExtensionMessage { isActive: boolean path?: string }> - activeSelection?: { - file: string - selection: { - startLine: number - endLine: number - } - } | null partialMessage?: ClineMessage glamaModels?: Record openRouterModels?: Record diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 96a9f9c..6a7c05a 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -50,8 +50,7 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, openedTabs, activeSelection, currentApiConfigName, listApiConfigMeta, customModes } = - useExtensionState() + const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState() const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) @@ -158,15 +157,8 @@ const ChatTextArea = forwardRef( })), ] - if (activeSelection) { - items.unshift({ - type: ContextMenuOptionType.OpenedFile, - value: `/${activeSelection.file}:${activeSelection.selection.startLine + 1}-${activeSelection.selection.endLine + 1}`, - }) - } - return items - }, [filePaths, openedTabs, activeSelection]) + }, [filePaths, openedTabs]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ecf59a3..47db6bf 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -28,10 +28,6 @@ export interface ExtensionStateContextType extends ExtensionState { 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 @@ -122,10 +118,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode [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, }) @@ -188,11 +180,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode case "workspaceUpdated": { const paths = message.filePaths ?? [] const tabs = message.openedTabs ?? [] - const selection = message.activeSelection ?? null setFilePaths(paths) setOpenedTabs(tabs) - setActiveSelection(selection) break } case "partialMessage": { @@ -260,7 +250,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode mcpServers, filePaths, openedTabs, - activeSelection, soundVolume: state.soundVolume, fuzzyMatchThreshold: state.fuzzyMatchThreshold, writeDelayMs: state.writeDelayMs, From 14683cc3c5d76229cd47541df5bcb109e24499f0 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sat, 1 Feb 2025 11:57:52 -0500 Subject: [PATCH 4/5] Cleanup and release --- .changeset/blue-masks-camp.md | 5 +++++ webview-ui/src/components/chat/ChatTextArea.tsx | 10 ++-------- webview-ui/src/components/chat/ContextMenu.tsx | 2 +- webview-ui/src/utils/context-mentions.ts | 12 +++++------- 4 files changed, 13 insertions(+), 16 deletions(-) create mode 100644 .changeset/blue-masks-camp.md diff --git a/.changeset/blue-masks-camp.md b/.changeset/blue-masks-camp.md new file mode 100644 index 0000000..a67e1c4 --- /dev/null +++ b/.changeset/blue-masks-camp.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add shortcuts to the currently open tabs in the "Add File" section of @-mentions (thanks @olup!) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 6a7c05a..a20922d 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -89,7 +89,6 @@ 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) @@ -136,18 +135,15 @@ const ChatTextArea = forwardRef( }, [inputValue, textAreaDisabled, setInputValue]) const queryItems = useMemo(() => { - const items = [ + return [ { 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 @@ -156,9 +152,7 @@ const ChatTextArea = forwardRef( value: path, })), ] - - return items - }, [filePaths, openedTabs]) + }, [filePaths, gitCommits, openedTabs]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 2d12f1a..85ec865 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -102,7 +102,7 @@ const ContextMenu: React.FC = ({ const getIconForOption = (option: ContextMenuQueryItem): string => { switch (option.type) { case ContextMenuOptionType.OpenedFile: - return "star-full" + return "window" case ContextMenuOptionType.File: return "file" case ContextMenuOptionType.Folder: diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index aa517d0..5cce936 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -132,12 +132,6 @@ 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 @@ -175,12 +169,16 @@ export function getContextMenuOptions( // Separate matches by type const fileMatches = matchingItems.filter( - (item) => item.type === ContextMenuOptionType.File || item.type === ContextMenuOptionType.Folder, + (item) => + item.type === ContextMenuOptionType.File || + item.type === ContextMenuOptionType.OpenedFile || + item.type === ContextMenuOptionType.Folder, ) const gitMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.Git) const otherMatches = matchingItems.filter( (item) => item.type !== ContextMenuOptionType.File && + item.type !== ContextMenuOptionType.OpenedFile && item.type !== ContextMenuOptionType.Folder && item.type !== ContextMenuOptionType.Git, ) From 70ad037016e60af76a798108187ed44d42f3684f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sat, 1 Feb 2025 12:15:03 -0500 Subject: [PATCH 5/5] Fix tests --- src/__mocks__/vscode.js | 27 +++++++++++++++++++ src/core/__tests__/Cline.test.ts | 1 + .../__tests__/WorkspaceTracker.test.ts | 11 ++++++++ .../chat/__tests__/ChatTextArea.test.tsx | 6 +++++ 4 files changed, 45 insertions(+) diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js index 6c25b10..ba44f8d 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.js @@ -5,9 +5,25 @@ const vscode = { createTextEditorDecorationType: jest.fn().mockReturnValue({ dispose: jest.fn(), }), + tabGroups: { + onDidChangeTabs: jest.fn(() => { + return { + dispose: jest.fn(), + } + }), + all: [], + }, }, workspace: { onDidSaveTextDocument: jest.fn(), + createFileSystemWatcher: jest.fn().mockReturnValue({ + onDidCreate: jest.fn().mockReturnValue({ dispose: jest.fn() }), + onDidDelete: jest.fn().mockReturnValue({ dispose: jest.fn() }), + dispose: jest.fn(), + }), + fs: { + stat: jest.fn(), + }, }, Disposable: class { dispose() {} @@ -57,6 +73,17 @@ const vscode = { Development: 2, Test: 3, }, + FileType: { + Unknown: 0, + File: 1, + Directory: 2, + SymbolicLink: 64, + }, + TabInputText: class { + constructor(uri) { + this.uri = uri + } + }, } module.exports = vscode diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index e49b660..4c1f697 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -128,6 +128,7 @@ jest.mock("vscode", () => { visibleTextEditors: [mockTextEditor], tabGroups: { all: [mockTabGroup], + onDidChangeTabs: jest.fn(() => ({ dispose: jest.fn() })), }, }, workspace: { diff --git a/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts b/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts index 44b5648..47b678a 100644 --- a/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts +++ b/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts @@ -16,6 +16,12 @@ const mockWatcher = { } jest.mock("vscode", () => ({ + window: { + tabGroups: { + onDidChangeTabs: jest.fn(() => ({ dispose: jest.fn() })), + all: [], + }, + }, workspace: { workspaceFolders: [ { @@ -61,6 +67,7 @@ describe("WorkspaceTracker", () => { expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "workspaceUpdated", filePaths: expect.arrayContaining(["file1.ts", "file2.ts"]), + openedTabs: [], }) expect((mockProvider.postMessageToWebview as jest.Mock).mock.calls[0][0].filePaths).toHaveLength(2) }) @@ -74,6 +81,7 @@ describe("WorkspaceTracker", () => { expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "workspaceUpdated", filePaths: ["newfile.ts"], + openedTabs: [], }) }) @@ -92,6 +100,7 @@ describe("WorkspaceTracker", () => { expect(mockProvider.postMessageToWebview).toHaveBeenLastCalledWith({ type: "workspaceUpdated", filePaths: [], + openedTabs: [], }) }) @@ -106,6 +115,7 @@ describe("WorkspaceTracker", () => { expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "workspaceUpdated", filePaths: expect.arrayContaining(["newdir"]), + openedTabs: [], }) const lastCall = (mockProvider.postMessageToWebview as jest.Mock).mock.calls.slice(-1)[0] expect(lastCall[0].filePaths).toHaveLength(1) @@ -126,6 +136,7 @@ describe("WorkspaceTracker", () => { expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "workspaceUpdated", filePaths: expect.arrayContaining(expectedFiles), + openedTabs: [], }) expect(calls[0][0].filePaths).toHaveLength(1000) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx index 5467c29..599e8d3 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx @@ -41,6 +41,7 @@ describe("ChatTextArea", () => { // Default mock implementation for useExtensionState ;(useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], + openedTabs: [], apiConfiguration: { apiProvider: "anthropic", }, @@ -51,6 +52,7 @@ describe("ChatTextArea", () => { it("should be disabled when textAreaDisabled is true", () => { ;(useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], + openedTabs: [], }) render() @@ -68,6 +70,7 @@ describe("ChatTextArea", () => { ;(useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], + openedTabs: [], apiConfiguration, }) @@ -85,6 +88,7 @@ describe("ChatTextArea", () => { it("should not send message when input is empty", () => { ;(useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], + openedTabs: [], apiConfiguration: { apiProvider: "openrouter", }, @@ -101,6 +105,7 @@ describe("ChatTextArea", () => { it("should show loading state while enhancing", () => { ;(useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], + openedTabs: [], apiConfiguration: { apiProvider: "openrouter", }, @@ -123,6 +128,7 @@ describe("ChatTextArea", () => { // Update apiConfiguration ;(useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], + openedTabs: [], apiConfiguration: { apiProvider: "openrouter", newSetting: "test",