diff --git a/src/crates/core/src/infrastructure/filesystem/file_operations.rs b/src/crates/core/src/infrastructure/filesystem/file_operations.rs index 906d8688..ef21271d 100644 --- a/src/crates/core/src/infrastructure/filesystem/file_operations.rs +++ b/src/crates/core/src/infrastructure/filesystem/file_operations.rs @@ -303,16 +303,21 @@ impl FileOperationService { } pub async fn copy_file(&self, from: &str, to: &str) -> BitFunResult { - let from_path = Path::new(from); - let to_path = Path::new(to); + let from_trim = from.trim(); + let to_trim = to.trim(); + let from_path = Path::new(from_trim); + let to_path = Path::new(to_trim); self.validate_file_access(from_path, false).await?; self.validate_file_access(to_path, true).await?; - if !from_path.exists() { + // Use symlink_metadata (do not follow symlinks). `Path::exists()` follows links and + // returns false for broken symlinks and some reparse-point / cloud placeholder edge cases + // even though the name is listed in the directory. + if fs::symlink_metadata(from_path).await.is_err() { return Err(BitFunError::service(format!( "Source file does not exist: {}", - from + from_trim ))); } @@ -336,16 +341,18 @@ impl FileOperationService { } pub async fn move_file(&self, from: &str, to: &str) -> BitFunResult<()> { - let from_path = Path::new(from); - let to_path = Path::new(to); + let from_trim = from.trim(); + let to_trim = to.trim(); + let from_path = Path::new(from_trim); + let to_path = Path::new(to_trim); self.validate_file_access(from_path, true).await?; self.validate_file_access(to_path, true).await?; - if !from_path.exists() { + if fs::symlink_metadata(from_path).await.is_err() { return Err(BitFunError::service(format!( "Source file does not exist: {}", - from + from_trim ))); } diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index 9c42458e..86b85179 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -96,19 +96,15 @@ $_tab-v-margin: 6px; // symmetric top/bottom gap inside SceneBar } } - &:only-child, - &:only-child#{&}--active { - cursor: default; - } - - // Active state + // Active state — same fill as .bitfun-nav-panel__item.is-active (no left rail on horizontal tabs) &--active { color: var(--color-text-primary); font-weight: 500; - background: var(--element-bg-base); + background: var(--element-bg-soft); .bitfun-scene-tab__icon { - color: var(--color-primary); + color: var(--color-text-primary); + opacity: 1; } &:hover .bitfun-scene-tab__close { @@ -116,6 +112,27 @@ $_tab-v-margin: 6px; // symmetric top/bottom gap inside SceneBar } } + // Single tab: no pill background (same whether active or not). + &:only-child { + cursor: default; + background: transparent; + box-shadow: none; + + &:hover { + background: transparent; + color: var(--color-text-secondary); + } + + &.bitfun-scene-tab--active { + background: transparent; + box-shadow: none; + + &:hover { + background: transparent; + } + } + } + &:focus-visible { outline: 2px solid var(--color-accent-500); outline-offset: -2px; @@ -226,47 +243,61 @@ $_tab-v-margin: 6px; // symmetric top/bottom gap inside SceneBar opacity: 0.94; } -// Light themes: inactive tabs use tiered bg.* tokens; active is a gentle step brighter -// (not full scene white + cast shadow) so it does not read as a separate floating card. +// Light themes: inactive = former active (soft tint on chrome); active = former inactive (lifted pill + edge). :root[data-theme-type='light'] .bitfun-scene-tab { - background: var(--color-bg-tertiary); - color: var(--color-text-secondary); + background: var(--element-bg-soft); + color: var(--color-text-primary); + box-shadow: none; &:hover { - background: var(--color-bg-quaternary); - color: var(--color-text-secondary); + background: var(--element-bg-medium); + color: var(--color-text-primary); + box-shadow: none; + } + + .bitfun-scene-tab__icon { + opacity: 1; } &--active { - color: var(--color-text-primary); - // Softer than full scene white + drop shadow: stays in the same family as idle tabs - background: color-mix(in srgb, var(--color-bg-scene) 72%, var(--color-bg-tertiary)); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--border-subtle) 42%, transparent); + background: var(--color-bg-secondary); + color: color-mix(in srgb, var(--color-text-primary) 86%, var(--color-text-muted)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--border-subtle) 38%, transparent); &:hover { - background: color-mix(in srgb, var(--color-bg-scene) 76%, var(--color-bg-tertiary)); + background: color-mix(in srgb, var(--color-bg-secondary) 82%, var(--color-bg-quaternary)); + color: color-mix(in srgb, var(--color-text-primary) 90%, var(--color-text-muted)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--border-subtle) 48%, transparent); } - } - // Single tab: still a visible chip when inactive; when active, align with scene card. - &:only-child:not(.bitfun-scene-tab--active) { - background: var(--color-bg-tertiary); - box-shadow: none; - color: var(--color-text-secondary); + .bitfun-scene-tab__icon { + color: inherit; + } + + .bitfun-scene-tab__subtitle { + color: var(--color-text-muted); + } } - &:only-child.bitfun-scene-tab--active { - background: color-mix(in srgb, var(--color-bg-scene) 72%, var(--color-bg-tertiary)); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--border-subtle) 42%, transparent); - color: var(--color-text-primary); + &:only-child { + background: transparent; + box-shadow: none; + color: color-mix(in srgb, var(--color-text-primary) 86%, var(--color-text-muted)); &:hover { - background: color-mix(in srgb, var(--color-bg-scene) 76%, var(--color-bg-tertiary)); + background: transparent; + color: color-mix(in srgb, var(--color-text-primary) 90%, var(--color-text-muted)); } - } - &:only-child:not(.bitfun-scene-tab--active):hover { - background: var(--color-bg-quaternary); + &.bitfun-scene-tab--active { + background: transparent; + box-shadow: none; + color: var(--color-text-primary); + + &:hover { + background: transparent; + } + } } } diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index 3c3f70c5..5901c2df 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -13,7 +13,6 @@ import { type FileExplorerToolbarHandlers, } from '@/tools/file-system'; import { Search, IconButton, Tooltip } from '@/component-library'; -import { useFileTreeGitSync } from '@/tools/file-system/hooks/useFileTreeGitSync'; import { FileSearchResults } from '@/tools/file-system/components/FileSearchResults'; import { useFileSearch } from '@/hooks'; import { workspaceAPI } from '@/infrastructure/api'; @@ -24,6 +23,7 @@ import { InputDialog, CubeLoading } from '@/component-library'; import { openFileInBestTarget } from '@/shared/utils/tabUtils'; import { PanelHeader } from './base'; import { createLogger } from '@/shared/utils/logger'; +import { basenamePath, dirnameAbsolutePath, normalizeLocalPathForRename, replaceBasename } from '@/shared/utils/pathUtils'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { isRemoteWorkspace } from '@/shared/types'; import { @@ -108,7 +108,6 @@ const FilesPanel: React.FC = ({ selectFile, expandFolder, expandFolderLazy, - setFileTree } = useFileSystem({ rootPath: workspacePath, autoLoad: true, @@ -117,22 +116,10 @@ const FilesPanel: React.FC = ({ enableAutoWatch: true, enableLazyLoad: true }); - const handleTreeUpdate = useCallback((updatedTree: FileSystemNode[]) => { - log.debug('File tree updated', { nodeCount: updatedTree.length }); - setFileTree(updatedTree); - }, [setFileTree]); - const handleNodeExpandLazy = useCallback((path: string) => { expandFolderLazy(path); }, [expandFolderLazy]); - useFileTreeGitSync({ - workspacePath, - fileTree, - onTreeUpdate: handleTreeUpdate, - autoRefresh: true - }); - const prevWorkspacePathRef = useRef(workspacePath); useEffect(() => { if (prevWorkspacePathRef.current !== undefined && prevWorkspacePathRef.current !== workspacePath) { @@ -235,22 +222,19 @@ const FilesPanel: React.FC = ({ }, []); const handleExecuteRename = useCallback(async (oldPath: string, newName: string) => { - const isWindows = oldPath.includes('\\'); - const separator = isWindows ? '\\' : '/'; - const pathParts = oldPath.split(separator); - const oldName = pathParts[pathParts.length - 1]; - - if (newName === oldName) { + const normalizedOld = normalizeLocalPathForRename(oldPath); + const oldName = basenamePath(normalizedOld); + + if (newName.trim() === oldName) { setRenamingPath(null); return; } - - pathParts[pathParts.length - 1] = newName; - const newPath = pathParts.join(separator); - + + const newPath = replaceBasename(normalizedOld, newName.trim()); + try { - await workspaceAPI.renameFile(oldPath, newPath); - log.info('File renamed', { oldPath, newPath }); + await workspaceAPI.renameFile(normalizedOld, newPath); + log.info('File renamed', { oldPath: normalizedOld, newPath }); setRenamingPath(null); loadFileTree(workspacePath || '', true); } catch (error) { @@ -367,11 +351,7 @@ const FilesPanel: React.FC = ({ }, [workspacePath, expandFolder, expandedFolders]); const getParentDirectory = useCallback((filePath: string): string => { - const isWindows = filePath.includes('\\'); - const separator = isWindows ? '\\' : '/'; - const parts = filePath.split(separator); - parts.pop(); - return parts.join(separator); + return dirnameAbsolutePath(filePath); }, []); const findNode = useCallback((nodes: FileSystemNode[], path: string): FileSystemNode | null => { diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss index 211c0e99..e420933f 100644 --- a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss @@ -247,16 +247,29 @@ } &__item--selected { - background: color-mix(in srgb, var(--color-accent-primary) 14%, white 86%); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent-primary) 30%, transparent); + // Fallback when color-mix is unsupported; keeps keyboard highlight visible on light surfaces + background: var(--element-bg-medium); + background: color-mix( + in srgb, + var(--color-accent-primary, var(--color-accent-500)) 22%, + var(--color-bg-elevated) 78% + ); .file-mention-picker__item-name { - color: color-mix(in srgb, var(--color-accent-primary) 78%, var(--color-text-primary)); + color: color-mix( + in srgb, + var(--color-accent-primary, var(--color-accent-500)) 72%, + var(--color-text-primary) + ); } .file-mention-picker__icon, .file-mention-picker__expand-icon { - color: color-mix(in srgb, var(--color-accent-primary) 70%, var(--color-text-primary)); + color: color-mix( + in srgb, + var(--color-accent-primary, var(--color-accent-500)) 65%, + var(--color-text-primary) + ); opacity: 0.92; } } diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts index b621f73e..f1cbffa4 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts @@ -333,6 +333,7 @@ export class ThemeService { root.style.setProperty('--color-primary', primaryAccent); root.style.setProperty('--color-primary-hover', primaryHover); root.style.setProperty('--color-accent', primaryAccent); + root.style.setProperty('--color-accent-primary', primaryAccent); const primaryRgb = accentColorToRgbChannels(primaryAccent); if (primaryRgb) { root.style.setProperty('--color-primary-rgb', primaryRgb); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index f12e0012..20ff1927 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -685,7 +685,8 @@ "renameSymbol": "Rename Symbol", "quickFix": "Quick Fix...", "goToSymbol": "Go to Symbol...", - "highlightAllOccurrences": "Highlight All Occurrences" + "highlightAllOccurrences": "Highlight All Occurrences", + "addToChat": "Add to Chat Context" }, "document": { "delete": "Delete Document" diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 2b634a22..8f5ee217 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -685,7 +685,8 @@ "renameSymbol": "重命名符号", "quickFix": "快速修复...", "goToSymbol": "转到符号...", - "highlightAllOccurrences": "高亮所有出现位置" + "highlightAllOccurrences": "高亮所有出现位置", + "addToChat": "添加到聊天上下文" }, "document": { "delete": "删除文档" diff --git a/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts b/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts index 8a3303d7..0f37fd88 100644 --- a/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts +++ b/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts @@ -243,6 +243,7 @@ export class ContextResolver { let cursorPosition: { line: number; column: number } | undefined; + let selectionRange: EditorContext['selectionRange']; try { const monacoGlobal = (window as any).monaco; @@ -308,15 +309,32 @@ export class ContextResolver { }; } else { - const selection = targetEditor.getSelection?.(); - if (selection) { + const monacoSelection = targetEditor.getSelection?.(); + if (monacoSelection) { cursorPosition = { - line: selection.startLineNumber, - column: selection.startColumn + line: monacoSelection.startLineNumber, + column: monacoSelection.startColumn }; } } } + + const monacoSelection = targetEditor.getSelection?.(); + if (monacoSelection) { + const isEmpty = + typeof monacoSelection.isEmpty === 'function' + ? monacoSelection.isEmpty() + : monacoSelection.startLineNumber === monacoSelection.endLineNumber && + monacoSelection.startColumn === monacoSelection.endColumn; + if (!isEmpty) { + selectionRange = { + startLine: monacoSelection.startLineNumber, + endLine: monacoSelection.endLineNumber, + startColumn: monacoSelection.startColumn, + endColumn: monacoSelection.endColumn + }; + } + } } } @@ -342,6 +360,7 @@ export class ContextResolver { filePath, cursorPosition, selectedText, + selectionRange, isReadOnly }; } diff --git a/src/web-ui/src/shared/context-menu-system/providers/EditorMenuProvider.ts b/src/web-ui/src/shared/context-menu-system/providers/EditorMenuProvider.ts index 1ce1103f..fad629d2 100644 --- a/src/web-ui/src/shared/context-menu-system/providers/EditorMenuProvider.ts +++ b/src/web-ui/src/shared/context-menu-system/providers/EditorMenuProvider.ts @@ -7,6 +7,73 @@ import { commandExecutor } from '../commands/CommandExecutor'; import { globalEventBus } from '@/infrastructure/event-bus'; import { i18nService } from '@/infrastructure/i18n'; import { lspExtensionRegistry } from '@/tools/lsp/services/LspExtensionRegistry'; +import type { CodeSnippetContext } from '@/shared/types/context'; +import { useContextStore } from '@/shared/stores/contextStore'; + +function fileNameFromPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const i = normalized.lastIndexOf('/'); + return i >= 0 ? normalized.slice(i + 1) : normalized; +} + +function languageHintFromPath(filePath: string): string | undefined { + const name = fileNameFromPath(filePath); + const dot = name.lastIndexOf('.'); + if (dot < 0) return undefined; + const ext = name.slice(dot + 1).toLowerCase(); + const map: Record = { + ts: 'typescript', + tsx: 'typescript', + mts: 'typescript', + cts: 'typescript', + js: 'javascript', + jsx: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + json: 'json', + md: 'markdown', + rs: 'rust', + py: 'python', + go: 'go', + css: 'css', + scss: 'scss', + less: 'less', + html: 'html', + vue: 'vue', + svelte: 'svelte', + yaml: 'yaml', + yml: 'yaml', + toml: 'toml', + xml: 'xml', + sql: 'sql', + sh: 'shell', + bash: 'shell', + ps1: 'powershell', + cpp: 'cpp', + cc: 'cpp', + cxx: 'cpp', + hpp: 'cpp', + c: 'c', + h: 'c', + java: 'java', + kt: 'kotlin', + kts: 'kotlin', + swift: 'swift', + rb: 'ruby', + php: 'php', + cs: 'csharp', + fs: 'fsharp', + scala: 'scala', + }; + return map[ext]; +} + +function newSnippetContextId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `code-snippet-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} export class EditorMenuProvider implements IMenuProvider { readonly id = 'editor'; readonly name = i18nService.t('common:contextMenu.editorMenu.name'); @@ -79,6 +146,46 @@ export class EditorMenuProvider implements IMenuProvider { } }); + if (editorContext.selectedText && editorContext.filePath) { + items.push({ + id: 'editor-separator-add-to-chat', + label: '', + separator: true + }); + + items.push({ + id: 'editor-add-to-chat', + label: i18nService.t('common:editor.addToChat'), + icon: 'MessageSquarePlus', + onClick: () => { + const filePath = editorContext.filePath!; + const startLine = + editorContext.selectionRange?.startLine ?? + editorContext.cursorPosition?.line ?? + 1; + const endLine = + editorContext.selectionRange?.endLine ?? + editorContext.cursorPosition?.line ?? + startLine; + const context: CodeSnippetContext = { + type: 'code-snippet', + id: newSnippetContextId(), + timestamp: Date.now(), + filePath, + fileName: fileNameFromPath(filePath), + startLine, + endLine, + selectedText: editorContext.selectedText!, + language: languageHintFromPath(filePath), + }; + useContextStore.getState().addContext(context); + window.dispatchEvent( + new CustomEvent('insert-context-tag', { detail: { context } }) + ); + } + }); + } + if (!editorContext.isReadOnly && editorContext.filePath && lspExtensionRegistry.isFileSupported(editorContext.filePath)) { diff --git a/src/web-ui/src/shared/context-menu-system/types/context.types.ts b/src/web-ui/src/shared/context-menu-system/types/context.types.ts index 2171bc21..66adf1cd 100644 --- a/src/web-ui/src/shared/context-menu-system/types/context.types.ts +++ b/src/web-ui/src/shared/context-menu-system/types/context.types.ts @@ -97,6 +97,14 @@ export interface EditorContext extends BaseContext { }; selectedText?: string; + + /** Monaco selection range when the user has a non-empty selection in the editor. */ + selectionRange?: { + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + }; isReadOnly?: boolean; } diff --git a/src/web-ui/src/shared/utils/pathUtils.ts b/src/web-ui/src/shared/utils/pathUtils.ts index 1cd9b073..32e697b4 100644 --- a/src/web-ui/src/shared/utils/pathUtils.ts +++ b/src/web-ui/src/shared/utils/pathUtils.ts @@ -79,3 +79,78 @@ export function joinPath(basePath: string, relativePath: string): string { return `${normalizedBase}/${normalizedRelative}`; } + +/** Last path segment (file or folder name). Handles mixed `/` and `\\`. */ +export function basenamePath(fullPath: string): string { + if (!fullPath || typeof fullPath !== 'string') return ''; + const i = Math.max(fullPath.lastIndexOf('/'), fullPath.lastIndexOf('\\')); + if (i < 0) return fullPath; + return fullPath.slice(i + 1); +} + +/** Parent directory; supports mixed separators and Unix root (`/` parent of `/foo`). */ +export function dirnameAbsolutePath(fullPath: string): string { + if (!fullPath || typeof fullPath !== 'string') return ''; + const i = Math.max(fullPath.lastIndexOf('/'), fullPath.lastIndexOf('\\')); + if (i < 0) return ''; + if (i === 0) return fullPath[0] === '/' ? '/' : ''; + return fullPath.slice(0, i); +} + +/** Replace the final segment; keeps the separator style before the basename. */ +export function replaceBasename(fullPath: string, newName: string): string { + if (!fullPath || typeof fullPath !== 'string') return newName; + const i = Math.max(fullPath.lastIndexOf('/'), fullPath.lastIndexOf('\\')); + if (i < 0) return newName; + return `${fullPath.slice(0, i + 1)}${newName}`; +} + +/** + * Normalize for local rename IPC: `normalizePath` except skip UNC (`\\?\`, `\\server\...`) + * so we do not turn backslashes into slashes there. + */ +export function normalizeLocalPathForRename(path: string): string { + const t = path.trim(); + if (t.startsWith('\\\\')) return t; + return normalizePath(t); +} + +/** + * True if two absolute filesystem paths refer to the same location. + * Normalizes `\\` vs `/`; on Windows-style roots (`C:` or `\\`) compares case-insensitively. + */ +export function pathsEquivalentFs(a: string, b: string): boolean { + if (a === b) return true; + const ka = a.replace(/\\/g, '/'); + const kb = b.replace(/\\/g, '/'); + if (ka === kb) return true; + const winLike = /^[a-zA-Z]:/.test(a.trim()) || a.startsWith('\\\\'); + if (winLike) return ka.toLowerCase() === kb.toLowerCase(); + return false; +} + +/** Whether `path` is expanded when the set may mix separators or drive letter case (Windows). */ +export function expandedFoldersContains(expandedFolders: Set, path: string): boolean { + if (expandedFolders.has(path)) return true; + for (const p of expandedFolders) { + if (pathsEquivalentFs(p, path)) return true; + } + return false; +} + +export function expandedFoldersDeleteEquivalent(set: Set, path: string): Set { + const next = new Set(set); + const toDelete: string[] = []; + next.forEach((p) => { + if (pathsEquivalentFs(p, path)) toDelete.push(p); + }); + toDelete.forEach((p) => next.delete(p)); + return next; +} + +/** Add `path` after removing any equivalent entry (single canonical key). */ +export function expandedFoldersAddEquivalent(set: Set, path: string): Set { + const next = expandedFoldersDeleteEquivalent(set, path); + next.add(path); + return next; +} diff --git a/src/web-ui/src/tools/file-system/components/FileExplorer.tsx b/src/web-ui/src/tools/file-system/components/FileExplorer.tsx index efd24121..fae7bf39 100644 --- a/src/web-ui/src/tools/file-system/components/FileExplorer.tsx +++ b/src/web-ui/src/tools/file-system/components/FileExplorer.tsx @@ -3,10 +3,10 @@ import { Folder, ChevronRight, FilePlus, FolderPlus, RefreshCw } from 'lucide-re import { FileTree } from './FileTree'; import { VirtualFileTree } from './VirtualFileTree'; import { FileExplorerProps, FileSystemNode, FlatFileNode } from '../types'; -import { GitStatusIndicator } from './GitStatusIndicator'; import { flattenFileTree } from '../utils/treeFlattening'; import { getNewItemParentPath } from '../utils/getNewItemParentPath'; import { i18nService, useI18n } from '@/infrastructure/i18n'; +import { expandedFoldersContains } from '@/shared/utils/pathUtils'; import { IconButton } from '@/component-library'; const VIRTUAL_SCROLL_THRESHOLD = 100; @@ -190,7 +190,7 @@ export const FileExplorer: React.FC = ({ }, [onFileSelect]); const handleVirtualToggleExpand = useCallback((path: string) => { - const isCurrentlyExpanded = expandedFolders.has(path); + const isCurrentlyExpanded = expandedFoldersContains(expandedFolders, path); if (externalOnNodeExpand) { externalOnNodeExpand(path, !isCurrentlyExpanded); } else { @@ -213,14 +213,6 @@ export const FileExplorer: React.FC = ({ {node.name} - {(() => { - if (!node.isDirectory && node.gitStatus) { - return ; - } - - return null; - })()} - {showFileSize && !node.isDirectory && node.size && ( {formatFileSize(node.size)} diff --git a/src/web-ui/src/tools/file-system/components/FileTree.tsx b/src/web-ui/src/tools/file-system/components/FileTree.tsx index a904173c..ebc1c577 100644 --- a/src/web-ui/src/tools/file-system/components/FileTree.tsx +++ b/src/web-ui/src/tools/file-system/components/FileTree.tsx @@ -3,6 +3,7 @@ import { FileTreeNode } from './FileTreeNode'; import { FileTreeProps } from '../types'; import { lazyCompressFileTree, shouldCompressPaths, CompressedNode } from '../utils/pathCompression'; import { useI18n } from '@/infrastructure/i18n'; +import { expandedFoldersContains } from '@/shared/utils/pathUtils'; export const FileTree: React.FC = ({ nodes, @@ -26,7 +27,7 @@ export const FileTree: React.FC = ({ const handleNodeExpand = useCallback((path: string) => { if (onNodeExpand) { - const isCurrentlyExpanded = expandedFolders.has(path); + const isCurrentlyExpanded = expandedFoldersContains(expandedFolders, path); onNodeExpand(path, !isCurrentlyExpanded); } else { setInternalExpandedFolders(prev => { @@ -55,7 +56,7 @@ export const FileTree: React.FC = ({ node={node} level={currentLevel} isSelected={selectedFile === node.path} - isExpanded={expandedFolders.has(node.path)} + isExpanded={expandedFoldersContains(expandedFolders, node.path)} selectedFile={selectedFile} expandedFolders={expandedFolders} onSelect={onNodeSelect} diff --git a/src/web-ui/src/tools/file-system/components/FileTreeNode.tsx b/src/web-ui/src/tools/file-system/components/FileTreeNode.tsx index 7b26a32f..14201171 100644 --- a/src/web-ui/src/tools/file-system/components/FileTreeNode.tsx +++ b/src/web-ui/src/tools/file-system/components/FileTreeNode.tsx @@ -6,26 +6,8 @@ import { getCompressionTooltip } from '../utils/pathCompression'; import { dragManager } from '../../../shared/services/DragManager'; import { fileTreeDragSource } from '../../../shared/context-system/drag-drop/FileTreeDragSource'; import { Input } from '../../../component-library/components/Input'; -import { GitStatusIndicator } from './GitStatusIndicator'; import { useI18n } from '@/infrastructure/i18n'; - -/** - * Get the Git status to display for a directory. - * Single status: show that status. Multiple: show 'modified'. - */ -function getDirectoryGitStatus( - childrenGitStatuses?: Set<'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged'> -): 'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged' | undefined { - if (!childrenGitStatuses || childrenGitStatuses.size === 0) { - return undefined; - } - - if (childrenGitStatuses.size === 1) { - return Array.from(childrenGitStatuses)[0]; - } - - return 'modified'; -} +import { expandedFoldersContains } from '@/shared/utils/pathUtils'; interface ExtendedFileTreeNodeProps extends FileTreeNodeProps { selectedFile?: string; @@ -209,23 +191,8 @@ export const FileTreeNode: React.FC = ({ const handleContextMenu = (_e: React.MouseEvent) => { }; - const getGitStatusClass = () => { - if (!node.isDirectory && node.gitStatus) { - return `bitfun-file-explorer__node--git-${node.gitStatus}`; - } - - if (node.isDirectory && !isExpanded && node.hasChildrenGitChanges) { - const dirStatus = getDirectoryGitStatus(node.childrenGitStatuses); - if (dirStatus) { - return `bitfun-file-explorer__node--git-${dirStatus}`; - } - } - - return ''; - }; - return ( -
+
= ({ ) : renderContent ? ( renderContent(node, level) ) : ( - <> - - {node.name} - - {(() => { - if (!node.isDirectory && node.gitStatus) { - return ; - } - - if (node.isDirectory && !isExpanded && node.hasChildrenGitChanges) { - const dirStatus = getDirectoryGitStatus(node.childrenGitStatuses); - if (dirStatus) { - return ; - } - } - - return null; - })()} - + + {node.name} + )} {renderActions && ( @@ -302,7 +253,9 @@ export const FileTreeNode: React.FC = ({ node={child} level={level + 1} isSelected={selectedFile === child.path} - isExpanded={expandedFolders?.has(child.path) || false} + isExpanded={ + expandedFolders ? expandedFoldersContains(expandedFolders, child.path) : false + } selectedFile={selectedFile} expandedFolders={expandedFolders} onSelect={onSelect} diff --git a/src/web-ui/src/tools/file-system/components/GitStatusIndicator.scss b/src/web-ui/src/tools/file-system/components/GitStatusIndicator.scss deleted file mode 100644 index 40f593a6..00000000 --- a/src/web-ui/src/tools/file-system/components/GitStatusIndicator.scss +++ /dev/null @@ -1,125 +0,0 @@ -.bitfun-git-status-indicator { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - margin-left: 6px; - padding: 0 4px; - font-size: 11px; - font-weight: 600; - line-height: 18px; - border-radius: 3px; - text-align: center; - user-select: none; - flex-shrink: 0; - - background-color: rgba(128, 128, 128, 0.2); - color: rgba(128, 128, 128, 0.9); - - &--compact { - min-width: 16px; - height: 16px; - font-size: 10px; - line-height: 16px; - padding: 0 3px; - } - - &--green { - background-color: rgba(115, 191, 105, 0.2); - color: rgb(115, 191, 105); - } - - &--blue { - background-color: rgba(229, 192, 123, 0.2); - color: rgb(229, 192, 123); - } - - &--yellow { - background-color: rgba(229, 192, 123, 0.2); - color: rgb(229, 192, 123); - } - - &--red { - background-color: rgba(229, 109, 109, 0.2); - color: rgb(229, 109, 109); - } - - &--orange { - background-color: rgba(232, 151, 91, 0.2); - color: rgb(232, 151, 91); - } - - &--purple { - background-color: rgba(180, 142, 217, 0.2); - color: rgb(180, 142, 217); - } - - &--cyan { - background-color: rgba(86, 217, 217, 0.2); - color: rgb(86, 217, 217); - } - - &--gray { - background-color: rgba(128, 128, 128, 0.2); - color: rgba(128, 128, 128, 0.9); - } - - &:hover { - opacity: 0.85; - cursor: help; - } - - animation: fadeIn 0.2s ease-in-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: scale(0.8); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@media (prefers-color-scheme: dark) { - .bitfun-git-status-indicator { - &--green { - background-color: rgba(115, 191, 105, 0.25); - color: rgb(135, 211, 125); - } - - &--blue { - background-color: rgba(229, 192, 123, 0.25); - color: rgb(249, 212, 143); - } - - &--yellow { - background-color: rgba(229, 192, 123, 0.25); - color: rgb(249, 212, 143); - } - - &--red { - background-color: rgba(229, 109, 109, 0.25); - color: rgb(249, 129, 129); - } - - &--orange { - background-color: rgba(232, 151, 91, 0.25); - color: rgb(252, 171, 111); - } - - &--purple { - background-color: rgba(180, 142, 217, 0.25); - color: rgb(200, 162, 237); - } - - &--cyan { - background-color: rgba(86, 217, 217, 0.25); - color: rgb(106, 237, 237); - } - } -} - diff --git a/src/web-ui/src/tools/file-system/components/GitStatusIndicator.tsx b/src/web-ui/src/tools/file-system/components/GitStatusIndicator.tsx deleted file mode 100644 index 1b4cf486..00000000 --- a/src/web-ui/src/tools/file-system/components/GitStatusIndicator.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react'; -import './GitStatusIndicator.scss'; -import { useI18n } from '@/infrastructure/i18n'; - -export interface GitStatusIndicatorProps { - status: 'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged'; - compact?: boolean; - className?: string; -} - -function getStatusInfo(status: GitStatusIndicatorProps['status'], t: (key: string) => string) { - switch (status) { - case 'untracked': - return { - label: 'U', - fullLabel: t('git.status.untracked'), - color: 'green', - description: t('git.statusDescription.untracked') - }; - case 'modified': - return { - label: 'M', - fullLabel: t('git.status.modified'), - color: 'yellow', - description: t('git.statusDescription.modified') - }; - case 'added': - return { - label: 'A', - fullLabel: t('git.status.added'), - color: 'green', - description: t('git.statusDescription.added') - }; - case 'deleted': - return { - label: 'D', - fullLabel: t('git.status.deleted'), - color: 'red', - description: t('git.statusDescription.deleted') - }; - case 'renamed': - return { - label: 'R', - fullLabel: t('git.status.renamed'), - color: 'purple', - description: t('git.statusDescription.renamed') - }; - case 'conflicted': - return { - label: 'C', - fullLabel: t('git.status.conflict'), - color: 'orange', - description: t('git.statusDescription.conflicted') - }; - case 'staged': - return { - label: 'M', - fullLabel: t('git.status.staged'), - color: 'cyan', - description: t('git.statusDescription.staged') - }; - default: - return { - label: '?', - fullLabel: t('git.status.unknown'), - color: 'gray', - description: t('git.statusDescription.unknown') - }; - } -} - -export const GitStatusIndicator: React.FC = ({ - status, - compact = false, - className = '' -}) => { - const { t } = useI18n('tools'); - const statusInfo = getStatusInfo(status, t); - - return ( - - {statusInfo.label} - - ); -}; - -export default GitStatusIndicator; - diff --git a/src/web-ui/src/tools/file-system/components/VirtualFileTree.tsx b/src/web-ui/src/tools/file-system/components/VirtualFileTree.tsx index 95cf7839..af0bb1b7 100644 --- a/src/web-ui/src/tools/file-system/components/VirtualFileTree.tsx +++ b/src/web-ui/src/tools/file-system/components/VirtualFileTree.tsx @@ -3,22 +3,8 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { ChevronRight, ChevronDown, Loader2 } from 'lucide-react'; import { VirtualFileTreeProps, FlatFileNode, FileSystemNode } from '../types'; import { getFileIcon, getFileIconClass } from '../utils/fileIcons'; -import { GitStatusIndicator } from './GitStatusIndicator'; import { useI18n } from '@/infrastructure/i18n'; - -function getDirectoryGitStatus( - childrenGitStatuses?: Set<'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged'> -): 'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged' | undefined { - if (!childrenGitStatuses || childrenGitStatuses.size === 0) { - return undefined; - } - - if (childrenGitStatuses.size === 1) { - return Array.from(childrenGitStatuses)[0]; - } - - return 'modified'; -} +import { expandedFoldersContains } from '@/shared/utils/pathUtils'; interface VirtualFileRowProps { node: FlatFileNode; @@ -60,21 +46,6 @@ const VirtualFileRow = React.memo(({ onToggleExpand(node.path); }, [node.path, onToggleExpand]); - const getGitStatusClass = () => { - if (!node.isDirectory && node.gitStatus) { - return `bitfun-file-explorer__node--git-${node.gitStatus}`; - } - - if (node.isDirectory && !isExpanded && node.hasChildrenGitChanges) { - const dirStatus = getDirectoryGitStatus(node.childrenGitStatuses); - if (dirStatus) { - return `bitfun-file-explorer__node--git-${dirStatus}`; - } - } - - return ''; - }; - const nodeForIcon: FileSystemNode = useMemo(() => ({ path: node.path, name: node.name, @@ -82,12 +53,11 @@ const VirtualFileRow = React.memo(({ extension: node.extension, size: node.size, lastModified: node.lastModified, - gitStatus: node.gitStatus, isCompressed: node.isCompressed, }), [node]); return ( -
+
(({ {node.name} - - {(() => { - if (!node.isDirectory && node.gitStatus) { - return ; - } - - if (node.isDirectory && !isExpanded && node.hasChildrenGitChanges) { - const dirStatus = getDirectoryGitStatus(node.childrenGitStatuses); - if (dirStatus) { - return ; - } - } - - return null; - })()}
); @@ -173,7 +128,7 @@ export const VirtualFileTree = forwardRef( const itemContent = useCallback((_index: number, node: FlatFileNode) => { const isSelected = selectedFile === node.path; - const isExpanded = expandedFolders.has(node.path); + const isExpanded = expandedFoldersContains(expandedFolders, node.path); return ( (null); const rootPathRef = useRef(rootPath); + rootPathRef.current = rootPath; const isLoadingRef = useRef(false); const optionsRef = useRef(state.options); const expandedFoldersRef = useRef(state.expandedFolders); @@ -307,14 +314,12 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem const expandFolder = useCallback((folderPath: string, expanded?: boolean) => { setState(prev => { - const newExpandedFolders = new Set(prev.expandedFolders); - const shouldExpand = expanded !== undefined ? expanded : !newExpandedFolders.has(folderPath); - - if (shouldExpand) { - newExpandedFolders.add(folderPath); - } else { - newExpandedFolders.delete(folderPath); - } + const shouldExpand = + expanded !== undefined ? expanded : !expandedFoldersContains(prev.expandedFolders, folderPath); + + const newExpandedFolders = shouldExpand + ? expandedFoldersAddEquivalent(prev.expandedFolders, folderPath) + : expandedFoldersDeleteEquivalent(prev.expandedFolders, folderPath); return { ...prev, @@ -329,7 +334,7 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem children: FileSystemNode[] ): FileSystemNode[] => { return nodes.map(node => { - if (node.path === targetPath) { + if (pathsEquivalentFs(node.path, targetPath)) { return { ...node, children: children, @@ -377,29 +382,21 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem }, [updateNodeChildrenInTree]); const expandFolderLazy = useCallback(async (folderPath: string) => { - if (state.expandedFolders.has(folderPath)) { - setState(prev => { - const newExpandedFolders = new Set(prev.expandedFolders); - newExpandedFolders.delete(folderPath); - return { - ...prev, - expandedFolders: newExpandedFolders - }; - }); + if (expandedFoldersContains(state.expandedFolders, folderPath)) { + setState(prev => ({ + ...prev, + expandedFolders: expandedFoldersDeleteEquivalent(prev.expandedFolders, folderPath), + })); return; } const cachedChildren = directoryCache.get(folderPath); const needsLoading = !loadedPathsRef.current.has(folderPath) && !cachedChildren; - setState(prev => { - const newExpandedFolders = new Set(prev.expandedFolders); - newExpandedFolders.add(folderPath); - return { - ...prev, - expandedFolders: newExpandedFolders - }; - }); + setState(prev => ({ + ...prev, + expandedFolders: expandedFoldersAddEquivalent(prev.expandedFolders, folderPath), + })); if (cachedChildren) { setState(prev => ({ diff --git a/src/web-ui/src/tools/file-system/hooks/useFileTreeGitSync.ts b/src/web-ui/src/tools/file-system/hooks/useFileTreeGitSync.ts deleted file mode 100644 index 35a36c93..00000000 --- a/src/web-ui/src/tools/file-system/hooks/useFileTreeGitSync.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { useEffect, useCallback, useRef, useState } from 'react'; -import { FileSystemNode } from '../types'; -import { gitStateManager } from '@/tools/git/state/GitStateManager'; -import { GitState } from '@/tools/git/state/types'; -import { globalEventBus } from '@/infrastructure/event-bus'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('useFileTreeGitSync'); - -export interface UseFileTreeGitSyncProps { - workspacePath?: string; - fileTree: FileSystemNode[]; - onTreeUpdate: (tree: FileSystemNode[]) => void; - autoRefresh?: boolean; - debounceDelay?: number; -} - -/** - * Parse Git status from backend response. - * Backend format: { status: 'M', index_status: null, workdir_status: 'M' } - */ -function parseGitStatusFromBackend( - status: string, - indexStatus: string | null | undefined, - workdirStatus: string | null | undefined -): 'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged' | undefined { - if (status === '?' || status === '??') return 'untracked'; - - if (status === 'U' || (indexStatus && indexStatus.includes('U')) || (workdirStatus && workdirStatus.includes('U'))) { - return 'conflicted'; - } - - if (indexStatus && indexStatus !== ' ') { - if (indexStatus === 'A') return 'added'; - if (indexStatus === 'D') return 'deleted'; - if (indexStatus === 'R') return 'renamed'; - if (indexStatus === 'M') return 'staged'; - return 'staged'; - } - - if (workdirStatus && workdirStatus !== ' ') { - if (workdirStatus === 'M') return 'modified'; - if (workdirStatus === 'D') return 'deleted'; - return 'modified'; - } - - if (status === 'M') return 'modified'; - if (status === 'A') return 'added'; - if (status === 'D') return 'deleted'; - if (status === 'R') return 'renamed'; - - return undefined; -} - -function collectChildrenGitStatuses(node: FileSystemNode): Set { - const statuses = new Set(); - - if (!node.children || node.children.length === 0) { - return statuses; - } - - for (const child of node.children) { - if (child.gitStatus) { - statuses.add(child.gitStatus); - } - - if (child.childrenGitStatuses) { - child.childrenGitStatuses.forEach(status => statuses.add(status)); - } - } - - return statuses; -} - -function buildGitStatusMap( - gitState: GitState | null -): Map }> { - const gitStatusMap = new Map }>(); - - if (!gitState || !gitState.isRepository) { - return gitStatusMap; - } - - gitState.staged?.forEach(file => { - const status = parseGitStatusFromBackend(file.status, file.index_status, file.workdir_status); - if (status) { - gitStatusMap.set(file.path.replace(/\\/g, '/'), { status: file.status, gitStatus: status }); - } - }); - - gitState.unstaged?.forEach(file => { - const status = parseGitStatusFromBackend(file.status, file.index_status, file.workdir_status); - const normalizedPath = file.path.replace(/\\/g, '/'); - if (status && !gitStatusMap.has(normalizedPath)) { - gitStatusMap.set(normalizedPath, { status: file.status, gitStatus: status }); - } - }); - - gitState.untracked?.forEach(filePath => { - const normalizedPath = filePath.replace(/\\/g, '/'); - if (!gitStatusMap.has(normalizedPath)) { - gitStatusMap.set(normalizedPath, { status: '??', gitStatus: 'untracked' }); - } - }); - - return gitStatusMap; -} - -function getNodeStatusInfo( - nodePath: string, - workspacePath: string | undefined, - gitStatusMap: Map }> -): { status: string; gitStatus: ReturnType } | undefined { - const normalizedNodePath = nodePath.replace(/\\/g, '/'); - - const absoluteMatch = gitStatusMap.get(normalizedNodePath); - if (absoluteMatch) return absoluteMatch; - - if (!workspacePath) return undefined; - - const normalizedWorkspacePath = workspacePath.replace(/\\/g, '/').replace(/\/+$/, ''); - if (!normalizedNodePath.startsWith(`${normalizedWorkspacePath}/`)) { - return undefined; - } - - const relativePath = normalizedNodePath.slice(normalizedWorkspacePath.length + 1); - return gitStatusMap.get(relativePath); -} - -/** - * Update file tree nodes with Git status. Stores complete Git info regardless of expansion state. - */ -function updateNodeGitStatus( - nodes: FileSystemNode[], - gitStatusMap: Map }>, - workspacePath?: string -): FileSystemNode[] { - return nodes.map(node => { - let updatedNode = { ...node }; - const statusInfo = getNodeStatusInfo(node.path, workspacePath, gitStatusMap); - const matched = !!statusInfo; - - if (statusInfo) { - updatedNode = { - ...updatedNode, - gitStatus: statusInfo.gitStatus, - gitStatusText: statusInfo.status - }; - } - - if (node.children && node.children.length > 0) { - updatedNode.children = updateNodeGitStatus(node.children, gitStatusMap, workspacePath); - - const childStatuses = collectChildrenGitStatuses(updatedNode); - - if (node.isDirectory) { - updatedNode = { - ...updatedNode, - hasChildrenGitChanges: childStatuses.size > 0, - childrenGitStatuses: childStatuses as any - }; - } - } - - if (!matched && !node.isDirectory && node.gitStatus) { - updatedNode = { - ...updatedNode, - gitStatus: undefined, - gitStatusText: undefined - }; - } - - return updatedNode; - }); -} - -export function useFileTreeGitSync({ - workspacePath, - fileTree, - onTreeUpdate, - autoRefresh = true, - debounceDelay = 300 -}: UseFileTreeGitSyncProps) { - const treeRef = useRef(fileTree); - const onTreeUpdateRef = useRef(onTreeUpdate); - const debounceTimerRef = useRef(undefined); - const updateInProgressRef = useRef(false); - - const [gitState, setGitState] = useState(() => - workspacePath ? gitStateManager.getState(workspacePath) : null - ); - const gitStateRef = useRef(gitState); - - useEffect(() => { - treeRef.current = fileTree; - }, [fileTree]); - - useEffect(() => { - onTreeUpdateRef.current = onTreeUpdate; - }, [onTreeUpdate]); - - useEffect(() => { - gitStateRef.current = gitState; - }, [gitState]); - - const applyGitStatusToTreeRef = useRef(null); - - const applyGitStatusToTree = useCallback(( - targetTree: FileSystemNode[], - state: GitState | null, - immediate: boolean = false - ) => { - const doApply = () => { - if (updateInProgressRef.current) return; - if (!targetTree || targetTree.length === 0) return; - - updateInProgressRef.current = true; - - try { - const gitStatusMap = buildGitStatusMap(state); - const updatedTree = updateNodeGitStatus(targetTree, gitStatusMap, workspacePath); - - onTreeUpdateRef.current(updatedTree); - - log.debug('Git status applied to file tree', { - totalFiles: gitStatusMap.size, - staged: state?.staged?.length || 0, - unstaged: state?.unstaged?.length || 0, - untracked: state?.untracked?.length || 0 - }); - } finally { - updateInProgressRef.current = false; - } - }; - - if (immediate) { - if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - doApply(); - } else { - if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = setTimeout(doApply, debounceDelay); - } - }, [debounceDelay, workspacePath]); - - const applyGitStatusImmediate = useCallback(() => { - applyGitStatusToTree(treeRef.current, gitStateRef.current, true); - }, [applyGitStatusToTree]); - - const refreshGitStatus = useCallback(async () => { - if (!workspacePath) return; - - await gitStateManager.refresh(workspacePath, { - layers: ['basic', 'status'], - reason: 'manual', - force: true, - }); - }, [workspacePath]); - - useEffect(() => { - applyGitStatusToTreeRef.current = applyGitStatusToTree; - }, [applyGitStatusToTree]); - - const prevTreeLengthRef = useRef(fileTree.length); - useEffect(() => { - const prevLength = prevTreeLengthRef.current; - const currentLength = fileTree.length; - prevTreeLengthRef.current = currentLength; - - if (prevLength === 0 && currentLength > 0) { - const currentGitState = gitStateRef.current; - if (currentGitState?.isRepository) { - applyGitStatusToTreeRef.current?.(fileTree, currentGitState, true); - } - } - }, [fileTree]); - - useEffect(() => { - if (!workspacePath) return; - - const normalizedPath = workspacePath.replace(/\\/g, '/'); - - const unsubscribe = gitStateManager.subscribe( - normalizedPath, - (newState, _prevState, changedLayers) => { - if (changedLayers.includes('status') || changedLayers.includes('basic')) { - setGitState(newState); - - if (!newState.isRefreshing) { - applyGitStatusToTreeRef.current?.(treeRef.current, newState, false); - } - } - }, - { - layers: ['basic', 'status'], - immediate: true, - } - ); - - return unsubscribe; - }, [workspacePath]); - - useEffect(() => { - if (!workspacePath || !autoRefresh) return; - - const handleFileTreeRefresh = () => { - refreshGitStatus(); - }; - - const handleSilentRefreshCompleted = (event: { path: string; fileTree: FileSystemNode[] }) => { - const currentGitState = gitStateRef.current; - if (currentGitState?.isRepository) { - applyGitStatusToTreeRef.current?.(event.fileTree, currentGitState, true); - } - - refreshGitStatus(); - }; - - globalEventBus.on('file-tree:refresh', handleFileTreeRefresh); - globalEventBus.on('file-tree:silent-refresh-completed', handleSilentRefreshCompleted); - - return () => { - globalEventBus.off('file-tree:refresh', handleFileTreeRefresh); - globalEventBus.off('file-tree:silent-refresh-completed', handleSilentRefreshCompleted); - }; - }, [workspacePath, autoRefresh, refreshGitStatus]); - - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - }; - }, []); - - return { - refresh: refreshGitStatus, - applyImmediate: applyGitStatusImmediate, - gitStatus: gitState, - loading: gitState?.isRefreshing ?? false - }; -} diff --git a/src/web-ui/src/tools/file-system/index.ts b/src/web-ui/src/tools/file-system/index.ts index e6b057e8..93fcb8f4 100644 --- a/src/web-ui/src/tools/file-system/index.ts +++ b/src/web-ui/src/tools/file-system/index.ts @@ -1,7 +1,6 @@ export { FileExplorer, FileTree, FileTreeNode } from './components'; export { useFileSystem, useFileTree } from './hooks'; -export { useFileTreeGitSync } from './hooks/useFileTreeGitSync'; export type { FileSystemNode, diff --git a/src/web-ui/src/tools/file-system/styles/FileExplorer.scss b/src/web-ui/src/tools/file-system/styles/FileExplorer.scss index d084e5ed..ccc2781a 100644 --- a/src/web-ui/src/tools/file-system/styles/FileExplorer.scss +++ b/src/web-ui/src/tools/file-system/styles/FileExplorer.scss @@ -131,40 +131,6 @@ $_indent-width: 8px; .bitfun-file-explorer__node { user-select: none; - - &--git-modified, - &--git-staged { - .bitfun-file-explorer__node-name { - color: rgb(229, 192, 123) !important; - } - } - - &--git-added, - &--git-untracked { - .bitfun-file-explorer__node-name { - color: rgb(115, 191, 105) !important; - } - } - - &--git-deleted { - .bitfun-file-explorer__node-name { - color: rgb(229, 109, 109) !important; - text-decoration: line-through; - } - } - - &--git-conflicted { - .bitfun-file-explorer__node-name { - color: rgb(232, 151, 91) !important; - font-weight: $font-weight-medium; - } - } - - &--git-renamed { - .bitfun-file-explorer__node-name { - color: rgb(180, 142, 217) !important; - } - } } .bitfun-file-explorer__node-content { @@ -390,26 +356,26 @@ $_indent-width: 8px; .bitfun-input-container { height: 26px; padding: 0 8px; - background: rgba(15, 15, 15, 0.95); + background: var(--color-bg-elevated); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: none !important; - box-shadow: - 0 0 0 1px rgba(59, 130, 246, 0.8), - 0 2px 8px rgba(0, 0, 0, 0.4); - + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--color-accent-500) 80%, transparent), + 0 2px 8px color-mix(in srgb, var(--color-text-primary) 10%, transparent); + &:hover { - background: rgba(10, 10, 10, 0.98); - box-shadow: - 0 0 0 1px rgba(59, 130, 246, 0.9), - 0 2px 8px rgba(0, 0, 0, 0.4); + background: var(--element-bg-soft); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--color-accent-500) 90%, transparent), + 0 2px 8px color-mix(in srgb, var(--color-text-primary) 12%, transparent); } - + &:focus-within { - background: rgba(8, 8, 8, 1); - box-shadow: - 0 0 0 1px rgba(59, 130, 246, 1), - 0 4px 12px rgba(0, 0, 0, 0.5); + background: var(--color-bg-elevated); + box-shadow: + 0 0 0 1px var(--color-accent-500), + 0 4px 12px color-mix(in srgb, var(--color-text-primary) 16%, transparent); } } @@ -422,7 +388,7 @@ $_indent-width: 8px; } .bitfun-input-prefix { - color: rgba(59, 130, 246, 0.8); + color: color-mix(in srgb, var(--color-accent-500) 85%, transparent); } } } diff --git a/src/web-ui/src/tools/file-system/types/index.ts b/src/web-ui/src/tools/file-system/types/index.ts index ac16ab3e..a03b42e4 100644 --- a/src/web-ui/src/tools/file-system/types/index.ts +++ b/src/web-ui/src/tools/file-system/types/index.ts @@ -14,14 +14,8 @@ export interface FileSystemNode { isSelected?: boolean; isExpanded?: boolean; - // Git status totalAnchors?: number; hasFixResult?: boolean; - - gitStatus?: 'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged'; - gitStatusText?: string; - hasChildrenGitChanges?: boolean; - childrenGitStatuses?: Set<'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged'>; } @@ -192,10 +186,6 @@ export interface FlatFileNode { size?: number; extension?: string; lastModified?: Date; - gitStatus?: FileSystemNode['gitStatus']; - gitStatusText?: string; - hasChildrenGitChanges?: boolean; - childrenGitStatuses?: Set<'untracked' | 'modified' | 'added' | 'deleted' | 'renamed' | 'conflicted' | 'staged'>; isCompressed?: boolean; originalNode?: FileSystemNode; } diff --git a/src/web-ui/src/tools/file-system/utils/pathCompression.ts b/src/web-ui/src/tools/file-system/utils/pathCompression.ts index afd5e9af..4e32a65f 100644 --- a/src/web-ui/src/tools/file-system/utils/pathCompression.ts +++ b/src/web-ui/src/tools/file-system/utils/pathCompression.ts @@ -1,5 +1,6 @@ import { FileSystemNode } from '../types'; import { i18nService } from '@/infrastructure/i18n'; +import { expandedFoldersContains } from '@/shared/utils/pathUtils'; export interface CompressedNode extends Omit { children?: CompressedNode[]; @@ -59,7 +60,7 @@ export function lazyCompressFileTree(fileTree: FileSystemNode[], expandedFolders } function lazyCompressNodePath(node: FileSystemNode, expandedFolders: Set): CompressedNode { - if (!expandedFolders.has(node.path)) { + if (!expandedFoldersContains(expandedFolders, node.path)) { return { ...node, children: node.children?.map(child => ({ diff --git a/src/web-ui/src/tools/file-system/utils/treeFlattening.ts b/src/web-ui/src/tools/file-system/utils/treeFlattening.ts index cff2887c..568a6624 100644 --- a/src/web-ui/src/tools/file-system/utils/treeFlattening.ts +++ b/src/web-ui/src/tools/file-system/utils/treeFlattening.ts @@ -1,4 +1,5 @@ import { FileSystemNode, FlatFileNode } from '../types'; +import { expandedFoldersContains } from '@/shared/utils/pathUtils'; function nodeToFlatNode( node: FileSystemNode, @@ -17,10 +18,6 @@ function nodeToFlatNode( size: node.size, extension: node.extension, lastModified: node.lastModified, - gitStatus: node.gitStatus, - gitStatusText: node.gitStatusText, - hasChildrenGitChanges: node.hasChildrenGitChanges, - childrenGitStatuses: node.childrenGitStatuses, isCompressed: node.isCompressed, originalNode: node, }; @@ -35,7 +32,7 @@ export function flattenFileTree( const result: FlatFileNode[] = []; for (const node of nodes) { - const isExpanded = expandedFolders.has(node.path); + const isExpanded = expandedFoldersContains(expandedFolders, node.path); const hasChildren = node.children && node.children.length > 0; const childrenLoaded = node.isDirectory ? (node.children !== undefined) : true; @@ -63,7 +60,7 @@ export function countVisibleNodes( for (const node of nodes) { count++; - if (node.isDirectory && expandedFolders.has(node.path) && node.children) { + if (node.isDirectory && expandedFoldersContains(expandedFolders, node.path) && node.children) { count += countVisibleNodes(node.children, expandedFolders); } } diff --git a/tests/e2e/specs/l1-file-tree.spec.ts b/tests/e2e/specs/l1-file-tree.spec.ts index 540d9c81..c54ee138 100644 --- a/tests/e2e/specs/l1-file-tree.spec.ts +++ b/tests/e2e/specs/l1-file-tree.spec.ts @@ -348,20 +348,6 @@ describe('L1 File Tree', () => { }); }); - describe('Git status indicators', () => { - it('files should have git status class if in git repo', async function () { - if (!hasWorkspace) { - this.skip(); - return; - } - - const gitStatusNodes = await browser.$$('[class*="git-modified"], [class*="git-added"], [class*="git-deleted"]'); - console.log('[L1] Files with git status:', gitStatusNodes.length); - - expect(gitStatusNodes.length).toBeGreaterThanOrEqual(0); - }); - }); - afterEach(async function () { if (this.currentTest?.state === 'failed') { await saveFailureScreenshot(`l1-file-tree-${this.currentTest.title}`);