From 9399ad48ebfa806d11f05f285e1b20bfae3a0a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 8 Jan 2026 11:57:35 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20Related=20Files=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20-=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=98=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=95=84=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Project 섹션에 All Files / Related 탭 추가 - Related 모드: 활성 파일과 관련된 파일만 트리에 표시 - Dependencies: 현재 파일이 import하는 파일들 (상단) - Dependents: 현재 파일을 import하는 파일들 (하단) - getDependents() 함수 구현 (getters.ts) - fullNodeMap을 순회하며 targetFilePath를 import하는 파일 찾기 - RelatedFilesView 위젯 생성 - Dependencies/Dependents를 접을 수 있는 섹션으로 분리 - 각 섹션에 파일 개수 표시 - FolderView 형식 그대로 유지 (TreeView 재사용) - FileExplorer에 filteredFiles props 추가 - 외부에서 필터링된 파일 목록 전달 가능 - fileTreeModeAtom 추가 (all | related) - AppSidebar가 모드에 따라 FileExplorer / RelatedFilesView 전환 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app/ui/AppSidebar/AppSidebar.tsx | 41 +++++- src/app/ui/AppSidebar/model/atoms.ts | 7 +- src/components/ide/OutlinePanel.tsx | 2 +- .../SourceFileNode/lib/definitionExtractor.ts | 2 +- src/entities/SourceFileNode/lib/getters.ts | 29 ++++ src/widgets/CodeDocView/lib/tsAdapter.ts | 4 +- src/widgets/FileExplorer/FileExplorer.tsx | 14 +- .../RelatedFilesView/RelatedFilesView.tsx | 132 ++++++++++++++++++ 8 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 src/widgets/RelatedFilesView/RelatedFilesView.tsx diff --git a/src/app/ui/AppSidebar/AppSidebar.tsx b/src/app/ui/AppSidebar/AppSidebar.tsx index 9e511cb..3ac919b 100644 --- a/src/app/ui/AppSidebar/AppSidebar.tsx +++ b/src/app/ui/AppSidebar/AppSidebar.tsx @@ -3,7 +3,7 @@ * 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'; @@ -14,13 +14,15 @@ import { activeTabAtom, openedTabsAtom } from '@/features/File/OpenFiles/model/a 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 [fileTreeMode, setFileTreeMode] = useAtom(fileTreeModeAtom); const { openFile } = useOpenFile(); const containerRef = useRef(null); @@ -90,7 +92,40 @@ export const AppSidebar: React.FC = () => { )} - {!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/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/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/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/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 && ( +
+ +
+ )} +
+ )} +
+ ); +} From df6be487cc5c91c54d9461fa0715ff9f6c351287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 8 Jan 2026 12:06:06 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20Workspace=EB=A5=BC=20Sidebar?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkspacePanel 위젯 생성 - 열린 파일 목록 표시 - 파일 클릭 시 해당 파일로 이동 - Close 버튼으로 패널 닫기 - AppSidebar에서 Workspace 섹션 제거 - Project 섹션만 남김 (All Files / Related 탭) - 불필요한 import 및 상태 제거 - rightPanelTypeAtom에 'workspace' 타입 추가 - workspace: 열린 파일 목록 (새로운 기본값) - definition, related: 레거시 (삭제됨) - AppTitleBar에 Workspace 탭 추가 - Definition, Related 탭 제거 - Workspace 탭만 표시 - App.tsx에서 WorkspacePanel 렌더링 - 오른쪽 패널 위치에 배치 - rightPanelOpen && rightPanelType === 'workspace' 조건부 렌더링 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.tsx | 11 ++++ src/app/ui/AppSidebar/AppSidebar.tsx | 48 -------------- src/app/ui/AppTitleBar/AppTitleBar.tsx | 32 ++------- src/entities/AppView/model/atoms.ts | 7 +- src/widgets/WorkspacePanel/WorkspacePanel.tsx | 65 +++++++++++++++++++ 5 files changed, 88 insertions(+), 75 deletions(-) create mode 100644 src/widgets/WorkspacePanel/WorkspacePanel.tsx diff --git a/src/App.tsx b/src/App.tsx index 590faff..e11b921 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'; @@ -25,6 +27,7 @@ import PipelineCanvas from '@/widgets/MainContents/PipelineCanvas/PipelineCanvas 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 @@ -168,6 +174,11 @@ const AppContent: React.FC = () => { {viewMode === 'ide' && } {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 3ac919b..932cfbc 100644 --- a/src/app/ui/AppSidebar/AppSidebar.tsx +++ b/src/app/ui/AppSidebar/AppSidebar.tsx @@ -9,10 +9,6 @@ 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 { RelatedFilesView } from '../../../widgets/RelatedFilesView/RelatedFilesView.tsx'; import { fileTreeModeAtom, isSidebarOpenAtom } from './model/atoms.ts'; @@ -20,65 +16,21 @@ 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 [fileTreeMode, setFileTreeMode] = useAtom(fileTreeModeAtom); - const { openFile } = useOpenFile(); 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 */}
- - {/* Related Tab */} - {/* Close Button */} diff --git a/src/entities/AppView/model/atoms.ts b/src/entities/AppView/model/atoms.ts index 8cc4944..9fa9335 100644 --- a/src/entities/AppView/model/atoms.ts +++ b/src/entities/AppView/model/atoms.ts @@ -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/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 ( + + ); + }) + )} +
+
+ ); +} From 79fd9e94f99af7fc38f2568d966105778f8a24b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 8 Jan 2026 14:20:43 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(Cmd+Shift+F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 파일에서 텍스트를 검색하는 Content Search 기능 구현: - Grep 스타일 검색 로직 (대소문자 구분, 단어 단위, 정규식 지원) - ContentSearchModal UI 컴포넌트 (검색 결과 트리 뷰) - Cmd+Shift+F 단축키로 모달 열기 - 파일별 매칭 결과 그룹화 및 하이라이트 - Shift+Shift (UnifiedSearch)와 별도 scope로 분리 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.tsx | 4 + .../KeyboardShortcuts/KeyboardShortcuts.tsx | 9 +- .../Search/ContentSearch/lib/searchContent.ts | 143 +++++++++ .../Search/ContentSearch/model/atoms.ts | 26 ++ .../Search/ContentSearch/model/types.ts | 25 ++ .../ContentSearch/ui/ContentSearchModal.tsx | 290 ++++++++++++++++++ 6 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 src/features/Search/ContentSearch/lib/searchContent.ts create mode 100644 src/features/Search/ContentSearch/model/atoms.ts create mode 100644 src/features/Search/ContentSearch/model/types.ts create mode 100644 src/features/Search/ContentSearch/ui/ContentSearchModal.tsx diff --git a/src/App.tsx b/src/App.tsx index e11b921..ffe28cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { viewModeAtom, } from '@/entities/AppView/model/atoms'; import { store } from '@/entities/AppView/model/store'; +import { ContentSearchModal } from '@/features/Search/ContentSearch/ui/ContentSearchModal'; import { UnifiedSearchModal } from '@/features/Search/UnifiedSearch/ui/UnifiedSearchModal'; import { JsonExplorer } from '@/pages/JsonExplorer/JsonExplorer'; import { deadCodePanelOpenAtom } from '@/pages/PageAnalysis/DeadCodePanel/model/atoms'; @@ -191,6 +192,9 @@ const AppContent: React.FC = () => { {/* Unified Search Modal (Shift+Shift) */} + + {/* Content Search Modal (Cmd+Shift+F) */} +
); }; diff --git a/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx b/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx index 18faf66..ea473ef 100644 --- a/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx +++ b/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx @@ -10,6 +10,7 @@ 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 { contentSearchModalOpenAtom } from '@/features/Search/ContentSearch/model/atoms'; import { searchModalOpenAtom } from '@/features/Search/UnifiedSearch/model/atoms'; const GLOBAL_HOTKEYS = { @@ -17,11 +18,13 @@ const GLOBAL_HOTKEYS = { TOGGLE_VIEW_MODE: 'backquote', CLOSE_FILE: 'mod+w', CLOSE_FILE_ESC: 'escape', + CONTENT_SEARCH: 'mod+shift+f', } as const; export const KeyboardShortcuts = () => { const setIsSidebarOpen = useSetAtom(isSidebarOpenAtom); const setSearchModalOpen = useSetAtom(searchModalOpenAtom); + const setContentSearchModalOpen = useSetAtom(contentSearchModalOpenAtom); const viewMode = useAtomValue(viewModeAtom); const setViewMode = useSetAtom(viewModeAtom); const { closeFile } = useOpenFile(); @@ -47,10 +50,14 @@ export const KeyboardShortcuts = () => { closeFile(); console.log('[KeyboardShortcuts] Close current file'); break; + case GLOBAL_HOTKEYS.CONTENT_SEARCH: + setContentSearchModalOpen(true); + console.log('[KeyboardShortcuts] Content search modal opened'); + break; } }, { enableOnFormTags: true }, - [setIsSidebarOpen, setViewMode, viewMode, closeFile] + [setIsSidebarOpen, setViewMode, viewMode, closeFile, setContentSearchModalOpen] ); // 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..9cd3c73 --- /dev/null +++ b/src/features/Search/ContentSearch/model/atoms.ts @@ -0,0 +1,26 @@ +/** + * ContentSearch Atoms + * State management for content search feature + */ + +import { atom } from 'jotai'; +import type { ContentSearchOptions, ContentSearchResult } from './types'; + +// Modal open state +export const contentSearchModalOpenAtom = atom(false); + +// 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/features/Search/ContentSearch/ui/ContentSearchModal.tsx b/src/features/Search/ContentSearch/ui/ContentSearchModal.tsx new file mode 100644 index 0000000..62910a4 --- /dev/null +++ b/src/features/Search/ContentSearch/ui/ContentSearchModal.tsx @@ -0,0 +1,290 @@ +/** + * ContentSearchModal - File content search modal (Cmd+Shift+F) + * Grep-style search across all files + */ + +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { Search, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'; +import { filesAtom } from '../../../../entities/AppView/model/atoms'; +import { useOpenFile } from '../../../File/OpenFiles/lib/useOpenFile'; +import { searchInContent } from '../lib/searchContent'; +import { + contentSearchLoadingAtom, + contentSearchModalOpenAtom, + contentSearchOptionsAtom, + contentSearchQueryAtom, + contentSearchResultsAtom, +} from '../model/atoms'; + +export function ContentSearchModal() { + const [isOpen, setIsOpen] = useAtom(contentSearchModalOpenAtom); + 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 [focusedIndex, setFocusedIndex] = useState(0); + + // Get scope control + const { enableScope, disableScope } = useHotkeysContext(); + + // Enable/disable scope + useEffect(() => { + if (isOpen) { + enableScope('contentSearch'); + } else { + disableScope('contentSearch'); + } + }, [isOpen, enableScope, disableScope]); + + // Focus input when modal opens + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + } + }, [isOpen]); + + // Debounced search + useEffect(() => { + if (!isOpen) return; + + const timeoutId = setTimeout(() => { + if (query.trim()) { + setLoading(true); + const searchResults = searchInContent(files, query, options); + setResults(searchResults); + setLoading(false); + setFocusedIndex(0); + } else { + setResults([]); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [query, options, files, isOpen, 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]); + + const handleClose = () => { + setIsOpen(false); + setQuery(''); + setResults([]); + setFocusedIndex(0); + }; + + const handleSelect = () => { + const focused = flatResults[focusedIndex]; + if (!focused) return; + + const result = results[focused.fileIndex]; + if (focused.type === 'file') { + openFile(result.filePath); + handleClose(); + } else if (focused.type === 'match' && focused.matchIndex !== undefined) { + openFile(result.filePath); + handleClose(); + // TODO: Scroll to line number + } + }; + + // Keyboard shortcuts (scoped to 'contentSearch') + useHotkeys( + 'escape', + (e) => { + e.preventDefault(); + handleClose(); + }, + { + scopes: ['contentSearch'], + enabled: isOpen, + enableOnFormTags: true, + }, + [isOpen] + ); + + useHotkeys( + 'down', + (e) => { + e.preventDefault(); + setFocusedIndex((prev) => Math.min(prev + 1, flatResults.length - 1)); + }, + { + scopes: ['contentSearch'], + enabled: isOpen, + enableOnFormTags: true, + }, + [isOpen, flatResults.length] + ); + + useHotkeys( + 'up', + (e) => { + e.preventDefault(); + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + }, + { + scopes: ['contentSearch'], + enabled: isOpen, + enableOnFormTags: true, + }, + [isOpen] + ); + + useHotkeys( + 'enter', + (e) => { + e.preventDefault(); + handleSelect(); + }, + { + scopes: ['contentSearch'], + enabled: isOpen, + enableOnFormTags: true, + }, + [isOpen, focusedIndex, flatResults, results] + ); + + if (!isOpen) return null; + + 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 */} +
+ + + +
+ + {/* Results */} +
+ {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 ( + + ); + })} +
+
+ ); + })} +
+ )} +
+
+
+ ); +} From 4be039a282242689f89d2f1e45bc5e87cd17b688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 8 Jan 2026 14:54:20 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20Content=20Search=EB=A5=BC=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EC=97=90=EC=84=9C=20mainContent=20=ED=83=AD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 워크플로우 흐름을 방해하지 않도록 검색을 메인 뷰로 전환: - ContentSearchModal 제거 → ContentSearchView 위젯으로 변경 - viewModeAtom에 'contentSearch' 타입 추가 - Cmd+Shift+F로 viewMode를 'contentSearch'로 전환 - contentSearchModalOpenAtom 제거 (viewMode로 표시/숨김 제어) - IDE/CodeDoc처럼 메인 콘텐츠 영역에서 탭 전환 방식으로 동작 - ESC로 IDE 모드로 복귀 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.tsx | 8 +- src/entities/AppView/model/atoms.ts | 4 +- .../KeyboardShortcuts/KeyboardShortcuts.tsx | 8 +- .../Search/ContentSearch/model/atoms.ts | 4 +- .../ContentSearch/ui/ContentSearchModal.tsx | 290 ------------------ .../ContentSearchView/ContentSearchView.tsx | 284 +++++++++++++++++ 6 files changed, 293 insertions(+), 305 deletions(-) delete mode 100644 src/features/Search/ContentSearch/ui/ContentSearchModal.tsx create mode 100644 src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx diff --git a/src/App.tsx b/src/App.tsx index ffe28cc..b726b34 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,11 +18,11 @@ import { viewModeAtom, } from '@/entities/AppView/model/atoms'; import { store } from '@/entities/AppView/model/store'; -import { ContentSearchModal } from '@/features/Search/ContentSearch/ui/ContentSearchModal'; import { UnifiedSearchModal } from '@/features/Search/UnifiedSearch/ui/UnifiedSearchModal'; import { JsonExplorer } from '@/pages/JsonExplorer/JsonExplorer'; import { deadCodePanelOpenAtom } from '@/pages/PageAnalysis/DeadCodePanel/model/atoms'; import { PageAnalysis } from '@/pages/PageAnalysis/PageAnalysis'; +import { ContentSearchView } from '@/widgets/MainContents/ContentSearchView/ContentSearchView'; import IDEScrollView from '@/widgets/MainContents/IDEScrollView/IDEScrollView'; import PipelineCanvas from '@/widgets/MainContents/PipelineCanvas/PipelineCanvas.tsx'; import type { SourceFileNode } from './entities/SourceFileNode/model/types'; @@ -169,11 +169,12 @@ const AppContent: React.FC = () => { {/* Left Sidebar: File Explorer */} - {/* Main Content Area: Canvas or IDEScrollView or CodeDocView */} + {/* Main Content Area: Canvas or IDEScrollView or CodeDocView or ContentSearchView */}
{viewMode === 'canvas' && } {viewMode === 'ide' && } {viewMode === 'codeDoc' && } + {viewMode === 'contentSearch' && }
{/* Right Panel: Workspace */} @@ -192,9 +193,6 @@ const AppContent: React.FC = () => { {/* Unified Search Modal (Shift+Shift) */} - - {/* Content Search Modal (Cmd+Shift+F) */} -
); }; diff --git a/src/entities/AppView/model/atoms.ts b/src/entities/AppView/model/atoms.ts index 9fa9335..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) diff --git a/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx b/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx index ea473ef..e122adf 100644 --- a/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx +++ b/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx @@ -10,7 +10,6 @@ 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 { contentSearchModalOpenAtom } from '@/features/Search/ContentSearch/model/atoms'; import { searchModalOpenAtom } from '@/features/Search/UnifiedSearch/model/atoms'; const GLOBAL_HOTKEYS = { @@ -24,7 +23,6 @@ const GLOBAL_HOTKEYS = { export const KeyboardShortcuts = () => { const setIsSidebarOpen = useSetAtom(isSidebarOpenAtom); const setSearchModalOpen = useSetAtom(searchModalOpenAtom); - const setContentSearchModalOpen = useSetAtom(contentSearchModalOpenAtom); const viewMode = useAtomValue(viewModeAtom); const setViewMode = useSetAtom(viewModeAtom); const { closeFile } = useOpenFile(); @@ -51,13 +49,13 @@ export const KeyboardShortcuts = () => { console.log('[KeyboardShortcuts] Close current file'); break; case GLOBAL_HOTKEYS.CONTENT_SEARCH: - setContentSearchModalOpen(true); - console.log('[KeyboardShortcuts] Content search modal opened'); + setViewMode('contentSearch'); + console.log('[KeyboardShortcuts] Content search view opened'); break; } }, { enableOnFormTags: true }, - [setIsSidebarOpen, setViewMode, viewMode, closeFile, setContentSearchModalOpen] + [setIsSidebarOpen, setViewMode, viewMode, closeFile] ); // Shift+Shift (더블탭) - 검색 모달 열기 diff --git a/src/features/Search/ContentSearch/model/atoms.ts b/src/features/Search/ContentSearch/model/atoms.ts index 9cd3c73..9fd235b 100644 --- a/src/features/Search/ContentSearch/model/atoms.ts +++ b/src/features/Search/ContentSearch/model/atoms.ts @@ -1,14 +1,12 @@ /** * 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'; -// Modal open state -export const contentSearchModalOpenAtom = atom(false); - // Search query export const contentSearchQueryAtom = atom(''); diff --git a/src/features/Search/ContentSearch/ui/ContentSearchModal.tsx b/src/features/Search/ContentSearch/ui/ContentSearchModal.tsx deleted file mode 100644 index 62910a4..0000000 --- a/src/features/Search/ContentSearch/ui/ContentSearchModal.tsx +++ /dev/null @@ -1,290 +0,0 @@ -/** - * ContentSearchModal - File content search modal (Cmd+Shift+F) - * Grep-style search across all files - */ - -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { Search, X } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'; -import { filesAtom } from '../../../../entities/AppView/model/atoms'; -import { useOpenFile } from '../../../File/OpenFiles/lib/useOpenFile'; -import { searchInContent } from '../lib/searchContent'; -import { - contentSearchLoadingAtom, - contentSearchModalOpenAtom, - contentSearchOptionsAtom, - contentSearchQueryAtom, - contentSearchResultsAtom, -} from '../model/atoms'; - -export function ContentSearchModal() { - const [isOpen, setIsOpen] = useAtom(contentSearchModalOpenAtom); - 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 [focusedIndex, setFocusedIndex] = useState(0); - - // Get scope control - const { enableScope, disableScope } = useHotkeysContext(); - - // Enable/disable scope - useEffect(() => { - if (isOpen) { - enableScope('contentSearch'); - } else { - disableScope('contentSearch'); - } - }, [isOpen, enableScope, disableScope]); - - // Focus input when modal opens - useEffect(() => { - if (isOpen) { - inputRef.current?.focus(); - } - }, [isOpen]); - - // Debounced search - useEffect(() => { - if (!isOpen) return; - - const timeoutId = setTimeout(() => { - if (query.trim()) { - setLoading(true); - const searchResults = searchInContent(files, query, options); - setResults(searchResults); - setLoading(false); - setFocusedIndex(0); - } else { - setResults([]); - } - }, 300); - - return () => clearTimeout(timeoutId); - }, [query, options, files, isOpen, 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]); - - const handleClose = () => { - setIsOpen(false); - setQuery(''); - setResults([]); - setFocusedIndex(0); - }; - - const handleSelect = () => { - const focused = flatResults[focusedIndex]; - if (!focused) return; - - const result = results[focused.fileIndex]; - if (focused.type === 'file') { - openFile(result.filePath); - handleClose(); - } else if (focused.type === 'match' && focused.matchIndex !== undefined) { - openFile(result.filePath); - handleClose(); - // TODO: Scroll to line number - } - }; - - // Keyboard shortcuts (scoped to 'contentSearch') - useHotkeys( - 'escape', - (e) => { - e.preventDefault(); - handleClose(); - }, - { - scopes: ['contentSearch'], - enabled: isOpen, - enableOnFormTags: true, - }, - [isOpen] - ); - - useHotkeys( - 'down', - (e) => { - e.preventDefault(); - setFocusedIndex((prev) => Math.min(prev + 1, flatResults.length - 1)); - }, - { - scopes: ['contentSearch'], - enabled: isOpen, - enableOnFormTags: true, - }, - [isOpen, flatResults.length] - ); - - useHotkeys( - 'up', - (e) => { - e.preventDefault(); - setFocusedIndex((prev) => Math.max(prev - 1, 0)); - }, - { - scopes: ['contentSearch'], - enabled: isOpen, - enableOnFormTags: true, - }, - [isOpen] - ); - - useHotkeys( - 'enter', - (e) => { - e.preventDefault(); - handleSelect(); - }, - { - scopes: ['contentSearch'], - enabled: isOpen, - enableOnFormTags: true, - }, - [isOpen, focusedIndex, flatResults, results] - ); - - if (!isOpen) return null; - - 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 */} -
- - - -
- - {/* Results */} -
- {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 ( - - ); - })} -
-
- ); - })} -
- )} -
-
-
- ); -} diff --git a/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx b/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx new file mode 100644 index 0000000..09a59a4 --- /dev/null +++ b/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx @@ -0,0 +1,284 @@ +/** + * 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, useState } from 'react'; +import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'; +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'; + +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 [focusedIndex, setFocusedIndex] = useState(0); + + const isActive = viewMode === 'contentSearch'; + + // Get scope control + const { enableScope, disableScope } = useHotkeysContext(); + + // Enable/disable scope + useEffect(() => { + if (isActive) { + enableScope('contentSearch'); + } else { + disableScope('contentSearch'); + } + }, [isActive, enableScope, disableScope]); + + // 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); + setFocusedIndex(0); + } 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]); + + const handleClose = () => { + setViewMode('ide'); + setQuery(''); + setResults([]); + setFocusedIndex(0); + }; + + const handleSelect = () => { + const focused = flatResults[focusedIndex]; + if (!focused) return; + + const result = results[focused.fileIndex]; + if (focused.type === 'file') { + openFile(result.filePath); + setViewMode('ide'); + } else if (focused.type === 'match' && focused.matchIndex !== undefined) { + openFile(result.filePath); + setViewMode('ide'); + // TODO: Scroll to line number + } + }; + + // Keyboard shortcuts (scoped to 'contentSearch') + useHotkeys( + 'escape', + (e) => { + e.preventDefault(); + handleClose(); + }, + { + scopes: ['contentSearch'], + enabled: isActive, + enableOnFormTags: true, + }, + [isActive] + ); + + useHotkeys( + 'down', + (e) => { + e.preventDefault(); + setFocusedIndex((prev) => Math.min(prev + 1, flatResults.length - 1)); + }, + { + scopes: ['contentSearch'], + enabled: isActive, + enableOnFormTags: true, + }, + [isActive, flatResults.length] + ); + + useHotkeys( + 'up', + (e) => { + e.preventDefault(); + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + }, + { + scopes: ['contentSearch'], + enabled: isActive, + enableOnFormTags: true, + }, + [isActive] + ); + + useHotkeys( + 'enter', + (e) => { + e.preventDefault(); + handleSelect(); + }, + { + scopes: ['contentSearch'], + enabled: isActive, + enableOnFormTags: true, + }, + [isActive, focusedIndex, flatResults, results] + ); + + 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 */} +
+ + + +
+ + {/* Results */} +
+ {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 ( + + ); + })} +
+
+ ); + })} +
+ )} +
+
+ ); +} From 1ee6f9012039a860821ec72771d73e887ac2b081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 8 Jan 2026 15:26:21 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20ContentSearchView=EC=97=90=20us?= =?UTF-8?q?eListKeyboardNavigation=20hooks=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재사용 가능한 hooks로 키보드 네비게이션 통합: - useListKeyboardNavigation 적용으로 ~80줄 코드 감소 - 클릭 시 선택만 (setFocusedIndex), Enter로 파일 열기 - Auto-scroll 자동 활성화 (itemRefs + scrollContainerRef) - 기존 수동 구현 useHotkeys 4개 제거 - Scope 관리 자동화 UX 개선: - 클릭: 항목 선택 (focus) - Enter: 선택된 파일 열기 + IDE 모드 전환 - ESC: IDE 모드 복귀 - 키보드 이동 시 자동 스크롤 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../ContentSearchView/ContentSearchView.tsx | 123 ++++-------------- 1 file changed, 26 insertions(+), 97 deletions(-) diff --git a/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx b/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx index 09a59a4..7b06c19 100644 --- a/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx +++ b/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx @@ -5,8 +5,7 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Search } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'; +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'; @@ -16,6 +15,7 @@ import { contentSearchQueryAtom, contentSearchResultsAtom, } from '../../../features/Search/ContentSearch/model/atoms'; +import { useListKeyboardNavigation } from '../../../shared/hooks/useListKeyboardNavigation'; export function ContentSearchView() { const viewMode = useAtomValue(viewModeAtom); @@ -29,22 +29,9 @@ export function ContentSearchView() { const { openFile } = useOpenFile(); const inputRef = useRef(null); - const [focusedIndex, setFocusedIndex] = useState(0); const isActive = viewMode === 'contentSearch'; - // Get scope control - const { enableScope, disableScope } = useHotkeysContext(); - - // Enable/disable scope - useEffect(() => { - if (isActive) { - enableScope('contentSearch'); - } else { - disableScope('contentSearch'); - } - }, [isActive, enableScope, disableScope]); - // Focus input when view opens useEffect(() => { if (isActive) { @@ -62,7 +49,7 @@ export function ContentSearchView() { const searchResults = searchInContent(files, query, options); setResults(searchResults); setLoading(false); - setFocusedIndex(0); + // Note: focusedIndex is automatically reset to 0 by useListKeyboardNavigation when items change } else { setResults([]); } @@ -83,84 +70,24 @@ export function ContentSearchView() { return flat; }, [results]); - const handleClose = () => { - setViewMode('ide'); - setQuery(''); - setResults([]); - setFocusedIndex(0); - }; - - const handleSelect = () => { - const focused = flatResults[focusedIndex]; - if (!focused) return; - - const result = results[focused.fileIndex]; - if (focused.type === 'file') { + // 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'); - } else if (focused.type === 'match' && focused.matchIndex !== undefined) { - openFile(result.filePath); - setViewMode('ide'); - // TODO: Scroll to line number - } - }; - - // Keyboard shortcuts (scoped to 'contentSearch') - useHotkeys( - 'escape', - (e) => { - e.preventDefault(); - handleClose(); - }, - { - scopes: ['contentSearch'], - enabled: isActive, - enableOnFormTags: true, + // TODO: Scroll to line number if match item }, - [isActive] - ); - - useHotkeys( - 'down', - (e) => { - e.preventDefault(); - setFocusedIndex((prev) => Math.min(prev + 1, flatResults.length - 1)); - }, - { - scopes: ['contentSearch'], - enabled: isActive, - enableOnFormTags: true, - }, - [isActive, flatResults.length] - ); - - useHotkeys( - 'up', - (e) => { - e.preventDefault(); - setFocusedIndex((prev) => Math.max(prev - 1, 0)); - }, - { - scopes: ['contentSearch'], - enabled: isActive, - enableOnFormTags: true, - }, - [isActive] - ); - - useHotkeys( - 'enter', - (e) => { - e.preventDefault(); - handleSelect(); - }, - { - scopes: ['contentSearch'], - enabled: isActive, - enableOnFormTags: true, + onClose: () => { + setViewMode('ide'); + setQuery(''); + setResults([]); }, - [isActive, focusedIndex, flatResults, results] - ); + scope: 'contentSearch', + enabled: isActive, + enableOnFormTags: true, + }); let currentFlatIndex = 0; @@ -211,7 +138,7 @@ export function ContentSearchView() { {/* Results */} -
+
{results.length === 0 ? (
{query ? 'No results found' : 'Type to search...'} @@ -227,10 +154,11 @@ export function ContentSearchView() { {/* File header */}
- {/* Results */} -
- {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 ( - + + {/* 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 +
+ )} +
); From 6945fa1bf3f56e69469732a05e290bbe53787f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 8 Jan 2026 18:19:23 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20Main=20Content=EC=97=90=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=ED=83=AD=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(IDE/Search)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TabContainer 컴포넌트로 IDE와 Search를 가로 탭으로 통합 - 동적 탭 열기/닫기 기능 (X 버튼, 마지막 탭 보호) - Cmd+Shift+Tab 단축키로 Search 탭 열기/전환 - ContentSearchView 좌우 패널 스크롤 독립화 - 탭 atoms: openedTabsAtom, activeTabIdAtom 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.tsx | 8 +- .../KeyboardShortcuts/KeyboardShortcuts.tsx | 34 +++++++- .../ContentSearchView/ContentSearchView.tsx | 2 +- src/widgets/MainContents/TabContainer.tsx | 81 +++++++++++++++++++ src/widgets/MainContents/model/atoms.ts | 20 +++++ 5 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 src/widgets/MainContents/TabContainer.tsx create mode 100644 src/widgets/MainContents/model/atoms.ts diff --git a/src/App.tsx b/src/App.tsx index b726b34..8a7b02a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,9 +22,8 @@ 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 { ContentSearchView } from '@/widgets/MainContents/ContentSearchView/ContentSearchView'; -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'; @@ -169,12 +168,11 @@ const AppContent: React.FC = () => { {/* Left Sidebar: File Explorer */} - {/* Main Content Area: Canvas or IDEScrollView or CodeDocView or ContentSearchView */} + {/* Main Content Area: Canvas or TabContainer (IDE/Search) or CodeDocView */}
{viewMode === 'canvas' && } - {viewMode === 'ide' && } + {(viewMode === 'ide' || viewMode === 'contentSearch') && } {viewMode === 'codeDoc' && } - {viewMode === 'contentSearch' && }
{/* Right Panel: Workspace */} diff --git a/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx b/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx index e122adf..d247160 100644 --- a/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx +++ b/src/features/KeyboardShortcuts/KeyboardShortcuts.tsx @@ -4,13 +4,14 @@ * - 렌더링 없는 로직 전용 컴포넌트 */ -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+\\', @@ -18,6 +19,7 @@ const GLOBAL_HOTKEYS = { CLOSE_FILE: 'mod+w', CLOSE_FILE_ESC: 'escape', CONTENT_SEARCH: 'mod+shift+f', + NEW_SEARCH_TAB: 'mod+shift+tab', } as const; export const KeyboardShortcuts = () => { @@ -26,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( @@ -52,10 +56,36 @@ export const KeyboardShortcuts = () => { 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/widgets/MainContents/ContentSearchView/ContentSearchView.tsx b/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx index 73659de..f239db8 100644 --- a/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx +++ b/src/widgets/MainContents/ContentSearchView/ContentSearchView.tsx @@ -126,7 +126,7 @@ export function ContentSearchView() { let currentFlatIndex = 0; return ( -
+
{/* Header */}
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');