From 597b4f07e2b393bc469a34b6b9145ee9b3365660 Mon Sep 17 00:00:00 2001 From: JinmingYang <2214962083@qq.com> Date: Sun, 8 Sep 2024 22:27:53 +0800 Subject: [PATCH] feat: connect remote api to select file or folders --- src/extension/ai/get-reference-file-paths.ts | 11 +- src/extension/commands/ask-ai/command.ts | 11 +- .../commands/batch-processor/command.ts | 12 +- .../batch-processor/get-pre-process-info.ts | 11 +- .../file-utils/get-fs-prompt-info.ts | 7 +- src/extension/file-utils/ignore-patterns.ts | 43 ++++++ src/extension/file-utils/traverse-fs.ts | 126 +++++++++-------- .../processors/file.processor.ts | 9 +- .../controllers/file.controller.ts | 30 ++++ src/extension/webview-api/index.ts | 3 +- .../file-selector/file-list-view.tsx | 12 +- .../file-selector/file-tree-view.tsx | 78 +++++++++-- .../chat/selectors/file-selector/index.tsx | 1 + .../files/mention-file-item.tsx | 16 +++ .../folders/mention-folder-item.tsx | 20 +++ .../mention-selector.tsx | 41 ++++-- src/webview/components/file-icon.tsx | 5 +- .../components/keyboard-shortcuts-info.tsx | 2 +- src/webview/components/truncate-start.tsx | 24 ++++ src/webview/components/ui/tabs.tsx | 2 +- src/webview/hooks/api/use-files.ts | 8 ++ src/webview/hooks/api/use-folders.ts | 8 ++ src/webview/hooks/chat/use-file-search.ts | 63 +++++---- src/webview/hooks/chat/use-mention-options.ts | 132 ++++++++++++++++++ src/webview/hooks/use-element-in-view.ts | 28 ++++ src/webview/hooks/use-event-in-view.ts | 38 +++++ src/webview/hooks/use-keyboard-navigation.ts | 71 +++++++++- src/webview/lexical/mentions/index.ts | 75 ---------- .../lexical/plugins/mention-plugin.tsx | 36 +++-- src/webview/types/chat.ts | 7 +- src/webview/utils/extract-folders.ts | 33 +++++ .../utils/file-icons/material-icons/file.svg | 2 +- .../file-icons/material-icons/folder-open.svg | 2 +- .../file-icons/material-icons/folder.svg | 2 +- 34 files changed, 730 insertions(+), 239 deletions(-) create mode 100644 src/webview/components/chat/selectors/mention-selector/files/mention-file-item.tsx create mode 100644 src/webview/components/chat/selectors/mention-selector/folders/mention-folder-item.tsx rename src/webview/components/chat/selectors/{ => mention-selector}/mention-selector.tsx (77%) create mode 100644 src/webview/components/truncate-start.tsx create mode 100644 src/webview/hooks/api/use-files.ts create mode 100644 src/webview/hooks/api/use-folders.ts create mode 100644 src/webview/hooks/chat/use-mention-options.ts create mode 100644 src/webview/hooks/use-element-in-view.ts create mode 100644 src/webview/hooks/use-event-in-view.ts delete mode 100644 src/webview/lexical/mentions/index.ts create mode 100644 src/webview/utils/extract-folders.ts diff --git a/src/extension/ai/get-reference-file-paths.ts b/src/extension/ai/get-reference-file-paths.ts index 487380e0..78319040 100644 --- a/src/extension/ai/get-reference-file-paths.ts +++ b/src/extension/ai/get-reference-file-paths.ts @@ -21,13 +21,14 @@ export const getReferenceFilePaths = async ({ const workspaceFolder = getWorkspaceFolder() const allRelativePaths: string[] = [] - await traverseFileOrFolders( - [workspaceFolder.uri.fsPath], - workspaceFolder.uri.fsPath, - fileInfo => { + await traverseFileOrFolders({ + type: 'file', + filesOrFolders: [workspaceFolder.uri.fsPath], + workspacePath: workspaceFolder.uri.fsPath, + itemCallback: fileInfo => { allRelativePaths.push(fileInfo.relativePath) } - ) + }) const currentFileRelativePath = vscode.workspace.asRelativePath(currentFilePath) diff --git a/src/extension/commands/ask-ai/command.ts b/src/extension/commands/ask-ai/command.ts index a0413b69..683e900c 100644 --- a/src/extension/commands/ask-ai/command.ts +++ b/src/extension/commands/ask-ai/command.ts @@ -43,11 +43,12 @@ export class AskAICommand extends BaseCommand { filesFullPath += ` "${quote([fullPath.trim()])}" ` } - await traverseFileOrFolders( - selectedFileOrFolders, - workspaceFolder.uri.fsPath, - processFile - ) + await traverseFileOrFolders({ + type: 'file', + filesOrFolders: selectedFileOrFolders, + workspacePath: workspaceFolder.uri.fsPath, + itemCallback: processFile + }) const aiCommand = await getConfigKey('aiCommand') const aiCommandCopyBeforeRun = await getConfigKey('aiCommandCopyBeforeRun') diff --git a/src/extension/commands/batch-processor/command.ts b/src/extension/commands/batch-processor/command.ts index dff75902..b2ee1022 100644 --- a/src/extension/commands/batch-processor/command.ts +++ b/src/extension/commands/batch-processor/command.ts @@ -26,11 +26,13 @@ export class BatchProcessorCommand extends BaseCommand { if (selectedItems.length === 0) throw new Error(t('error.noSelection')) const selectedFileOrFolders = selectedItems.map(item => item.fsPath) - const filesInfo = await traverseFileOrFolders( - selectedFileOrFolders, - workspaceFolder.uri.fsPath, - fileInfo => fileInfo - ) + const filesInfo = await traverseFileOrFolders({ + type: 'file', + filesOrFolders: selectedFileOrFolders, + workspacePath: workspaceFolder.uri.fsPath, + itemCallback: fileInfo => fileInfo + }) + const fileRelativePathsForProcess = filesInfo .filter(fileInfo => !isTmpFileUri(vscode.Uri.file(fileInfo.fullPath))) .map(fileInfo => fileInfo.relativePath) diff --git a/src/extension/commands/batch-processor/get-pre-process-info.ts b/src/extension/commands/batch-processor/get-pre-process-info.ts index 71600dee..85bc6eb4 100644 --- a/src/extension/commands/batch-processor/get-pre-process-info.ts +++ b/src/extension/commands/batch-processor/get-pre-process-info.ts @@ -31,13 +31,14 @@ export const getPreProcessInfo = async ({ const workspaceFolder = getWorkspaceFolder() const allFileRelativePaths: string[] = [] - await traverseFileOrFolders( - [workspaceFolder.uri.fsPath], - workspaceFolder.uri.fsPath, - fileInfo => { + await traverseFileOrFolders({ + type: 'file', + filesOrFolders: [workspaceFolder.uri.fsPath], + workspacePath: workspaceFolder.uri.fsPath, + itemCallback: fileInfo => { allFileRelativePaths.push(fileInfo.relativePath) } - ) + }) const modelProvider = await createModelProvider() const aiRunnable = await modelProvider.createStructuredOutputRunnable({ diff --git a/src/extension/file-utils/get-fs-prompt-info.ts b/src/extension/file-utils/get-fs-prompt-info.ts index 00cc6266..b94fdd89 100644 --- a/src/extension/file-utils/get-fs-prompt-info.ts +++ b/src/extension/file-utils/get-fs-prompt-info.ts @@ -48,7 +48,12 @@ export const getFileOrFoldersPromptInfo = async ( result.promptFullContent += promptFullContent } - await traverseFileOrFolders(fileOrFolders, workspacePath, processFile) + await traverseFileOrFolders({ + type: 'file', + filesOrFolders: fileOrFolders, + workspacePath, + itemCallback: processFile + }) return result } diff --git a/src/extension/file-utils/ignore-patterns.ts b/src/extension/file-utils/ignore-patterns.ts index 0d9f8487..8c74f83d 100644 --- a/src/extension/file-utils/ignore-patterns.ts +++ b/src/extension/file-utils/ignore-patterns.ts @@ -94,3 +94,46 @@ export const getAllValidFiles = async ( } }) } + +/** + * Retrieves all valid folders in the specified directory path. + * @param fullDirPath - The full path of the directory. + * @returns A promise that resolves to an array of strings representing the absolute paths of the valid folders. + */ +export const getAllValidFolders = async ( + fullDirPath: string +): Promise => { + const shouldIgnore = await createShouldIgnore(fullDirPath) + + const filesOrFolders = await glob('**/*', { + cwd: fullDirPath, + nodir: false, + absolute: true, + follow: false, + dot: true, + ignore: { + ignored(p) { + return shouldIgnore(p.fullpath()) + }, + childrenIgnored(p) { + try { + return shouldIgnore(p.fullpath()) + } catch { + return false + } + } + } + }) + + const folders: string[] = [] + const promises = filesOrFolders.map(async fileOrFolder => { + const stat = await VsCodeFS.stat(fileOrFolder) + if (stat.type === vscode.FileType.Directory) { + folders.push(fileOrFolder) + } + }) + + await Promise.allSettled(promises) + + return folders +} diff --git a/src/extension/file-utils/traverse-fs.ts b/src/extension/file-utils/traverse-fs.ts index 378eb698..2164e989 100644 --- a/src/extension/file-utils/traverse-fs.ts +++ b/src/extension/file-utils/traverse-fs.ts @@ -2,35 +2,33 @@ import * as path from 'path' import type { MaybePromise } from '@extension/types/common' import * as vscode from 'vscode' -import { getAllValidFiles } from './ignore-patterns' +import { getAllValidFiles, getAllValidFolders } from './ignore-patterns' import { VsCodeFS } from './vscode-fs' -/** - * Represents information about a file. - */ export interface FileInfo { - /** - * The content of the file. - */ + type: 'file' content: string - - /** - * The relative path of the file. - */ relativePath: string + fullPath: string +} - /** - * The full path of the file. - */ +export interface FolderInfo { + type: 'folder' + relativePath: string fullPath: string } -/** - * Retrieves information about a file. - * @param filePath - The path of the file. - * @param workspacePath - The path of the workspace. - * @returns A Promise that resolves to the file information, or null if the file does not exist. - */ +type FsType = 'file' | 'folder' + +interface TraverseOptions { + type: Type + filesOrFolders: string[] + workspacePath: string + itemCallback: ( + itemInfo: Type extends 'file' ? FileInfo : FolderInfo + ) => MaybePromise +} + const getFileInfo = async ( filePath: string, workspacePath: string @@ -42,62 +40,76 @@ const getFileInfo = async ( const relativePath = path.relative(workspacePath, filePath) return { + type: 'file', content: fileContent, relativePath, fullPath: filePath } } -/** - * Traverses through an array of file or folder paths and performs a callback function on each file. - * Returns an array of results from the callback function. - * - * @param filesOrFolders - An array of file or folder paths. - * @param workspacePath - The path of the workspace. - * @param fileCallback - The callback function to be performed on each file. - * @returns An array of results from the callback function. - */ -export const traverseFileOrFolders = async ( - filesOrFolders: string[], - workspacePath: string, - fileCallback: (fileInfo: FileInfo) => MaybePromise +const getFolderInfo = async ( + folderPath: string, + workspacePath: string +): Promise => { + const relativePath = path.relative(workspacePath, folderPath) + + return { + type: 'folder', + relativePath, + fullPath: folderPath + } +} + +export const traverseFileOrFolders = async ( + props: TraverseOptions ): Promise => { - const filePathSet = new Set() + const { type = 'file', filesOrFolders, workspacePath, itemCallback } = props + const itemPathSet = new Set() const results: T[] = [] + const processFolder = async (folderPath: string) => { + if (itemPathSet.has(folderPath)) return + + itemPathSet.add(folderPath) + const folderInfo = await getFolderInfo(folderPath, workspacePath) + results.push(await itemCallback(folderInfo as any)) + } + + const processFile = async (filePath: string) => { + if (itemPathSet.has(filePath)) return + + itemPathSet.add(filePath) + const fileInfo = await getFileInfo(filePath, workspacePath) + results.push(await itemCallback(fileInfo as any)) + } + await Promise.allSettled( filesOrFolders.map(async fileOrFolder => { - // Convert relative path to absolute path const absolutePath = path.isAbsolute(fileOrFolder) ? fileOrFolder : path.join(workspacePath, fileOrFolder) const stat = await VsCodeFS.stat(absolutePath) if (stat.type === vscode.FileType.Directory) { - const allFiles = await getAllValidFiles(absolutePath) - - await Promise.allSettled( - allFiles.map(async filePath => { - if (filePathSet.has(filePath)) return - filePathSet.add(filePath) - - const fileInfo = await getFileInfo(filePath, workspacePath) - if (fileInfo) { - results.push(await fileCallback(fileInfo)) - } - }) - ) + if (type === 'folder') { + const allFolders = await getAllValidFolders(absolutePath) + await Promise.allSettled( + allFolders.map(async folderPath => { + await processFolder(folderPath) + }) + ) + } else if (type === 'file') { + const allFiles = await getAllValidFiles(absolutePath) + await Promise.allSettled( + allFiles.map(async filePath => { + await processFile(filePath) + }) + ) + } } - if (stat.type === vscode.FileType.File) { - if (filePathSet.has(absolutePath)) return - filePathSet.add(absolutePath) - - const fileInfo = await getFileInfo(absolutePath, workspacePath) - - if (fileInfo) { - results.push(await fileCallback(fileInfo)) - } + if (stat.type === vscode.FileType.File && type === 'file') { + await processFile(absolutePath) } }) ) diff --git a/src/extension/webview-api/chat-context-processor/processors/file.processor.ts b/src/extension/webview-api/chat-context-processor/processors/file.processor.ts index eaba0317..e34afb74 100644 --- a/src/extension/webview-api/chat-context-processor/processors/file.processor.ts +++ b/src/extension/webview-api/chat-context-processor/processors/file.processor.ts @@ -21,16 +21,17 @@ export class FileProcessor implements ContextProcessor { const workspacePath = getWorkspaceFolder().uri.fsPath const processFolder = async (folder: string): Promise => { - const files = await traverseFileOrFolders( - [folder], + const files = await traverseFileOrFolders({ + type: 'file', + filesOrFolders: [folder], workspacePath, - async (fileInfo: FileInfo) => { + itemCallback: async (fileInfo: FileInfo) => { const { relativePath, fullPath } = fileInfo const languageId = getLanguageId(path.extname(relativePath).slice(1)) const content = await VsCodeFS.readFileOrOpenDocumentContent(fullPath) return `\`\`\`${languageId}:${relativePath}\n${content}\n\`\`\`\n\n` } - ) + }) return files.join('') } diff --git a/src/extension/webview-api/controllers/file.controller.ts b/src/extension/webview-api/controllers/file.controller.ts index 94f58513..3e710041 100644 --- a/src/extension/webview-api/controllers/file.controller.ts +++ b/src/extension/webview-api/controllers/file.controller.ts @@ -1,4 +1,10 @@ +import { + traverseFileOrFolders, + type FileInfo, + type FolderInfo +} from '@extension/file-utils/traverse-fs' import { VsCodeFS } from '@extension/file-utils/vscode-fs' +import { getWorkspaceFolder } from '@extension/utils' import * as vscode from 'vscode' import { Controller } from '../types' @@ -44,4 +50,28 @@ export class FileController extends Controller { async readdir(req: { path: string }): Promise { return await VsCodeFS.readdir(req.path) } + + async traverseWorkspaceFiles(req: { + filesOrFolders: string[] + }): Promise { + const workspaceFolder = getWorkspaceFolder() + return await traverseFileOrFolders({ + type: 'file', + filesOrFolders: req.filesOrFolders, + workspacePath: workspaceFolder.uri.fsPath, + itemCallback: fileInfo => fileInfo + }) + } + + async traverseWorkspaceFolders(req: { + folders: string[] + }): Promise { + const workspaceFolder = getWorkspaceFolder() + return await traverseFileOrFolders({ + type: 'folder', + filesOrFolders: req.folders, + workspacePath: workspaceFolder.uri.fsPath, + itemCallback: folderInfo => folderInfo + }) + } } diff --git a/src/extension/webview-api/index.ts b/src/extension/webview-api/index.ts index ca9adc08..c00ca084 100644 --- a/src/extension/webview-api/index.ts +++ b/src/extension/webview-api/index.ts @@ -2,6 +2,7 @@ import { getErrorMsg } from '@extension/utils' import * as vscode from 'vscode' import { ChatController } from './controllers/chat.controller' +import { FileController } from './controllers/file.controller' import type { Controller, ControllerClass, @@ -81,7 +82,7 @@ class APIManager { } } -export const controllers = [ChatController] as const +export const controllers = [ChatController, FileController] as const export type Controllers = typeof controllers export const setupWebviewAPIManager = ( diff --git a/src/webview/components/chat/selectors/file-selector/file-list-view.tsx b/src/webview/components/chat/selectors/file-selector/file-list-view.tsx index c4b67417..5cdcad0e 100644 --- a/src/webview/components/chat/selectors/file-selector/file-list-view.tsx +++ b/src/webview/components/chat/selectors/file-selector/file-list-view.tsx @@ -4,6 +4,7 @@ import { KeyboardShortcutsInfo, type ShortcutInfo } from '@webview/components/keyboard-shortcuts-info' +import { TruncateStart } from '@webview/components/truncate-start' import { Command, CommandEmpty, @@ -32,9 +33,11 @@ export const FileListView: React.FC = ({ selectedFiles, onSelect }) => { + const listRef = useRef(null) const itemRefs = useRef<(HTMLDivElement | null)[]>([]) const { focusedIndex, handleKeyDown } = useKeyboardNavigation({ + listRef, itemCount: filteredFiles.length, itemRefs, onEnter: el => el?.click() @@ -60,12 +63,12 @@ export const FileListView: React.FC = ({ } }} className={cn( - 'cursor-pointer px-1.5 py-1 flex items-center justify-between hover:bg-secondary', + 'cursor-pointer text-sm px-1 py-1 flex items-center hover:bg-secondary', isSelected && 'text-primary', focusedIndex === index && 'bg-secondary' )} > -
+
= ({ {fileName}
+ {file.relativePath} ) }, @@ -83,9 +87,9 @@ export const FileListView: React.FC = ({ ) return ( -
+
- + No files found. {filteredFiles.map((file, index) => renderItem(file, index))} diff --git a/src/webview/components/chat/selectors/file-selector/file-tree-view.tsx b/src/webview/components/chat/selectors/file-selector/file-tree-view.tsx index 15b75ac7..7ae1ca9f 100644 --- a/src/webview/components/chat/selectors/file-selector/file-tree-view.tsx +++ b/src/webview/components/chat/selectors/file-selector/file-tree-view.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDownIcon, ChevronRightIcon } from '@radix-ui/react-icons' import { FileIcon } from '@webview/components/file-icon' import { KeyboardShortcutsInfo, @@ -91,11 +92,35 @@ export const FileTreeView: React.FC = ({ initializedRef.current = true }, [treeItems, selectedIds, getAllParentIds]) + // useEffect(() => { + // if (!initializedRef.current) return + + // const newAutoExpandedIds = new Set() + // const expandSearchNodes = (items: TreeItem[]) => { + // items.forEach(item => { + // if ( + // searchQuery && + // item.name.toLowerCase().includes(searchQuery.toLowerCase()) + // ) { + // newAutoExpandedIds.add(item.id) + // getAllParentIds(treeItems, item.id).forEach(id => + // newAutoExpandedIds.add(id) + // ) + // } + // if (item.children) expandSearchNodes(item.children) + // }) + // } + + // expandSearchNodes(treeItems) + // setAutoExpandedIds(newAutoExpandedIds) + // }, [treeItems, searchQuery, getAllParentIds]) + useEffect(() => { if (!initializedRef.current) return const newAutoExpandedIds = new Set() - const expandSearchNodes = (items: TreeItem[]) => { + const expandSearchNodes = (items: TreeItem[]): boolean => { + let shouldExpand = false items.forEach(item => { if ( searchQuery && @@ -105,9 +130,18 @@ export const FileTreeView: React.FC = ({ getAllParentIds(treeItems, item.id).forEach(id => newAutoExpandedIds.add(id) ) + shouldExpand = true + } + + if (item.children) { + const childrenMatch = expandSearchNodes(item.children) + if (childrenMatch) { + newAutoExpandedIds.add(item.id) + shouldExpand = true + } } - if (item.children) expandSearchNodes(item.children) }) + return shouldExpand } expandSearchNodes(treeItems) @@ -163,10 +197,12 @@ export const FileTreeView: React.FC = ({ visibleItem => visibleItem.id === item.id ) + const ArrowIcon = isExpanded ? ChevronDownIcon : ChevronRightIcon + return (
= ({ className="mx-1 custom-checkbox" /> + {hasChildren && } + = ({ return (
- +
+ +
) @@ -242,6 +282,16 @@ function convertFilesToTreeItems(files: FileInfo[]): TreeItem[] { }) }) + const sortItems = (items: TreeItem[]): TreeItem[] => + items.sort((a, b) => { + // Folders come before files + if (a.children && !b.children) return -1 + if (!a.children && b.children) return 1 + + // Alphabetical sorting within each group + return a.name.localeCompare(b.name) + }) + const buildTreeItems = (node: any, path: string[] = []): TreeItem => { const name = path[path.length - 1] || 'root' const fullPath = path.join('/') @@ -263,13 +313,13 @@ function convertFilesToTreeItems(files: FileInfo[]): TreeItem[] { return { id: fullPath || 'root', name, - children, + children: sortItems(children), fullPath, relativePath: fullPath } } - return Object.entries(root).map(([key, value]) => - buildTreeItems(value, [key]) + return sortItems( + Object.entries(root).map(([key, value]) => buildTreeItems(value, [key])) ) } diff --git a/src/webview/components/chat/selectors/file-selector/index.tsx b/src/webview/components/chat/selectors/file-selector/index.tsx index 7ad35ed0..280f0a06 100644 --- a/src/webview/components/chat/selectors/file-selector/index.tsx +++ b/src/webview/components/chat/selectors/file-selector/index.tsx @@ -135,6 +135,7 @@ export const FileSelector: React.FC = ({ tabRefs.current[index] = el } }} + onKeyDown={e => e.preventDefault()} > {tab.label} diff --git a/src/webview/components/chat/selectors/mention-selector/files/mention-file-item.tsx b/src/webview/components/chat/selectors/mention-selector/files/mention-file-item.tsx new file mode 100644 index 00000000..f91b2efc --- /dev/null +++ b/src/webview/components/chat/selectors/mention-selector/files/mention-file-item.tsx @@ -0,0 +1,16 @@ +import { FileIcon } from '@webview/components/file-icon' +import { TruncateStart } from '@webview/components/truncate-start' +import type { MentionOption } from '@webview/types/chat' + +export const MentionFileItem = (mentionOption: MentionOption) => ( +
+
+ + {mentionOption.label} +
+ {mentionOption.data.relativePath} +
+) diff --git a/src/webview/components/chat/selectors/mention-selector/folders/mention-folder-item.tsx b/src/webview/components/chat/selectors/mention-selector/folders/mention-folder-item.tsx new file mode 100644 index 00000000..da8607d4 --- /dev/null +++ b/src/webview/components/chat/selectors/mention-selector/folders/mention-folder-item.tsx @@ -0,0 +1,20 @@ +import { ChevronRightIcon } from '@radix-ui/react-icons' +import { FileIcon } from '@webview/components/file-icon' +import { TruncateStart } from '@webview/components/truncate-start' +import type { MentionOption } from '@webview/types/chat' + +export const MentionFolderItem: React.FC = mentionOption => ( +
+
+ + + {mentionOption.label} +
+ {mentionOption.data.relativePath} +
+) diff --git a/src/webview/components/chat/selectors/mention-selector.tsx b/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx similarity index 77% rename from src/webview/components/chat/selectors/mention-selector.tsx rename to src/webview/components/chat/selectors/mention-selector/mention-selector.tsx index d93ce1e4..a963f288 100644 --- a/src/webview/components/chat/selectors/mention-selector.tsx +++ b/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx @@ -18,6 +18,7 @@ import { cn } from '@webview/utils/common' import { useEvent } from 'react-use' export interface SelectedMentionStrategy { + name: string strategy: IMentionStrategy strategyAddData: any } @@ -44,6 +45,7 @@ export const MentionSelector: React.FC = ({ const commandRef = useRef(null) const [currentOptions, setCurrentOptions] = useState(mentionOptions) + const maxItemLength = mentionOptions.length > 8 ? mentionOptions.length : 8 const [isOpen = false, setIsOpen] = useControllableState({ prop: open, @@ -58,11 +60,13 @@ export const MentionSelector: React.FC = ({ }, [isOpen, mentionOptions]) const filteredOptions = useMemo(() => { - if (!searchQuery) return currentOptions - return currentOptions.filter(option => - option.label.toLowerCase().includes(searchQuery.toLowerCase()) - ) - }, [currentOptions, searchQuery]) + if (!searchQuery) return currentOptions.slice(0, maxItemLength) + return currentOptions + .filter(option => + option.label.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .slice(0, maxItemLength) + }, [currentOptions, searchQuery, maxItemLength]) const itemRefs = useRef<(HTMLDivElement | null)[]>([]) const { focusedIndex, setFocusedIndex, handleKeyDown } = @@ -83,10 +87,13 @@ export const MentionSelector: React.FC = ({ setCurrentOptions(option.children) onCloseWithoutSelect?.() } else { - onSelect({ - strategy: option.mentionStrategies[0]!, - strategyAddData: option.data || { label: option.label } - }) + if (option.mentionStrategy) { + onSelect({ + name: option.label, + strategy: option.mentionStrategy, + strategyAddData: option.data || { label: option.label } + }) + } setIsOpen(false) } } @@ -95,7 +102,10 @@ export const MentionSelector: React.FC = ({ {children} = ({ > {filteredOptions.map((option, index) => ( handleSelect(option)} className={cn( 'px-1.5 py-1', - focusedIndex === index && - 'bg-primary text-primary-foreground' + focusedIndex === index && 'bg-secondary' )} ref={el => { if (itemRefs.current) { @@ -126,7 +135,11 @@ export const MentionSelector: React.FC = ({ } }} > - {option.label} + {option.customRender ? ( + + ) : ( + option.label + )} ))} diff --git a/src/webview/components/file-icon.tsx b/src/webview/components/file-icon.tsx index edbb26a7..67e38092 100644 --- a/src/webview/components/file-icon.tsx +++ b/src/webview/components/file-icon.tsx @@ -23,6 +23,9 @@ export const FileIcon: FC = props => { if (!MaterialSvgComponent) return null return ( - + ) } diff --git a/src/webview/components/keyboard-shortcuts-info.tsx b/src/webview/components/keyboard-shortcuts-info.tsx index e7aef293..4fb013c9 100644 --- a/src/webview/components/keyboard-shortcuts-info.tsx +++ b/src/webview/components/keyboard-shortcuts-info.tsx @@ -54,7 +54,7 @@ export const KeyboardShortcutsInfo: React.FC = ({
{ + children: ReactNode +} + +export const TruncateStart: React.FC = ({ + children, + className, + style, + ...props +}) => ( + + {children} + +) diff --git a/src/webview/components/ui/tabs.tsx b/src/webview/components/ui/tabs.tsx index da0709a4..93170cf0 100644 --- a/src/webview/components/ui/tabs.tsx +++ b/src/webview/components/ui/tabs.tsx @@ -22,7 +22,7 @@ const tabListVariants = cva( ) const tabsTriggerVariants = cva( - 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow px-3 py-1 rounded-md', + 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow px-3 py-1 rounded-md', { variants: { mode: { diff --git a/src/webview/hooks/api/use-files.ts b/src/webview/hooks/api/use-files.ts new file mode 100644 index 00000000..b0acc2d9 --- /dev/null +++ b/src/webview/hooks/api/use-files.ts @@ -0,0 +1,8 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '@webview/services/api-client' + +export const useFiles = () => + useQuery({ + queryKey: ['files'], + queryFn: () => api.file.traverseWorkspaceFiles({ filesOrFolders: ['./'] }) + }) diff --git a/src/webview/hooks/api/use-folders.ts b/src/webview/hooks/api/use-folders.ts new file mode 100644 index 00000000..2bab4bb6 --- /dev/null +++ b/src/webview/hooks/api/use-folders.ts @@ -0,0 +1,8 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '@webview/services/api-client' + +export const useFolders = () => + useQuery({ + queryKey: ['folders'], + queryFn: () => api.file.traverseWorkspaceFolders({ folders: ['./'] }) + }) diff --git a/src/webview/hooks/chat/use-file-search.ts b/src/webview/hooks/chat/use-file-search.ts index 7dd8aa4a..9d7b1b48 100644 --- a/src/webview/hooks/chat/use-file-search.ts +++ b/src/webview/hooks/chat/use-file-search.ts @@ -1,5 +1,7 @@ import { useMemo, useState } from 'react' -import type { FileInfo } from '@webview/types/chat' +import { getFileNameFromPath } from '@webview/utils/common' + +import { useFiles } from '../api/use-files' // const initialFiles: FileInfo[] = [ // { @@ -20,34 +22,47 @@ import type { FileInfo } from '@webview/types/chat' // // ... 其他文件 // ] -const mockFiles = [ - { - relativePath: 'src/components/Button.tsx', - fullPath: '/project/src/components/Button.tsx' - }, - { - relativePath: 'src/components/Input.tsx', - fullPath: '/project/src/components/Input.tsx' - }, - { - relativePath: 'src/utils/helpers.ts', - fullPath: '/project/src/utils/helpers.ts' - }, - { - relativePath: 'public/images/logo.png', - fullPath: '/project/public/images/logo.png' - } -] as FileInfo[] +// const mockFiles = [ +// { +// relativePath: 'src/components/Button.tsx', +// fullPath: '/project/src/components/Button.tsx' +// }, +// { +// relativePath: 'src/components/Input.tsx', +// fullPath: '/project/src/components/Input.tsx' +// }, +// { +// relativePath: 'src/utils/helpers.ts', +// fullPath: '/project/src/utils/helpers.ts' +// }, +// { +// relativePath: 'public/images/logo.png', +// fullPath: '/project/public/images/logo.png' +// } +// ] as FileInfo[] export const useFileSearch = () => { const [searchQuery, setSearchQuery] = useState('') + const { data: workspaceFiles = [] } = useFiles() const filteredFiles = useMemo(() => { - if (!searchQuery) return mockFiles - return mockFiles.filter(file => - file.relativePath.toLowerCase().includes(searchQuery.toLowerCase()) - ) - }, [searchQuery]) + if (!searchQuery) + return workspaceFiles.sort((a, b) => + getFileNameFromPath(a.relativePath).localeCompare( + getFileNameFromPath(b.relativePath) + ) + ) + + return workspaceFiles + .filter(file => + file.relativePath.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .sort((a, b) => { + const aIndex = a.relativePath.indexOf(searchQuery) + const bIndex = b.relativePath.indexOf(searchQuery) + return aIndex - bIndex + }) + }, [searchQuery, workspaceFiles]) return { searchQuery, setSearchQuery, filteredFiles } } diff --git a/src/webview/hooks/chat/use-mention-options.ts b/src/webview/hooks/chat/use-mention-options.ts new file mode 100644 index 00000000..b957f0ec --- /dev/null +++ b/src/webview/hooks/chat/use-mention-options.ts @@ -0,0 +1,132 @@ +import { useMemo } from 'react' +import { MentionFileItem } from '@webview/components/chat/selectors/mention-selector/files/mention-file-item' +import { MentionFolderItem } from '@webview/components/chat/selectors/mention-selector/folders/mention-folder-item' +import { RelevantCodeSnippetsMentionStrategy } from '@webview/lexical/mentions/codebase/relevant-code-snippets-mention-strategy' +import { SelectedFilesMentionStrategy } from '@webview/lexical/mentions/files/selected-files-mention-strategy' +import { SelectedFoldersMentionStrategy } from '@webview/lexical/mentions/folders/selected-folders-mention-strategy' +import { GitCommitsMentionStrategy } from '@webview/lexical/mentions/git/git-commits-mention-strategy' +import { GitDiffsMentionStrategy } from '@webview/lexical/mentions/git/git-diffs-mention-strategy' +import { GitPullRequestsMentionStrategy } from '@webview/lexical/mentions/git/git-pull-requests-mention-strategy' +import { EnableWebToolMentionStrategy } from '@webview/lexical/mentions/web/enable-web-tool-mention-strategy' +import { MentionCategory, type MentionOption } from '@webview/types/chat' +import { getFileNameFromPath } from '@webview/utils/common' + +import { useFiles } from '../api/use-files' +import { useFolders } from '../api/use-folders' + +export const useMentionOptions = () => { + const { data: files = [] } = useFiles() + const { data: folders = [] } = useFolders() + + const filesMentionOptions = useMemo( + () => + files.map( + file => ({ + id: `file#${file.fullPath}`, + label: getFileNameFromPath(file.fullPath), + category: MentionCategory.Files, + mentionStrategy: new SelectedFilesMentionStrategy(), + searchKeywords: [file.relativePath], + searchWeight: 100, + data: file, + customRender: MentionFileItem + }), + [files] + ), + [files] + ) + + const foldersMentionOptions = useMemo( + () => + folders.map(folder => ({ + id: `folder#${folder.fullPath}`, + label: getFileNameFromPath(folder.fullPath), + category: MentionCategory.Folders, + mentionStrategy: new SelectedFoldersMentionStrategy(), + searchKeywords: [folder.relativePath], + searchWeight: 90, + data: folder, + customRender: MentionFolderItem + })), + [files] + ) + + const mentionOptions = useMemo( + () => [ + { + id: 'files', + label: 'Files', + category: MentionCategory.Files, + searchKeywords: ['files'], + children: filesMentionOptions + }, + { + id: 'folders', + label: 'Folders', + category: MentionCategory.Folders, + searchKeywords: ['folders'], + children: foldersMentionOptions + }, + { + id: 'code', + label: 'Code', + category: MentionCategory.Code, + searchKeywords: ['code'] + // mentionStrategies: [new CodeChunksMentionStrategy()] + }, + { + id: 'web', + label: 'Web', + category: MentionCategory.Web, + searchKeywords: ['web'], + mentionStrategy: new EnableWebToolMentionStrategy() + }, + { + id: 'docs', + label: 'Docs', + category: MentionCategory.Docs, + searchKeywords: ['docs'] + // mentionStrategies: [new AllowSearchDocSiteUrlsToolMentionStrategy()] + }, + { + id: 'git', + label: 'Git', + category: MentionCategory.Git, + searchKeywords: ['git'], + children: [ + { + id: 'git#commit', + label: 'Commit', + category: MentionCategory.Git, + searchKeywords: ['commit'], + mentionStrategy: new GitCommitsMentionStrategy() + }, + { + id: 'git#diff', + label: 'Diff', + category: MentionCategory.Git, + searchKeywords: ['diff'], + mentionStrategy: new GitDiffsMentionStrategy() + }, + { + id: 'git#pull-requests', + label: 'Pull Requests', + category: MentionCategory.Git, + searchKeywords: ['pull requests'], + mentionStrategy: new GitPullRequestsMentionStrategy() + } + ] + }, + { + id: 'codebase', + label: 'Codebase', + category: MentionCategory.Codebase, + searchKeywords: ['codebase'], + mentionStrategy: new RelevantCodeSnippetsMentionStrategy() + } + ], + [filesMentionOptions, foldersMentionOptions] + ) + + return mentionOptions +} diff --git a/src/webview/hooks/use-element-in-view.ts b/src/webview/hooks/use-element-in-view.ts new file mode 100644 index 00000000..a3d56204 --- /dev/null +++ b/src/webview/hooks/use-element-in-view.ts @@ -0,0 +1,28 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +export interface UseElementInViewProps { + ref?: React.RefObject +} + +export function useElementInView( + props?: UseElementInViewProps +): { ref: React.RefObject; inView: boolean } { + const { ref = useRef(null) } = props || {} + const [inView, setInView] = useState(false) + + const observer = useMemo( + () => + new IntersectionObserver(([entry]) => + setInView(entry?.isIntersecting ?? false) + ), + [ref] + ) + + useEffect(() => { + if (!ref.current) return + observer.observe(ref.current) + return () => observer.disconnect() + }, []) + + return { ref, inView } +} diff --git a/src/webview/hooks/use-event-in-view.ts b/src/webview/hooks/use-event-in-view.ts new file mode 100644 index 00000000..f2a795fe --- /dev/null +++ b/src/webview/hooks/use-event-in-view.ts @@ -0,0 +1,38 @@ +import { useRef } from 'react' +import { useEvent } from 'react-use' +import type { + ListenerType1, + ListenerType2, + UseEventOptions, + UseEventTarget +} from 'react-use/lib/useEvent' + +import { useElementInView } from './use-element-in-view' + +declare type AddEventListener = T extends ListenerType1 + ? T['addEventListener'] + : T extends ListenerType2 + ? T['on'] + : never +export const useEventInView = ( + name: Parameters>[0], + handler?: Parameters>[1] | null | undefined, + target?: Window | T | null, + options?: UseEventOptions | undefined +) => { + const containerRef = useRef(null) + const { inView } = useElementInView({ ref: containerRef }) + + useEvent( + name, + event => { + if (inView) { + handler?.(event) + } + }, + target, + options + ) + + return containerRef +} diff --git a/src/webview/hooks/use-keyboard-navigation.ts b/src/webview/hooks/use-keyboard-navigation.ts index 4d1e7dae..2ab2447d 100644 --- a/src/webview/hooks/use-keyboard-navigation.ts +++ b/src/webview/hooks/use-keyboard-navigation.ts @@ -5,6 +5,7 @@ import { useCallbackRef } from './use-callback-ref' interface UseKeyboardNavigationProps { itemCount: number itemRefs: RefObject<(HTMLElement | null)[]> + listRef?: RefObject mode?: 'tab' | 'arrow' onTab?: (el: HTMLElement | undefined, index: number) => void onEnter?: (el: HTMLElement | undefined, index: number) => void @@ -12,15 +13,18 @@ interface UseKeyboardNavigationProps { defaultStartIndex?: number startIndex?: number getVisibleIndex?: (index: number) => number + loop?: boolean } export const useKeyboardNavigation = (props: UseKeyboardNavigationProps) => { const { itemCount, itemRefs, + listRef, mode = 'arrow', defaultStartIndex, - startIndex + startIndex, + loop = true } = props const defaultIndex = defaultStartIndex ?? -1 @@ -31,6 +35,50 @@ export const useKeyboardNavigation = (props: UseKeyboardNavigationProps) => { const onEnter = useCallbackRef(props.onEnter) const getVisibleIndex = useCallbackRef(props.getVisibleIndex) + const getNextIndex = useCallback( + (currentIndex: number, step: number): number => { + let nextIndex = currentIndex + step + if (loop) { + nextIndex = (nextIndex + itemCount) % itemCount + } else { + nextIndex = Math.max(0, Math.min(nextIndex, itemCount - 1)) + } + return getVisibleIndex?.(nextIndex) ?? nextIndex + }, + [itemCount, loop, getVisibleIndex] + ) + + const scrollIntoView = useCallback( + (index: number, direction: 'up' | 'down') => { + if (!listRef?.current || !itemRefs.current?.[index]) return + + const list = listRef.current + const item = itemRefs.current[index] + + if (!item) return + + const listRect = list.getBoundingClientRect() + const itemRect = item.getBoundingClientRect() + + if (direction === 'down') { + if (itemRect.bottom > listRect.bottom) { + list.scrollTop += itemRect.bottom - listRect.bottom + } else if (index === 0 && loop) { + // If looping to the first item from the last, scroll to top + list.scrollTop = 0 + } + } else if (direction === 'up') { + if (itemRect.top < listRect.top) { + list.scrollTop -= listRect.top - itemRect.top + } else if (index === itemCount - 1 && loop) { + // If looping to the last item from the first, scroll to bottom + list.scrollTop = list.scrollHeight - list.clientHeight + } + } + }, + [listRef, itemRefs, itemCount, loop] + ) + const handleKeyDown = useCallback( (event: KeyboardEvent | React.KeyboardEvent) => { const currentEl = itemRefs.current?.[focusedIndex] as @@ -43,33 +91,40 @@ export const useKeyboardNavigation = (props: UseKeyboardNavigationProps) => { case 'Tab': if (mode === 'tab') { event.preventDefault() - const newIndex = (focusedIndex + 1) % itemCount + event.stopPropagation() + const newIndex = getNextIndex(focusedIndex, 1) setFocusedIndex(newIndex) onTab?.(itemRefs.current?.[newIndex] as HTMLElement, newIndex) + scrollIntoView(newIndex, 'down') } break case 'ArrowDown': if (mode === 'arrow') { event.preventDefault() + event.stopPropagation() setFocusedIndex(prev => { - const nextIndex = Math.min(prev + 1, itemCount - 1) - return getVisibleIndex?.(nextIndex) ?? nextIndex + const newIndex = getNextIndex(prev, 1) + scrollIntoView(newIndex, 'down') + return newIndex }) } break case 'ArrowUp': if (mode === 'arrow') { event.preventDefault() + event.stopPropagation() setFocusedIndex(prev => { - const nextIndex = Math.max(prev - 1, 0) - return getVisibleIndex?.(nextIndex) ?? nextIndex + const newIndex = getNextIndex(prev, -1) + scrollIntoView(newIndex, 'up') + return newIndex }) } break case 'Enter': if (focusedIndex !== -1) { event.preventDefault() + event.stopPropagation() if (isWithCtrlKey) { onCtrlEnter?.(currentEl, focusedIndex) @@ -89,7 +144,9 @@ export const useKeyboardNavigation = (props: UseKeyboardNavigationProps) => { onEnter, onCtrlEnter, onTab, - getVisibleIndex + getVisibleIndex, + getNextIndex, + scrollIntoView ] ) diff --git a/src/webview/lexical/mentions/index.ts b/src/webview/lexical/mentions/index.ts deleted file mode 100644 index 0020a6de..00000000 --- a/src/webview/lexical/mentions/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { MentionCategory, type MentionOption } from '@webview/types/chat' - -import { CodeChunksMentionStrategy } from './code/code-chunks-mention-strategy' -import { RelevantCodeSnippetsMentionStrategy } from './codebase/relevant-code-snippets-mention-strategy' -import { AllowSearchDocSiteUrlsToolMentionStrategy } from './docs/allow-search-doc-site-urls-mention-strategy' -import { SelectedFilesMentionStrategy } from './files/selected-files-mention-strategy' -import { SelectedImagesMentionStrategy } from './files/selected-images-mention-strategy' -import { SelectedFoldersMentionStrategy } from './folders/selected-folders-mention-strategy' -import { GitCommitsMentionStrategy } from './git/git-commits-mention-strategy' -import { GitDiffsMentionStrategy } from './git/git-diffs-mention-strategy' -import { GitPullRequestsMentionStrategy } from './git/git-pull-requests-mention-strategy' -import { EnableWebToolMentionStrategy } from './web/enable-web-tool-mention-strategy' - -export const createMentionOptions = (): MentionOption[] => [ - { - label: 'Files', - category: MentionCategory.Files, - mentionStrategies: [ - new SelectedFilesMentionStrategy(), - new SelectedImagesMentionStrategy() - ] - }, - { - label: 'Folders', - category: MentionCategory.Folders, - mentionStrategies: [new SelectedFoldersMentionStrategy()] - }, - { - label: 'Code', - category: MentionCategory.Code, - mentionStrategies: [new CodeChunksMentionStrategy()] - }, - { - label: 'Web', - category: MentionCategory.Web, - mentionStrategies: [new EnableWebToolMentionStrategy()] - }, - { - label: 'Docs', - category: MentionCategory.Docs, - mentionStrategies: [new AllowSearchDocSiteUrlsToolMentionStrategy()] - }, - { - label: 'Git', - category: MentionCategory.Git, - // mentionStrategies: [ - // new GitCommitsMentionStrategy(), - // new GitDiffsMentionStrategy(), - // new GitPullRequestsMentionStrategy() - // ] - mentionStrategies: [], - children: [ - { - label: 'Commit', - category: MentionCategory.Git, - mentionStrategies: [new GitCommitsMentionStrategy()] - }, - { - label: 'Diff', - category: MentionCategory.Git, - mentionStrategies: [new GitDiffsMentionStrategy()] - }, - { - label: 'Pull Requests', - category: MentionCategory.Git, - mentionStrategies: [new GitPullRequestsMentionStrategy()] - } - ] - }, - { - label: 'Codebase', - category: MentionCategory.Codebase, - mentionStrategies: [new RelevantCodeSnippetsMentionStrategy()] - } -] diff --git a/src/webview/lexical/plugins/mention-plugin.tsx b/src/webview/lexical/plugins/mention-plugin.tsx index 4e3f9ea5..5ba5a6bb 100644 --- a/src/webview/lexical/plugins/mention-plugin.tsx +++ b/src/webview/lexical/plugins/mention-plugin.tsx @@ -3,7 +3,8 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { MentionSelector, type SelectedMentionStrategy -} from '@webview/components/chat/selectors/mention-selector' +} from '@webview/components/chat/selectors/mention-selector/mention-selector' +import { useMentionOptions } from '@webview/hooks/chat/use-mention-options' import type { IMentionStrategy } from '@webview/types/chat' import { $createTextNode, @@ -19,7 +20,6 @@ import { } from '../hooks/use-mention-manager' import { useMentionSearch } from '../hooks/use-mention-search' import { useNearestMentionPosition } from '../hooks/use-nearest-mention-position' -import { createMentionOptions } from '../mentions' import { $createMentionNode } from '../nodes/mention-node' export interface MentionPluginProps extends UseMentionManagerProps {} @@ -29,7 +29,8 @@ export const MentionPlugin: FC = props => { const { addMention } = useMentionManager(props) const [isOpen, setIsOpen] = useState(false) const mentionPosition = useNearestMentionPosition(editor) - const mentionOptions = createMentionOptions() + // const mentionOptions = createMentionOptions() + const mentionOptions = useMentionOptions() const { searchQuery, setSearchQuery, clearMentionInput } = useMentionSearch( editor, @@ -39,14 +40,20 @@ export const MentionPlugin: FC = props => { useEditorCommands(editor, isOpen, setIsOpen) const handleMentionSelect = useCallback( - ({ strategy, strategyAddData }: SelectedMentionStrategy) => { + ({ name, strategy, strategyAddData }: SelectedMentionStrategy) => { setIsOpen(false) setSearchQuery('') editor.update(() => { const selection = $getSelection() if ($isRangeSelection(selection)) { - insertMention(selection, strategy, strategyAddData, searchQuery) + insertMention({ + name, + selection, + strategy, + strategyAddData, + searchQuery + }) } }) addMention({ strategy, strategyAddData }) @@ -84,12 +91,19 @@ export const MentionPlugin: FC = props => { ) } -const insertMention = ( - selection: RangeSelection, - strategy: IMentionStrategy, - strategyAddData: any, +const insertMention = ({ + name, + selection, + strategy, + strategyAddData, + searchQuery +}: { + name: string + selection: RangeSelection + strategy: IMentionStrategy + strategyAddData: any searchQuery: string -) => { +}) => { // Delete the @ symbol and the search query const anchorOffset = selection.anchor.offset selection.anchor.offset = anchorOffset - (searchQuery.length + 1) @@ -97,7 +111,7 @@ const insertMention = ( selection.removeText() // Create and insert the mention node - const mentionText = `@${strategyAddData.label || strategy.name}` + const mentionText = `@${name}` const mentionNode = $createMentionNode( strategy.category, strategyAddData, diff --git a/src/webview/types/chat.ts b/src/webview/types/chat.ts index dd3269a8..bd87f533 100644 --- a/src/webview/types/chat.ts +++ b/src/webview/types/chat.ts @@ -1,3 +1,4 @@ +import type { FC } from 'react' import type { Attachments, Conversation @@ -11,11 +12,15 @@ export interface ModelOption { } export interface MentionOption { + id: string label: string category: MentionCategory - mentionStrategies: IMentionStrategy[] + mentionStrategy?: IMentionStrategy + searchKeywords?: string[] + searchWeight?: number children?: MentionOption[] data?: any + customRender?: FC } export enum MentionCategory { diff --git a/src/webview/utils/extract-folders.ts b/src/webview/utils/extract-folders.ts new file mode 100644 index 00000000..65853e7d --- /dev/null +++ b/src/webview/utils/extract-folders.ts @@ -0,0 +1,33 @@ +import type { FileInfo, FolderInfo } from '@extension/file-utils/traverse-fs' + +export const extractFolders = (files: FileInfo[]): FolderInfo[] => { + const folderSet = new Set() + + files.forEach(file => { + const parts = file.relativePath.split('/') + let currentPath = '' + + for (let i = 0; i < parts.length - 1; i++) { + currentPath += (i > 0 ? '/' : '') + parts[i] + folderSet.add(currentPath) + } + }) + + return Array.from(folderSet) + .sort() + .map(relativePath => { + const file = files.find(f => + f.relativePath.startsWith(`${relativePath}/`) + ) + return { + type: 'folder', + relativePath, + fullPath: file + ? file.fullPath.slice( + 0, + file.fullPath.lastIndexOf(file.relativePath) + relativePath.length + ) + : '' + } + }) +} diff --git a/src/webview/utils/file-icons/material-icons/file.svg b/src/webview/utils/file-icons/material-icons/file.svg index 3ae2144f..08b2edb6 100644 --- a/src/webview/utils/file-icons/material-icons/file.svg +++ b/src/webview/utils/file-icons/material-icons/file.svg @@ -1,4 +1,4 @@ + fill="currentColor" /> diff --git a/src/webview/utils/file-icons/material-icons/folder-open.svg b/src/webview/utils/file-icons/material-icons/folder-open.svg index c3ad4487..83565e64 100644 --- a/src/webview/utils/file-icons/material-icons/folder-open.svg +++ b/src/webview/utils/file-icons/material-icons/folder-open.svg @@ -2,5 +2,5 @@ xmlns="http://www.w3.org/2000/svg"> + fill="currentColor" /> diff --git a/src/webview/utils/file-icons/material-icons/folder.svg b/src/webview/utils/file-icons/material-icons/folder.svg index 3c8d23a5..fd3e3e8c 100644 --- a/src/webview/utils/file-icons/material-icons/folder.svg +++ b/src/webview/utils/file-icons/material-icons/folder.svg @@ -1,5 +1,5 @@ + fill="currentColor" fill-rule="nonzero" />