@@ -49,7 +57,7 @@ export const Sidebar = () => {
-
diff --git a/src/components/organisms/TabBar/TabBar.module.scss b/src/components/organisms/TabBar/TabBar.module.scss
index b70e8c9f..4130477c 100644
--- a/src/components/organisms/TabBar/TabBar.module.scss
+++ b/src/components/organisms/TabBar/TabBar.module.scss
@@ -1,4 +1,5 @@
@use '@/styles/variables' as *;
+@use 'sass:color';
.tabBar {
display: flex;
@@ -8,7 +9,6 @@
white-space: nowrap;
background-color: var(--tab-bar-bg);
- // 스크롤바 스타일링
&::-webkit-scrollbar {
height: 3px;
}
@@ -53,6 +53,26 @@
&:first-child {
border-left: none;
}
+
+ &.deleted {
+ color: var(--tab-text-deleted);
+ background-color: var(--tab-bg-deleted);
+
+ &:hover {
+ color: var(--tab-text-deleted-hover);
+ background-color: var(--tab-bg-deleted-hover);
+ }
+ }
+
+ &.changed {
+ color: var(--tab-text-changed);
+ background-color: var(--tab-bg-changed);
+
+ &:hover {
+ color: var(--tab-text-changed-hover);
+ background-color: var(--tab-bg-changed-hover);
+ }
+ }
}
.active {
@@ -64,6 +84,28 @@
color: var(--tab-text-active);
background-color: var(--tab-bg-active);
}
+
+ &.deleted {
+ border-bottom: 2px solid var(--tab-active-line-deleted);
+ color: var(--tab-text-active-deleted);
+ background-color: var(--tab-bg-active-deleted);
+
+ &:hover {
+ color: var(--tab-text-active-deleted);
+ background-color: var(--tab-bg-active-deleted);
+ }
+ }
+
+ &.changed {
+ border-bottom: 2px solid var(--tab-active-line-changed);
+ color: var(--tab-text-active-changed);
+ background-color: var(--tab-bg-active-changed);
+
+ &:hover {
+ color: var(--tab-text-active-changed);
+ background-color: var(--tab-bg-active-changed);
+ }
+ }
}
.tabContent {
@@ -80,8 +122,6 @@
height: 12px;
opacity: 0.8;
transition: opacity 0.2s ease;
-
- // 픽셀아트 아이콘의 선명함을 위해
image-rendering: pixelated;
image-rendering: crisp-edges;
image-rendering: crisp-edges;
@@ -89,6 +129,24 @@
:global(.dark) & {
filter: brightness(0) invert(1);
}
+
+ &.deletedIcon {
+ opacity: 0.5;
+ filter: grayscale(100%);
+
+ :global(.dark) & {
+ filter: brightness(0) invert(1) grayscale(100%);
+ }
+ }
+
+ &.changedIcon {
+ opacity: 0.7;
+ filter: hue-rotate(200deg);
+
+ :global(.dark) & {
+ filter: brightness(0) invert(1) hue-rotate(200deg);
+ }
+ }
}
.tabName {
@@ -96,6 +154,16 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+
+ &.deletedText {
+ font-style: italic;
+ text-decoration: line-through;
+ }
+
+ &.changedText {
+ font-style: italic;
+ text-decoration: line-through;
+ }
}
.closeBtn {
@@ -144,6 +212,17 @@
opacity: 1;
animation: pulse 2s infinite;
}
+
+ &.deleted {
+ background-color: $red;
+ opacity: 1;
+ }
+
+ &.changed {
+ background-color: $blue-3;
+ opacity: 1;
+ animation: pulse 2s infinite;
+ }
}
@keyframes pulse {
@@ -157,7 +236,6 @@
}
}
-// 반응형 디자인
@media (width <= 768px) {
.tab {
max-width: 150px;
@@ -181,3 +259,37 @@
height: 5px;
}
}
+
+:root {
+ --tab-text-deleted: #{$red};
+ --tab-text-deleted-hover: #{$red};
+ --tab-bg-deleted: #{$gray-10};
+ --tab-bg-deleted-hover: #{$gray-9};
+ --tab-text-active-deleted: #{$red};
+ --tab-bg-active-deleted: #{$white-2};
+ --tab-active-line-deleted: #{$red};
+ --tab-text-changed: #{$blue-3};
+ --tab-text-changed-hover: #{$blue-3};
+ --tab-bg-changed: #{color.scale($blue-3, $lightness: 95%)};
+ --tab-bg-changed-hover: #{color.scale($blue-3, $lightness: 90%)};
+ --tab-text-active-changed: #{$blue-3};
+ --tab-bg-active-changed: #{$white-2};
+ --tab-active-line-changed: #{$blue-3};
+}
+
+:global(.dark) {
+ --tab-text-deleted: #{$red};
+ --tab-text-deleted-hover: #{color.scale($red, $lightness: 10%)};
+ --tab-bg-deleted: #{$gray-2};
+ --tab-bg-deleted-hover: #{$gray-3};
+ --tab-text-active-deleted: #{$red};
+ --tab-bg-active-deleted: #{$gray-4};
+ --tab-active-line-deleted: #{$red};
+ --tab-text-changed: #{$blue-3};
+ --tab-text-changed-hover: #{color.scale($blue-3, $lightness: 10%)};
+ --tab-bg-changed: #{color.scale($blue-3, $lightness: -80%)};
+ --tab-bg-changed-hover: #{color.scale($blue-3, $lightness: -75%)};
+ --tab-text-active-changed: #{$blue-3};
+ --tab-bg-active-changed: #{$gray-4};
+ --tab-active-line-changed: #{$blue-3};
+}
diff --git a/src/components/organisms/TabBar/TabBar.tsx b/src/components/organisms/TabBar/TabBar.tsx
index 99578bcb..5aedb940 100644
--- a/src/components/organisms/TabBar/TabBar.tsx
+++ b/src/components/organisms/TabBar/TabBar.tsx
@@ -13,7 +13,6 @@ const TabBar = ({ repoId }: TabBarProps) => {
const { openTabs, closeTab, activateTab } = useTabStore();
const navigate = useNavigate();
- // 탭 클릭 핸들러
const handleTabClick = (tab: (typeof openTabs)[0]) => {
if (!tab.isActive) {
activateTab(tab.id);
@@ -30,24 +29,20 @@ const TabBar = ({ repoId }: TabBarProps) => {
}
};
- // 탭 닫기 핸들러
const handleTabClose = (e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
closeTab(tabId);
- // 탭을 닫은 후 남은 활성 탭으로 네비게이션
const remainingTabs = openTabs.filter(tab => tab.id !== tabId);
const activeTab = remainingTabs.find(tab => tab.isActive);
if (activeTab) {
- // 다른 활성 탭이 있으면 그 탭으로 이동
navigate({
to: '/$repoId/',
params: { repoId },
search: { file: activeTab.path },
});
} else if (remainingTabs.length > 0) {
- // 활성 탭이 없으면 마지막 탭으로
const lastTab = remainingTabs[remainingTabs.length - 1];
activateTab(lastTab.id);
navigate({
@@ -56,7 +51,6 @@ const TabBar = ({ repoId }: TabBarProps) => {
search: { file: lastTab.path },
});
} else {
- // 모든 탭이 닫혔으면 레포 메인으로
navigate({
to: '/$repoId/',
params: { repoId },
@@ -65,7 +59,6 @@ const TabBar = ({ repoId }: TabBarProps) => {
}
};
- // 탭이 없으면 렌더링하지 않음
if (openTabs.length === 0) {
return null;
}
@@ -75,23 +68,45 @@ const TabBar = ({ repoId }: TabBarProps) => {
{openTabs.map(tab => (
handleTabClick(tab)}
- title={tab.path}
+ title={
+ tab.isDeleted
+ ? `${tab.path} (삭제됨)`
+ : tab.hasFileTreeMismatch
+ ? `${tab.path} (위치 또는 이름 변경됨 - 파일트리에서 다시 선택해주세요)`
+ : tab.path
+ }
>
})
-
{tab.name}
+
+ {tab.name}
+
= ({ isConnected, connectedCount, messages,
const [totalMessages, setTotalMessages] = useState([]);
const prevMessagesRef = useRef([]);
const [searchResults, setSearchResults] = useState(null);
+ const [showLoading, setShowLoading] = useState(true);
// 현재 사용자 ID (메시지 비교용)
const currentUserId = getCurrentUserId();
// const { data, isSuccess } = useGetPreviousChat(repoId);
- const { data, fetchNextPage, hasNextPage, isLoading, isSuccess } =
- useGetChatMessagesInfinite(repoId);
+ const { data, fetchNextPage, hasNextPage, isSuccess } = useGetChatMessagesInfinite(repoId);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setShowLoading(false);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }, []);
// SearchMessageData 타입을 ChatReceivedMessage로 변환
const searchMessages: ChatReceivedMessage[] = searchResults
@@ -168,7 +176,7 @@ const Chat: React.FC = ({ isConnected, connectedCount, messages,
{/* 로딩 중일 때 로딩 컴포넌트 표시 */}
- {!isConnected && !isLoading && }
+ {(!isConnected || showLoading) && }
{/* 채팅 메시지 목록 */}
diff --git a/src/features/Repo/fileTree/FileTree.tsx b/src/features/Repo/fileTree/FileTree.tsx
index 8403b6e0..80241025 100644
--- a/src/features/Repo/fileTree/FileTree.tsx
+++ b/src/features/Repo/fileTree/FileTree.tsx
@@ -11,6 +11,7 @@ import { useFileTreeOperations } from './hooks/useFileTreeOperations';
import { useFileTreeDragDrop } from './hooks/useFileTreeDragDrop';
import { useFileTreeExternalDrop } from './hooks/useFileTreeExternalDrop';
import { useYjsFileTree } from '@/hooks/repo/useYjsFileTree';
+import { useTabStore } from '@/stores/tabStore';
import { isValidNode, getNodeId, findNodeById, filterValidNodes, debugNode } from './helpers';
import styles from './FileTree.module.scss';
import type { FileTreeProps, FileTreeNode } from './types';
@@ -40,6 +41,9 @@ const FileTree: React.FC
= ({
// YJS는 협업 모드에서만 활성화
const { yMap, needsRefresh, clearRefreshFlag } = useYjsFileTree(repositoryId || 0);
+ // 탭 스토어 추가
+ const { syncTabsWithFileTree } = useTabStore();
+
const { handleFileClick, handleFolderToggle } = useFileTreeActions({
repoId,
repositoryId,
@@ -120,6 +124,43 @@ const FileTree: React.FC = ({
onFileUpload: uploadFiles,
});
+ // 파일트리 데이터가 변경될 때마다 탭과 동기화
+ useEffect(() => {
+ if (treeData && treeData.length > 0) {
+ const flattenNodes = (
+ nodes: FileTreeNode[]
+ ): Array<{ fileId: number; fileName: string; path: string }> => {
+ const result: Array<{ fileId: number; fileName: string; path: string }> = [];
+
+ const traverse = (nodeList: FileTreeNode[]) => {
+ for (const node of nodeList) {
+ if (node.fileType === 'FILE') {
+ result.push({
+ fileId: node.fileId,
+ fileName: node.fileName,
+ path: node.path,
+ });
+ }
+ if (node.children && node.children.length > 0) {
+ traverse(node.children as FileTreeNode[]);
+ }
+ }
+ };
+
+ traverse(nodes);
+ return result;
+ };
+
+ const fileNodes = flattenNodes(treeData);
+ console.log('파일트리 변경 감지 - 탭 동기화:', {
+ fileCount: fileNodes.length,
+ repositoryId,
+ });
+
+ syncTabsWithFileTree(fileNodes);
+ }
+ }, [treeData, syncTabsWithFileTree, repositoryId]);
+
// YJS 파일트리 실시간 동기화
useEffect(() => {
if (!enableCollaboration || !yMap || !needsRefresh || !clearRefreshFlag) return;
diff --git a/src/features/Repo/fileTree/components/CreateFileModal/CreateFileModal.tsx b/src/features/Repo/fileTree/components/CreateFileModal/CreateFileModal.tsx
index ff4dcb87..377df2c9 100644
--- a/src/features/Repo/fileTree/components/CreateFileModal/CreateFileModal.tsx
+++ b/src/features/Repo/fileTree/components/CreateFileModal/CreateFileModal.tsx
@@ -114,7 +114,7 @@ const CreateFileModal: React.FC = ({
}
onConfirm(trimmedName, parentNode?.path);
- toast.success(`${isFile ? '파일' : '폴더'}이 생성되었습니다.`);
+ toast.success(`${isFile ? '파일이' : '폴더가'} 생성되었습니다.`);
cleanupModal();
onOpenChange(false);
}, [
diff --git a/src/features/Repo/savePoint/SavePoint.tsx b/src/features/Repo/savePoint/SavePoint.tsx
index 2f139f2c..831f2603 100644
--- a/src/features/Repo/savePoint/SavePoint.tsx
+++ b/src/features/Repo/savePoint/SavePoint.tsx
@@ -1,7 +1,9 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { SaveModal } from './SaveModal';
import { useSavePoint } from './hooks/useSavePoint';
import { useSaveHistoryMutation, useRestoreHistoryMutation } from './hooks/useSavePointApi';
+import { useYjsSavePoint } from '@/hooks/repo/useYjsSavePoint';
+import { useTabStore } from '@/stores/tabStore';
import AlertDialog from '@/components/molecules/AlertDialog/AlertDialog';
import { useToast } from '@/hooks/common/useToast';
import styles from './SavePoint.module.scss';
@@ -23,12 +25,44 @@ export function SavePoint({ repoId }: SavePointProps) {
});
const toast = useToast();
+ const { clearAllTabs } = useTabStore();
+ const repositoryIdNumber = parseInt(repoId, 10);
+ const { yMap, broadcastHistoryUpdate } = useYjsSavePoint(repositoryIdNumber);
- // Yjs 동기화가 포함된 훅들 사용
const { histories, isLoading, error, refetch } = useSavePoint({ repositoryId: repoId });
const saveMutation = useSaveHistoryMutation(repoId);
const restoreMutation = useRestoreHistoryMutation(repoId);
+ useEffect(() => {
+ if (!yMap) return;
+
+ const handleRestoreNotification = () => {
+ const lastOperation = yMap.get('lastOperation') as
+ | {
+ type: string;
+ data: unknown;
+ timestamp: number;
+ clientId?: number;
+ }
+ | undefined;
+
+ if (lastOperation?.type === 'restore') {
+ const now = Date.now();
+ const timeDiff = now - lastOperation.timestamp;
+
+ if (timeDiff < 3000) {
+ console.log('복원 이벤트 감지 - 모든 탭 초기화');
+ clearAllTabs();
+ localStorage.removeItem('tab-storage');
+ toast.info('프로젝트가 복원되었습니다. 파일트리에서 파일을 다시 열어주세요.');
+ }
+ }
+ };
+
+ yMap.observe(handleRestoreNotification);
+ return () => yMap.unobserve(handleRestoreNotification);
+ }, [yMap, clearAllTabs, toast]);
+
const handleSave = async (message: string) => {
try {
await saveMutation.mutateAsync({ message });
@@ -51,6 +85,15 @@ export function SavePoint({ repoId }: SavePointProps) {
try {
await restoreMutation.mutateAsync(restoreDialog.historyId);
setRestoreDialog({ isOpen: false, historyId: null, message: '' });
+
+ clearAllTabs();
+ localStorage.removeItem('tab-storage');
+
+ broadcastHistoryUpdate('restore', {
+ historyId: restoreDialog.historyId,
+ timestamp: Date.now(),
+ });
+
toast.success('세이브 포인트가 성공적으로 복원되었습니다.');
} catch {
toast.error(
@@ -150,7 +193,7 @@ export function SavePoint({ repoId }: SavePointProps) {
open={restoreDialog.isOpen}
onOpenChange={open => !open && handleRestoreCancel()}
title="해당 세이브 포인트로 복원"
- description={`"${restoreDialog.message}" 상태로 복원하시겠습니까?\n현재 작업 내용이 손실될 수 있습니다.`}
+ description={`"${restoreDialog.message}" 상태로 복원하시겠습니까?\n현재 작업 내용이 손실될 수 있습니다.\n\n모든 사용자의 열린 탭이 초기화됩니다.`}
confirmText={restoreMutation.isPending ? '복원 중...' : '복원'}
cancelText="취소"
onConfirm={handleRestoreConfirm}
diff --git a/src/hooks/repo/useFileSave.ts b/src/hooks/repo/useFileSave.ts
index a2de820a..f7e7c752 100644
--- a/src/hooks/repo/useFileSave.ts
+++ b/src/hooks/repo/useFileSave.ts
@@ -19,20 +19,16 @@ export const useFileSave = ({
const queryClient = useQueryClient();
const saveTimeoutRef = useRef(null);
- // 지속적 저장을 위한 상태
const [lastSaved, setLastSaved] = useState(null);
const continuousTimerRef = useRef(null);
const currentTabIdRef = useRef(null);
const lastContentRef = useRef('');
- // 협업 모드에 따른 저장 주기 설정
- const defaultInterval = collaborationMode ? 10000 : 5000; // 협업: 10초, 일반: 5초
+ const defaultInterval = collaborationMode ? 10000 : 5000;
const saveInterval = continuousSaveInterval || defaultInterval;
- // TabStore를 ref로 안정화
const tabStoreRef = useRef(useTabStore.getState());
- // 컴포넌트 언마운트 시 타이머 정리
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
@@ -46,17 +42,15 @@ export const useFileSave = ({
};
}, []);
- // TabStore 상태 업데이트
useEffect(() => {
tabStoreRef.current = useTabStore.getState();
});
- // 파일 저장 뮤테이션
const saveFileMutation = useMutation({
mutationFn: async ({ fileId, content }: { fileId: number; content: string }) => {
return saveFileContent(repositoryId, fileId, content);
},
- onSuccess: (data, variables) => {
+ onSuccess: (_, variables) => {
const { fileId, content } = variables;
console.log('파일 저장 성공:', {
@@ -66,7 +60,6 @@ export const useFileSave = ({
timestamp: new Date().toISOString(),
});
- // 상태 변경을 비동기로 처리하여 무한루프 방지
setTimeout(() => {
const { openTabs, setTabDirty } = tabStoreRef.current;
const tab = openTabs.find(tab => tab.fileId === fileId);
@@ -75,11 +68,9 @@ export const useFileSave = ({
}
}, 0);
- // 지속적 저장 상태 업데이트
setLastSaved(new Date());
lastContentRef.current = content;
- // 파일 내용 캐시 무효화
queryClient.invalidateQueries({
queryKey: ['fileContent', repositoryId],
});
@@ -89,7 +80,6 @@ export const useFileSave = ({
},
});
- // 지속적 저장 실행 함수
const executeContinuousSave = useCallback(() => {
const currentTabId = currentTabIdRef.current;
if (!currentTabId) return;
@@ -100,7 +90,6 @@ export const useFileSave = ({
const currentContent = currentTab.content || '';
- // 빈 내용은 저장하지 않음 (파일 내용 손실 방지)
if (!currentContent || currentContent.trim() === '') {
console.log('지속적 저장 건너뜀 - 빈 내용:', {
tabId: currentTabId,
@@ -109,7 +98,6 @@ export const useFileSave = ({
return;
}
- // 내용이 변경되었는지 확인
if (currentContent !== lastContentRef.current) {
console.log('지속적 저장 실행:', {
tabId: currentTabId,
@@ -119,7 +107,6 @@ export const useFileSave = ({
lastContentLength: lastContentRef.current.length,
});
- // 직접 API 호출 (무한루프 방지)
saveFileContent(repositoryId, currentTab.fileId, currentContent)
.then(() => {
setLastSaved(new Date());
@@ -135,17 +122,14 @@ export const useFileSave = ({
}
}, [repositoryId, collaborationMode]);
- // 지속적 저장 활성화
const enableContinuousSave = useCallback(
(tabId: string) => {
- // 기존 타이머 정리
if (continuousTimerRef.current) {
clearInterval(continuousTimerRef.current);
}
currentTabIdRef.current = tabId;
- // 현재 내용 초기화
const { openTabs } = tabStoreRef.current;
const currentTab = openTabs.find(tab => tab.id === tabId);
if (currentTab) {
@@ -158,13 +142,11 @@ export const useFileSave = ({
collaborationMode,
});
- // 지속적 저장 타이머 시작
continuousTimerRef.current = setInterval(executeContinuousSave, saveInterval);
},
[saveInterval, executeContinuousSave, collaborationMode]
);
- // 지속적 저장 비활성화
const disableContinuousSave = useCallback(() => {
if (continuousTimerRef.current) {
clearInterval(continuousTimerRef.current);
@@ -180,7 +162,6 @@ export const useFileSave = ({
lastContentRef.current = '';
}, [collaborationMode]);
- // 즉시 저장 (Ctrl+S)
const saveCurrentFile = useCallback(() => {
if (!enabled) return;
@@ -205,18 +186,15 @@ export const useFileSave = ({
}
}, [enabled, saveFileMutation, collaborationMode]);
- // 자동 저장 - 빈 내용 저장 방지 추가
const autoSaveFile = useCallback(
(tabId: string, content: string) => {
if (!enabled) return;
- // 기존 타이머 반드시 클리어
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
- // 빈 내용은 저장하지 않음 (파일 내용 손실 방지)
if (!content || content.trim() === '') {
console.log('자동 저장 건너뜀 - 빈 내용:', {
tabId,
@@ -225,8 +203,7 @@ export const useFileSave = ({
return;
}
- // 협업 모드에서는 더 긴 지연 시간 (Yjs 동기화와 충돌 방지)
- const saveDelay = collaborationMode ? 3000 : 1500; // 협업: 3초, 일반: 1.5초
+ const saveDelay = collaborationMode ? 3000 : 1500;
console.log('자동 저장 예약:', {
tabId,
@@ -239,7 +216,6 @@ export const useFileSave = ({
const { openTabs } = tabStoreRef.current;
const currentTab = openTabs.find(tab => tab.id === tabId);
- // 추가 검증: 탭 내용이 유효한지 확인
if (
currentTab?.isDirty &&
currentTab.fileId &&
diff --git a/src/hooks/repo/useTabStore.ts b/src/hooks/repo/useTabStore.ts
index d108392a..d7a08cd8 100644
--- a/src/hooks/repo/useTabStore.ts
+++ b/src/hooks/repo/useTabStore.ts
@@ -1,7 +1,6 @@
import { useTabStore } from '@/stores/tabStore';
import type { OpenTab } from '@/types/repo/repo.types';
-// 하이드레이션을 포함한 메인 훅
export const useTabStoreHydrated = () => {
const hasHydrated = useTabStore(state => state._hasHydrated);
const openTabs = useTabStore(state => state.openTabs);
@@ -13,6 +12,7 @@ export const useTabStoreHydrated = () => {
const setTabContent = useTabStore(state => state.setTabContent);
const setTabDirty = useTabStore(state => state.setTabDirty);
const clearTabsForRepo = useTabStore(state => state.clearTabsForRepo);
+ const clearAllTabs = useTabStore(state => state.clearAllTabs);
const keepOnlyCurrentRepoTabs = useTabStore(state => state.keepOnlyCurrentRepoTabs);
return {
@@ -26,11 +26,11 @@ export const useTabStoreHydrated = () => {
setTabContent,
setTabDirty,
clearTabsForRepo,
+ clearAllTabs,
keepOnlyCurrentRepoTabs,
};
};
-// 편의성 훅들
export const useActiveTab = (): OpenTab | undefined => {
return useTabStore(state => state.openTabs.find(tab => tab.isActive));
};
diff --git a/src/hooks/repo/useYjsCollaboration.ts b/src/hooks/repo/useYjsCollaboration.ts
index 19810a28..8fb28372 100644
--- a/src/hooks/repo/useYjsCollaboration.ts
+++ b/src/hooks/repo/useYjsCollaboration.ts
@@ -84,7 +84,6 @@ export const useYjsCollaboration = ({
const cleanupInProgressRef = useRef(false);
const syncTimeoutRef = useRef(null);
- // 커서 위치 업데이트 방지를 위한 ref들
const lastCursorPositionRef = useRef<{ line: number; column: number } | null>(null);
const cursorUpdateTimeoutRef = useRef(null);
const isUpdatingCursorRef = useRef(false);
@@ -93,7 +92,6 @@ export const useYjsCollaboration = ({
useCollaborationStore();
const { openTabs, setTabContent } = useTabStore();
- // 현재 활성 탭 찾기
const activeTab = openTabs.find(tab => tab.isActive);
const cleanup = useCallback(() => {
@@ -101,13 +99,11 @@ export const useYjsCollaboration = ({
cleanupInProgressRef.current = true;
try {
- // 커서 관련 타이머 정리
if (cursorUpdateTimeoutRef.current) {
clearTimeout(cursorUpdateTimeoutRef.current);
cursorUpdateTimeoutRef.current = null;
}
- // 동기화 타이머 정리
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = null;
@@ -148,7 +144,6 @@ export const useYjsCollaboration = ({
clearUsers();
currentUserIdRef.current = '';
- // ref 초기화
lastCursorPositionRef.current = null;
isUpdatingCursorRef.current = false;
} catch (cleanupError) {
@@ -159,21 +154,17 @@ export const useYjsCollaboration = ({
}
}, [roomId, leaveRoom, setConnectionStatus, clearUsers]);
- // 탭 내용을 Yjs로 동기화 (지연 적용으로 중복 방지)
const syncTabContentToYjs = useCallback(
(yjsContent: string) => {
if (!activeTab || !setTabContent) return;
- // 기존 타이머 클리어
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
- // 100ms 지연으로 중복 업데이트 방지
syncTimeoutRef.current = setTimeout(() => {
const currentTabContent = activeTab.content || '';
- // 내용이 실제로 다를 때만 업데이트
if (yjsContent !== currentTabContent) {
console.log('Yjs → Tab 동기화:', {
roomId,
@@ -183,22 +174,18 @@ export const useYjsCollaboration = ({
});
setTabContent(activeTab.id, yjsContent);
- // setTabDirty(activeTab.id, false); // 일단 제거
}
}, 100);
},
[activeTab, setTabContent, roomId]
);
- // 안전한 커서 위치 업데이트 함수
const updateCursorPosition = useCallback(
(newPosition: { line: number; column: number }) => {
- // 이미 업데이트 중이면 건너뛰기
if (isUpdatingCursorRef.current) {
return;
}
- // 이전 위치와 동일하면 건너뛰기
const lastPosition = lastCursorPositionRef.current;
if (
lastPosition &&
@@ -208,12 +195,10 @@ export const useYjsCollaboration = ({
return;
}
- // 기존 타이머 클리어
if (cursorUpdateTimeoutRef.current) {
clearTimeout(cursorUpdateTimeoutRef.current);
}
- // 디바운싱으로 무한 루프 방지 (50ms)
cursorUpdateTimeoutRef.current = setTimeout(() => {
try {
isUpdatingCursorRef.current = true;
@@ -221,7 +206,6 @@ export const useYjsCollaboration = ({
const connection = connections.get(roomId);
if (!connection) return;
- // awareness 업데이트
connection.provider.awareness.setLocalStateField('cursor', newPosition);
lastCursorPositionRef.current = newPosition;
@@ -232,7 +216,6 @@ export const useYjsCollaboration = ({
} catch (cursorError) {
console.error('커서 위치 업데이트 중 오류:', cursorError);
} finally {
- // 100ms 후에 업데이트 플래그 해제 (데코레이션 업데이트 완료 대기)
setTimeout(() => {
isUpdatingCursorRef.current = false;
}, 100);
@@ -305,7 +288,6 @@ export const useYjsCollaboration = ({
setError(null);
provider.awareness.setLocalStateField('user', currentUser);
- // 연결 성공 후 내용 동기화
setTimeout(() => {
syncInitialContent();
}, 200);
@@ -384,7 +366,6 @@ export const useYjsCollaboration = ({
lastTabContent: connection.lastTabContent,
});
- // 중복 동기화 방지
if (connection.lastTabContent === currentTabContent && currentYjsContent.length > 0) {
console.log('중복 동기화 방지:', { roomId });
connection.isContentInitialized = true;
@@ -392,18 +373,15 @@ export const useYjsCollaboration = ({
}
if (currentYjsContent.length === 0 && currentTabContent.length > 0) {
- // Yjs가 비어있고 탭에 내용이 있으면 → 탭 내용을 Yjs로
console.log('탭 → Yjs 동기화 (새 문서)');
connection.yText.insert(0, currentTabContent);
connection.lastTabContent = currentTabContent;
} else if (currentYjsContent.length > 0 && currentYjsContent !== currentTabContent) {
- // Yjs에 내용이 있고 탭과 다르면 → Yjs 내용을 탭으로
console.log('Yjs → 탭 동기화 (기존 문서)');
model.setValue(currentYjsContent);
syncTabContentToYjs(currentYjsContent);
connection.lastTabContent = currentYjsContent;
} else {
- // 둘 다 비어있거나 동일하면 현재 상태 유지
console.log('동기화 불필요 (빈 문서 또는 동일한 내용)');
connection.lastTabContent = currentTabContent;
}
@@ -411,7 +389,6 @@ export const useYjsCollaboration = ({
connection.isContentInitialized = true;
};
- // Monaco Binding 설정
const editorSet = new Set([editor]);
const binding = new MonacoBinding(
connection.yText,
@@ -422,7 +399,6 @@ export const useYjsCollaboration = ({
bindingRef.current = binding;
- // Yjs 내용 변경 시 탭 동기화
const handleYjsChange = () => {
if (connection && connection.isContentInitialized) {
const newContent = connection.yText.toString();
@@ -435,9 +411,7 @@ export const useYjsCollaboration = ({
connection.yText.observe(handleYjsChange);
- // 안전한 커서 위치 추적
const handleCursorChange = (event: { position: { lineNumber: number; column: number } }) => {
- // MonacoBinding에 의한 업데이트 중이면 건너뛰기
if (isUpdatingCursorRef.current) {
return;
}
@@ -458,7 +432,6 @@ export const useYjsCollaboration = ({
setIsConnected(connected);
if (connected) {
- // 초기 사용자 정보만 설정 (커서는 실제 변경시에만)
connection.provider.awareness.setLocalStateField('user', currentUser);
setTimeout(syncInitialContent, 200);
}
@@ -493,7 +466,7 @@ export const useYjsCollaboration = ({
}
return cleanup;
- }, [enabled, editor, roomId, userId, userName, activeTab?.id, initialize, cleanup]);
+ }, [enabled, editor, roomId, userId, userName, activeTab, initialize, cleanup]);
const connection = connections.get(roomId);
const isLoading =
diff --git a/src/hooks/repo/useYjsSavePoint.ts b/src/hooks/repo/useYjsSavePoint.ts
index 43532d27..c6ca23b1 100644
--- a/src/hooks/repo/useYjsSavePoint.ts
+++ b/src/hooks/repo/useYjsSavePoint.ts
@@ -1,62 +1,129 @@
-import { useEffect, useState, useCallback } from 'react';
+import { useEffect, useState, useCallback, useRef } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
-// 전역 관리 객체들
-const yDocMap = new Map();
-const providerMap = new Map();
-const yMapMap = new Map>>();
+interface SavePointConnection {
+ doc: Y.Doc;
+ provider: WebsocketProvider;
+ map: Y.Map>;
+ activeUsers: Set;
+ cleanupTimer?: NodeJS.Timeout;
+}
+
+const savePointConnections = new Map();
+
+const cleanupSavePointConnection = (repositoryId: number) => {
+ const connection = savePointConnections.get(repositoryId);
+ if (!connection) return;
+
+ console.log(`SavePoint 연결 정리: repository-${repositoryId}`);
+
+ try {
+ if (connection.cleanupTimer) {
+ clearTimeout(connection.cleanupTimer);
+ }
+
+ connection.provider.disconnect();
+ connection.provider.destroy();
+ connection.doc.destroy();
+ savePointConnections.delete(repositoryId);
+
+ console.log(`SavePoint 연결 정리 완료: repository-${repositoryId}`);
+ } catch (error) {
+ console.error(`SavePoint 연결 정리 실패: repository-${repositoryId}`, error);
+ }
+};
export function useYjsSavePoint(repositoryId: number) {
const [yDoc, setYDoc] = useState(null);
const [provider, setProvider] = useState(null);
const [yMap, setYMap] = useState> | null>(null);
+ const userIdRef = useRef(
+ `user-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
+ );
+ const mountedRef = useRef(true);
+
useEffect(() => {
- if (!repositoryId) return;
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
- let yDoc = yDocMap.get(repositoryId);
- let provider = providerMap.get(repositoryId);
- let yMap = yMapMap.get(repositoryId);
+ useEffect(() => {
+ if (!repositoryId) return;
- if (!yDoc) {
- yDoc = new Y.Doc();
- yDocMap.set(repositoryId, yDoc);
- }
+ const userId = userIdRef.current;
+ let connection = savePointConnections.get(repositoryId);
- if (!provider) {
- // WebSocket URL을 환경변수에서 가져오기
+ if (!connection) {
+ const doc = new Y.Doc();
+ const map = doc.getMap>('save-point');
const wsUrl = import.meta.env.VITE_YJS_WEBSOCKET_URL || 'ws://localhost:1234';
- provider = new WebsocketProvider(
- wsUrl,
- `savepoint-${repositoryId}`, // YJS 서버가 인식할 수 있는 룸 이름
- yDoc
- );
- providerMap.set(repositoryId, provider);
+ const roomName = `savepoint-${repositoryId}`;
+
+ const provider = new WebsocketProvider(wsUrl, roomName, doc, {
+ connect: true,
+ maxBackoffTime: 30000,
+ resyncInterval: 30000,
+ });
+
+ connection = {
+ doc,
+ provider,
+ map,
+ activeUsers: new Set(),
+ };
+
+ provider.on('status', (event: { status: string }) => {
+ if (!mountedRef.current) return;
+
+ console.log(`SavePoint 연결 상태: ${event.status} (repository-${repositoryId})`);
+ });
+
+ savePointConnections.set(repositoryId, connection);
+ console.log(`새 SavePoint 연결 생성: repository-${repositoryId}`);
}
- if (!yMap) {
- yMap = yDoc.getMap>('save-point');
- yMapMap.set(repositoryId, yMap);
+ if (connection.cleanupTimer) {
+ clearTimeout(connection.cleanupTimer);
+ connection.cleanupTimer = undefined;
+ console.log(`기존 SavePoint cleanup timer 취소: repository-${repositoryId}`);
}
- setYDoc(yDoc);
- setProvider(provider);
- setYMap(yMap);
+ connection.activeUsers.add(userId);
+
+ if (mountedRef.current) {
+ setYDoc(connection.doc);
+ setProvider(connection.provider);
+ setYMap(connection.map);
+ }
return () => {
- setProvider(null);
- setYDoc(null);
- setYMap(null);
+ if (!connection) return;
+
+ connection.activeUsers.delete(userId);
+
+ if (connection.activeUsers.size === 0) {
+ connection.cleanupTimer = setTimeout(() => {
+ cleanupSavePointConnection(repositoryId);
+ }, 60000);
+ console.log(`SavePoint cleanup timer 설정: repository-${repositoryId} (60초)`);
+ }
+
+ if (mountedRef.current) {
+ setYDoc(null);
+ setProvider(null);
+ setYMap(null);
+ }
};
}, [repositoryId]);
- // API에서 최신 히스토리를 가져와서 YJS에 동기화
const syncHistoriesFromServer = useCallback(async () => {
if (!yMap) return;
try {
- // 기존 API 클라이언트 사용
const response = await fetch(`/api/repositories/${repositoryId}/histories`, {
credentials: 'include',
headers: {
@@ -72,7 +139,6 @@ export function useYjsSavePoint(repositoryId: number) {
const latestHistoriesResponse = await response.json();
const latestHistories = latestHistoriesResponse.data || [];
- // YJS Map에 최신 데이터 적용
yMap.set('histories', latestHistories);
yMap.set('lastUpdated', { value: Date.now() });
@@ -82,19 +148,32 @@ export function useYjsSavePoint(repositoryId: number) {
}
}, [repositoryId, yMap]);
- // 다른 클라이언트들에게 히스토리 업데이트 알림
const broadcastHistoryUpdate = useCallback(
(operation: string, data: unknown) => {
if (!yMap || !provider) return;
- yMap.set('lastOperation', {
+ const operationData = {
type: operation,
data: data,
timestamp: Date.now(),
clientId: provider.awareness?.clientID,
- });
+ };
+
+ yMap.set('lastOperation', operationData);
+
+ if (operation === 'restore') {
+ console.log('복원 이벤트 브로드캐스트:', {
+ operation,
+ timestamp: operationData.timestamp,
+ repositoryId,
+ });
+
+ setTimeout(() => {
+ yMap.set('forceRefresh', { value: Date.now() });
+ }, 100);
+ }
},
- [yMap, provider]
+ [yMap, provider, repositoryId]
);
return {
diff --git a/src/pages/Repo/RepoPage.tsx b/src/pages/Repo/RepoPage.tsx
index f4aa4660..10068d2f 100644
--- a/src/pages/Repo/RepoPage.tsx
+++ b/src/pages/Repo/RepoPage.tsx
@@ -8,6 +8,7 @@ import { useFileSave } from '@/hooks/repo/useFileSave';
import { useCollaborationStore } from '@/stores/collaborationStore';
import { useAuthStore } from '@/stores/authStore';
import { useFileContentLoader } from '@/hooks/repo/useFileContentLoader';
+import { useYjsSavePoint } from '@/hooks/repo/useYjsSavePoint';
import Loading from '@/components/molecules/Loading/Loading';
import styles from './RepoPage.module.scss';
import TabBar from '@/components/organisms/TabBar/TabBar';
@@ -16,7 +17,6 @@ import CodeRunner from '@/features/CodeRunner/CodeRunner';
import { FileTree } from '@/features/Repo/fileTree';
import { SavePoint } from '@/features/Repo/savePoint';
-// Repository 타입 확장 (currentUser 포함)
interface RepositoryWithUser {
repositoryId: number;
repositoryName: string;
@@ -39,7 +39,14 @@ export function RepoPage() {
const repoId = params.repoId;
const filePath = search.file;
- const { openTabs, activateTab, hasHydrated, keepOnlyCurrentRepoTabs } = useTabStoreHydrated();
+ const {
+ openTabs,
+ activateTab,
+ hasHydrated,
+ keepOnlyCurrentRepoTabs,
+ clearTabsForRepo,
+ clearAllTabs,
+ } = useTabStoreHydrated();
const {
isVisible: isFileSectionVisible,
activeSection,
@@ -49,7 +56,6 @@ export function RepoPage() {
const { getUserInfo } = useAuthStore();
const containerRef = useRef(null);
- // 파일 섹션과 에디터 그룹 간의 수평 리사이저
const {
width: fileSectionWidth,
isResizing: isHorizontalResizing,
@@ -61,19 +67,15 @@ export function RepoPage() {
containerRef,
});
- // repoId를 숫자로 변환
const repositoryId = repoId ? parseInt(repoId, 10) : 0;
- // 저장소 정보 조회 및 협업 모드 설정
const { data: repositoryInfo } = useRepositoryInfo({
repositoryId: repoId || '',
enabled: hasHydrated && !!repoId,
});
- // 타입 안전하게 repositoryInfo 처리
const typedRepositoryInfo = repositoryInfo as RepositoryWithUser | undefined;
- // 협업 모드 자동 설정
const enableCollaboration = Boolean(typedRepositoryInfo?.isShared);
console.log('RepoPage 상태:', {
@@ -86,6 +88,37 @@ export function RepoPage() {
activeTabPath: openTabs.find(tab => tab.isActive)?.path,
});
+ const { yMap: savePointYMap } = useYjsSavePoint(repositoryId);
+
+ useEffect(() => {
+ if (!savePointYMap) return;
+
+ const handleRestoreEvent = () => {
+ const lastOperation = savePointYMap.get('lastOperation') as
+ | {
+ type: string;
+ data: unknown;
+ timestamp: number;
+ }
+ | undefined;
+
+ if (lastOperation?.type === 'restore') {
+ const now = Date.now();
+ const timeDiff = now - lastOperation.timestamp;
+
+ if (timeDiff < 3000) {
+ console.log('RepoPage: 복원 이벤트 감지 - 모든 탭과 에디터 상태 초기화');
+ clearAllTabs();
+ localStorage.removeItem('tab-storage');
+ window.location.reload();
+ }
+ }
+ };
+
+ savePointYMap.observe(handleRestoreEvent);
+ return () => savePointYMap.unobserve(handleRestoreEvent);
+ }, [savePointYMap, clearAllTabs]);
+
useFileContentLoader({
repositoryId,
repoId: repoId || '',
@@ -93,14 +126,11 @@ export function RepoPage() {
enableCollaboration,
});
- // 사용자 정보 설정 (협업 모드용) - authStore nickname 우선 사용
useEffect(() => {
if (enableCollaboration && !currentUser.id) {
- // authStore에서 실제 사용자 정보 가져오기
const authUser = getUserInfo();
if (authUser) {
- // authStore의 정보를 우선 사용
setCurrentUser({
id: String(authUser.id || `user-${Date.now()}`),
name: authUser.nickname || authUser.username || 'Anonymous User',
@@ -112,7 +142,6 @@ export function RepoPage() {
name: authUser.nickname || authUser.username,
});
} else if (typedRepositoryInfo?.currentUser) {
- // authStore 정보가 없으면 repository 정보 사용 (fallback)
const repoUser = typedRepositoryInfo.currentUser;
setCurrentUser({
id: String(repoUser.id || `user-${Date.now()}`),
@@ -125,7 +154,6 @@ export function RepoPage() {
name: repoUser.name,
});
} else {
- // 둘 다 없으면 기본 사용자 생성
const fallbackUser = {
id: `user-${Date.now()}`,
name: 'Anonymous User',
@@ -144,16 +172,13 @@ export function RepoPage() {
getUserInfo,
]);
- // 파일 저장 시스템 (협업/일반 모드 모두 지원)
const { enableContinuousSave, disableContinuousSave } = useFileSave({
repositoryId,
enabled: true,
collaborationMode: enableCollaboration,
- // 협업 모드에서는 더 긴 주기로 저장 (Yjs와 충돌 방지)
continuousSaveInterval: enableCollaboration ? 10000 : 5000,
});
- // 활성 탭 변경 시 지속적 저장 관리
const activeTab = openTabs.find(tab => tab.isActive);
const activeTabId = activeTab?.id;
const activeTabFileId = activeTab?.fileId;
@@ -174,7 +199,6 @@ export function RepoPage() {
disableContinuousSave();
};
} else {
- // 활성 탭이 없거나 fileId가 없으면 저장 비활성화
disableContinuousSave();
}
}, [
@@ -186,7 +210,6 @@ export function RepoPage() {
disableContinuousSave,
]);
- // 레포 변경 감지 및 다른 레포 탭 정리
useEffect(() => {
if (!hasHydrated || !repoId) return;
@@ -196,10 +219,8 @@ export function RepoPage() {
currentTabsCount: openTabs.length,
});
- // 현재 레포의 탭만 남기고 나머지 정리
keepOnlyCurrentRepoTabs(repoId);
- // 협업 모드가 비활성화되면 사용자 목록 정리
if (!enableCollaboration) {
clearUsers();
}
@@ -212,17 +233,23 @@ export function RepoPage() {
clearUsers,
]);
- // 키보드 단축키 추가
+ useEffect(() => {
+ return () => {
+ if (repoId) {
+ console.log('RepoPage 언마운트 - 모든 탭 정리:', repoId);
+ clearTabsForRepo(repoId);
+ }
+ };
+ }, [repoId, clearTabsForRepo]);
+
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
- // Ctrl+B로 현재 활성 섹션 토글 (files가 기본)
const currentSection = activeSection || 'files';
if (isFileSectionVisible && activeSection === currentSection) {
toggleVisibility();
} else {
- // 닫혀있거나 다른 섹션이면 files 섹션 열기
if (!isFileSectionVisible) {
toggleVisibility();
}
@@ -234,12 +261,10 @@ export function RepoPage() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [toggleVisibility, isFileSectionVisible, activeSection]);
- // URL 파일 경로 변경 처리 (하이드레이션 완료 후에만)
useEffect(() => {
if (!hasHydrated) return;
if (filePath && repoId) {
- // 현재 열린 탭 중에서 해당 경로의 탭 찾기
const existingTab = openTabs.find(tab => tab.path === filePath);
if (existingTab && !existingTab.isActive) {
@@ -249,7 +274,6 @@ export function RepoPage() {
console.log('URL 경로에 해당하는 탭이 없음:', filePath);
}
} else if (!filePath && openTabs.length > 0) {
- // URL에 파일 경로가 없으면 첫 번째 탭을 활성화
const firstTab = openTabs[0];
if (firstTab && !firstTab.isActive) {
console.log('첫 번째 탭 활성화:', firstTab.name);
@@ -258,7 +282,6 @@ export function RepoPage() {
}
}, [filePath, repoId, openTabs, activateTab, hasHydrated]);
- // 사이드바 섹션에 따른 컴포넌트 렌더링
const renderSidebarContent = () => {
switch (activeSection) {
case 'files':
@@ -276,7 +299,6 @@ export function RepoPage() {
}
};
- // 유효하지 않은 repoId 처리
if (!repoId || isNaN(repositoryId)) {
return (
@@ -286,7 +308,6 @@ export function RepoPage() {
);
}
- // 하이드레이션 완료까지 로딩 표시
if (!hasHydrated) {
return
;
}
@@ -298,14 +319,12 @@ export function RepoPage() {
!isFileSectionVisible ? styles.hideFileSection : ''
}`}
>
- {/* 파일 구조 섹션 */}
{isFileSectionVisible && (
<>
{renderSidebarContent()}
- {/* 수평 리사이저 */}
)}
- {/* 에디터 + 터미널 그룹 */}
- {/* 코드 에디터 */}
@@ -341,7 +358,6 @@ export function RepoPage() {
- {/* 터미널 */}
diff --git a/src/stores/tabStore.ts b/src/stores/tabStore.ts
index b67edc54..86e8eaa7 100644
--- a/src/stores/tabStore.ts
+++ b/src/stores/tabStore.ts
@@ -93,12 +93,11 @@ export const useTabStore = create
()(
...tab,
isActive: tab.id === tabId,
fileId: tab.id === tabId && fileId ? fileId : tab.fileId,
- isLoading: tab.id === tabId ? true : tab.isLoading, // 로딩 상태 설정
+ isLoading: tab.id === tabId ? true : tab.isLoading,
})),
});
console.log('기존 탭 활성화 및 fileId 업데이트:', existingTab.name);
} else {
- // 새 탭 생성
const finalFileName =
fileName ||
(filePath.includes('/') ? filePath.split('/').pop() || 'untitled' : filePath);
@@ -109,9 +108,11 @@ export const useTabStore = create()(
path: filePath,
isActive: true,
isDirty: false,
- content: '', // 초기에는 빈 내용으로 시작
+ content: '',
fileId,
- isLoading: true, // 새 탭은 로딩 상태로 시작
+ isLoading: true,
+ isDeleted: false,
+ hasFileTreeMismatch: false,
};
set({
@@ -129,6 +130,14 @@ export const useTabStore = create()(
setTabContent: (tabId: string, content: string) => {
const state = get();
+ const targetTab = state.openTabs.find(tab => tab.id === tabId);
+
+ // 삭제된 탭이나 파일트리 불일치 탭은 내용 변경 불가
+ if (targetTab && (targetTab.isDeleted || targetTab.hasFileTreeMismatch)) {
+ console.warn('삭제되었거나 변경된 파일의 내용 변경 시도 차단:', tabId);
+ return;
+ }
+
console.log('setTabContent 호출:', {
tabId,
contentLength: content.length,
@@ -136,8 +145,7 @@ export const useTabStore = create()(
set({
openTabs: state.openTabs.map(tab => {
- if (tab.id === tabId) {
- // 기존 내용과 다른지 확인
+ if (tab.id === tabId && !tab.isDeleted && !tab.hasFileTreeMismatch) {
const isContentChanged = tab.content !== content;
const isInitialLoad = tab.content === '';
@@ -153,7 +161,6 @@ export const useTabStore = create()(
return {
...tab,
content,
- // 초기 로드시는 clean 상태, 내용 변경시는 기존 dirty 상태 유지
isDirty: isInitialLoad ? false : tab.isDirty,
};
}
@@ -162,9 +169,16 @@ export const useTabStore = create()(
});
},
- // 파일에서 처음 내용을 로드할 때 사용할 메서드 (항상 clean 상태)
setTabContentFromFile: (tabId: string, content: string) => {
const state = get();
+ const targetTab = state.openTabs.find(tab => tab.id === tabId);
+
+ // 삭제된 탭이나 파일트리 불일치 탭은 내용 변경 불가
+ if (targetTab && (targetTab.isDeleted || targetTab.hasFileTreeMismatch)) {
+ console.warn('삭제되었거나 변경된 파일의 내용 로드 차단:', tabId);
+ return;
+ }
+
console.log('setTabContentFromFile 호출:', {
tabId,
contentLength: content.length,
@@ -173,12 +187,12 @@ export const useTabStore = create()(
set({
openTabs: state.openTabs.map(tab =>
- tab.id === tabId
+ tab.id === tabId && !tab.isDeleted && !tab.hasFileTreeMismatch
? {
...tab,
content,
- isDirty: false, // 파일에서 로드한 내용은 항상 clean 상태
- isLoading: false, // 로딩 완료
+ isDirty: false,
+ isLoading: false,
}
: tab
),
@@ -189,6 +203,11 @@ export const useTabStore = create()(
const state = get();
const targetTab = state.openTabs.find(tab => tab.id === tabId);
+ // 삭제된 탭이나 파일트리 불일치 탭은 dirty 상태 변경 불가
+ if (targetTab && (targetTab.isDeleted || targetTab.hasFileTreeMismatch)) {
+ return;
+ }
+
if (targetTab && targetTab.isDirty !== isDirty) {
console.log('탭 dirty 상태 변경:', {
tabId,
@@ -198,12 +217,15 @@ export const useTabStore = create()(
});
set({
- openTabs: state.openTabs.map(tab => (tab.id === tabId ? { ...tab, isDirty } : tab)),
+ openTabs: state.openTabs.map(tab =>
+ tab.id === tabId && !tab.isDeleted && !tab.hasFileTreeMismatch
+ ? { ...tab, isDirty }
+ : tab
+ ),
});
}
},
- // 탭 로딩 상태 설정
setTabLoading: (tabId: string, isLoading: boolean) => {
const state = get();
const targetTab = state.openTabs.find(tab => tab.id === tabId);
@@ -222,7 +244,6 @@ export const useTabStore = create()(
}
},
- // 레포지토리 관련
clearTabsForRepo: (repoId: string) => {
const state = get();
const beforeCount = state.openTabs.length;
@@ -238,6 +259,11 @@ export const useTabStore = create()(
set({ openTabs: filteredTabs });
},
+ clearAllTabs: () => {
+ console.log('모든 탭 정리 (복원 등으로 인한)');
+ set({ openTabs: [] });
+ },
+
keepOnlyCurrentRepoTabs: (repoId: string) => {
const state = get();
const currentRepoTabs = state.openTabs.filter(tab => tab.id.startsWith(`${repoId}/`));
@@ -258,7 +284,103 @@ export const useTabStore = create()(
}
},
- // 디버그 헬퍼
+ updateTabFromFileTree: (fileId: number, fileName: string, path: string) => {
+ const state = get();
+ set({
+ openTabs: state.openTabs.map(tab => {
+ if (tab.fileId === fileId) {
+ const repoId = tab.id.split('/')[0];
+ const newTabId = `${repoId}/${path}`;
+ console.log('파일트리 변경으로 탭을 변경된 상태로 표시:', {
+ oldId: tab.id,
+ newId: newTabId,
+ oldName: tab.name,
+ newName: fileName,
+ oldPath: tab.path,
+ newPath: path,
+ });
+ return {
+ ...tab,
+ hasFileTreeMismatch: true,
+ isDeleted: false,
+ };
+ }
+ return tab;
+ }),
+ });
+ },
+
+ markTabAsDeleted: (fileId: number) => {
+ const state = get();
+ const targetTab = state.openTabs.find(tab => tab.fileId === fileId);
+ if (targetTab) {
+ console.log('파일 삭제로 탭을 삭제됨으로 표시:', {
+ tabId: targetTab.id,
+ name: targetTab.name,
+ fileId,
+ });
+ set({
+ openTabs: state.openTabs.map(tab =>
+ tab.fileId === fileId
+ ? {
+ ...tab,
+ isDeleted: true,
+ hasFileTreeMismatch: false,
+ }
+ : tab
+ ),
+ });
+ }
+ },
+
+ syncTabsWithFileTree: (
+ fileTreeNodes: Array<{ fileId: number; fileName: string; path: string }>
+ ) => {
+ const state = get();
+ const fileTreeMap = new Map(fileTreeNodes.map(node => [node.fileId, node]));
+
+ console.log('파일트리와 탭 동기화:', {
+ fileTreeCount: fileTreeNodes.length,
+ tabCount: state.openTabs.length,
+ });
+
+ const updatedTabs = state.openTabs.map(tab => {
+ if (!tab.fileId) return tab;
+
+ const fileTreeNode = fileTreeMap.get(tab.fileId);
+
+ if (!fileTreeNode) {
+ console.log('파일트리에서 제거된 탭을 삭제 상태로 표시:', {
+ tabId: tab.id,
+ name: tab.name,
+ });
+ return {
+ ...tab,
+ isDeleted: true,
+ hasFileTreeMismatch: false,
+ };
+ }
+
+ if (fileTreeNode.fileName !== tab.name || fileTreeNode.path !== tab.path) {
+ console.log('파일트리 변경으로 탭을 변경된 상태로 표시:', {
+ oldName: tab.name,
+ newName: fileTreeNode.fileName,
+ oldPath: tab.path,
+ newPath: fileTreeNode.path,
+ });
+ return {
+ ...tab,
+ isDeleted: false,
+ hasFileTreeMismatch: true,
+ };
+ }
+
+ return { ...tab, isDeleted: false, hasFileTreeMismatch: false };
+ });
+
+ set({ openTabs: updatedTabs });
+ },
+
getTabById: (tabId: string) => {
const state = get();
return state.openTabs.find(tab => tab.id === tabId);
@@ -283,11 +405,9 @@ export const useTabStore = create()(
name: 'tab-storage',
storage: createJSONStorage(() => localStorage),
- // 하이드레이션 로직 개선
onRehydrateStorage: () => (state, error) => {
if (error) {
console.error('탭 상태 복원 실패:', error);
- // 에러 발생시 localStorage 클리어하여 무한루프 방지
try {
localStorage.removeItem('tab-storage');
console.log('손상된 탭 저장소 정리됨');
@@ -301,7 +421,6 @@ export const useTabStore = create()(
dirtyTabs: state.openTabs.filter(tab => tab.isDirty).length,
});
- // 활성 탭이 없거나 여러 개면 정리
const activeTabs = state.openTabs.filter(tab => tab.isActive);
if (state.openTabs.length > 0 && activeTabs.length !== 1) {
console.log('활성 탭 상태 정리:', {
@@ -309,11 +428,10 @@ export const useTabStore = create()(
totalCount: state.openTabs.length,
});
- // 모든 탭을 비활성화하고 첫 번째 탭만 활성화
state.openTabs = state.openTabs.map((tab, index) => ({
...tab,
isActive: index === 0,
- isLoading: false, // 복원 시 로딩 상태 초기화
+ isLoading: false,
}));
if (state.openTabs.length > 0) {
@@ -321,26 +439,22 @@ export const useTabStore = create()(
}
}
- // 하이드레이션 완료 표시
state.setHasHydrated(true);
}
},
- // 저장할 데이터 최적화
partialize: state => ({
openTabs: state.openTabs.map(tab => ({
...tab,
- // 저장할 때는 dirty 상태를 false로 리셋 (새로고침 시 clean 상태로 시작)
isDirty: false,
- // 로딩 상태도 false로 리셋
isLoading: false,
- // 내용이 너무 크면 저장하지 않음 (성능 최적화)
content: (tab.content?.length || 0) > 100000 ? '' : tab.content,
+ isDeleted: false,
+ hasFileTreeMismatch: false,
})),
}),
- // 저장/복원 에러 시 재시도 방지
- version: 1, // 스키마 버전 관리
+ version: 1,
}
)
);
diff --git a/src/types/repo/repo.types.ts b/src/types/repo/repo.types.ts
index f707cc02..ef3b0f3c 100644
--- a/src/types/repo/repo.types.ts
+++ b/src/types/repo/repo.types.ts
@@ -13,9 +13,11 @@ export interface OpenTab {
id: string;
name: string;
path: string;
- isActive: boolean;
- isDirty: boolean;
content: string;
+ isDirty: boolean;
+ isActive: boolean;
fileId?: number;
isLoading?: boolean;
+ isDeleted?: boolean;
+ hasFileTreeMismatch?: boolean;
}
diff --git a/src/types/repo/tabStore.types.ts b/src/types/repo/tabStore.types.ts
index 9f8ba541..460b551d 100644
--- a/src/types/repo/tabStore.types.ts
+++ b/src/types/repo/tabStore.types.ts
@@ -1,33 +1,29 @@
-import type { OpenTab } from '@/types/repo/repo.types';
+import type { OpenTab } from './repo.types';
export interface TabStore {
- // 상태
openTabs: OpenTab[];
_hasHydrated: boolean;
- // 기본 관리
+ setHasHydrated: (hasHydrated: boolean) => void;
setOpenTabs: (tabs: OpenTab[]) => void;
addTab: (tab: OpenTab) => void;
closeTab: (id: string) => void;
activateTab: (id: string) => void;
-
- // 파일 관련
openFileByPath: (repoId: string, filePath: string, fileName?: string, fileId?: number) => void;
setTabContent: (tabId: string, content: string) => void;
setTabContentFromFile: (tabId: string, content: string) => void;
setTabDirty: (tabId: string, isDirty: boolean) => void;
- setTabLoading: (tabId: string, isLoading: boolean) => void; // 새로 추가
-
- // 레포지토리 관련
+ setTabLoading: (tabId: string, isLoading: boolean) => void;
clearTabsForRepo: (repoId: string) => void;
+ clearAllTabs: () => void;
keepOnlyCurrentRepoTabs: (repoId: string) => void;
-
- // 디버그 헬퍼
+ updateTabFromFileTree: (fileId: number, fileName: string, path: string) => void;
+ markTabAsDeleted: (fileId: number) => void;
+ syncTabsWithFileTree: (
+ fileTreeNodes: Array<{ fileId: number; fileName: string; path: string }>
+ ) => void;
getTabById: (tabId: string) => OpenTab | undefined;
getActiveTab: () => OpenTab | undefined;
getDirtyTabs: () => OpenTab[];
getTabsByRepo: (repoId: string) => OpenTab[];
-
- // 하이드레이션
- setHasHydrated: (hasHydrated: boolean) => void;
}
diff --git a/src/utils/editorLanguages.ts b/src/utils/editorLanguages.ts
index d7f54171..921b00ad 100644
--- a/src/utils/editorLanguages.ts
+++ b/src/utils/editorLanguages.ts
@@ -11,7 +11,25 @@ export const LANGUAGE_CONFIGS: Record = {
language: 'spring-boot',
id: 'java',
monacoLanguage: 'java',
- fileExtensions: ['.java', '.xml', '.properties', '.yaml', '.yml'],
+ fileExtensions: [
+ '.java',
+ '.xml',
+ '.properties',
+ '.yaml',
+ '.yml',
+ '.gradle',
+ '.gradle.kts',
+ '.sql',
+ '.jsp',
+ '.jspx',
+ '.ftl',
+ '.vm',
+ '.groovy',
+ '.kt',
+ '.kts',
+ '.conf',
+ '.toml',
+ ],
defaultTheme: 'vs-dark',
snippets: ['sysout', 'psvm', 'fori'],
},
@@ -19,7 +37,31 @@ export const LANGUAGE_CONFIGS: Record = {
language: 'react',
id: 'typescript',
monacoLanguage: 'typescript',
- fileExtensions: ['.ts', '.tsx', '.js', '.jsx', '.json', '.css', '.scss'],
+ fileExtensions: [
+ '.ts',
+ '.tsx',
+ '.js',
+ '.jsx',
+ '.json',
+ '.css',
+ '.scss',
+ '.sass',
+ '.less',
+ '.stylus',
+ '.postcss',
+ '.vue',
+ '.svelte',
+ '.mjs',
+ '.cjs',
+ '.d.ts',
+ '.mdx',
+ '.html',
+ '.htm',
+ '.env',
+ '.env.local',
+ '.env.development',
+ '.env.production',
+ ],
defaultTheme: 'vs-dark',
snippets: ['rfc', 'useState', 'useEffect'],
},
@@ -27,7 +69,30 @@ export const LANGUAGE_CONFIGS: Record = {
language: 'fastapi',
id: 'python',
monacoLanguage: 'python',
- fileExtensions: ['.py', '.pyi', '.pyx', '.requirements.txt'],
+ fileExtensions: [
+ '.py',
+ '.pyi',
+ '.pyx',
+ '.requirements.txt',
+ '.pyc',
+ '.pyo',
+ '.pyw',
+ '.ipynb',
+ '.toml',
+ '.cfg',
+ '.ini',
+ '.txt',
+ '.lock',
+ '.pipfile',
+ '.dockerfile',
+ '.dockerignore',
+ '.env',
+ '.jinja',
+ '.jinja2',
+ '.j2',
+ '.iml',
+ '.http',
+ ],
defaultTheme: 'vs-dark',
snippets: ['def', 'class', 'if', 'for'],
},
diff --git a/src/utils/fileExtensions.ts b/src/utils/fileExtensions.ts
index 56387469..ac5b5655 100644
--- a/src/utils/fileExtensions.ts
+++ b/src/utils/fileExtensions.ts
@@ -8,6 +8,13 @@ import articleIcon from '@/assets/icons/article.svg';
import slidersIcon from '@/assets/icons/sliders.svg';
import folderClosedIcon from '@/assets/icons/folder-closed.svg';
import folderOpenIcon from '@/assets/icons/folder-open.svg';
+import chartIcon from '@/assets/icons/chart.svg';
+import noteIcon from '@/assets/icons/note.svg';
+import lockIcon from '@/assets/icons/lock.svg';
+import archiveIcon from '@/assets/icons/archive.svg';
+import bookIcon from '@/assets/icons/book.svg';
+import imageIcon from '@/assets/icons/image.svg';
+import gitIcon from '@/assets/icons/git-merge.svg';
export const getLanguageFromFile = (fileName: string): string => {
const extension = fileName.split('.').pop()?.toLowerCase();
@@ -19,6 +26,17 @@ export const getLanguageFromFile = (fileName: string): string => {
properties: 'properties',
yaml: 'yaml',
yml: 'yaml',
+ gradle: 'groovy',
+ kts: 'kotlin',
+ sql: 'sql',
+ jsp: 'html',
+ jspx: 'xml',
+ ftl: 'html',
+ vm: 'html',
+ groovy: 'groovy',
+ kt: 'kotlin',
+ conf: 'ini',
+ toml: 'toml',
// React/TypeScript
ts: 'typescript',
@@ -28,11 +46,36 @@ export const getLanguageFromFile = (fileName: string): string => {
json: 'json',
css: 'css',
scss: 'scss',
+ sass: 'scss',
+ less: 'less',
+ stylus: 'stylus',
+ postcss: 'css',
+ vue: 'html',
+ svelte: 'html',
+ mjs: 'javascript',
+ cjs: 'javascript',
+ mdx: 'markdown',
+ htm: 'html',
+ env: 'properties',
// Python/FastAPI
py: 'python',
pyi: 'python',
pyx: 'python',
+ pyc: 'python',
+ pyo: 'python',
+ pyw: 'python',
+ ipynb: 'json',
+ cfg: 'ini',
+ ini: 'ini',
+ lock: 'yaml',
+ pipfile: 'toml',
+ dockerignore: 'plaintext',
+ jinja: 'html',
+ jinja2: 'html',
+ j2: 'html',
+ iml: 'xml',
+ http: 'plaintext',
// 기타 - Monaco Editor 언어 ID에 맞춰 수정
md: 'markdown',
@@ -40,6 +83,13 @@ export const getLanguageFromFile = (fileName: string): string => {
html: 'html',
dockerfile: 'dockerfile',
gitignore: 'plaintext',
+ log: 'plaintext',
+ eslintrc: 'json',
+ prettierrc: 'json',
+ editorconfig: 'ini',
+ nvmrc: 'plaintext',
+ npmrc: 'ini',
+ svg: 'xml',
};
return extensionMap[extension || ''] || 'plaintext';
@@ -55,6 +105,17 @@ export const getFileIcon = (fileName: string): string => {
properties: slidersIcon,
yaml: fileAltIcon,
yml: fileAltIcon,
+ gradle: scriptIcon,
+ kts: scriptIcon,
+ sql: chartIcon,
+ jsp: codeIcon,
+ jspx: codeIcon,
+ ftl: noteIcon,
+ vm: noteIcon,
+ groovy: scriptIcon,
+ kt: codeIcon,
+ conf: slidersIcon,
+ toml: slidersIcon,
// React/TypeScript
ts: codeIcon,
@@ -64,14 +125,47 @@ export const getFileIcon = (fileName: string): string => {
json: fileAltIcon,
css: colorsSwatchIcon,
scss: colorsSwatchIcon,
+ sass: colorsSwatchIcon,
+ less: colorsSwatchIcon,
+ stylus: colorsSwatchIcon,
+ postcss: colorsSwatchIcon,
+ vue: codeIcon,
+ svelte: codeIcon,
+ mjs: scriptIcon,
+ cjs: scriptIcon,
+ mdx: articleIcon,
+ htm: codeIcon,
+ env: lockIcon,
// Python/FastAPI
py: scriptIcon,
+ pyc: archiveIcon,
+ pyo: archiveIcon,
+ pyw: scriptIcon,
+ ipynb: bookIcon,
+ cfg: slidersIcon,
+ ini: slidersIcon,
+ lock: lockIcon,
+ pipfile: fileAltIcon,
+ dockerignore: fileOffIcon,
+ jinja: noteIcon,
+ jinja2: noteIcon,
+ j2: noteIcon,
+ iml: slidersIcon,
+ http: scriptIcon,
// 기타
md: articleIcon,
txt: notesIcon,
html: codeIcon,
+ log: notesIcon,
+ eslintrc: slidersIcon,
+ prettierrc: slidersIcon,
+ editorconfig: slidersIcon,
+ nvmrc: fileAltIcon,
+ npmrc: slidersIcon,
+ gitignore: gitIcon,
+ svg: imageIcon,
};
return iconMap[extension || ''] || fileOffIcon;