diff --git a/src/assets/icons/book.svg b/src/assets/icons/book.svg new file mode 100644 index 00000000..fd749ece --- /dev/null +++ b/src/assets/icons/book.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/chart.svg b/src/assets/icons/chart.svg new file mode 100644 index 00000000..0c8e42aa --- /dev/null +++ b/src/assets/icons/chart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/git-merge.svg b/src/assets/icons/git-merge.svg new file mode 100644 index 00000000..b2d3c968 --- /dev/null +++ b/src/assets/icons/git-merge.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/image.svg b/src/assets/icons/image.svg new file mode 100644 index 00000000..9f24f683 --- /dev/null +++ b/src/assets/icons/image.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/note.svg b/src/assets/icons/note.svg new file mode 100644 index 00000000..dde7c3bb --- /dev/null +++ b/src/assets/icons/note.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/molecules/GitHubLoginButton/GitHubLoginButton.tsx b/src/components/molecules/GitHubLoginButton/GitHubLoginButton.tsx index 5e56d9b6..45651c24 100644 --- a/src/components/molecules/GitHubLoginButton/GitHubLoginButton.tsx +++ b/src/components/molecules/GitHubLoginButton/GitHubLoginButton.tsx @@ -31,8 +31,6 @@ const GitHubLoginButton = () => { } const handleMessage = (event: MessageEvent) => { - if (event.origin !== 'http://localhost:8080') return; - const { type, response } = event.data; if (type === 'GITHUB_LOGIN_SUCCESS') { setAuthSocialLogin(response); diff --git a/src/components/organisms/CodeEditor/MonacoCollaborativeEditor.module.scss b/src/components/organisms/CodeEditor/MonacoCollaborativeEditor.module.scss index 29a87713..424f516e 100644 --- a/src/components/organisms/CodeEditor/MonacoCollaborativeEditor.module.scss +++ b/src/components/organisms/CodeEditor/MonacoCollaborativeEditor.module.scss @@ -9,7 +9,6 @@ background: var(--editor-bg); } -// 에디터 컨테이너 .editorContainer { position: relative; flex: 1; @@ -31,7 +30,6 @@ } } -// 로딩 상태 .editorLoading { display: flex; flex-direction: column; @@ -68,7 +66,6 @@ } } -// 플레이스홀더 .editorPlaceholder { display: flex; align-items: center; @@ -142,7 +139,120 @@ } } -// 반응형 디자인 +.readOnlyStatus { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--readonly-border); + font-size: $font-size-xxsmall; + font-weight: $font-weight-medium; + color: var(--readonly-text); + background: var(--readonly-bg); + + @media (width <= 768px) { + padding: 6px 12px; + font-size: 11px; + } +} + +.readOnlyIcon { + flex-shrink: 0; + font-size: 14px; +} + +.readOnlyMessage { + flex: 1; + line-height: 1.4; +} + +.saveStatus { + position: absolute; + top: 8px; + right: 16px; + z-index: 100; + pointer-events: none; +} + +.savingIndicator { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid var(--save-indicator-border); + border-radius: 4px; + font-size: 11px; + font-weight: $font-weight-medium; + color: var(--save-indicator-text); + background: var(--save-indicator-bg); + backdrop-filter: blur(4px); +} + +.savingSpinner { + width: 12px; + height: 12px; + border: 2px solid var(--save-spinner-bg); + border-top: 2px solid var(--save-spinner-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.saveMode { + font-size: 10px; + opacity: 0.8; +} + +.errorStatus { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--error-border); + font-size: $font-size-xxsmall; + font-weight: $font-weight-medium; + color: var(--error-text); + background: var(--error-bg); +} + +.errorIcon { + flex-shrink: 0; + width: 16px; + height: 16px; + border-radius: 50%; + font-size: 12px; + font-weight: bold; + line-height: 16px; + text-align: center; + color: $white-2; + background: $red; +} + +.errorMessage { + flex: 1; + line-height: 1.4; +} + +.retryButton { + padding: 4px 8px; + border: 1px solid var(--retry-border); + border-radius: 4px; + font-size: 11px; + font-weight: $font-weight-medium; + color: var(--retry-text); + background: var(--retry-bg); + transition: all 0.2s ease; + cursor: pointer; + + &:hover { + background: var(--retry-hover); + transform: translateY(-1px); + } +} + +.connectionStatus { + color: var(--editor-point-color); +} + @media (width <= 768px) { .collaborativeEditor { height: 100%; @@ -169,6 +279,38 @@ } } -.connectionStatus { - color: var(--editor-point-color); +:root { + --readonly-bg: #{$gray-10}; + --readonly-border: #{$gray-8}; + --readonly-text: #{$red}; + --save-indicator-bg: #{$white-2}; + --save-indicator-border: #{$gray-8}; + --save-indicator-text: #{$gray-3}; + --save-spinner-bg: #{$gray-8}; + --save-spinner-color: #{$blue-3}; + --error-bg: rgb(220 38 38 / 10%); + --error-border: #{$red}; + --error-text: #{$red}; + --retry-bg: #{$white-2}; + --retry-border: #{$gray-7}; + --retry-text: #{$gray-3}; + --retry-hover: #{$gray-10}; +} + +:global(.dark) { + --readonly-bg: #{$gray-2}; + --readonly-border: #{$gray-4}; + --readonly-text: #{$red}; + --save-indicator-bg: #{$gray-2}; + --save-indicator-border: #{$gray-4}; + --save-indicator-text: #{$gray-7}; + --save-spinner-bg: #{$gray-4}; + --save-spinner-color: #{$blue-3}; + --error-bg: rgb(220 38 38 / 15%); + --error-border: #{$red}; + --error-text: #{$red}; + --retry-bg: #{$gray-2}; + --retry-border: #{$gray-4}; + --retry-text: #{$gray-7}; + --retry-hover: #{$gray-3}; } diff --git a/src/components/organisms/CodeEditor/MonacoCollaborativeEditor.tsx b/src/components/organisms/CodeEditor/MonacoCollaborativeEditor.tsx index 03481bb1..09411616 100644 --- a/src/components/organisms/CodeEditor/MonacoCollaborativeEditor.tsx +++ b/src/components/organisms/CodeEditor/MonacoCollaborativeEditor.tsx @@ -36,26 +36,23 @@ const MonacoCollaborativeEditor: React.FC = ({ const { getUserInfo } = useAuthStore(); const { isDarkMode } = useThemeStore(); - // 활성 탭 정보 const activeTab = openTabs.find(tab => tab.isActive); const language = activeTab ? getLanguageFromFile(activeTab.name) : 'plaintext'; - // 협업 모드가 활성화되어 있을 때만 룸 ID 생성 const roomId = activeTab && enableCollaboration ? `repo-${repoId}-${activeTab.path}` : ''; - // 협업 모드가 비활성화되어 있으면 Yjs 관련 기능 완전 비활성화 const shouldUseCollaboration = enableCollaboration && Boolean(activeTab) && Boolean(roomId); - // 사용자 정보 설정 (협업 모드일 때만) - authStore nickname 우선 사용 const authUser = getUserInfo(); const finalUserId = userId || currentUser.id || String(authUser?.id) || `user-${Date.now()}`; const finalUserName = userName || currentUser.name || authUser?.nickname || authUser?.username || 'Anonymous'; - // 에디터 내용 변경 핸들러 + const isTabReadOnly = activeTab && (activeTab.isDeleted || activeTab.hasFileTreeMismatch); + const handleContentChange = useCallback( (content: string) => { - if (!activeTab) return; + if (!activeTab || isTabReadOnly) return; console.log('에디터 내용 변경:', { tabId: activeTab.id, @@ -64,26 +61,21 @@ const MonacoCollaborativeEditor: React.FC = ({ isDirty: content !== (activeTab.content || ''), }); - // 에디터 스토어 업데이트 updateContent(content); - // 탭 스토어 업데이트 (협업 모드에서는 Yjs가 관리하므로 dirty 상태만 업데이트) if (enableCollaboration) { - // 협업 모드: Yjs가 탭 내용을 관리하므로 dirty 상태만 설정 const isDirty = content !== (activeTab.content || ''); if (isDirty) { setTabDirty(activeTab.id, true); } } else { - // 일반 모드: 탭 내용과 dirty 상태 모두 업데이트 setTabContent(activeTab.id, content); setTabDirty(activeTab.id, true); } }, - [activeTab, updateContent, setTabContent, setTabDirty, enableCollaboration] + [activeTab, updateContent, setTabContent, setTabDirty, enableCollaboration, isTabReadOnly] ); - // Monaco Editor 훅 const { editorRef, editorContainerRef, handleEditorDidMount, handleEditorChange, isSaving } = useMonacoEditor({ language, @@ -92,27 +84,22 @@ const MonacoCollaborativeEditor: React.FC = ({ enableCollaboration: shouldUseCollaboration, }); - // Yjs 협업 훅 - 협업 모드일 때만 활성화 const { isConnected, isLoading, error } = useYjsCollaboration({ roomId, editor: editorRef.current as unknown as MonacoEditorInstance | null, userId: finalUserId, userName: finalUserName, - enabled: shouldUseCollaboration, + enabled: shouldUseCollaboration && !isTabReadOnly, }); - // 일반 모드에서 탭 내용 변경 감지 및 에디터 업데이트 useEffect(() => { - // 협업 모드가 아닐 때만 실행 if (enableCollaboration || !activeTab || !editorRef.current) return; - // Monaco Editor의 타입 정의 interface MonacoEditorMethods { getValue(): string; setValue(value: string): void; } - // 안전하게 에디터 값 가져오기 const getEditorValue = (): string => { try { const editor = editorRef.current as unknown as MonacoEditorMethods; @@ -125,7 +112,6 @@ const MonacoCollaborativeEditor: React.FC = ({ return ''; }; - // 안전하게 에디터 값 설정하기 const setEditorValue = (value: string): void => { try { const editor = editorRef.current as unknown as MonacoEditorMethods; @@ -140,7 +126,19 @@ const MonacoCollaborativeEditor: React.FC = ({ const currentEditorValue = getEditorValue(); const tabContent = activeTab.content || ''; - // 에디터가 비어있고 탭에 내용이 있을 때만 업데이트 (덮어쓰기 방지) + if (activeTab.isDeleted || activeTab.hasFileTreeMismatch) { + if (currentEditorValue !== tabContent) { + console.log('읽기 전용 탭 내용 즉시 업데이트:', { + tabId: activeTab.id, + isDeleted: activeTab.isDeleted, + hasFileTreeMismatch: activeTab.hasFileTreeMismatch, + }); + setEditorValue(tabContent); + updateContent(tabContent); + } + return; + } + if (currentEditorValue === '' && tabContent !== '') { console.log('일반 모드 - 빈 에디터에 탭 내용 로드:', { tabId: activeTab.id, @@ -150,8 +148,6 @@ const MonacoCollaborativeEditor: React.FC = ({ }); setEditorValue(tabContent); - - // 에디터 스토어도 동기화 updateContent(tabContent); console.log('일반 모드 - 에디터 내용 업데이트 완료:', { @@ -159,7 +155,6 @@ const MonacoCollaborativeEditor: React.FC = ({ contentLength: tabContent.length, }); } else if (currentEditorValue !== tabContent && tabContent !== '') { - // 내용이 다르고 탭에 내용이 있으면 탭 내용으로 업데이트 console.log('일반 모드 - 탭 내용과 에디터 동기화:', { tabId: activeTab.id, fileName: activeTab.name, @@ -170,26 +165,20 @@ const MonacoCollaborativeEditor: React.FC = ({ setEditorValue(tabContent); updateContent(tabContent); } - }, [ - activeTab?.content, - activeTab?.id, - activeTab?.name, - enableCollaboration, // 협업 모드 변경 감지 - updateContent, - editorRef, - ]); - - // 에디터 변경 이벤트 핸들러 - 로딩 상태 확인 추가 + }, [activeTab, enableCollaboration, updateContent, editorRef]); + const onEditorChange = useCallback( (value: string | undefined) => { - // 탭이 로딩 중이면 무시 - if (activeTab?.isLoading) { - console.log('탭 로딩 중 - onChange 무시:', { tabId: activeTab.id }); + if (!activeTab || activeTab.isLoading || isTabReadOnly) { + console.log('탭 로딩 중이거나 읽기 전용 - onChange 무시:', { + tabId: activeTab?.id, + isLoading: activeTab?.isLoading, + isReadOnly: isTabReadOnly, + }); return; } - if (value !== undefined && activeTab && value !== (activeTab.content || '')) { - // 빈 내용으로 변경하는 경우 매우 신중하게 처리 + if (value !== undefined && value !== (activeTab.content || '')) { if (value === '' && (activeTab.content || '').length > 0) { console.warn('빈 내용으로 변경 시도 - 검증 필요:', { tabId: activeTab.id, @@ -206,10 +195,9 @@ const MonacoCollaborativeEditor: React.FC = ({ handleEditorChange(value, activeTab?.id); } }, - [handleEditorChange, activeTab?.id, activeTab?.content, activeTab?.isLoading] + [activeTab, handleEditorChange, isTabReadOnly] ); - // 협업 모드 상태 로깅 useEffect(() => { if (activeTab) { console.log('MonacoCollaborativeEditor 상태:', { @@ -221,11 +209,13 @@ const MonacoCollaborativeEditor: React.FC = ({ isConnected, contentLength: activeTab.content?.length || 0, isLoading: activeTab.isLoading, + isDeleted: activeTab.isDeleted, + hasFileTreeMismatch: activeTab.hasFileTreeMismatch, + isReadOnly: isTabReadOnly, }); } - }, [activeTab?.id, enableCollaboration, shouldUseCollaboration, roomId, isConnected, activeTab]); + }, [activeTab, enableCollaboration, shouldUseCollaboration, roomId, isConnected, isTabReadOnly]); - // 활성 탭이 없는 경우 플레이스홀더 표시 if (!activeTab) { return ( = ({ ); } - // Monaco Editor 옵션 const editorOptions = getMonacoEditorOptions(language, isDarkMode); return (
- {/* 협업 상태 표시 */} {enableCollaboration && (
{isConnected && } @@ -249,7 +237,6 @@ const MonacoCollaborativeEditor: React.FC = ({
)} - {/* 에러 표시 */} {enableCollaboration && error && (
! @@ -260,8 +247,18 @@ const MonacoCollaborativeEditor: React.FC = ({
)} - {/* 저장 상태 표시 */} - {isSaving && ( + {isTabReadOnly && ( +
+ 🔒 + + {activeTab.isDeleted + ? '이 파일은 삭제되었습니다. 파일트리에서 다른 파일을 선택해주세요.' + : '이 파일의 위치 또는 이름이 변경되었습니다. 파일트리에서 다시 한번 선택해주세요.'} + +
+ )} + + {isSaving && !isTabReadOnly && (
@@ -272,8 +269,7 @@ const MonacoCollaborativeEditor: React.FC = ({ )}
- {/* 커서 오버레이 (협업 모드에서만) */} - {enableCollaboration && isConnected && ( + {enableCollaboration && isConnected && !isTabReadOnly && ( = ({ onMount={handleEditorDidMount} options={{ ...editorOptions, - // 협업 모드에서는 읽기 전용 설정을 조정, 로딩 중에도 읽기 전용 - readOnly: (enableCollaboration && isLoading) || activeTab.isLoading, + readOnly: (enableCollaboration && isLoading) || activeTab.isLoading || isTabReadOnly, }} theme={isDarkMode ? 'vs-dark' : 'vs'} /> diff --git a/src/components/organisms/Sidebar/RepoSidebar/RepoSidebar.tsx b/src/components/organisms/Sidebar/RepoSidebar/RepoSidebar.tsx index a0f2e8c3..ef7fc14f 100644 --- a/src/components/organisms/Sidebar/RepoSidebar/RepoSidebar.tsx +++ b/src/components/organisms/Sidebar/RepoSidebar/RepoSidebar.tsx @@ -23,6 +23,14 @@ export const Sidebar = () => { } }; + const handleExitClick = () => { + if (repoId) { + navigate({ + to: '/main', + }); + } + }; + return (