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

feat: opened tabs and selection in the @ menu

loup 11 месяцев назад
Родитель
Сommit
064dc4e52f

+ 52 - 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,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

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

+ 24 - 3
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -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) => {

+ 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 "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

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

@@ -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,

+ 15 - 2
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