Skip to content

Commit 064dc4e

Browse files
olupmrubens
authored andcommitted
feat: opened tabs and selection in the @ menu
1 parent 90ba9e1 commit 064dc4e

File tree

7 files changed

+131
-10
lines changed

7 files changed

+131
-10
lines changed

src/integrations/workspace/WorkspaceTracker.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
22
import * as path from "path"
33
import { listFiles } from "../../services/glob/list-files"
44
import { ClineProvider } from "../../core/webview/ClineProvider"
5+
import { toRelativePath } from "../../utils/path"
56

67
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
78
const MAX_INITIAL_FILES = 1_000
@@ -48,6 +49,52 @@ class WorkspaceTracker {
4849
)
4950

5051
this.disposables.push(watcher)
52+
53+
// Listen for tab changes
54+
this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate()))
55+
56+
// Listen for editor/selection changes
57+
this.disposables.push(vscode.window.onDidChangeActiveTextEditor(() => this.workspaceDidUpdate()))
58+
this.disposables.push(vscode.window.onDidChangeTextEditorSelection(() => this.workspaceDidUpdate()))
59+
60+
/*
61+
An event that is emitted when a workspace folder is added or removed.
62+
**Note:** this event will not fire if the first workspace folder is added, removed or changed,
63+
because in that case the currently executing extensions (including the one that listens to this
64+
event) will be terminated and restarted so that the (deprecated) `rootPath` property is updated
65+
to point to the first workspace folder.
66+
*/
67+
// 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)
68+
// this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this)))
69+
}
70+
71+
private getOpenedTabsInfo() {
72+
return vscode.window.tabGroups.all.flatMap((group) =>
73+
group.tabs
74+
.filter((tab) => tab.input instanceof vscode.TabInputText)
75+
.map((tab) => {
76+
const path = (tab.input as vscode.TabInputText).uri.fsPath
77+
return {
78+
label: tab.label,
79+
isActive: tab.isActive,
80+
path: toRelativePath(path, cwd || ""),
81+
}
82+
}),
83+
)
84+
}
85+
86+
private getActiveSelectionInfo() {
87+
const editor = vscode.window.activeTextEditor
88+
if (!editor) return null
89+
if (editor.selection.isEmpty) return null
90+
91+
return {
92+
file: toRelativePath(editor.document.uri.fsPath, cwd || ""),
93+
selection: {
94+
startLine: editor.selection.start.line,
95+
endLine: editor.selection.end.line,
96+
},
97+
}
5198
}
5299

53100
private workspaceDidUpdate() {
@@ -59,12 +106,13 @@ class WorkspaceTracker {
59106
if (!cwd) {
60107
return
61108
}
109+
110+
const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd))
62111
this.providerRef.deref()?.postMessageToWebview({
63112
type: "workspaceUpdated",
64-
filePaths: Array.from(this.filePaths).map((file) => {
65-
const relativePath = path.relative(cwd, file).toPosix()
66-
return file.endsWith("/") ? relativePath + "/" : relativePath
67-
}),
113+
filePaths: relativeFilePaths,
114+
openedTabs: this.getOpenedTabsInfo(),
115+
activeSelection: this.getActiveSelectionInfo(),
68116
})
69117
this.updateTimer = null
70118
}, 300) // Debounce for 300ms

src/shared/ExtensionMessage.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export interface ExtensionMessage {
5757
lmStudioModels?: string[]
5858
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
5959
filePaths?: string[]
60+
openedTabs?: Array<{
61+
label: string
62+
isActive: boolean
63+
path?: string
64+
}>
65+
activeSelection?: {
66+
file: string
67+
selection: {
68+
startLine: number
69+
endLine: number
70+
}
71+
} | null
6072
partialMessage?: ClineMessage
6173
glamaModels?: Record<string, ModelInfo>
6274
openRouterModels?: Record<string, ModelInfo>

src/utils/path.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,8 @@ export function getReadablePath(cwd: string, relPath?: string): string {
9999
}
100100
}
101101
}
102+
103+
export const toRelativePath = (filePath: string, cwd: string) => {
104+
const relativePath = path.relative(cwd, filePath).toPosix()
105+
return filePath.endsWith("/") ? relativePath + "/" : relativePath
106+
}

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
5050
},
5151
ref,
5252
) => {
53-
const { filePaths, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState()
53+
const { filePaths, openedTabs, activeSelection, currentApiConfigName, listApiConfigMeta, customModes } =
54+
useExtensionState()
5455
const [gitCommits, setGitCommits] = useState<any[]>([])
5556
const [showDropdown, setShowDropdown] = useState(false)
5657

@@ -89,6 +90,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
8990
return () => window.removeEventListener("message", messageHandler)
9091
}, [setInputValue])
9192

93+
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
9294
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
9395
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
9496
const [showContextMenu, setShowContextMenu] = useState(false)
@@ -135,17 +137,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
135137
}, [inputValue, textAreaDisabled, setInputValue])
136138

137139
const queryItems = useMemo(() => {
138-
return [
140+
const items = [
139141
{ type: ContextMenuOptionType.Problems, value: "problems" },
140142
...gitCommits,
143+
// Add opened tabs
144+
...openedTabs
145+
.filter((tab) => tab.path)
146+
.map((tab) => ({
147+
type: ContextMenuOptionType.OpenedFile,
148+
value: "/" + tab.path,
149+
})),
150+
151+
// Add regular file paths
141152
...filePaths
142153
.map((file) => "/" + file)
154+
.filter((path) => !openedTabs.some((tab) => tab.path && "/" + tab.path === path)) // Filter out paths that are already in openedTabs
143155
.map((path) => ({
144156
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
145157
value: path,
146158
})),
147159
]
148-
}, [filePaths, gitCommits])
160+
161+
if (activeSelection) {
162+
items.unshift({
163+
type: ContextMenuOptionType.OpenedFile,
164+
value: `/${activeSelection.file}:${activeSelection.selection.startLine + 1}-${activeSelection.selection.endLine + 1}`,
165+
})
166+
}
167+
168+
return items
169+
}, [filePaths, openedTabs, activeSelection])
149170

150171
useEffect(() => {
151172
const handleClickOutside = (event: MouseEvent) => {

webview-ui/src/components/chat/ContextMenu.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
7474
return <span>Git Commits</span>
7575
}
7676
case ContextMenuOptionType.File:
77+
case ContextMenuOptionType.OpenedFile:
7778
case ContextMenuOptionType.Folder:
7879
if (option.value) {
7980
return (
@@ -100,6 +101,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
100101

101102
const getIconForOption = (option: ContextMenuQueryItem): string => {
102103
switch (option.type) {
104+
case ContextMenuOptionType.OpenedFile:
105+
return "star-full"
103106
case ContextMenuOptionType.File:
104107
return "file"
105108
case ContextMenuOptionType.Folder:
@@ -194,6 +197,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
194197
{(option.type === ContextMenuOptionType.Problems ||
195198
((option.type === ContextMenuOptionType.File ||
196199
option.type === ContextMenuOptionType.Folder ||
200+
option.type === ContextMenuOptionType.OpenedFile ||
197201
option.type === ContextMenuOptionType.Git) &&
198202
option.value)) && (
199203
<i

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export interface ExtensionStateContextType extends ExtensionState {
2727
openAiModels: string[]
2828
mcpServers: McpServer[]
2929
filePaths: string[]
30+
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
31+
activeSelection: {
32+
file: string
33+
selection: { startLine: number; endLine: number }
34+
} | null
3035
setApiConfiguration: (config: ApiConfiguration) => void
3136
setCustomInstructions: (value?: string) => void
3237
setAlwaysAllowReadOnly: (value: boolean) => void
@@ -116,6 +121,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
116121
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
117122
[glamaDefaultModelId]: glamaDefaultModelInfo,
118123
})
124+
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
125+
const [activeSelection, setActiveSelection] = useState<{
126+
file: string
127+
selection: { startLine: number; endLine: number }
128+
} | null>(null)
119129
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
120130
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
121131
})
@@ -176,7 +186,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
176186
break
177187
}
178188
case "workspaceUpdated": {
179-
setFilePaths(message.filePaths ?? [])
189+
const paths = message.filePaths ?? []
190+
const tabs = message.openedTabs ?? []
191+
const selection = message.activeSelection ?? null
192+
193+
setFilePaths(paths)
194+
setOpenedTabs(tabs)
195+
setActiveSelection(selection)
180196
break
181197
}
182198
case "partialMessage": {
@@ -243,6 +259,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
243259
openAiModels,
244260
mcpServers,
245261
filePaths,
262+
openedTabs,
263+
activeSelection,
246264
soundVolume: state.soundVolume,
247265
fuzzyMatchThreshold: state.fuzzyMatchThreshold,
248266
writeDelayMs: state.writeDelayMs,

webview-ui/src/utils/context-mentions.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export function removeMention(text: string, position: number): { newText: string
4848
}
4949

5050
export enum ContextMenuOptionType {
51+
OpenedFile = "openedFile",
5152
File = "file",
5253
Folder = "folder",
5354
Problems = "problems",
@@ -80,8 +81,14 @@ export function getContextMenuOptions(
8081
if (query === "") {
8182
if (selectedType === ContextMenuOptionType.File) {
8283
const files = queryItems
83-
.filter((item) => item.type === ContextMenuOptionType.File)
84-
.map((item) => ({ type: ContextMenuOptionType.File, value: item.value }))
84+
.filter(
85+
(item) =>
86+
item.type === ContextMenuOptionType.File || item.type === ContextMenuOptionType.OpenedFile,
87+
)
88+
.map((item) => ({
89+
type: item.type,
90+
value: item.value,
91+
}))
8592
return files.length > 0 ? files : [{ type: ContextMenuOptionType.NoResults }]
8693
}
8794

@@ -125,6 +132,12 @@ export function getContextMenuOptions(
125132
}
126133
if (query.startsWith("http")) {
127134
suggestions.push({ type: ContextMenuOptionType.URL, value: query })
135+
} else {
136+
suggestions.push(
137+
...queryItems
138+
.filter((item) => item.type !== ContextMenuOptionType.OpenedFile)
139+
.filter((item) => item.value?.toLowerCase().includes(lowerQuery)),
140+
)
128141
}
129142

130143
// Add exact SHA matches to suggestions

0 commit comments

Comments
 (0)