Просмотр исходного кода

Merge pull request #714 from RooVetGit/opened_tabs_and_selection_mentions

Mention shortcuts to open tabs
Matt Rubens 11 месяцев назад
Родитель
Сommit
94b831c6ab

+ 5 - 0
.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!)

+ 27 - 0
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

+ 1 - 0
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: {

+ 22 - 4
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,23 @@ class WorkspaceTracker {
 		)
 
 		this.disposables.push(watcher)
+
+		this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate()))
+	}
+
+	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 workspaceDidUpdate() {
@@ -59,12 +77,12 @@ 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(),
 			})
 			this.updateTimer = null
 		}, 300) // Debounce for 300ms

+ 11 - 0
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)
 

+ 5 - 0
src/shared/ExtensionMessage.ts

@@ -57,6 +57,11 @@ export interface ExtensionMessage {
 	lmStudioModels?: string[]
 	vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
 	filePaths?: string[]
+	openedTabs?: Array<{
+		label: string
+		isActive: boolean
+		path?: string
+	}>
 	partialMessage?: ClineMessage
 	glamaModels?: Record<string, ModelInfo>
 	openRouterModels?: Record<string, ModelInfo>

+ 5 - 0
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
+}

+ 9 - 2
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -50,7 +50,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 		},
 		ref,
 	) => {
-		const { filePaths, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState()
+		const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState()
 		const [gitCommits, setGitCommits] = useState<any[]>([])
 		const [showDropdown, setShowDropdown] = useState(false)
 
@@ -138,14 +138,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			return [
 				{ type: ContextMenuOptionType.Problems, value: "problems" },
 				...gitCommits,
+				...openedTabs
+					.filter((tab) => tab.path)
+					.map((tab) => ({
+						type: ContextMenuOptionType.OpenedFile,
+						value: "/" + tab.path,
+					})),
 				...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])
+		}, [filePaths, gitCommits, openedTabs])
 
 		useEffect(() => {
 			const handleClickOutside = (event: MouseEvent) => {

+ 4 - 0
webview-ui/src/components/chat/ContextMenu.tsx

@@ -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 "window"
 			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

+ 0 - 212
webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx

@@ -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<typeof useExtensionState>
-
-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(<AutoApproveMenu />)
-
-		// 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(<AutoApproveMenu />)
-
-		// 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(<AutoApproveMenu />)
-
-		const mainCheckbox = screen.getByRole("checkbox")
-		fireEvent.click(mainCheckbox)
-
-		expect(defaultMockState.setAutoApprovalEnabled).toHaveBeenCalledWith(true)
-	})
-
-	it("toggles individual permissions", () => {
-		render(<AutoApproveMenu />)
-
-		// 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(<AutoApproveMenu />)
-
-		// 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(<AutoApproveMenu />)
-
-		// 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()
-	})
-})

+ 6 - 0
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(<ChatTextArea {...defaultProps} textAreaDisabled={true} />)
@@ -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",

+ 8 - 1
webview-ui/src/context/ExtensionStateContext.tsx

@@ -27,6 +27,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	openAiModels: string[]
 	mcpServers: McpServer[]
 	filePaths: string[]
+	openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
 	setApiConfiguration: (config: ApiConfiguration) => void
 	setCustomInstructions: (value?: string) => void
 	setAlwaysAllowReadOnly: (value: boolean) => void
@@ -116,6 +117,7 @@ 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 [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
 		[openRouterDefaultModelId]: openRouterDefaultModelInfo,
 	})
@@ -176,7 +178,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 					break
 				}
 				case "workspaceUpdated": {
-					setFilePaths(message.filePaths ?? [])
+					const paths = message.filePaths ?? []
+					const tabs = message.openedTabs ?? []
+
+					setFilePaths(paths)
+					setOpenedTabs(tabs)
 					break
 				}
 				case "partialMessage": {
@@ -243,6 +249,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		openAiModels,
 		mcpServers,
 		filePaths,
+		openedTabs,
 		soundVolume: state.soundVolume,
 		fuzzyMatchThreshold: state.fuzzyMatchThreshold,
 		writeDelayMs: state.writeDelayMs,

+ 14 - 3
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 }]
 		}
 
@@ -162,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,
 	)