From 56b0b8c6cedcf8ac5f6a44ebee297c1e3f65bc42 Mon Sep 17 00:00:00 2001 From: JinmingYang <2214962083@qq.com> Date: Tue, 10 Sep 2024 00:29:03 +0800 Subject: [PATCH] feat: add preview support for file selector --- .../controllers/system.controller.ts | 27 +++++ src/extension/webview-api/index.ts | 7 +- .../chat/editor/file-attachments.tsx | 2 +- .../chat/editor/file-info-popover.tsx | 2 +- .../file-selector/file-list-view.tsx | 3 +- .../file-selector/file-tree-view.tsx | 96 +--------------- .../chat/selectors/file-selector/index.tsx | 4 +- .../files/mention-file-item.tsx | 2 +- .../files/mention-file-preview.tsx | 60 ++++++++++ .../mention-selector/mention-selector.tsx | 92 ++++++++++------ ...use-file-search.ts => use-files-search.ts} | 4 +- .../hooks/chat/use-files-tree-items.ts | 104 ++++++++++++++++++ src/webview/hooks/chat/use-mention-options.ts | 36 ++++-- src/webview/hooks/use-keyboard-navigation.ts | 27 ++++- src/webview/main.tsx | 17 ++- src/webview/types/chat.ts | 4 +- src/webview/types/vscode.d.ts | 1 + src/webview/utils/common.ts | 2 - src/webview/utils/extract-folders.ts | 33 ------ src/webview/utils/path.ts | 72 ++++++++++++ 20 files changed, 408 insertions(+), 187 deletions(-) create mode 100644 src/extension/webview-api/controllers/system.controller.ts create mode 100644 src/webview/components/chat/selectors/mention-selector/files/mention-file-preview.tsx rename src/webview/hooks/chat/{use-file-search.ts => use-files-search.ts} (95%) create mode 100644 src/webview/hooks/chat/use-files-tree-items.ts delete mode 100644 src/webview/utils/extract-folders.ts create mode 100644 src/webview/utils/path.ts diff --git a/src/extension/webview-api/controllers/system.controller.ts b/src/extension/webview-api/controllers/system.controller.ts new file mode 100644 index 0000000..574c893 --- /dev/null +++ b/src/extension/webview-api/controllers/system.controller.ts @@ -0,0 +1,27 @@ +import * as os from 'os' + +import { Controller } from '../types' + +export interface SystemInfo { + os: string + cpu: string + memory: string + platform: string +} + +export class SystemController extends Controller { + readonly name = 'system' + + async getSystemInfo(): Promise { + return { + os: `${os.type()} ${os.release()}`, + cpu: os.cpus()[0]?.model || 'Unknown', + memory: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB`, + platform: os.platform() + } + } + + async isWindows(): Promise { + return os.platform() === 'win32' + } +} diff --git a/src/extension/webview-api/index.ts b/src/extension/webview-api/index.ts index c00ca08..0d7d25a 100644 --- a/src/extension/webview-api/index.ts +++ b/src/extension/webview-api/index.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode' import { ChatController } from './controllers/chat.controller' import { FileController } from './controllers/file.controller' +import { SystemController } from './controllers/system.controller' import type { Controller, ControllerClass, @@ -82,7 +83,11 @@ class APIManager { } } -export const controllers = [ChatController, FileController] as const +export const controllers = [ + ChatController, + FileController, + SystemController +] as const export type Controllers = typeof controllers export const setupWebviewAPIManager = ( diff --git a/src/webview/components/chat/editor/file-attachments.tsx b/src/webview/components/chat/editor/file-attachments.tsx index 2b43fa3..fbb7f15 100644 --- a/src/webview/components/chat/editor/file-attachments.tsx +++ b/src/webview/components/chat/editor/file-attachments.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Cross1Icon, PlusIcon } from '@radix-ui/react-icons' import { FileIcon } from '@webview/components/file-icon' import type { FileInfo } from '@webview/types/chat' -import { getFileNameFromPath } from '@webview/utils/common' +import { getFileNameFromPath } from '@webview/utils/path' import { Button } from '../../ui/button' import { FileSelector } from '../selectors/file-selector' diff --git a/src/webview/components/chat/editor/file-info-popover.tsx b/src/webview/components/chat/editor/file-info-popover.tsx index 5dca4ea..4680d99 100644 --- a/src/webview/components/chat/editor/file-info-popover.tsx +++ b/src/webview/components/chat/editor/file-info-popover.tsx @@ -6,7 +6,7 @@ import { } from '@webview/components/ui/popover' import { useControllableState } from '@webview/hooks/use-controllable-state' import { FileInfo } from '@webview/types/chat' -import { getFileNameFromPath } from '@webview/utils/common' +import { getFileNameFromPath } from '@webview/utils/path' interface FFileInfoPopoverProps { file: FileInfo 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 5cdcad0..44c5e1b 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 @@ -14,7 +14,8 @@ import { } from '@webview/components/ui/command' import { useKeyboardNavigation } from '@webview/hooks/use-keyboard-navigation' import { FileInfo } from '@webview/types/chat' -import { cn, getFileNameFromPath } from '@webview/utils/common' +import { cn } from '@webview/utils/common' +import { getFileNameFromPath } from '@webview/utils/path' import { useEvent } from 'react-use' const keyboardShortcuts: ShortcutInfo[] = [ 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 7ae1ca9..0eb5f8d 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 @@ -6,6 +6,7 @@ import { type ShortcutInfo } from '@webview/components/keyboard-shortcuts-info' import { Tree, TreeItem, TreeNodeRenderProps } from '@webview/components/tree' +import { useFilesTreeItems } from '@webview/hooks/chat/use-files-tree-items' import { useKeyboardNavigation } from '@webview/hooks/use-keyboard-navigation' import { FileInfo } from '@webview/types/chat' import { cn } from '@webview/utils/common' @@ -34,8 +35,7 @@ export const FileTreeView: React.FC = ({ const [autoExpandedIds, setAutoExpandedIds] = useState>(new Set()) const initializedRef = useRef(false) const visibleItemRefs = useRef<(HTMLInputElement | null)[]>([]) - const treeItems = useMemo(() => convertFilesToTreeItems(files), [files]) - const flattenedItems = useMemo(() => flattenTreeItems(treeItems), [treeItems]) + const { treeItems, flattenedItems } = useFilesTreeItems({ files }) const selectedIds = useMemo( () => selectedFiles.map(file => file.fullPath), @@ -92,29 +92,6 @@ 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 @@ -254,72 +231,3 @@ export const FileTreeView: React.FC = ({ ) } - -// Helper functions (flattenTreeItems and convertFilesToTreeItems) remain unchanged -function flattenTreeItems(items: TreeItem[]): TreeItem[] { - return items.reduce((acc: TreeItem[], item) => { - acc.push(item) - if (item.children) { - acc.push(...flattenTreeItems(item.children)) - } - return acc - }, []) -} - -function convertFilesToTreeItems(files: FileInfo[]): TreeItem[] { - const root: Record = {} - - files.forEach(file => { - const parts = file.relativePath.split('/') - let current = root - - parts.forEach((part, index) => { - if (!current[part]) { - current[part] = - index === parts.length - 1 ? { ...file, isLeaf: true } : {} - } - if (index < parts.length - 1) current = current[part] - }) - }) - - 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('/') - - if (node.isLeaf) { - return { - id: node.fullPath, - name, - isLeaf: true, - fullPath: node.fullPath, - relativePath: node.relativePath - } - } - - const children = Object.entries(node).map(([key, value]) => - buildTreeItems(value, [...path, key]) - ) - - return { - id: fullPath || 'root', - name, - children: sortItems(children), - fullPath, - relativePath: fullPath - } - } - - 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 280f0a0..ee8f6c3 100644 --- a/src/webview/components/chat/selectors/file-selector/index.tsx +++ b/src/webview/components/chat/selectors/file-selector/index.tsx @@ -15,7 +15,7 @@ import { TabsList, TabsTrigger } from '@webview/components/ui/tabs' -import { useFileSearch } from '@webview/hooks/chat/use-file-search' +import { useFilesSearch } from '@webview/hooks/chat/use-files-search' import { useControllableState } from '@webview/hooks/use-controllable-state' import { useKeyboardNavigation } from '@webview/hooks/use-keyboard-navigation' import type { FileInfo } from '@webview/types/chat' @@ -57,7 +57,7 @@ export const FileSelector: React.FC = ({ onChange: onOpenChange }) - const { searchQuery, setSearchQuery, filteredFiles } = useFileSearch() + const { searchQuery, setSearchQuery, filteredFiles } = useFilesSearch() const [activeTab, setActiveTab] = useState('list') const [topSearchQuery, setTopSearchQuery] = useState('') const inputRef = useRef(null) 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 index f91b2ef..2e53833 100644 --- 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 @@ -2,7 +2,7 @@ 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) => ( +export const MentionFileItem: React.FC = mentionOption => (
= memo( + mentionOption => { + const fileInfo = mentionOption.data as FileInfo + const { treeItems, getAllChildrenIds } = useFilesTreeItems({ + files: [fileInfo] + }) + + const allExpandedIds = useMemo( + () => getAllChildrenIds(treeItems[0]!), + [treeItems, getAllChildrenIds] + ) + + const renderItem = useCallback( + ({ + item, + isExpanded, + hasChildren, + onToggleExpand, + level + }: TreeNodeRenderProps) => ( +
+ {hasChildren && } + + + + {item.name} +
+ ), + [] + ) + + return ( +
+
+ +
+
+ ) + } +) diff --git a/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx b/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx index b070b64..80b9a30 100644 --- a/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx +++ b/src/webview/components/chat/selectors/mention-selector/mention-selector.tsx @@ -64,7 +64,7 @@ export const MentionSelector: React.FC = ({ }) const itemRefs = useRef<(HTMLDivElement | null)[]>([]) - const { focusedIndex, setFocusedIndex, handleKeyDown } = + const { focusedIndex, setFocusedIndex, handleKeyDown, listEventHandlers } = useKeyboardNavigation({ itemCount: filteredOptions.length, itemRefs, @@ -73,6 +73,8 @@ export const MentionSelector: React.FC = ({ useEvent('keydown', handleKeyDown) + const focusedOption = filteredOptions[focusedIndex] + useEffect(() => { setFocusedIndex(0) }, [filteredOptions]) @@ -127,38 +129,64 @@ export const MentionSelector: React.FC = ({ onCloseAutoFocus={e => e.preventDefault()} onKeyDown={e => e.stopPropagation()} > - - - No results found. - + + +
+ + e.preventDefault()} + onCloseAutoFocus={e => e.preventDefault()} + onKeyDown={e => e.stopPropagation()} > - {filteredOptions.map((option, index) => ( - handleSelect(option)} - className={cn( - 'px-1.5 py-1', - focusedIndex === index && 'bg-secondary' - )} - ref={el => { - if (itemRefs.current) { - itemRefs.current[index] = el - } - }} - > - {option.customRender ? ( - - ) : ( - option.label - )} - - ))} - - - + {focusedOption?.customRenderPreview && ( + + )} + + + + + + No results found. + + {filteredOptions.map((option, index) => ( + handleSelect(option)} + className={cn( + 'px-1.5 py-1', + focusedIndex === index && 'bg-secondary' + )} + ref={el => { + if (itemRefs.current) { + itemRefs.current[index] = el + } + }} + > + {option.customRenderItem ? ( + + ) : ( +
+ {option.itemIcon && ( + + )} + {option.label} +
+ )} +
+ ))} +
+
+
+
) diff --git a/src/webview/hooks/chat/use-file-search.ts b/src/webview/hooks/chat/use-files-search.ts similarity index 95% rename from src/webview/hooks/chat/use-file-search.ts rename to src/webview/hooks/chat/use-files-search.ts index 9d7b1b4..04dabd6 100644 --- a/src/webview/hooks/chat/use-file-search.ts +++ b/src/webview/hooks/chat/use-files-search.ts @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { getFileNameFromPath } from '@webview/utils/common' +import { getFileNameFromPath } from '@webview/utils/path' import { useFiles } from '../api/use-files' @@ -41,7 +41,7 @@ import { useFiles } from '../api/use-files' // } // ] as FileInfo[] -export const useFileSearch = () => { +export const useFilesSearch = () => { const [searchQuery, setSearchQuery] = useState('') const { data: workspaceFiles = [] } = useFiles() diff --git a/src/webview/hooks/chat/use-files-tree-items.ts b/src/webview/hooks/chat/use-files-tree-items.ts new file mode 100644 index 0000000..ca4a202 --- /dev/null +++ b/src/webview/hooks/chat/use-files-tree-items.ts @@ -0,0 +1,104 @@ +import { useCallback, useMemo } from 'react' +import type { TreeItem } from '@webview/components/tree' +import type { FileInfo } from '@webview/types/chat' +import { pathDirname, pathJoin, toUnixPath } from '@webview/utils/path' + +export interface UseFilesTreeItemsOptions { + files: FileInfo[] +} + +export const useFilesTreeItems = (options: UseFilesTreeItemsOptions) => { + const { files } = options + const treeItems = useMemo(() => convertFilesToTreeItems(files), [files]) + const flattenedItems = useMemo(() => flattenTreeItems(treeItems), [treeItems]) + + const getAllChildrenIds = useCallback((item: TreeItem): string[] => { + const ids: string[] = [item.id] + if (item.children) { + item.children.forEach(child => { + ids.push(...getAllChildrenIds(child)) + }) + } + return ids + }, []) + + return { treeItems, flattenedItems, getAllChildrenIds } +} + +// Helper functions (flattenTreeItems and convertFilesToTreeItems) remain unchanged +const flattenTreeItems = (items: TreeItem[]): TreeItem[] => + items.reduce((acc: TreeItem[], item) => { + acc.push(item) + if (item.children) { + acc.push(...flattenTreeItems(item.children)) + } + return acc + }, []) + +const convertFilesToTreeItems = (files: FileInfo[]): TreeItem[] => { + const root: Record = {} + + files.forEach(file => { + const parts = toUnixPath(file.relativePath).split('/') + let current = root + + parts.forEach((part, index) => { + if (!current[part]) { + current[part] = + index === parts.length - 1 ? { ...file, isLeaf: true } : {} + } + if (index < parts.length - 1) current = current[part] + }) + }) + + 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 = toPlatformPath(path.join('/')) + + if (node.isLeaf) { + return { + id: node.fullPath, + name, + isLeaf: true, + fullPath: node.fullPath, + relativePath: node.relativePath + } + } + + const children = Object.entries(node).map(([key, value]) => + buildTreeItems(value, [...path, key]) + ) + + // For non-leaf nodes (directories) + const relativePath = pathJoin(...path) + let fullPath = '' + + // Try to infer the fullPath from children + if (children.length > 0 && children[0]?.fullPath) { + const childFullPath = children[0].fullPath + fullPath = pathJoin(pathDirname(childFullPath), relativePath) + } + + return { + id: fullPath || 'root', + name, + children: sortItems(children), + fullPath, + relativePath + } + } + + return sortItems( + Object.entries(root).map(([key, value]) => buildTreeItems(value, [key])) + ) +} diff --git a/src/webview/hooks/chat/use-mention-options.ts b/src/webview/hooks/chat/use-mention-options.ts index a959c17..b53167f 100644 --- a/src/webview/hooks/chat/use-mention-options.ts +++ b/src/webview/hooks/chat/use-mention-options.ts @@ -1,5 +1,15 @@ import { useMemo } from 'react' +import { + CardStackIcon, + CodeIcon, + CubeIcon, + FileIcon, + GlobeIcon, + IdCardIcon, + TransformIcon +} from '@radix-ui/react-icons' import { MentionFileItem } from '@webview/components/chat/selectors/mention-selector/files/mention-file-item' +import { MentionFilePreview } from '@webview/components/chat/selectors/mention-selector/files/mention-file-preview' 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' @@ -13,7 +23,7 @@ import { SearchSortStrategy, type MentionOption } from '@webview/types/chat' -import { getFileNameFromPath } from '@webview/utils/common' +import { getFileNameFromPath } from '@webview/utils/path' import { useFiles } from '../api/use-files' import { useFolders } from '../api/use-folders' @@ -34,7 +44,8 @@ export const useMentionOptions = () => { searchKeywords: [file.relativePath], searchSortStrategy: SearchSortStrategy.EndMatch, data: file, - customRender: MentionFileItem + customRenderItem: MentionFileItem, + customRenderPreview: MentionFilePreview }) satisfies MentionOption, [files] ), @@ -53,7 +64,7 @@ export const useMentionOptions = () => { searchKeywords: [folder.relativePath], searchSortStrategy: SearchSortStrategy.EndMatch, data: folder, - customRender: MentionFolderItem + customRenderItem: MentionFolderItem }) satisfies MentionOption ), [files] @@ -66,20 +77,23 @@ export const useMentionOptions = () => { label: 'Files', category: MentionCategory.Files, searchKeywords: ['files'], - children: filesMentionOptions + children: filesMentionOptions, + itemIcon: FileIcon }, { id: 'folders', label: 'Folders', category: MentionCategory.Folders, searchKeywords: ['folders'], - children: foldersMentionOptions + children: foldersMentionOptions, + itemIcon: CardStackIcon }, { id: 'code', label: 'Code', category: MentionCategory.Code, - searchKeywords: ['code'] + searchKeywords: ['code'], + itemIcon: CodeIcon // mentionStrategies: [new CodeChunksMentionStrategy()] }, { @@ -87,13 +101,15 @@ export const useMentionOptions = () => { label: 'Web', category: MentionCategory.Web, searchKeywords: ['web'], - mentionStrategy: new EnableWebToolMentionStrategy() + mentionStrategy: new EnableWebToolMentionStrategy(), + itemIcon: GlobeIcon }, { id: 'docs', label: 'Docs', category: MentionCategory.Docs, - searchKeywords: ['docs'] + searchKeywords: ['docs'], + itemIcon: IdCardIcon // mentionStrategies: [new AllowSearchDocSiteUrlsToolMentionStrategy()] }, { @@ -101,6 +117,7 @@ export const useMentionOptions = () => { label: 'Git', category: MentionCategory.Git, searchKeywords: ['git'], + itemIcon: TransformIcon, children: [ { id: 'git#commit', @@ -130,7 +147,8 @@ export const useMentionOptions = () => { label: 'Codebase', category: MentionCategory.Codebase, searchKeywords: ['codebase'], - mentionStrategy: new RelevantCodeSnippetsMentionStrategy() + mentionStrategy: new RelevantCodeSnippetsMentionStrategy(), + itemIcon: CubeIcon } ], [filesMentionOptions, foldersMentionOptions] diff --git a/src/webview/hooks/use-keyboard-navigation.ts b/src/webview/hooks/use-keyboard-navigation.ts index 2ab2447..76185b5 100644 --- a/src/webview/hooks/use-keyboard-navigation.ts +++ b/src/webview/hooks/use-keyboard-navigation.ts @@ -76,7 +76,7 @@ export const useKeyboardNavigation = (props: UseKeyboardNavigationProps) => { } } }, - [listRef, itemRefs, itemCount, loop] + [itemCount, loop] ) const handleKeyDown = useCallback( @@ -150,5 +150,28 @@ export const useKeyboardNavigation = (props: UseKeyboardNavigationProps) => { ] ) - return { focusedIndex, setFocusedIndex, handleKeyDown } + // for list item hover + const handleMouseOver = (event: React.MouseEvent) => { + event.stopPropagation() + event.preventDefault() + + const target = event.relatedTarget as HTMLElement + const index = + itemRefs.current?.findIndex( + el => el === target || el?.contains(target) + ) ?? -1 + + if (index === -1 || index === focusedIndex) return + + setFocusedIndex(index) + } + + return { + focusedIndex, + setFocusedIndex, + handleKeyDown, + listEventHandlers: { + onMouseOver: handleMouseOver + } + } } diff --git a/src/webview/main.tsx b/src/webview/main.tsx index aa88c5c..5f337c4 100644 --- a/src/webview/main.tsx +++ b/src/webview/main.tsx @@ -2,9 +2,16 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' +import { api } from './services/api-client' -ReactDOM.createRoot(document.getElementById('app')!).render( - - - -) +async function main() { + window.isWin = await api.system.isWindows({}) + + ReactDOM.createRoot(document.getElementById('app')!).render( + + + + ) +} + +main() diff --git a/src/webview/types/chat.ts b/src/webview/types/chat.ts index 5f47852..f743786 100644 --- a/src/webview/types/chat.ts +++ b/src/webview/types/chat.ts @@ -25,7 +25,9 @@ export interface MentionOption { searchSortStrategy?: SearchSortStrategy children?: MentionOption[] data?: any - customRender?: FC + customRenderItem?: FC + itemIcon?: FC<{ className?: string }> // only works when customRenderItem is not provided + customRenderPreview?: FC } export enum MentionCategory { diff --git a/src/webview/types/vscode.d.ts b/src/webview/types/vscode.d.ts index a02f73d..cc509e6 100644 --- a/src/webview/types/vscode.d.ts +++ b/src/webview/types/vscode.d.ts @@ -2,6 +2,7 @@ import type { WebviewToExtensionsMsg } from '@shared/types' declare global { interface Window { + isWin: boolean acquireVsCodeApi(): { postMessage(msg: WebviewToExtensionsMsg): void setState(state: any): void diff --git a/src/webview/utils/common.ts b/src/webview/utils/common.ts index 4f293d3..0012826 100644 --- a/src/webview/utils/common.ts +++ b/src/webview/utils/common.ts @@ -35,5 +35,3 @@ export const removeDuplicates = ( return seen.has(key) ? false : seen.add(key) }) } - -export const getFileNameFromPath = (path: string) => path.split('/').pop() || '' diff --git a/src/webview/utils/extract-folders.ts b/src/webview/utils/extract-folders.ts deleted file mode 100644 index 65853e7..0000000 --- a/src/webview/utils/extract-folders.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/path.ts b/src/webview/utils/path.ts new file mode 100644 index 0000000..76d8822 --- /dev/null +++ b/src/webview/utils/path.ts @@ -0,0 +1,72 @@ +export const toUnixPath = (path: string) => path.replace(/[\\/]+/g, '/') + +export const toPlatformPath = (path: string): string => { + const unixPath = toUnixPath(path) + if (window.isWin) return unixPath.replace(/\//g, '\\') + return unixPath +} + +export const getFileNameFromPath = (path: string) => { + const normalizedPath = toUnixPath(path).replace(/\/$/, '') + return normalizedPath.split('/').pop() || '' +} + +const getPathSep = () => (window.isWin ? '\\' : '/') +const pathSplitRegexp = /[/\\]/ + +export const pathDirname = (path: string): string => { + const pathSep = getPathSep() + const normalizedPath = toPlatformPath(path).replace( + new RegExp(`${pathSep}$`), + '' + ) + const parts = normalizedPath.split(pathSep) + + if (parts.length === 1) { + return window.isWin && /^[A-Z]:$/.test(normalizedPath) + ? normalizedPath + : '.' + } + + if (window.isWin && parts.length === 2 && /^[A-Z]:$/.test(parts[0] || '')) { + return normalizedPath + } + + return parts.slice(0, -1).join(pathSep) || pathSep +} + +export const pathJoin = (...parts: string[]): string => + parts.filter(Boolean).join(getPathSep()) + +export const pathIsAbsolute = (path: string): boolean => { + if (window.isWin) { + return /^([A-Z]:[\\/]|\\\\)/.test(path) + } + return path.startsWith('/') +} + +export const pathRelative = (from: string, to: string): string => { + const fromParts = from.split(pathSplitRegexp) + const toParts = to.split(pathSplitRegexp) + + if ( + window.isWin && + fromParts[0] !== toParts[0] && + /^[A-Z]:$/.test(fromParts[0] || '') && + /^[A-Z]:$/.test(toParts[0] || '') + ) { + // if different drive, return target path directly + return to + } + + while ( + fromParts.length > 0 && + toParts.length > 0 && + fromParts[0] === toParts[0] + ) { + fromParts.shift() + toParts.shift() + } + + return pathJoin(...Array(fromParts.length).fill('..'), ...toParts) +}