diff --git a/src/App.tsx b/src/App.tsx index 590faff..8a7b02a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,8 @@ import { graphDataAtom, parseErrorAtom, parseProgressAtom, + rightPanelOpenAtom, + rightPanelTypeAtom, viewModeAtom, } from '@/entities/AppView/model/atoms'; import { store } from '@/entities/AppView/model/store'; @@ -20,11 +22,12 @@ import { UnifiedSearchModal } from '@/features/Search/UnifiedSearch/ui/UnifiedSe import { JsonExplorer } from '@/pages/JsonExplorer/JsonExplorer'; import { deadCodePanelOpenAtom } from '@/pages/PageAnalysis/DeadCodePanel/model/atoms'; import { PageAnalysis } from '@/pages/PageAnalysis/PageAnalysis'; -import IDEScrollView from '@/widgets/MainContents/IDEScrollView/IDEScrollView'; import PipelineCanvas from '@/widgets/MainContents/PipelineCanvas/PipelineCanvas.tsx'; +import { TabContainer } from '@/widgets/MainContents/TabContainer'; import type { SourceFileNode } from './entities/SourceFileNode/model/types'; import { KeyboardShortcuts } from './features/KeyboardShortcuts/KeyboardShortcuts'; import CodeDocView from './widgets/CodeDocView/CodeDocView'; +import { WorkspacePanel } from './widgets/WorkspacePanel/WorkspacePanel'; const AppContent: React.FC = () => { // Parse project when files change @@ -34,6 +37,9 @@ const AppContent: React.FC = () => { const setParseProgress = useSetAtom(parseProgressAtom); const viewMode = useAtomValue(viewModeAtom); const deadCodePanelOpen = useAtomValue(deadCodePanelOpenAtom); + const rightPanelOpen = useAtomValue(rightPanelOpenAtom); + const rightPanelType = useAtomValue(rightPanelTypeAtom); + const setRightPanelOpen = useSetAtom(rightPanelOpenAtom); const workerRef = useRef(null); // ๐Ÿ”ฅ Web Worker for Project Parsing @@ -162,12 +168,17 @@ const AppContent: React.FC = () => { {/* Left Sidebar: File Explorer */} - {/* Main Content Area: Canvas or IDEScrollView or CodeDocView */} + {/* Main Content Area: Canvas or TabContainer (IDE/Search) or CodeDocView */}
{viewMode === 'canvas' && } - {viewMode === 'ide' && } + {(viewMode === 'ide' || viewMode === 'contentSearch') && } {viewMode === 'codeDoc' && }
+ + {/* Right Panel: Workspace */} + {rightPanelOpen && rightPanelType === 'workspace' && ( + setRightPanelOpen(false)} /> + )} )} diff --git a/src/app/ui/AppSidebar/AppSidebar.tsx b/src/app/ui/AppSidebar/AppSidebar.tsx index 9e511cb..932cfbc 100644 --- a/src/app/ui/AppSidebar/AppSidebar.tsx +++ b/src/app/ui/AppSidebar/AppSidebar.tsx @@ -3,80 +3,34 @@ * Provides resizable sidebar layout for file navigation */ -import { useAtomValue } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { ChevronDown, ChevronRight } from 'lucide-react'; import type React from 'react'; import { useRef, useState } from 'react'; import { Sidebar } from '@/components/ide/Sidebar.tsx'; import { viewModeAtom } from '@/entities/AppView/model/atoms'; -import { useOpenFile } from '@/features/File/OpenFiles/lib/useOpenFile.ts'; -import { activeTabAtom, openedTabsAtom } from '@/features/File/OpenFiles/model/atoms.ts'; -import { FileIcon } from '../../../entities/SourceFileNode/ui/FileIcon.tsx'; -import { getFileName } from '../../../shared/pathUtils.ts'; import { FileExplorer } from '../../../widgets/FileExplorer/FileExplorer.tsx'; -import { isSidebarOpenAtom } from './model/atoms.ts'; +import { RelatedFilesView } from '../../../widgets/RelatedFilesView/RelatedFilesView.tsx'; +import { fileTreeModeAtom, isSidebarOpenAtom } from './model/atoms.ts'; export const AppSidebar: React.FC = () => { const isSidebarOpen = useAtomValue(isSidebarOpenAtom); const _viewMode = useAtomValue(viewModeAtom); - const openedTabs = useAtomValue(openedTabsAtom); - const activeTab = useAtomValue(activeTabAtom); - const { openFile } = useOpenFile(); + const [fileTreeMode, setFileTreeMode] = useAtom(fileTreeModeAtom); const containerRef = useRef(null); // Collapsible states - const [isOpenedFilesCollapsed, setIsOpenedFilesCollapsed] = useState(false); const [isFileExplorerCollapsed, setIsFileExplorerCollapsed] = useState(false); if (!isSidebarOpen) { return null; } - const workspaceLabel = 'Workspace'; const projectLabel = 'Project'; return (
- {/* WORKSPACE */} - {openedTabs.length > 0 && ( -
- - {!isOpenedFilesCollapsed && ( -
- {openedTabs.map((filePath) => { - const fileName = getFileName(filePath); - const isActive = filePath === activeTab; - - return ( - - ); - })} -
- )} -
- )} - {/* PROJECT */}
- {!isFileExplorerCollapsed && } + + {!isFileExplorerCollapsed && ( + <> + {/* Mode Tabs */} +
+ + +
+ + {fileTreeMode === 'all' ? ( + + ) : ( + + )} + + )}
diff --git a/src/app/ui/AppSidebar/model/atoms.ts b/src/app/ui/AppSidebar/model/atoms.ts index b695298..4a062fc 100644 --- a/src/app/ui/AppSidebar/model/atoms.ts +++ b/src/app/ui/AppSidebar/model/atoms.ts @@ -1,8 +1,13 @@ /** * AppSidebar Widget - Atoms - * ์‚ฌ์ด๋“œ๋ฐ” ํ‘œ์‹œ ์—ฌ๋ถ€ ์ƒํƒœ + * ์‚ฌ์ด๋“œ๋ฐ” ํ‘œ์‹œ ์—ฌ๋ถ€ ๋ฐ ํŒŒ์ผ ํŠธ๋ฆฌ ๋ชจ๋“œ ์ƒํƒœ */ import { atom } from 'jotai'; // ์‚ฌ์ด๋“œ๋ฐ” ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ (Cmd/Ctrl + \ ํ† ๊ธ€) export const isSidebarOpenAtom = atom(true); + +// ํŒŒ์ผ ํŠธ๋ฆฌ ๋ชจ๋“œ: 'all' | 'related' +// all: ๋ชจ๋“  ํŒŒ์ผ ํ‘œ์‹œ +// related: ํ™œ์„ฑ ํŒŒ์ผ๊ณผ ๊ด€๋ จ๋œ ํŒŒ์ผ๋งŒ ํ‘œ์‹œ (dependencies + dependents) +export const fileTreeModeAtom = atom<'all' | 'related'>('all'); diff --git a/src/app/ui/AppTitleBar/AppTitleBar.tsx b/src/app/ui/AppTitleBar/AppTitleBar.tsx index 704fbeb..abc503f 100644 --- a/src/app/ui/AppTitleBar/AppTitleBar.tsx +++ b/src/app/ui/AppTitleBar/AppTitleBar.tsx @@ -4,7 +4,7 @@ */ import { useAtom } from 'jotai'; -import { ListTree, Network, X } from 'lucide-react'; +import { FolderOpen, X } from 'lucide-react'; import { TitleBar } from '@/components/ide/TitleBar.tsx'; import { rightPanelOpenAtom, rightPanelTypeAtom } from '@/entities/AppView/model/atoms'; @@ -19,40 +19,22 @@ export function AppTitleBar() { {/* Right Panel Tabs */}
- {/* Definition Tab */} + {/* Workspace Tab */} - - {/* Related Tab */} - {/* Close Button */} diff --git a/src/components/ide/OutlinePanel.tsx b/src/components/ide/OutlinePanel.tsx index c132244..dcd1123 100644 --- a/src/components/ide/OutlinePanel.tsx +++ b/src/components/ide/OutlinePanel.tsx @@ -1,7 +1,7 @@ import { ChevronRight, X } from 'lucide-react'; import { useEffect, useState } from 'react'; -import type { OutlineNode } from '../../shared/outlineExtractor'; import type { DefinitionSymbol } from '../../entities/SourceFileNode/lib/definitionExtractor'; +import type { OutlineNode } from '../../shared/outlineExtractor'; import { OutlinePanelItem } from './OutlinePanelItem'; export interface OutlinePanelProps { diff --git a/src/entities/AppView/model/atoms.ts b/src/entities/AppView/model/atoms.ts index 8cc4944..c2cb4f4 100644 --- a/src/entities/AppView/model/atoms.ts +++ b/src/entities/AppView/model/atoms.ts @@ -53,8 +53,8 @@ export const fullNodeMapAtom = atom((get) => { // View Mode Atoms // ============================================================================ -// ๋ทฐ ๋ชจ๋“œ - Canvas vs IDE vs CodeDoc vs JsonExplorer view (localStorage ์ €์žฅ) -export type ViewMode = 'canvas' | 'ide' | 'codeDoc' | 'jsonExplorer'; +// ๋ทฐ ๋ชจ๋“œ - Canvas vs IDE vs CodeDoc vs JsonExplorer vs ContentSearch view (localStorage ์ €์žฅ) +export type ViewMode = 'canvas' | 'ide' | 'codeDoc' | 'jsonExplorer' | 'contentSearch'; export const viewModeAtom = atomWithStorage('viewMode', 'ide'); // Default to IDE mode // ๋ฌธ์„œ ๋ชจ๋“œ - Dark vs Light (for CodeDocView) @@ -83,5 +83,8 @@ export const hoveredIdentifierAtom = atom(null); // ์šฐ์ธก ํŒจ๋„ ํ‘œ์‹œ ์—ฌ๋ถ€ (๊ธฐ๋ณธ๊ฐ’: true - ๋ฏธ๋ฆฌ ์—ด์–ด๋‘ ) export const rightPanelOpenAtom = atom(true); -// ์šฐ์ธก ํŒจ๋„ ํƒ€์ž… ('definition' | 'related') -export const rightPanelTypeAtom = atom<'definition' | 'related'>('definition'); +// ์šฐ์ธก ํŒจ๋„ ํƒ€์ž… ('workspace' | 'definition' | 'related') +// workspace: ์—ด๋ฆฐ ํŒŒ์ผ ๋ชฉ๋ก +// definition: ํŒŒ์ผ ์ •์˜/์•„์›ƒ๋ผ์ธ (์‚ญ์ œ๋จ, ๋ ˆ๊ฑฐ์‹œ) +// related: ๊ด€๋ จ ํŒŒ์ผ (์‚ญ์ œ๋จ, ๋ ˆ๊ฑฐ์‹œ) +export const rightPanelTypeAtom = atom<'workspace' | 'definition' | 'related'>('workspace'); diff --git a/src/entities/SourceFileNode/lib/definitionExtractor.ts b/src/entities/SourceFileNode/lib/definitionExtractor.ts index 112fdc0..a8d922b 100644 --- a/src/entities/SourceFileNode/lib/definitionExtractor.ts +++ b/src/entities/SourceFileNode/lib/definitionExtractor.ts @@ -5,8 +5,8 @@ */ import ts from 'typescript'; -import type { SourceFileNode } from '../model/types'; import { createLanguageService } from '../../../shared/tsParser/utils/languageService'; +import type { SourceFileNode } from '../model/types'; // Re-export OutlinePanel types (from LIMN component) export type SymbolKind = diff --git a/src/entities/SourceFileNode/lib/getters.ts b/src/entities/SourceFileNode/lib/getters.ts index b93bb8d..adda167 100644 --- a/src/entities/SourceFileNode/lib/getters.ts +++ b/src/entities/SourceFileNode/lib/getters.ts @@ -122,3 +122,32 @@ export function getLocalIdentifiers(node: SourceFileNode): Set { visit(node.sourceFile); return locals; } + +/** + * ํŠน์ • ํŒŒ์ผ์„ importํ•˜๊ณ  ์žˆ๋Š” ํŒŒ์ผ๋“ค์„ ์ฐพ์Œ (dependents) + * @param targetFilePath - ์ฐพ์œผ๋ ค๋Š” ํŒŒ์ผ ๊ฒฝ๋กœ + * @param fullNodeMap - ์ „์ฒด ํŒŒ์ผ ๋…ธ๋“œ ๋งต + * @param files - ํŒŒ์ผ ์ฝ˜ํ…์ธ  ๋งต + * @param resolvePath - ๊ฒฝ๋กœ ํ•ด์„ ํ•จ์ˆ˜ + * @returns targetFilePath๋ฅผ importํ•˜๋Š” ํŒŒ์ผ ๊ฒฝ๋กœ ๋ฐฐ์—ด + */ +export function getDependents( + targetFilePath: string, + fullNodeMap: Map, + files: Record, + resolvePath: (from: string, to: string, files: Record) => string | null +): string[] { + const dependents: string[] = []; + + fullNodeMap.forEach((node) => { + // Symbol ๋…ธ๋“œ๋Š” ์Šคํ‚ต (type === 'file'๋งŒ ์ฒ˜๋ฆฌ) + if (node.type !== 'file') return; + + const deps = getDependencies(node, files, resolvePath); + if (deps.includes(targetFilePath)) { + dependents.push(node.filePath); + } + }); + + return dependents; +} diff --git a/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx b/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx index 18faf66..d247160 100644 --- a/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx +++ b/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx @@ -4,19 +4,22 @@ * - ๋ Œ๋”๋ง ์—†๋Š” ๋กœ์ง ์ „์šฉ ์ปดํฌ๋„ŒํŠธ */ -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useEffect, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { isSidebarOpenAtom } from '@/app/ui/AppSidebar/model/atoms'; import { viewModeAtom } from '@/entities/AppView/model/atoms'; import { useOpenFile } from '@/features/File/OpenFiles/lib/useOpenFile'; import { searchModalOpenAtom } from '@/features/Search/UnifiedSearch/model/atoms'; +import { activeTabIdAtom, openedTabsAtom } from '@/widgets/MainContents/model/atoms'; const GLOBAL_HOTKEYS = { TOGGLE_SIDEBAR: 'mod+\\', TOGGLE_VIEW_MODE: 'backquote', CLOSE_FILE: 'mod+w', CLOSE_FILE_ESC: 'escape', + CONTENT_SEARCH: 'mod+shift+f', + NEW_SEARCH_TAB: 'mod+shift+tab', } as const; export const KeyboardShortcuts = () => { @@ -25,6 +28,8 @@ export const KeyboardShortcuts = () => { const viewMode = useAtomValue(viewModeAtom); const setViewMode = useSetAtom(viewModeAtom); const { closeFile } = useOpenFile(); + const [openedTabs, setOpenedTabs] = useAtom(openedTabsAtom); + const setActiveTabId = useSetAtom(activeTabIdAtom); // Global hotkeys (no ref needed - always active) useHotkeys( @@ -47,10 +52,40 @@ export const KeyboardShortcuts = () => { closeFile(); console.log('[KeyboardShortcuts] Close current file'); break; + case GLOBAL_HOTKEYS.CONTENT_SEARCH: + setViewMode('contentSearch'); + console.log('[KeyboardShortcuts] Content search view opened'); + break; + case GLOBAL_HOTKEYS.NEW_SEARCH_TAB: { + // ์ƒˆ Search ํƒญ ์—ด๊ธฐ + const existingSearchTab = openedTabs.find((tab) => tab.type === 'search'); + + if (existingSearchTab) { + // ์ด๋ฏธ Search ํƒญ์ด ์žˆ์œผ๋ฉด ๊ทธ ํƒญ์œผ๋กœ ์ „ํ™˜ + setActiveTabId(existingSearchTab.id); + } else { + // ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ + const newSearchTab = { + id: `search-${Date.now()}`, + type: 'search' as const, + label: 'Search', + }; + setOpenedTabs([...openedTabs, newSearchTab]); + setActiveTabId(newSearchTab.id); + } + + // viewMode๋ฅผ TabContainer๊ฐ€ ํ‘œ์‹œ๋˜๋„๋ก ์„ค์ • + if (viewMode !== 'ide' && viewMode !== 'contentSearch') { + setViewMode('contentSearch'); + } + + console.log('[KeyboardShortcuts] New search tab opened'); + break; + } } }, { enableOnFormTags: true }, - [setIsSidebarOpen, setViewMode, viewMode, closeFile] + [setIsSidebarOpen, setViewMode, viewMode, closeFile, openedTabs, setOpenedTabs, setActiveTabId] ); // Shift+Shift (๋”๋ธ”ํƒญ) - ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ ์—ด๊ธฐ diff --git a/src/features/Search/ContentSearch/lib/searchContent.ts b/src/features/Search/ContentSearch/lib/searchContent.ts new file mode 100644 index 0000000..3217410 --- /dev/null +++ b/src/features/Search/ContentSearch/lib/searchContent.ts @@ -0,0 +1,143 @@ +/** + * Content Search Logic + * Grep-style search across all files + */ + +import { getFileName } from '../../../../shared/pathUtils'; +import type { ContentMatch, ContentSearchOptions, ContentSearchResult } from '../model/types'; + +const MAX_MATCHES_PER_FILE = 100; +const MAX_TOTAL_RESULTS = 500; + +/** + * Search for query in all files + */ +export function searchInContent( + files: Record, + query: string, + options: ContentSearchOptions +): ContentSearchResult[] { + if (!query.trim()) { + return []; + } + + const results: ContentSearchResult[] = []; + let totalResultCount = 0; + + for (const [filePath, content] of Object.entries(files)) { + if (totalResultCount >= MAX_TOTAL_RESULTS) { + break; + } + + const matches = searchInFile(content, query, options); + + if (matches.length > 0) { + results.push({ + filePath, + fileName: getFileName(filePath), + matches: matches.slice(0, MAX_MATCHES_PER_FILE), + totalMatches: matches.length, + }); + totalResultCount += matches.length; + } + } + + return results; +} + +/** + * Search for query in a single file + */ +function searchInFile(content: string, query: string, options: ContentSearchOptions): ContentMatch[] { + const matches: ContentMatch[] = []; + const lines = content.split('\n'); + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const lineText = lines[lineIndex]; + const lineMatches = findMatchesInLine(lineText, query, lineIndex + 1, options); + matches.push(...lineMatches); + } + + return matches; +} + +/** + * Find all matches in a single line + */ +function findMatchesInLine( + lineText: string, + query: string, + lineNumber: number, + options: ContentSearchOptions +): ContentMatch[] { + const matches: ContentMatch[] = []; + + if (options.useRegex) { + // Regex search + try { + const flags = options.caseSensitive ? 'g' : 'gi'; + const regex = new RegExp(query, flags); + let match: RegExpExecArray | null = regex.exec(lineText); + + while (match !== null) { + matches.push({ + line: lineNumber, + column: match.index + 1, + text: lineText, + matchStart: match.index, + matchEnd: match.index + match[0].length, + }); + match = regex.exec(lineText); + } + } catch (_e) { + // Invalid regex, skip + return []; + } + } else { + // Plain text search + const searchText = options.caseSensitive ? lineText : lineText.toLowerCase(); + const searchQuery = options.caseSensitive ? query : query.toLowerCase(); + + if (options.wholeWord) { + // Whole word search + const wordRegex = new RegExp(`\\b${escapeRegex(searchQuery)}\\b`, options.caseSensitive ? 'g' : 'gi'); + let match: RegExpExecArray | null = wordRegex.exec(lineText); + + while (match !== null) { + matches.push({ + line: lineNumber, + column: match.index + 1, + text: lineText, + matchStart: match.index, + matchEnd: match.index + match[0].length, + }); + match = wordRegex.exec(lineText); + } + } else { + // Plain substring search + let startIndex = 0; + let matchIndex = searchText.indexOf(searchQuery, startIndex); + + while (matchIndex !== -1) { + matches.push({ + line: lineNumber, + column: matchIndex + 1, + text: lineText, + matchStart: matchIndex, + matchEnd: matchIndex + searchQuery.length, + }); + startIndex = matchIndex + 1; + matchIndex = searchText.indexOf(searchQuery, startIndex); + } + } + } + + return matches; +} + +/** + * Escape special regex characters + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/features/Search/ContentSearch/model/atoms.ts b/src/features/Search/ContentSearch/model/atoms.ts new file mode 100644 index 0000000..9fd235b --- /dev/null +++ b/src/features/Search/ContentSearch/model/atoms.ts @@ -0,0 +1,24 @@ +/** + * ContentSearch Atoms + * State management for content search feature + * Note: View visibility is controlled by viewModeAtom ('contentSearch') + */ + +import { atom } from 'jotai'; +import type { ContentSearchOptions, ContentSearchResult } from './types'; + +// Search query +export const contentSearchQueryAtom = atom(''); + +// Search results +export const contentSearchResultsAtom = atom([]); + +// Search options +export const contentSearchOptionsAtom = atom({ + caseSensitive: false, + useRegex: false, + wholeWord: false, +}); + +// Loading state +export const contentSearchLoadingAtom = atom(false); diff --git a/src/features/Search/ContentSearch/model/types.ts b/src/features/Search/ContentSearch/model/types.ts new file mode 100644 index 0000000..1a15042 --- /dev/null +++ b/src/features/Search/ContentSearch/model/types.ts @@ -0,0 +1,25 @@ +/** + * ContentSearch Types + * Types for file content search results + */ + +export interface ContentSearchResult { + filePath: string; + fileName: string; + matches: ContentMatch[]; + totalMatches: number; +} + +export interface ContentMatch { + line: number; + column: number; + text: string; + matchStart: number; + matchEnd: number; +} + +export interface ContentSearchOptions { + caseSensitive: boolean; + useRegex: boolean; + wholeWord: boolean; +} diff --git a/src/widgets/CodeDocView/lib/tsAdapter.ts b/src/widgets/CodeDocView/lib/tsAdapter.ts index 3940e07..9200c28 100644 --- a/src/widgets/CodeDocView/lib/tsAdapter.ts +++ b/src/widgets/CodeDocView/lib/tsAdapter.ts @@ -4,8 +4,8 @@ */ import * as ts from 'typescript'; -import type { SourceFileNode } from '../../../entities/SourceFileNode/model/types'; import { extractDefinitions } from '../../../entities/SourceFileNode/lib/definitionExtractor'; +import type { SourceFileNode } from '../../../entities/SourceFileNode/model/types'; import { getFileName } from '../../../shared/pathUtils'; import type { DocBlock, DocData, ImportItem, SymbolDetail } from '../model/types'; import { parseCodeDoc } from './parseCodeDoc'; @@ -126,7 +126,7 @@ function _generateFlowchart(sourceFile: ts.SourceFile, body: ts.Block | ts.Node) const getId = () => `N${nodeIdCounter++}`; const getSubgraphId = () => `SG${subgraphIdCounter++}`; // Mermaid ํ…์ŠคํŠธ์—์„œ ํŠน์ˆ˜๋ฌธ์ž๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ - const escapeMermaid = (str: string) => + const _escapeMermaid = (str: string) => str .replace(/"/g, '#quot;') // " โ†’ #quot; .replace(/\[/g, '#91;') // [ โ†’ #91; diff --git a/src/widgets/FileExplorer/FileExplorer.tsx b/src/widgets/FileExplorer/FileExplorer.tsx index f46d2f4..cdebe79 100644 --- a/src/widgets/FileExplorer/FileExplorer.tsx +++ b/src/widgets/FileExplorer/FileExplorer.tsx @@ -19,18 +19,26 @@ import { getFlatItemList } from './lib/getFlatItemList'; import { getInitialCollapsedFolders } from './lib/getInitialCollapsedFolders'; import { FolderBreadcrumb } from './ui/FolderBreadcrumb'; -export function FileExplorer({ containerRef }: { containerRef: React.RefObject }) { +interface FileExplorerProps { + containerRef: React.RefObject; + filteredFiles?: Record; +} + +export function FileExplorer({ containerRef, filteredFiles }: FileExplorerProps) { const files = useAtomValue(filesAtom); const activeTab = useAtomValue(activeTabAtom); const openedTabs = useAtomValue(openedTabsAtom); const { openFile } = useOpenFile(); const [focusedFolder, setFocusedFolder] = useAtom(focusedFolderAtom); + // Use filtered files if provided, otherwise use all files + const displayFiles = filteredFiles || files; + // Collapsed folders state - initial: root level open, others collapsed - const [collapsedFolders, setCollapsedFolders] = useState>(() => getInitialCollapsedFolders(files)); + const [collapsedFolders, setCollapsedFolders] = useState>(() => getInitialCollapsedFolders(displayFiles)); // Build file tree from flat file list (with Folder Focus Mode support) - const fileTree = useMemo(() => buildFileTree(files, focusedFolder), [files, focusedFolder]); + const fileTree = useMemo(() => buildFileTree(displayFiles, focusedFolder), [displayFiles, focusedFolder]); // Flat list of all visible items for keyboard navigation const flatItemList = useMemo(() => getFlatItemList(fileTree, collapsedFolders), [fileTree, collapsedFolders]); diff --git a/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx b/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx new file mode 100644 index 0000000..f239db8 --- /dev/null +++ b/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx @@ -0,0 +1,290 @@ +/** + * ContentSearchView - File content search view (Cmd+Shift+F) + * Grep-style search across all files (mainContent tab, not modal) + */ + +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { Search } from 'lucide-react'; +import { useEffect, useMemo, useRef } from 'react'; +import { filesAtom, viewModeAtom } from '../../../entities/AppView/model/atoms'; +import { useOpenFile } from '../../../features/File/OpenFiles/lib/useOpenFile'; +import { searchInContent } from '../../../features/Search/ContentSearch/lib/searchContent'; +import { + contentSearchLoadingAtom, + contentSearchOptionsAtom, + contentSearchQueryAtom, + contentSearchResultsAtom, +} from '../../../features/Search/ContentSearch/model/atoms'; +import { useListKeyboardNavigation } from '../../../shared/hooks/useListKeyboardNavigation'; +import { getFileName } from '../../../shared/pathUtils'; + +export function ContentSearchView() { + const viewMode = useAtomValue(viewModeAtom); + const setViewMode = useSetAtom(viewModeAtom); + const [query, setQuery] = useAtom(contentSearchQueryAtom); + const [options, setOptions] = useAtom(contentSearchOptionsAtom); + const setResults = useSetAtom(contentSearchResultsAtom); + const setLoading = useSetAtom(contentSearchLoadingAtom); + const results = useAtomValue(contentSearchResultsAtom); + const files = useAtomValue(filesAtom); + const { openFile } = useOpenFile(); + + const inputRef = useRef(null); + + const isActive = viewMode === 'contentSearch'; + + // Focus input when view opens + useEffect(() => { + if (isActive) { + inputRef.current?.focus(); + } + }, [isActive]); + + // Debounced search + useEffect(() => { + if (!isActive) return; + + const timeoutId = setTimeout(() => { + if (query.trim()) { + setLoading(true); + const searchResults = searchInContent(files, query, options); + setResults(searchResults); + setLoading(false); + // Note: focusedIndex is automatically reset to 0 by useListKeyboardNavigation when items change + } else { + setResults([]); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [query, options, files, isActive, setResults, setLoading]); + + // Flatten results for navigation + const flatResults = useMemo(() => { + const flat: Array<{ type: 'file' | 'match'; fileIndex: number; matchIndex?: number }> = []; + results.forEach((result, fileIndex) => { + flat.push({ type: 'file', fileIndex }); + result.matches.forEach((_, matchIndex) => { + flat.push({ type: 'match', fileIndex, matchIndex }); + }); + }); + return flat; + }, [results]); + + // Keyboard navigation with auto-scroll + const { focusedIndex, setFocusedIndex, itemRefs, scrollContainerRef } = useListKeyboardNavigation({ + items: flatResults, + onSelect: (item) => { + const result = results[item.fileIndex]; + openFile(result.filePath); + setViewMode('ide'); + // TODO: Scroll to line number if match item + }, + onClose: () => { + setViewMode('ide'); + setQuery(''); + setResults([]); + }, + scope: 'contentSearch', + enabled: isActive, + enableOnFormTags: true, + }); + + // Get current preview info + const previewInfo = useMemo(() => { + if (flatResults.length === 0 || focusedIndex >= flatResults.length) return null; + + const focused = flatResults[focusedIndex]; + const result = results[focused.fileIndex]; + const fileContent = files[result.filePath] || ''; + const matchLine = focused.matchIndex !== undefined ? result.matches[focused.matchIndex].line : undefined; + + return { + filePath: result.filePath, + fileName: getFileName(result.filePath), + content: fileContent, + matchLine, + }; + }, [flatResults, focusedIndex, results, files]); + + // Auto-scroll to matched line in preview + const previewRef = useRef(null); + useEffect(() => { + if (!previewRef.current || !previewInfo?.matchLine) return; + + // Find line element and scroll into view + const lineElements = previewRef.current.querySelectorAll('[data-line]'); + const targetLine = Array.from(lineElements).find( + (el) => el.getAttribute('data-line') === String(previewInfo.matchLine) + ); + + if (targetLine) { + targetLine.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [previewInfo?.matchLine]); + + let currentFlatIndex = 0; + + return ( +
+ {/* Header */} +
+ + setQuery(e.target.value)} + placeholder="Search in files... (Cmd+Shift+F)" + className="flex-1 bg-transparent text-sm text-text-primary placeholder-text-tertiary outline-none" + /> +
+ + {/* Options */} +
+ + + +
+ + {/* Main content: Results (left) + Preview (right) */} +
+ {/* Results List */} +
+ {results.length === 0 ? ( +
+ {query ? 'No results found' : 'Type to search...'} +
+ ) : ( +
+ {results.map((result, _fileIndex) => { + const fileItemIndex = currentFlatIndex++; + const isFileFocused = focusedIndex === fileItemIndex; + + return ( +
+ {/* File header */} + + + {/* Matches */} +
+ {result.matches.map((match, matchIndex) => { + const matchItemIndex = currentFlatIndex++; + const isMatchFocused = focusedIndex === matchItemIndex; + + return ( + + ); + })} +
+
+ ); + })} +
+ )} +
+ + {/* Preview Panel */} +
+ {previewInfo ? ( + <> + {/* Preview Header */} +
+ {previewInfo.fileName} + {previewInfo.filePath} +
+ + {/* Preview Content */} +
+                {previewInfo.content.split('\n').map((line, index) => {
+                  const lineNumber = index + 1;
+                  const isMatchLine = lineNumber === previewInfo.matchLine;
+
+                  return (
+                    
+ + {lineNumber} + + {line} +
+ ); + })} +
+ + ) : ( +
+ Select a search result to preview +
+ )} +
+
+
+ ); +} diff --git a/src/widgets/MainContents/TabContainer.tsx b/src/widgets/MainContents/TabContainer.tsx new file mode 100644 index 0000000..61ab811 --- /dev/null +++ b/src/widgets/MainContents/TabContainer.tsx @@ -0,0 +1,81 @@ +/** + * TabContainer - Main content tab container + * Dynamic horizontal tabs for IDE and Search views + */ + +import { useAtom } from 'jotai'; +import { X } from 'lucide-react'; +import { ContentSearchView } from './ContentSearchView/ContentSearchView'; +import IDEScrollView from './IDEScrollView/IDEScrollView'; +import { activeTabIdAtom, openedTabsAtom } from './model/atoms'; + +export function TabContainer() { + const [openedTabs, setOpenedTabs] = useAtom(openedTabsAtom); + const [activeTabId, setActiveTabId] = useAtom(activeTabIdAtom); + + const activeTab = openedTabs.find((tab) => tab.id === activeTabId); + + const handleCloseTab = (tabId: string) => { + const tabIndex = openedTabs.findIndex((tab) => tab.id === tabId); + if (tabIndex === -1 || openedTabs.length === 1) return; // ๋งˆ์ง€๋ง‰ ํƒญ์€ ๋‹ซ์ง€ ์•Š์Œ + + const newTabs = openedTabs.filter((tab) => tab.id !== tabId); + setOpenedTabs(newTabs); + + // ๋‹ซ์€ ํƒญ์ด ํ™œ์„ฑ ํƒญ์ด๋ฉด ๋‹ค๋ฅธ ํƒญ์œผ๋กœ ์ „ํ™˜ + if (activeTabId === tabId) { + const newActiveIndex = Math.max(0, tabIndex - 1); + setActiveTabId(newTabs[newActiveIndex].id); + } + }; + + return ( +
+ {/* Tab Bar */} +
+ {openedTabs.map((tab) => { + const isActive = tab.id === activeTabId; + const isClosable = openedTabs.length > 1; + + return ( +
+ + {isClosable && ( + + )} +
+ ); + })} +
+ + {/* Tab Content */} +
+ {activeTab?.type === 'ide' && } + {activeTab?.type === 'search' && } +
+
+ ); +} diff --git a/src/widgets/MainContents/model/atoms.ts b/src/widgets/MainContents/model/atoms.ts new file mode 100644 index 0000000..38f7efd --- /dev/null +++ b/src/widgets/MainContents/model/atoms.ts @@ -0,0 +1,20 @@ +/** + * MainContents Tab State + * Dynamic tab system for main content area + */ + +import { atom } from 'jotai'; + +export type TabType = 'ide' | 'search'; + +export interface ContentTab { + id: string; + type: TabType; + label: string; +} + +// Opened tabs (array of tabs) +export const openedTabsAtom = atom([{ id: 'ide-default', type: 'ide', label: 'IDE' }]); + +// Active tab ID +export const activeTabIdAtom = atom('ide-default'); diff --git a/src/widgets/RelatedFilesView/RelatedFilesView.tsx b/src/widgets/RelatedFilesView/RelatedFilesView.tsx new file mode 100644 index 0000000..aa0a69f --- /dev/null +++ b/src/widgets/RelatedFilesView/RelatedFilesView.tsx @@ -0,0 +1,132 @@ +/** + * RelatedFilesView - Shows files related to the active file + * Displays dependencies (imports) and dependents (imported by) in separate sections + */ + +import { useAtomValue } from 'jotai'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { filesAtom, fullNodeMapAtom } from '@/entities/AppView/model/atoms'; +import { getDependencies, getDependents } from '@/entities/SourceFileNode/lib/getters'; +import { activeTabAtom } from '@/features/File/OpenFiles/model/atoms'; +import { resolvePath } from '@/shared/tsParser/utils/pathResolver'; +import { FileExplorer } from '../FileExplorer/FileExplorer'; + +export function RelatedFilesView({ containerRef }: { containerRef: React.RefObject }) { + const files = useAtomValue(filesAtom); + const fullNodeMap = useAtomValue(fullNodeMapAtom); + const activeTab = useAtomValue(activeTabAtom); + + const [isDependenciesCollapsed, setIsDependenciesCollapsed] = useState(false); + const [isDependentsCollapsed, setIsDependentsCollapsed] = useState(false); + + // Calculate dependencies and dependents for active file + const { dependencies, dependents } = useMemo(() => { + if (!activeTab || !fullNodeMap.has(activeTab)) { + return { dependencies: [], dependents: [] }; + } + + const node = fullNodeMap.get(activeTab); + if (!node || node.type !== 'file') { + return { dependencies: [], dependents: [] }; + } + + const deps = getDependencies(node, files, resolvePath); + const dependentsFiles = getDependents(activeTab, fullNodeMap, files, resolvePath); + + return { + dependencies: deps, + dependents: dependentsFiles, + }; + }, [activeTab, fullNodeMap, files]); + + // Filter files to only include dependencies + const dependenciesFiles = useMemo(() => { + const filtered: Record = {}; + dependencies.forEach((filePath) => { + if (files[filePath]) { + filtered[filePath] = files[filePath]; + } + }); + return filtered; + }, [dependencies, files]); + + // Filter files to only include dependents + const dependentsFilesRecord = useMemo(() => { + const filtered: Record = {}; + dependents.forEach((filePath) => { + if (files[filePath]) { + filtered[filePath] = files[filePath]; + } + }); + return filtered; + }, [dependents, files]); + + if (!activeTab) { + return ( +
+ Open a file to see related files +
+ ); + } + + if (dependencies.length === 0 && dependents.length === 0) { + return ( +
+ No related files found +
+ ); + } + + return ( +
+ {/* Dependencies Section */} + {dependencies.length > 0 && ( +
+ + {!isDependenciesCollapsed && ( +
+ +
+ )} +
+ )} + + {/* Dependents Section */} + {dependents.length > 0 && ( +
+ + {!isDependentsCollapsed && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/src/widgets/WorkspacePanel/WorkspacePanel.tsx b/src/widgets/WorkspacePanel/WorkspacePanel.tsx new file mode 100644 index 0000000..ff9f47a --- /dev/null +++ b/src/widgets/WorkspacePanel/WorkspacePanel.tsx @@ -0,0 +1,65 @@ +/** + * WorkspacePanel - Right panel showing opened files + * Displays list of currently opened files with quick navigation + */ + +import { useAtomValue } from 'jotai'; +import { X } from 'lucide-react'; +import { useOpenFile } from '@/features/File/OpenFiles/lib/useOpenFile'; +import { activeTabAtom, openedTabsAtom } from '@/features/File/OpenFiles/model/atoms'; +import { FileIcon } from '../../entities/SourceFileNode/ui/FileIcon'; +import { getFileName } from '../../shared/pathUtils'; + +interface WorkspacePanelProps { + onClose: () => void; +} + +export function WorkspacePanel({ onClose }: WorkspacePanelProps) { + const openedTabs = useAtomValue(openedTabsAtom); + const activeTab = useAtomValue(activeTabAtom); + const { openFile } = useOpenFile(); + + return ( +
+ {/* Header */} +
+ Workspace + +
+ + {/* Content */} +
+ {openedTabs.length === 0 ? ( +
+ No files opened +
+ ) : ( + openedTabs.map((filePath) => { + const fileName = getFileName(filePath); + const isActive = filePath === activeTab; + + return ( + + ); + }) + )} +
+
+ ); +}