diff --git a/src/ClaudeDev.ts b/src/ClaudeDev.ts index 803b981..06e4bf4 100644 --- a/src/ClaudeDev.ts +++ b/src/ClaudeDev.ts @@ -11,7 +11,7 @@ import { serializeError } from "serialize-error" import * as vscode from "vscode" import { ApiHandler, buildApiHandler } from "./api" import { TerminalManager } from "./integrations/TerminalManager" -import { LIST_FILES_LIMIT, listFiles, parseSourceCodeForDefinitionsTopLevel } from "./parse-source-code" +import { listFiles, parseSourceCodeForDefinitionsTopLevel } from "./parse-source-code" import { ClaudeDevProvider } from "./providers/ClaudeDevProvider" import { ApiConfiguration } from "./shared/api" import { ClaudeRequestResult } from "./shared/ClaudeRequestResult" @@ -1187,8 +1187,8 @@ export class ClaudeDev { try { const recursive = recursiveRaw?.toLowerCase() === "true" const absolutePath = path.resolve(cwd, relDirPath) - const files = await listFiles(absolutePath, recursive) - const result = this.formatFilesList(absolutePath, files) + const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200) + const result = this.formatFilesList(absolutePath, files, didHitLimit) const message = JSON.stringify({ tool: recursive ? "listFilesRecursive" : "listFilesTopLevel", @@ -1245,7 +1245,7 @@ export class ClaudeDev { } } - formatFilesList(absolutePath: string, files: string[]): string { + formatFilesList(absolutePath: string, files: string[], didHitLimit: boolean): string { const sorted = files .map((file) => { // convert absolute path to relative path @@ -1273,11 +1273,12 @@ export class ClaudeDev { // the shorter one comes first return aParts.length - bParts.length }) - if (sorted.length >= LIST_FILES_LIMIT) { - const truncatedList = sorted.slice(0, LIST_FILES_LIMIT).join("\n") - return `${truncatedList}\n\n(Truncated at ${LIST_FILES_LIMIT} results. Try listing files in subdirectories if you need to explore further.)` + if (didHitLimit) { + return `${sorted.join( + "\n" + )}\n\n(Truncated at 200 results. Try listing files in subdirectories if you need to explore further.)` } else if (sorted.length === 0 || (sorted.length === 1 && sorted[0] === "")) { - return "No files found or you do not have permission to view this directory." + return "No files found." } else { return sorted.join("\n") } @@ -1937,8 +1938,8 @@ ${this.customInstructions.trim()} if (includeFileDetails) { const isDesktop = cwd === path.join(os.homedir(), "Desktop") - const files = await listFiles(cwd, !isDesktop) - const result = this.formatFilesList(cwd, files) + const [files, didHitLimit] = await listFiles(cwd, !isDesktop, 200) + const result = this.formatFilesList(cwd, files, didHitLimit) details += `\n\n# Current Working Directory (${cwd}) Files\n${result}${ isDesktop ? "\n(Note: Only top-level contents shown for Desktop by default. Use list_files to explore further if necessary.)" diff --git a/src/integrations/WorkspaceTracker.ts b/src/integrations/WorkspaceTracker.ts new file mode 100644 index 0000000..48c3ff6 --- /dev/null +++ b/src/integrations/WorkspaceTracker.ts @@ -0,0 +1,108 @@ +import * as vscode from "vscode" +import * as path from "path" +import { listFiles } from "../parse-source-code/index" +import { ClaudeDevProvider } from "../providers/ClaudeDevProvider" + +class WorkspaceTracker { + private providerRef: WeakRef + private disposables: vscode.Disposable[] = [] + private filePaths: Set = new Set() + + constructor(provider: ClaudeDevProvider) { + this.providerRef = new WeakRef(provider) + this.registerListeners() + } + + async initializeFilePaths() { + // should not auto get filepaths for desktop since it would immediately show permission popup before claude every creates a file + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + if (!cwd) { + return + } + const [files, _] = await listFiles(cwd, true, 500) + files.forEach((file) => this.filePaths.add(file)) + this.workspaceDidUpdate() + } + + private registerListeners() { + // Listen for file creation + this.disposables.push(vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this))) + + // Listen for file deletion + this.disposables.push(vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this))) + + // Listen for file renaming + this.disposables.push(vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this))) + + // Listen for file changes + this.disposables.push(vscode.workspace.onDidChangeTextDocument(this.onFileChanged.bind(this))) + + // Listen for workspace folder changes + this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this))) + } + + private onFilesCreated(event: vscode.FileCreateEvent) { + event.files.forEach(async (file) => { + this.filePaths.add(file.fsPath) + this.workspaceDidUpdate() + }) + } + + private onFilesDeleted(event: vscode.FileDeleteEvent) { + event.files.forEach((file) => { + if (this.filePaths.delete(file.fsPath)) { + this.workspaceDidUpdate() + } + }) + } + + private onFilesRenamed(event: vscode.FileRenameEvent) { + event.files.forEach(async (file) => { + this.filePaths.delete(file.oldUri.fsPath) + this.filePaths.add(file.newUri.fsPath) + this.workspaceDidUpdate() + }) + } + + private async onFileChanged(event: vscode.TextDocumentChangeEvent) { + const filePath = event.document.uri.fsPath + if (!this.filePaths.has(filePath)) { + this.filePaths.add(filePath) + this.workspaceDidUpdate() + } + } + + private async onWorkspaceFoldersChanged(event: vscode.WorkspaceFoldersChangeEvent) { + for (const folder of event.added) { + const [files, _] = await listFiles(folder.uri.fsPath, true, 50) + files.forEach((file) => this.filePaths.add(file)) + } + for (const folder of event.removed) { + this.filePaths.forEach((filePath) => { + if (filePath.startsWith(folder.uri.fsPath)) { + this.filePaths.delete(filePath) + } + }) + } + this.workspaceDidUpdate() + } + + private workspaceDidUpdate() { + console.log("Workspace updated. Current file paths:", Array.from(this.filePaths)) + // Add your logic here for when the workspace is updated + this.providerRef.deref()?.postMessageToWebview({ + type: "workspaceUpdated", + filePaths: Array.from(this.filePaths), + }) + } + + public getFilePaths(): string[] { + return Array.from(this.filePaths) + } + + public dispose() { + this.disposables.forEach((d) => d.dispose()) + } +} + +export default WorkspaceTracker diff --git a/src/parse-source-code/index.ts b/src/parse-source-code/index.ts index c52edb2..a0c56a6 100644 --- a/src/parse-source-code/index.ts +++ b/src/parse-source-code/index.ts @@ -4,8 +4,6 @@ import os from "os" import * as path from "path" import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser" -export const LIST_FILES_LIMIT = 200 - // TODO: implement caching behavior to avoid having to keep analyzing project for new tasks. export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise { // check if the path exists @@ -18,7 +16,7 @@ export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Pr } // Get all files at top level (not gitignored) - const allFiles = await listFiles(dirPath, false) + const [allFiles, _] = await listFiles(dirPath, false, 200) let result = "" @@ -55,18 +53,18 @@ export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Pr return result ? result : "No source code definitions found." } -export async function listFiles(dirPath: string, recursive: boolean): Promise { +export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> { const absolutePath = path.resolve(dirPath) // Do not allow listing files in root or home directory, which Claude tends to want to do when the user's prompt is vague. const root = process.platform === "win32" ? path.parse(absolutePath).root : "/" const isRoot = absolutePath === root if (isRoot) { - return [root] + return [[root], false] } const homeDir = os.homedir() const isHomeDir = absolutePath === homeDir if (isHomeDir) { - return [homeDir] + return [[homeDir], false] } const dirsToIgnore = [ @@ -98,26 +96,24 @@ export async function listFiles(dirPath: string, recursive: boolean): Promise= limit] } // globby doesnt natively support top down level by level globbing, so we implement it ourselves -async function globbyLevelByLevel(options?: Options) { +async function globbyLevelByLevel(limit: number, options?: Options) { let results: string[] = [] const globbingProcess = async () => { let currentLevel = 0 - while (results.length < LIST_FILES_LIMIT) { + while (results.length < limit) { const pattern = currentLevel === 0 ? "*" : `${"*/".repeat(currentLevel)}*` const filesAtLevel = await globby(pattern, options) if (filesAtLevel.length === 0) { break } results.push(...filesAtLevel) - if (results.length >= LIST_FILES_LIMIT) { - results = results.slice(0, LIST_FILES_LIMIT) + if (results.length >= limit) { + results = results.slice(0, limit) break } currentLevel++ diff --git a/src/providers/ClaudeDevProvider.ts b/src/providers/ClaudeDevProvider.ts index 01509f4..3555425 100644 --- a/src/providers/ClaudeDevProvider.ts +++ b/src/providers/ClaudeDevProvider.ts @@ -11,6 +11,7 @@ import { HistoryItem } from "../shared/HistoryItem" import axios from "axios" import { getTheme } from "../utils/getTheme" import { openFile, openImage } from "../utils/open-file" +import WorkspaceTracker from "../integrations/WorkspaceTracker" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -50,11 +51,13 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider { private disposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel private claudeDev?: ClaudeDev + private workspaceTracker?: WorkspaceTracker private latestAnnouncementId = "sep-14-2024" // update to some unique identifier when we add a new announcement constructor(readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel) { this.outputChannel.appendLine("ClaudeDevProvider instantiated") ClaudeDevProvider.activeInstances.add(this) + this.workspaceTracker = new WorkspaceTracker(this) this.revertKodu() } @@ -98,6 +101,8 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider { x.dispose() } } + this.workspaceTracker?.dispose() + this.workspaceTracker = undefined this.outputChannel.appendLine("Disposed all disposables") ClaudeDevProvider.activeInstances.delete(this) } @@ -306,6 +311,7 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider { await this.postStateToWebview() const theme = await getTheme() await this.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }) + this.workspaceTracker?.initializeFilePaths() break case "newTask": // Code that should run in response to the hello message command diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 9433f81..7468376 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -5,12 +5,13 @@ import { HistoryItem } from "./HistoryItem" // webview will hold state export interface ExtensionMessage { - type: "action" | "state" | "selectedImages" | "ollamaModels" | "theme" + type: "action" | "state" | "selectedImages" | "ollamaModels" | "theme" | "workspaceUpdated" text?: string action?: "chatButtonTapped" | "settingsButtonTapped" | "historyButtonTapped" | "didBecomeVisible" state?: ExtensionState images?: string[] models?: string[] + filePaths?: string[] } export interface ExtensionState { diff --git a/webview-ui/src/components/ChatTextArea.tsx b/webview-ui/src/components/ChatTextArea.tsx index dc5b3cf..2646f34 100644 --- a/webview-ui/src/components/ChatTextArea.tsx +++ b/webview-ui/src/components/ChatTextArea.tsx @@ -1,6 +1,7 @@ -import React, { forwardRef, useCallback, useEffect, useRef, useState, useLayoutEffect } from "react" +import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" import DynamicTextArea from "react-textarea-autosize" -import { insertMention, shouldShowContextMenu, getContextMenuOptions, removeMention } from "../utils/mention-context" +import { useExtensionState } from "../context/ExtensionStateContext" +import { getContextMenuOptions, insertMention, removeMention, shouldShowContextMenu } from "../utils/mention-context" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" import Thumbnails from "./Thumbnails" @@ -49,6 +50,18 @@ const ChatTextArea = forwardRef( const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) const contextMenuContainerRef = useRef(null) + const { filePaths } = useExtensionState() + + const searchPaths = React.useMemo(() => { + return [ + { type: "problems", path: "problems" }, + ...filePaths.map((path) => ({ + type: path.endsWith("/") ? "folder" : "file", + path: path, + })), + ] + }, [filePaths]) + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -70,7 +83,7 @@ const ChatTextArea = forwardRef( const handleMentionSelect = useCallback( (type: string, value: string) => { - if (value === "File" || value === "Folder") { + if (value === "file" || value === "folder") { setSelectedType(type.toLowerCase()) setSearchQuery("") setSelectedMenuIndex(0) @@ -108,17 +121,18 @@ const ChatTextArea = forwardRef( const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (showContextMenu) { - // if (event.key === "Escape") { - // // event.preventDefault() - // setShowContextMenu(false) - // return - // } + if (event.key === "Escape") { + // event.preventDefault() + setSelectedType(null) + setSelectedMenuIndex(3) // File by default + return + } if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault() setSelectedMenuIndex((prevIndex) => { const direction = event.key === "ArrowUp" ? -1 : 1 - const options = getContextMenuOptions(searchQuery, selectedType) + const options = getContextMenuOptions(searchQuery, selectedType, searchPaths) const optionsLength = options.length if (optionsLength === 0) return prevIndex @@ -144,7 +158,9 @@ const ChatTextArea = forwardRef( } if (event.key === "Enter" && selectedMenuIndex !== -1) { event.preventDefault() - const selectedOption = getContextMenuOptions(searchQuery, selectedType)[selectedMenuIndex] + const selectedOption = getContextMenuOptions(searchQuery, selectedType, searchPaths)[ + selectedMenuIndex + ] if (selectedOption && selectedOption.type !== "url") { handleMentionSelect(selectedOption.type, selectedOption.value) } @@ -203,6 +219,7 @@ const ChatTextArea = forwardRef( cursorPosition, setInputValue, justDeletedSpaceAfterMention, + searchPaths, ] ) @@ -361,6 +378,7 @@ const ChatTextArea = forwardRef( selectedIndex={selectedMenuIndex} setSelectedIndex={setSelectedMenuIndex} selectedType={selectedType} + searchPaths={searchPaths} /> )} diff --git a/webview-ui/src/components/ContextMenu.tsx b/webview-ui/src/components/ContextMenu.tsx index 8ac6f4d..740ee51 100644 --- a/webview-ui/src/components/ContextMenu.tsx +++ b/webview-ui/src/components/ContextMenu.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from "react" +import React, { useEffect, useRef, useState } from "react" import { getContextMenuOptions } from "../utils/mention-context" interface ContextMenuProps { @@ -8,6 +8,7 @@ interface ContextMenuProps { selectedIndex: number setSelectedIndex: (index: number) => void selectedType: string | null + searchPaths: { type: string; path: string }[] } const ContextMenu: React.FC = ({ @@ -17,13 +18,16 @@ const ContextMenu: React.FC = ({ selectedIndex, setSelectedIndex, selectedType, + searchPaths, }) => { - const [filteredOptions, setFilteredOptions] = useState(getContextMenuOptions(searchQuery, selectedType)) + const [filteredOptions, setFilteredOptions] = useState( + getContextMenuOptions(searchQuery, selectedType, searchPaths) + ) const menuRef = useRef(null) useEffect(() => { - setFilteredOptions(getContextMenuOptions(searchQuery, selectedType)) - }, [searchQuery, selectedType]) + setFilteredOptions(getContextMenuOptions(searchQuery, selectedType, searchPaths)) + }, [searchQuery, selectedType, searchPaths]) useEffect(() => { if (menuRef.current) { @@ -84,23 +88,23 @@ const ContextMenu: React.FC = ({ onMouseEnter={() => option.type !== "url" && setSelectedIndex(index)}>
- {option.value === "File" + {option.value === "file" ? "Add File" - : option.value === "Folder" + : option.value === "folder" ? "Add Folder" - : option.value === "Problems" - ? "Workspace Problems" - : option.value === "URL" + : option.value === "problems" + ? "Problems" + : option.value === "url" ? "Paste URL to scrape" : option.value}
- {(option.value === "File" || option.value === "Folder") && ( + {(option.value === "file" || option.value === "folder") && ( )} {(option.type === "problems" || ((option.type === "file" || option.type === "folder") && - option.value !== "File" && - option.value !== "Folder")) && ( + option.value !== "file" && + option.value !== "folder")) && ( )} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 791307c..574f397 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -9,6 +9,7 @@ interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean showWelcome: boolean theme: any + filePaths: string[] setApiConfiguration: (config: ApiConfiguration) => void setCustomInstructions: (value?: string) => void setAlwaysAllowReadOnly: (value: boolean) => void @@ -27,6 +28,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) const [theme, setTheme] = useState(undefined) + const [filePaths, setFilePaths] = useState([]) + const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data if (message.type === "state" && message.state) { @@ -50,6 +53,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode if (message.type === "theme" && message.text) { setTheme(convertTextMateToHljs(JSON.parse(message.text))) } + if (message.type === "workspaceUpdated" && message.filePaths) { + setFilePaths(message.filePaths) + } }, []) useEvent("message", handleMessage) @@ -63,6 +69,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode didHydrateState, showWelcome, theme, + filePaths, setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })), setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })), setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })), diff --git a/webview-ui/src/utils/mention-context.ts b/webview-ui/src/utils/mention-context.ts index 00eee2b..ee204d5 100644 --- a/webview-ui/src/utils/mention-context.ts +++ b/webview-ui/src/utils/mention-context.ts @@ -1,16 +1,16 @@ -export const mockPaths = [ - { type: "problems", path: "Problems" }, - { type: "file", path: "/src/components/Header.tsx" }, - { type: "file", path: "/src/components/Footer.tsx" }, - { type: "file", path: "/src/utils/helpers.ts" }, - { type: "folder", path: "/src/components" }, - { type: "folder", path: "/src/utils" }, - { type: "folder", path: "/public/images" }, - { type: "file", path: "/public/index.html" }, - { type: "file", path: "/package.json" }, - { type: "folder", path: "/node_modules" }, - { type: "file", path: "/README.md" }, -] +// export const mockPaths = [ +// { type: "problems", path: "Problems" }, +// { type: "file", path: "/src/components/Header.tsx" }, +// { type: "file", path: "/src/components/Footer.tsx" }, +// { type: "file", path: "/src/utils/helpers.ts" }, +// { type: "folder", path: "/src/components" }, +// { type: "folder", path: "/src/utils" }, +// { type: "folder", path: "/public/images" }, +// { type: "file", path: "/public/index.html" }, +// { type: "file", path: "/package.json" }, +// { type: "folder", path: "/node_modules" }, +// { type: "file", path: "/README.md" }, +// ] export function insertMention(text: string, position: number, value: string): string { const beforeCursor = text.slice(0, position) @@ -48,39 +48,42 @@ export function removeMention(text: string, position: number): { newText: string return { newText: text, newPosition: position } } -export function searchPaths(query: string): { type: string; path: string }[] { - const lowerQuery = query.toLowerCase() - return mockPaths.filter( - (item) => item.path.toLowerCase().includes(lowerQuery) || item.type.toLowerCase().includes(lowerQuery) - ) -} +// export function queryPaths( +// query: string, +// searchPaths: { type: string; path: string }[] +// ): { type: string; path: string }[] { +// const lowerQuery = query.toLowerCase() +// return searchPaths.filter( +// (item) => item.path.toLowerCase().includes(lowerQuery) || item.type.toLowerCase().includes(lowerQuery) +// ) +// } export function getContextMenuOptions( query: string, - selectedType: string | null = null + selectedType: string | null = null, + searchPaths: { type: string; path: string }[] ): { type: string; value: string; icon: string }[] { - if (selectedType === "file") { - return mockPaths - .filter((item) => item.type === "file") - .map((item) => ({ type: "file", value: item.path, icon: "file" })) - } - - if (selectedType === "folder") { - return mockPaths - .filter((item) => item.type === "folder") - .map((item) => ({ type: "folder", value: item.path, icon: "folder" })) - } - if (query === "") { + if (selectedType === "file") { + return searchPaths + .filter((item) => item.type === "file") + .map((item) => ({ type: "file", value: item.path, icon: "file" })) + } + + if (selectedType === "folder") { + return searchPaths + .filter((item) => item.type === "folder") + .map((item) => ({ type: "folder", value: item.path, icon: "folder" })) + } return [ - { type: "url", value: "URL", icon: "link" }, + { type: "url", value: "url", icon: "link" }, { type: "problems", - value: "Problems", + value: "problems", icon: "warning", }, - { type: "folder", value: "Folder", icon: "folder" }, - { type: "file", value: "File", icon: "file" }, + { type: "folder", value: "folder", icon: "folder" }, + { type: "file", value: "file", icon: "file" }, ] } @@ -91,9 +94,7 @@ export function getContextMenuOptions( return [{ type: "url", value: query, icon: "link" }] } else { // Search for files and folders - const matchingPaths = mockPaths.filter( - (item) => item.path.toLowerCase().includes(lowerQuery) || item.type.toLowerCase().includes(lowerQuery) - ) + const matchingPaths = searchPaths.filter((item) => item.path.toLowerCase().includes(lowerQuery)) if (matchingPaths.length > 0) { return matchingPaths.map((item) => ({ @@ -104,14 +105,14 @@ export function getContextMenuOptions( } else { // If no matches, show all options return [ - { type: "url", value: "URL", icon: "link" }, + { type: "url", value: "url", icon: "link" }, { type: "problems", - value: "Problems", + value: "problems", icon: "warning", }, - { type: "folder", value: "Folder", icon: "folder" }, - { type: "file", value: "File", icon: "file" }, + { type: "folder", value: "folder", icon: "folder" }, + { type: "file", value: "file", icon: "file" }, ] } }