diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index fcb07050..5fadd6a5 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -90,6 +90,8 @@ interface ExtendedFlexiblePanelProps extends FlexiblePanelProps { onDirtyStateChange?: (isDirty: boolean) => void; /** Whether this panel is the active/visible tab in its EditorGroup */ isActive?: boolean; + /** File no longer exists on disk (from editor); drives tab "deleted" label */ + onFileMissingFromDiskChange?: (missing: boolean) => void; } const FlexiblePanel: React.FC = memo(({ @@ -101,6 +103,7 @@ const FlexiblePanel: React.FC = memo(({ onBeforeClose, onDirtyStateChange, isActive = true, + onFileMissingFromDiskChange, }) => { const { t } = useI18n('components'); @@ -273,6 +276,8 @@ const FlexiblePanel: React.FC = memo(({ readOnly={markdownEditorData.readOnly || false} jumpToLine={markdownJumpToLine} jumpToColumn={markdownJumpToColumn} + isActiveTab={isActive} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} onContentChange={(_newContent, hasChanges) => { if (onDirtyStateChange) { onDirtyStateChange(hasChanges); @@ -407,6 +412,8 @@ const FlexiblePanel: React.FC = memo(({ showMinimap={true} theme="vs-dark" className={fileViewerClass} + isActiveTab={isActive} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} /> ); @@ -442,6 +449,8 @@ const FlexiblePanel: React.FC = memo(({ showMinimap={true} theme="vs-dark" onContentChange={codeData.onContentChange} + isActiveTab={isActive} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} /> @@ -467,6 +476,8 @@ const FlexiblePanel: React.FC = memo(({ jumpToLine={editorData.jumpToLine} jumpToColumn={editorData.jumpToColumn} jumpToRange={editorData.jumpToRange} + isActiveTab={isActive} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} onContentChange={(newContent, hasChanges) => { if (onContentChange) { onContentChange({ diff --git a/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx b/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx index 86e7f417..fac0805d 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx @@ -34,6 +34,9 @@ export interface TabOperations { updateTabContent: (tabId: string, groupId: EditorGroupId, content: PanelContent) => void; /** Set tab dirty state */ setTabDirty: (tabId: string, groupId: EditorGroupId, isDirty: boolean) => void; + + /** File missing on disk (for tab chrome) */ + setTabFileDeletedFromDisk: (tabId: string, groupId: EditorGroupId, deleted: boolean) => void; /** Promote tab state (preview -> active) */ promoteTab: (tabId: string, groupId: EditorGroupId) => void; /** Pin/unpin tab */ diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx index 7ca82439..2cf476ad 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx @@ -49,6 +49,7 @@ export const EditorArea: React.FC = ({ setActiveGroup, updateTabContent, setTabDirty, + setTabFileDeletedFromDisk, } = useCanvasStore(); const handleTabClick = useCallback((groupId: EditorGroupId) => (tabId: string) => { @@ -110,6 +111,13 @@ export const EditorArea: React.FC = ({ setTabDirty(tabId, groupId, isDirty); }, [setTabDirty]); + const handleTabFileDeletedFromDiskChange = useCallback( + (groupId: EditorGroupId) => (tabId: string, missing: boolean) => { + setTabFileDeletedFromDisk(tabId, groupId, missing); + }, + [setTabFileDeletedFromDisk] + ); + const renderEditorGroup = (groupId: EditorGroupId, group: typeof primaryGroup) => ( = ({ onGroupFocus={handleGroupFocus(groupId)} onContentChange={handleContentChange(groupId)} onDirtyStateChange={handleDirtyStateChange(groupId)} + onTabFileDeletedFromDiskChange={handleTabFileDeletedFromDiskChange(groupId)} onOpenMissionControl={groupId === 'primary' ? onOpenMissionControl : undefined} onCloseAllTabs={handleCloseAllTabs(groupId)} onInteraction={onInteraction} diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx index 0bcc5844..1128542c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx @@ -37,6 +37,7 @@ export interface EditorGroupProps { onGroupFocus: () => void; onContentChange: (tabId: string, content: PanelContent) => void; onDirtyStateChange: (tabId: string, isDirty: boolean) => void; + onTabFileDeletedFromDiskChange?: (tabId: string, missing: boolean) => void; onOpenMissionControl?: () => void; onCloseAllTabs?: () => Promise | void; onInteraction?: (itemId: string, userInput: string) => Promise; @@ -61,6 +62,7 @@ export const EditorGroup: React.FC = ({ onGroupFocus, onContentChange, onDirtyStateChange, + onTabFileDeletedFromDiskChange, onOpenMissionControl, onCloseAllTabs, onInteraction, @@ -163,6 +165,11 @@ export const EditorGroup: React.FC = ({ isActive={group.activeTabId === tab.id} onContentChange={group.activeTabId === tab.id ? handleContentChange : undefined} onDirtyStateChange={group.activeTabId === tab.id ? handleDirtyStateChange : undefined} + onFileMissingFromDiskChange={ + onTabFileDeletedFromDiskChange + ? (missing) => onTabFileDeletedFromDiskChange(tab.id, missing) + : undefined + } onInteraction={onInteraction} workspacePath={workspacePath} /> diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx index a41a7f25..1634cc5c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx @@ -103,6 +103,11 @@ export const ThumbnailCard: React.FC = ({ return t('canvas.groupTertiary'); }, [groupId, t]); + const titleWithDeleted = useMemo(() => { + const suffix = tab.fileDeletedFromDisk ? ` - ${t('tabs.fileDeleted')}` : ''; + return `${tab.title}${suffix}`; + }, [tab.fileDeletedFromDisk, tab.title, t]); + // Handle close const handleClose = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -135,7 +140,7 @@ export const ThumbnailCard: React.FC = ({ return (
= ({
{tab.state === 'pinned' && } - {tab.title} + {titleWithDeleted} {tab.isDirty && }
diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts index 8e7d7761..b85fa185 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts @@ -68,6 +68,9 @@ interface CanvasStoreActions { /** Set tab dirty state */ setTabDirty: (tabId: string, groupId: EditorGroupId, isDirty: boolean) => void; + + /** Mark whether the tab's file is missing on disk (editor-detected) */ + setTabFileDeletedFromDisk: (tabId: string, groupId: EditorGroupId, deleted: boolean) => void; /** Promote tab state (preview -> active) */ promoteTab: (tabId: string, groupId: EditorGroupId) => void; @@ -641,6 +644,16 @@ const createCanvasStoreHook = () => create()( } }); }, + + setTabFileDeletedFromDisk: (tabId, groupId, deleted) => { + set((draft) => { + const group = getGroup(draft, groupId); + const tab = group.tabs.find(t => t.id === tabId); + if (tab) { + tab.fileDeletedFromDisk = deleted; + } + }); + }, promoteTab: (tabId, groupId) => { set((draft) => { diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss index cc91d87b..52973410 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss @@ -62,6 +62,12 @@ } } + &.is-file-deleted { + .canvas-tab__title { + color: var(--color-text-secondary, rgba(255, 255, 255, 0.55)); + } + } + // Dragging &.is-dragging { opacity: 0.5; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx index 16628cad..f69e39d1 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx @@ -63,9 +63,11 @@ export const Tab: React.FC = ({ // Build tooltip text const unsavedSuffix = tab.isDirty ? ` (${t('tabs.unsaved')})` : ''; + const deletedSuffix = tab.fileDeletedFromDisk ? ` - ${t('tabs.fileDeleted')}` : ''; + const titleDisplay = `${tab.title}${deletedSuffix}`; const tooltipText = tab.content.data?.filePath - ? `${tab.content.data.filePath}${unsavedSuffix}` - : `${tab.title}${unsavedSuffix}`; + ? `${tab.content.data.filePath}${deletedSuffix}${unsavedSuffix}` + : `${titleDisplay}${unsavedSuffix}`; // Handle single click - respond immediately const handleClick = useCallback((e: React.MouseEvent) => { @@ -113,6 +115,7 @@ export const Tab: React.FC = ({ 'canvas-tab', isActive && 'is-active', tab.isDirty && 'is-dirty', + tab.fileDeletedFromDisk && 'is-file-deleted', isDragging && 'is-dragging', getStateClassName(tab.state), isTaskDetail && 'is-task-detail', @@ -153,7 +156,7 @@ export const Tab: React.FC = ({ {/* Title */} - {tab.title} + {titleDisplay} {/* Dirty state indicator */} diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx index 7f8e2c5a..6fd4aa59 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx @@ -76,6 +76,9 @@ const estimateTabWidth = (title: string): number => { return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, estimated)); }; +const tabTitleForWidthEstimate = (tab: CanvasTab, deletedLabel: string): string => + tab.fileDeletedFromDisk ? `${tab.title} - ${deletedLabel}` : tab.title; + export const TabBar: React.FC = ({ tabs, groupId, @@ -108,7 +111,10 @@ export const TabBar: React.FC = ({ const visibleTabs = useMemo(() => tabs.filter(t => !t.isHidden), [tabs]); // Build cache key (id + title because title changes affect width) - const getTabCacheKey = useCallback((tab: CanvasTab) => `${tab.id}:${tab.title}`, []); + const getTabCacheKey = useCallback( + (tab: CanvasTab) => `${tab.id}:${tab.title}:${tab.fileDeletedFromDisk ? '1' : '0'}`, + [] + ); // Get tab width: use cache if available, otherwise estimate const getTabWidth = useCallback((tab: CanvasTab): number => { @@ -118,8 +124,8 @@ export const TabBar: React.FC = ({ return cached; } // Estimated width - return estimateTabWidth(tab.title); - }, [getTabCacheKey]); + return estimateTabWidth(tabTitleForWidthEstimate(tab, t('tabs.fileDeleted'))); + }, [getTabCacheKey, t]); // Compute visible tab count based on DOM measurements const calculateVisibleTabs = useCallback(() => { diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx index 7d78a0ed..e695307f 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx @@ -189,17 +189,20 @@ export const TabOverflowMenu: React.FC = ({ {/* Overflow tab list */}
- {overflowTabs.map((tab) => ( + {overflowTabs.map((tab) => { + const deletedSuffix = tab.fileDeletedFromDisk ? ` - ${t('tabs.fileDeleted')}` : ''; + const titleWithDeleted = `${tab.title}${deletedSuffix}`; + return (
handleTabClick(tab.id)} > - {tab.state === 'preview' && {tab.title}} - {tab.state !== 'preview' && tab.title} + {tab.state === 'preview' && {titleWithDeleted}} + {tab.state !== 'preview' && titleWithDeleted} {tab.isDirty && ( @@ -213,7 +216,8 @@ export const TabOverflowMenu: React.FC = ({
- ))} + ); + })}
, document.body diff --git a/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts b/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts index 2d554ea6..c5387770 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts @@ -23,6 +23,8 @@ export interface CanvasTab { state: TabState; /** Whether there are unsaved changes */ isDirty: boolean; + /** File path no longer exists on disk (or is not a file); tab title shows a deleted label */ + fileDeletedFromDisk?: boolean; /** Whether hidden (for persistent sessions like terminal) */ isHidden?: boolean; /** Created timestamp */ diff --git a/src/web-ui/src/locales/en-US/components.json b/src/web-ui/src/locales/en-US/components.json index c531b8c5..d5315777 100644 --- a/src/web-ui/src/locales/en-US/components.json +++ b/src/web-ui/src/locales/en-US/components.json @@ -336,6 +336,7 @@ "pin": "Pin Tab", "unpin": "Unpin Tab", "unsaved": "Unsaved", + "fileDeleted": "Deleted", "missionControl": "Mission Control", "hiddenTabsCount": "{{count}} hidden tabs", "confirmCloseWithDirty": "File \"{{title}}\" has unsaved changes.\n\nDiscard changes and close?", diff --git a/src/web-ui/src/locales/en-US/tools.json b/src/web-ui/src/locales/en-US/tools.json index 25b79765..8f55458a 100644 --- a/src/web-ui/src/locales/en-US/tools.json +++ b/src/web-ui/src/locales/en-US/tools.json @@ -46,7 +46,15 @@ "loadingFile": "Loading file...", "saving": "Saving...", "initFailedWithMessage": "Failed to initialize editor: {{message}}", - "externalModifiedConfirm": "This file was modified by another program.\n\nYou have unsaved changes. Discard them and reload the file?" + "externalModifiedConfirm": "This file was modified by another program.\n\nYou have unsaved changes. Discard them and reload the file?", + "externalModifiedTitle": "File changed on disk", + "externalModifiedDetail": "The file's metadata on disk (modified time / size) no longer matches the version this editor is based on. You have unsaved local changes. Reloading will discard them.", + "discardAndReload": "Discard and reload", + "keepLocalEdits": "Keep my edits", + "saveConflictTitle": "Save conflict", + "saveConflictDetail": "The file on disk was modified by another program before save. Overwrite the file with your editor content, or discard local changes and reload from disk.", + "overwriteSave": "Overwrite", + "reloadFromDisk": "Reload from disk" }, "diffEditor": { "loading": "Loading diff editor...", diff --git a/src/web-ui/src/locales/zh-CN/components.json b/src/web-ui/src/locales/zh-CN/components.json index 1a67f947..cf288633 100644 --- a/src/web-ui/src/locales/zh-CN/components.json +++ b/src/web-ui/src/locales/zh-CN/components.json @@ -336,6 +336,7 @@ "pin": "固定标签", "unpin": "取消固定", "unsaved": "未保存", + "fileDeleted": "已删除", "missionControl": "全景模式", "hiddenTabsCount": "{{count}} 个隐藏标签", "confirmCloseWithDirty": "文件 \"{{title}}\" 有未保存的更改。\n\n是否放弃更改并关闭?", diff --git a/src/web-ui/src/locales/zh-CN/tools.json b/src/web-ui/src/locales/zh-CN/tools.json index c792197f..effb53fa 100644 --- a/src/web-ui/src/locales/zh-CN/tools.json +++ b/src/web-ui/src/locales/zh-CN/tools.json @@ -46,7 +46,15 @@ "loadingFile": "正在加载文件...", "saving": "正在保存...", "initFailedWithMessage": "编辑器初始化失败: {{message}}", - "externalModifiedConfirm": "该文件已被外部程序修改。\n\n您当前有未保存的修改,是否要放弃修改并重新加载文件?" + "externalModifiedConfirm": "该文件已被外部程序修改。\n\n您当前有未保存的修改,是否要放弃修改并重新加载文件?", + "externalModifiedTitle": "磁盘上的文件已变更", + "externalModifiedDetail": "检测到该文件在磁盘上的版本(修改时间/大小)与当前编辑器所依据的版本不一致。您有未保存的本地修改。若重新加载,本地未保存内容将丢失。", + "discardAndReload": "放弃修改并重新加载", + "keepLocalEdits": "保留本地编辑", + "saveConflictTitle": "保存冲突", + "saveConflictDetail": "保存前检测到磁盘上的文件已被其他程序修改。您可以选择用当前编辑覆盖磁盘,或放弃本地修改并从磁盘重新加载。", + "overwriteSave": "覆盖保存", + "reloadFromDisk": "从磁盘重新加载" }, "diffEditor": { "loading": "正在加载差异编辑器...", diff --git a/src/web-ui/src/shared/utils/fsErrorUtils.ts b/src/web-ui/src/shared/utils/fsErrorUtils.ts new file mode 100644 index 00000000..9ff1bb26 --- /dev/null +++ b/src/web-ui/src/shared/utils/fsErrorUtils.ts @@ -0,0 +1,23 @@ +/** + * Heuristic detection of "file not found" from API/FS errors (local + remote). + */ + +export function isLikelyFileNotFoundError(err: unknown): boolean { + const s = String(err).toLowerCase(); + return ( + s.includes('no such file') || + s.includes('does not exist') || + s.includes('not found') || + s.includes('os error 2') || + s.includes('enoent') || + s.includes('path not found') + ); +} + +/** Metadata from get_file_metadata: missing remote file uses is_file false and is_dir false. */ +export function isFileMissingFromMetadata(fileInfo: Record | null | undefined): boolean { + if (!fileInfo || typeof fileInfo !== 'object') { + return true; + } + return fileInfo.is_file !== true; +} diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index ea2fed67..733d38e3 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -25,6 +25,16 @@ import { CubeLoading } from '@/component-library'; import { getMonacoLanguage } from '@/infrastructure/language-detection'; import { createLogger } from '@/shared/utils/logger'; import { isSamePath } from '@/shared/utils/pathUtils'; +import { + diskVersionFromMetadata, + diskVersionsDiffer, + type DiskFileVersion, +} from '../utils/diskFileVersion'; +import { confirmDialog } from '@/component-library/components/ConfirmDialog/confirmService'; +import { + isFileMissingFromMetadata, + isLikelyFileNotFoundError, +} from '@/shared/utils/fsErrorUtils'; import { useI18n } from '@/infrastructure/i18n'; import { EditorBreadcrumb } from './EditorBreadcrumb'; import { EditorStatusBar } from './EditorStatusBar'; @@ -70,6 +80,10 @@ export interface CodeEditorProps { jumpToColumn?: number; /** Jump to line range (preferred, supports single or multi-line selection) */ jumpToRange?: import('@/component-library/components/Markdown').LineRange; + /** When false, disk sync polling is paused (e.g. background editor tab). */ + isActiveTab?: boolean; + /** File path is not an existing file on disk (drives tab "deleted" label). */ + onFileMissingFromDiskChange?: (missing: boolean) => void; } const LARGE_FILE_SIZE_THRESHOLD_BYTES = 1 * 1024 * 1024; // 1MB @@ -78,6 +92,9 @@ const LARGE_FILE_RENDER_LINE_LIMIT = 10000; const LARGE_FILE_MAX_TOKENIZATION_LINE_LENGTH = 2000; const LARGE_FILE_EXPANSION_LABELS = ['show more', '显示更多', '展开更多']; +/** Poll disk metadata for open file; only while tab is active (see isActiveTab). */ +const FILE_SYNC_POLL_INTERVAL_MS = 1000; + function hasVeryLongLine(content: string, maxLineLength: number): boolean { let currentLineLength = 0; for (let i = 0; i < content.length; i++) { @@ -117,7 +134,9 @@ const CodeEditor: React.FC = ({ enableLsp = true, jumpToLine, jumpToColumn, - jumpToRange + jumpToRange, + isActiveTab = true, + onFileMissingFromDiskChange, }) => { // Decode URL-encoded paths (e.g. d%3A/path -> d:/path) const filePath = useMemo(() => { @@ -139,7 +158,7 @@ const CodeEditor: React.FC = ({ }, [language]); const [content, setContent] = useState(''); - const [hasChanges, setHasChanges] = useState(false); + const [, setHasChanges] = useState(false); const [loading, setLoading] = useState(true); const [showLoadingOverlay, setShowLoadingOverlay] = useState(false); const loadingOverlayDelayRef = useRef | null>(null); @@ -196,7 +215,23 @@ const CodeEditor: React.FC = ({ const modelRef = useRef(null); const isUnmountedRef = useRef(false); const isCheckingFileRef = useRef(false); - const lastModifiedTimeRef = useRef(0); + /** Last disk state known to match loaded/saved editor content (mtime + size; local + remote). */ + const diskVersionRef = useRef(null); + const lastReportedMissingRef = useRef(undefined); + + const reportFileMissingFromDisk = useCallback( + (missing: boolean) => { + if (!onFileMissingFromDiskChange) { + return; + } + if (lastReportedMissingRef.current === missing) { + return; + } + lastReportedMissingRef.current = missing; + onFileMissingFromDiskChange(missing); + }, + [onFileMissingFromDiskChange] + ); const contentChangeListenerRef = useRef(null); const ctrlDecorationsRef = useRef([]); const lastHoverWordRef = useRef(null); @@ -262,6 +297,42 @@ const CodeEditor: React.FC = ({ }); }, []); + const applyDiskSnapshotToEditor = useCallback( + ( + fileContent: string, + version: DiskFileVersion | null, + options?: { restoreCursor?: monaco.IPosition | null } + ) => { + updateLargeFileMode(fileContent); + if (isUnmountedRef.current) { + return; + } + isLoadingContentRef.current = true; + setContent(fileContent); + originalContentRef.current = fileContent; + setHasChanges(false); + hasChangesRef.current = false; + if (version) { + diskVersionRef.current = version; + } + applyExternalContentToModel(fileContent); + const pos = options?.restoreCursor; + if (pos && editorRef.current) { + editorRef.current.setPosition(pos); + } + onContentChange?.(fileContent, false); + reportFileMissingFromDisk(false); + queueMicrotask(() => { + isLoadingContentRef.current = false; + if (modelRef.current && !isUnmountedRef.current && filePath) { + savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); + monacoModelManager.markAsSaved(filePath); + } + }); + }, + [applyExternalContentToModel, filePath, onContentChange, reportFileMissingFromDisk, updateLargeFileMode] + ); + const shouldBlockLargeFileExpansionClick = useCallback((target: EventTarget | null): boolean => { if (!(target instanceof HTMLElement)) { return false; @@ -1119,6 +1190,24 @@ const CodeEditor: React.FC = ({ setHasChanges(false); hasChangesRef.current = false; applyExternalContentToModel(content); + try { + const { invoke } = await import('@tauri-apps/api/core'); + const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } + } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } + log.warn('Failed to sync disk version after encoding change', err); + } queueMicrotask(() => { if (modelRef.current && !isUnmountedRef.current) { savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); @@ -1126,9 +1215,12 @@ const CodeEditor: React.FC = ({ } }); } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.warn('Failed to reload file with new encoding', err); } - }, [applyExternalContentToModel, filePath, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, reportFileMissingFromDisk, updateLargeFileMode]); const handleLanguageConfirm = useCallback((languageId: string) => { userLanguageOverrideRef.current = true; @@ -1154,10 +1246,19 @@ const CodeEditor: React.FC = ({ const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + return; + } + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; } } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.warn('Failed to sync file metadata when skipping load', err); } })(); @@ -1171,22 +1272,33 @@ const CodeEditor: React.FC = ({ try { const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); + + const fileContent = await workspaceAPI.readFileContent(filePath); + reportFileMissingFromDisk(false); let fileSizeBytes: number | undefined; try { - const fileInfo: any = await invoke('get_file_metadata', { + const fileInfoAfter: any = await invoke('get_file_metadata', { request: { path: filePath } }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; + if (isFileMissingFromMetadata(fileInfoAfter)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfoAfter); + if (v) { + diskVersionRef.current = v; + } } - if (typeof fileInfo?.size === 'number') { - fileSizeBytes = fileInfo.size; + if (typeof fileInfoAfter?.size === 'number') { + fileSizeBytes = fileInfoAfter.size; } } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.warn('Failed to get file metadata', err); } - const fileContent = await workspaceAPI.readFileContent(filePath); updateLargeFileMode(fileContent, fileSizeBytes); setContent(fileContent); @@ -1221,13 +1333,16 @@ const CodeEditor: React.FC = ({ } setError(displayError); log.error('Failed to load file', err); + if (errStr.includes('does not exist') || errStr.includes('No such file')) { + reportFileMissingFromDisk(true); + } } finally { setLoading(false); queueMicrotask(() => { isLoadingContentRef.current = false; }); } - }, [applyExternalContentToModel, filePath, detectedLanguage, t, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, detectedLanguage, reportFileMissingFromDisk, t, updateLargeFileMode]); // Save file content const saveFileContent = useCallback(async () => { @@ -1249,36 +1364,67 @@ const CodeEditor: React.FC = ({ const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); - // Use latest content read from model + const fileInfoPre: any = await invoke('get_file_metadata', { + request: { path: filePath } + }); + if (isFileMissingFromMetadata(fileInfoPre)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + } + const diskNow = diskVersionFromMetadata(fileInfoPre); + const baseline = diskVersionRef.current; + + if (diskNow && baseline && diskVersionsDiffer(diskNow, baseline)) { + const overwrite = await confirmDialog({ + title: t('editor.codeEditor.saveConflictTitle'), + message: t('editor.codeEditor.saveConflictDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.overwriteSave'), + cancelText: t('editor.codeEditor.reloadFromDisk'), + confirmDanger: true, + }); + if (!overwrite) { + const diskContent = await workspaceAPI.readFileContent(filePath); + const fileInfoAfter: any = await invoke('get_file_metadata', { + request: { path: filePath } + }); + const vAfter = diskVersionFromMetadata(fileInfoAfter); + applyDiskSnapshotToEditor(diskContent, vAfter); + return; + } + } + await workspaceAPI.writeFileContent(workspacePath || '', filePath, currentContent); - - // Use MonacoGlobalManager to mark as saved + monacoModelManager.markAsSaved(filePath); - + originalContentRef.current = currentContent; setHasChanges(false); hasChangesRef.current = false; - - // Sync local versionId + if (modelRef.current) { savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); } - - // Call onSave callback to clear dirty state + onSave?.(currentContent); - // Update file modification time try { const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); - lastModifiedTimeRef.current = fileInfo.modified; + if (!isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } } catch (err) { - log.warn('Failed to update file modification time', err); + log.warn('Failed to update file disk version after save', err); } globalEventBus.emit('file-tree:refresh'); - } catch (err) { const errorMsg = t('editor.common.saveFailedWithMessage', { message: String(err) }); setError(errorMsg); @@ -1286,7 +1432,7 @@ const CodeEditor: React.FC = ({ } finally { setSaving(false); } - }, [filePath, workspacePath, content, hasChanges, onSave, t]); + }, [filePath, workspacePath, onSave, reportFileMissingFromDisk, t, applyDiskSnapshotToEditor]); useEffect(() => { saveFileContentRef.current = saveFileContent; @@ -1332,76 +1478,84 @@ const CodeEditor: React.FC = ({ } }, []); - // Check file modifications const checkFileModification = useCallback(async () => { - if (!filePath || isCheckingFileRef.current) return; + if (!filePath || !isActiveTab || isCheckingFileRef.current) { + return; + } isCheckingFileRef.current = true; try { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + const { invoke } = await import('@tauri-apps/api/core'); const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + return; + } + reportFileMissingFromDisk(false); + const currentVersion = diskVersionFromMetadata(fileInfo); + if (!currentVersion) { + return; + } - const currentModifiedTime = fileInfo.modified; + const baseline = diskVersionRef.current; + if (!baseline) { + diskVersionRef.current = currentVersion; + return; + } - if (lastModifiedTimeRef.current !== 0 && currentModifiedTime > lastModifiedTimeRef.current) { - const { workspaceAPI } = await import('@/infrastructure/api'); - const fileContent = await workspaceAPI.readFileContent(filePath); - const editorBuffer = modelRef.current?.getValue(); - if (editorBuffer !== undefined && fileContent === editorBuffer) { - lastModifiedTimeRef.current = currentModifiedTime; - return; - } + if (!diskVersionsDiffer(currentVersion, baseline)) { + return; + } - log.info('File modified externally', { filePath }); + const { workspaceAPI } = await import('@/infrastructure/api'); + const fileContent = await workspaceAPI.readFileContent(filePath); + const editorBuffer = modelRef.current?.getValue(); + if (editorBuffer !== undefined && fileContent === editorBuffer) { + diskVersionRef.current = currentVersion; + return; + } - if (hasChangesRef.current) { - const shouldReload = window.confirm( - t('editor.codeEditor.externalModifiedConfirm') - ); - if (!shouldReload) { - lastModifiedTimeRef.current = currentModifiedTime; - return; - } - } - updateLargeFileMode(fileContent); - - if (!isUnmountedRef.current) { - isLoadingContentRef.current = true; - setContent(fileContent); - originalContentRef.current = fileContent; - setHasChanges(false); - hasChangesRef.current = false; - lastModifiedTimeRef.current = currentModifiedTime; - applyExternalContentToModel(fileContent); - - onContentChange?.(fileContent, false); - - queueMicrotask(() => { - isLoadingContentRef.current = false; - if (modelRef.current && !isUnmountedRef.current) { - savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); - monacoModelManager.markAsSaved(filePath); - } - }); + log.info('File modified externally', { filePath }); + + if (hasChangesRef.current) { + const shouldReload = await confirmDialog({ + title: t('editor.codeEditor.externalModifiedTitle'), + message: t('editor.codeEditor.externalModifiedDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.discardAndReload'), + cancelText: t('editor.codeEditor.keepLocalEdits'), + confirmDanger: true, + }); + if (!shouldReload) { + diskVersionRef.current = currentVersion; + return; } - } else { - lastModifiedTimeRef.current = currentModifiedTime; } + + applyDiskSnapshotToEditor(fileContent, currentVersion); } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.error('Failed to check file modification', err); } finally { isCheckingFileRef.current = false; } - }, [applyExternalContentToModel, filePath, onContentChange, t, updateLargeFileMode]); + }, [applyDiskSnapshotToEditor, filePath, isActiveTab, reportFileMissingFromDisk, t]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); useEffect(() => { - // Reset the flag when filePath changes loadFileContentCalledRef.current = false; + diskVersionRef.current = null; + lastReportedMissingRef.current = undefined; }, [filePath]); useEffect(() => { @@ -1411,16 +1565,23 @@ const CodeEditor: React.FC = ({ } }, [loadFileContent]); - // Periodic file modification check useEffect(() => { - const intervalId = setInterval(() => { - checkFileModification(); - }, 5000); + if (!filePath || !isActiveTab) { + return; + } + + const tick = () => { + void checkFileModification(); + }; + + const intervalId = window.setInterval(tick, FILE_SYNC_POLL_INTERVAL_MS); + document.addEventListener('visibilitychange', tick); return () => { - clearInterval(intervalId); + window.clearInterval(intervalId); + document.removeEventListener('visibilitychange', tick); }; - }, [checkFileModification]); + }, [checkFileModification, filePath, isActiveTab]); useEffect(() => { const editor = editorRef.current; @@ -1604,70 +1765,47 @@ const CodeEditor: React.FC = ({ const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; } } catch (err) { - log.warn('Failed to sync mtime after noop file-changed', err); + log.warn('Failed to sync disk version after noop file-changed', err); } return; } if (hasChangesRef.current) { - const shouldReload = window.confirm( - t('editor.codeEditor.externalModifiedConfirm') - ); + const shouldReload = await confirmDialog({ + title: t('editor.codeEditor.externalModifiedTitle'), + message: t('editor.codeEditor.externalModifiedDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.discardAndReload'), + cancelText: t('editor.codeEditor.keepLocalEdits'), + confirmDanger: true, + }); if (!shouldReload) { try { const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; } } catch (err) { - log.warn('Failed to sync mtime after declining external reload', err); + log.warn('Failed to sync disk version after declining external reload', err); } return; } } - const fileContent = diskContent; - updateLargeFileMode(fileContent); - - const currentPosition = editor?.getPosition(); - - isLoadingContentRef.current = true; - setContent(fileContent); - originalContentRef.current = fileContent; - setHasChanges(false); - hasChangesRef.current = false; - applyExternalContentToModel(fileContent); - - try { - const fileInfo: any = await invoke('get_file_metadata', { - request: { path: filePath } - }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; - } - } catch (err) { - log.warn('Failed to update file modification time after external reload', err); - } - - if (editor && currentPosition) { - editor.setPosition(currentPosition); - } - - onContentChange?.(fileContent, false); - - queueMicrotask(() => { - isLoadingContentRef.current = false; - if (modelRef.current && !isUnmountedRef.current) { - savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); - monacoModelManager.markAsSaved(filePath); - } + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath } }); + const ver = diskVersionFromMetadata(fileInfo); + const currentPosition = editor?.getPosition() ?? null; + applyDiskSnapshotToEditor(diskContent, ver, { restoreCursor: currentPosition }); } catch (error) { log.error('Failed to reload file', error); } @@ -1684,7 +1822,7 @@ const CodeEditor: React.FC = ({ return () => { unsubscribers.forEach(unsub => unsub()); }; - }, [applyExternalContentToModel, monacoReady, filePath, updateLargeFileMode, onContentChange, t]); + }, [applyDiskSnapshotToEditor, applyExternalContentToModel, monacoReady, filePath, t]); useEffect(() => { userLanguageOverrideRef.current = false; diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index c8197eb7..aae16037 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -16,12 +16,25 @@ import { CubeLoading, Button } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; import CodeEditor from './CodeEditor'; +import { + diskVersionFromMetadata, + diskVersionsDiffer, + type DiskFileVersion, +} from '../utils/diskFileVersion'; +import { confirmDialog } from '@/component-library/components/ConfirmDialog/confirmService'; +import { + isFileMissingFromMetadata, + isLikelyFileNotFoundError, +} from '@/shared/utils/fsErrorUtils'; import './MarkdownEditor.scss'; -const log = createLogger('MarkdownEditor'); import 'katex/dist/katex.min.css'; import 'highlight.js/styles/github-dark.css'; +const log = createLogger('MarkdownEditor'); + +const FILE_SYNC_POLL_INTERVAL_MS = 1000; + export interface MarkdownEditorProps { /** File path - loads from file if provided, otherwise uses initialContent */ filePath?: string; @@ -43,6 +56,10 @@ export interface MarkdownEditorProps { jumpToLine?: number; /** Jump to column (auto-jump after file opens) */ jumpToColumn?: number; + /** When false, disk sync polling is paused (background tab). */ + isActiveTab?: boolean; + /** File missing on disk (tab chrome); skipped when embedded CodeEditor handles the same path */ + onFileMissingFromDiskChange?: (missing: boolean) => void; } const MarkdownEditor: React.FC = ({ @@ -56,6 +73,8 @@ const MarkdownEditor: React.FC = ({ onSave, jumpToLine, jumpToColumn, + isActiveTab = true, + onFileMissingFromDiskChange, }) => { const { t } = useI18n('tools'); const { isLight } = useTheme(); @@ -67,14 +86,53 @@ const MarkdownEditor: React.FC = ({ const [editability, setEditability] = useState(() => analyzeMarkdownEditability(initialContent)); const editorRef = useRef(null); const isUnmountedRef = useRef(false); - const lastModifiedTimeRef = useRef(0); + const diskVersionRef = useRef(null); + const isCheckingDiskRef = useRef(false); + const hasChangesRef = useRef(false); const lastJumpPositionRef = useRef<{ filePath: string; line: number } | null>(null); const onContentChangeRef = useRef(onContentChange); const contentRef = useRef(content); const lastReportedDirtyRef = useRef(null); + const unsafeViewModeRef = useRef(unsafeViewMode); + unsafeViewModeRef.current = unsafeViewMode; + const lastReportedMissingRef = useRef(undefined); + + const reportFileMissingFromDisk = useCallback( + (missing: boolean) => { + if (!onFileMissingFromDiskChange) { + return; + } + const isUnsafeSplit = + !!filePath && + (editability.mode === 'unsafe' || + editability.containsRenderOnlyBlocks || + editability.containsRawHtmlInlines); + if (isUnsafeSplit && unsafeViewModeRef.current === 'source') { + return; + } + if (lastReportedMissingRef.current === missing) { + return; + } + lastReportedMissingRef.current = missing; + onFileMissingFromDiskChange(missing); + }, + [editability.containsRawHtmlInlines, editability.containsRenderOnlyBlocks, editability.mode, filePath, onFileMissingFromDiskChange] + ); + onContentChangeRef.current = onContentChange; contentRef.current = content; + useEffect(() => { + hasChangesRef.current = hasChanges; + }, [hasChanges]); + + const toNormalizedMarkdown = useCallback((raw: string) => { + const nextEditability = analyzeMarkdownEditability(raw); + const nextContent = + nextEditability.mode === 'unsafe' ? raw : nextEditability.canonicalMarkdown; + return { nextEditability, nextContent }; + }, []); + const basePath = React.useMemo(() => { if (!filePath) return undefined; const normalizedPath = filePath.replace(/\\/g, '/'); @@ -106,23 +164,32 @@ const MarkdownEditor: React.FC = ({ try { const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); - + const fileContent = await workspaceAPI.readFileContent(filePath); + reportFileMissingFromDisk(false); try { - const fileInfo: any = await invoke('get_file_metadata', { - request: { path: filePath } + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath }, }); - lastModifiedTimeRef.current = fileInfo.modified; + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.warn('Failed to get file metadata', err); } - + if (!isUnmountedRef.current) { - const nextEditability = analyzeMarkdownEditability(fileContent); - const nextContent = nextEditability.mode === 'unsafe' - ? fileContent - : nextEditability.canonicalMarkdown; + const { nextEditability, nextContent } = toNormalizedMarkdown(fileContent); setEditability(nextEditability); setContent(nextContent); @@ -146,19 +213,23 @@ const MarkdownEditor: React.FC = ({ displayError = t('editor.common.permissionDenied'); } setError(displayError); + if (errStr.includes('does not exist') || errStr.includes('No such file')) { + reportFileMissingFromDisk(true); + } } } finally { if (!isUnmountedRef.current) { setLoading(false); } } - }, [filePath, t]); + }, [filePath, reportFileMissingFromDisk, t, toNormalizedMarkdown]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); useEffect(() => { - // Reset the flag when filePath changes loadFileContentCalledRef.current = false; + diskVersionRef.current = null; + lastReportedMissingRef.current = undefined; }, [filePath]); useEffect(() => { @@ -186,6 +257,116 @@ const MarkdownEditor: React.FC = ({ } }, [filePath, initialContent, loadFileContent]); + const checkMarkdownDisk = useCallback(async () => { + if (!filePath || !isActiveTab || isUnmountedRef.current || isCheckingDiskRef.current) { + return; + } + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + + isCheckingDiskRef.current = true; + try { + const { workspaceAPI } = await import('@/infrastructure/api'); + const { invoke } = await import('@tauri-apps/api/core'); + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + return; + } + reportFileMissingFromDisk(false); + const currentVersion = diskVersionFromMetadata(fileInfo); + if (!currentVersion) { + return; + } + const baseline = diskVersionRef.current; + if (!baseline) { + diskVersionRef.current = currentVersion; + return; + } + if (!diskVersionsDiffer(currentVersion, baseline)) { + return; + } + + const raw = await workspaceAPI.readFileContent(filePath); + const { nextEditability, nextContent } = toNormalizedMarkdown(raw); + if (nextContent === contentRef.current) { + diskVersionRef.current = currentVersion; + return; + } + + if (hasChangesRef.current) { + const shouldReload = await confirmDialog({ + title: t('editor.codeEditor.externalModifiedTitle'), + message: t('editor.codeEditor.externalModifiedDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.discardAndReload'), + cancelText: t('editor.codeEditor.keepLocalEdits'), + confirmDanger: true, + }); + if (!shouldReload) { + diskVersionRef.current = currentVersion; + return; + } + } + + if (!isUnmountedRef.current) { + setEditability(nextEditability); + setContent(nextContent); + contentRef.current = nextContent; + setHasChanges(false); + lastReportedDirtyRef.current = false; + onContentChangeRef.current?.(nextContent, false); + setTimeout(() => { + editorRef.current?.setInitialContent?.(nextContent); + }, 0); + editorRef.current?.markSaved?.(); + reportFileMissingFromDisk(false); + } + + const fileInfoAfter: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + if (!isFileMissingFromMetadata(fileInfoAfter)) { + const vAfter = diskVersionFromMetadata(fileInfoAfter); + if (vAfter) { + diskVersionRef.current = vAfter; + } + } + } catch (e) { + if (isLikelyFileNotFoundError(e)) { + reportFileMissingFromDisk(true); + } + log.error('Markdown disk sync check failed', e); + } finally { + isCheckingDiskRef.current = false; + } + }, [filePath, isActiveTab, reportFileMissingFromDisk, t, toNormalizedMarkdown]); + + const isUnsafeSplitUi = + !!filePath && + (editability.mode === 'unsafe' || + editability.containsRenderOnlyBlocks || + editability.containsRawHtmlInlines); + const pollMarkdownDisk = !isUnsafeSplitUi || unsafeViewMode !== 'source'; + + useEffect(() => { + if (!filePath || !isActiveTab || !pollMarkdownDisk) { + return; + } + const tick = () => { + void checkMarkdownDisk(); + }; + const intervalId = window.setInterval(tick, FILE_SYNC_POLL_INTERVAL_MS); + document.addEventListener('visibilitychange', tick); + return () => { + window.clearInterval(intervalId); + document.removeEventListener('visibilitychange', tick); + }; + }, [checkMarkdownDisk, filePath, isActiveTab, pollMarkdownDisk]); + const saveFileContent = useCallback(async () => { if (!hasChanges || isUnmountedRef.current) return; @@ -196,13 +377,72 @@ const MarkdownEditor: React.FC = ({ const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); + const fileInfoPre: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + if (isFileMissingFromMetadata(fileInfoPre)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + } + const diskNow = diskVersionFromMetadata(fileInfoPre); + const baseline = diskVersionRef.current; + + if (diskNow && baseline && diskVersionsDiffer(diskNow, baseline)) { + const overwrite = await confirmDialog({ + title: t('editor.codeEditor.saveConflictTitle'), + message: t('editor.codeEditor.saveConflictDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.overwriteSave'), + cancelText: t('editor.codeEditor.reloadFromDisk'), + confirmDanger: true, + }); + if (!overwrite) { + const raw = await workspaceAPI.readFileContent(filePath); + const { nextEditability, nextContent } = toNormalizedMarkdown(raw); + if (!isUnmountedRef.current) { + setEditability(nextEditability); + setContent(nextContent); + contentRef.current = nextContent; + setHasChanges(false); + lastReportedDirtyRef.current = false; + editorRef.current?.markSaved?.(); + onContentChangeRef.current?.(nextContent, false); + setTimeout(() => { + editorRef.current?.setInitialContent?.(nextContent); + }, 0); + reportFileMissingFromDisk(false); + } + try { + const fileInfoAfter: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + if (!isFileMissingFromMetadata(fileInfoAfter)) { + const v = diskVersionFromMetadata(fileInfoAfter); + if (v) { + diskVersionRef.current = v; + } + } + } catch (err) { + log.warn('Failed to sync disk version after save conflict reload', err); + } + return; + } + } + await workspaceAPI.writeFileContent(workspacePath, filePath, content); - + try { - const fileInfo: any = await invoke('get_file_metadata', { - request: { path: filePath } + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath }, }); - lastModifiedTimeRef.current = fileInfo.modified; + if (!isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } } catch (err) { log.warn('Failed to get file metadata', err); } @@ -229,7 +469,7 @@ const MarkdownEditor: React.FC = ({ setError(t('editor.common.saveFailedWithMessage', { message: errorMessage })); } } - }, [content, filePath, workspacePath, hasChanges, onSave, t]); + }, [content, filePath, workspacePath, hasChanges, onSave, reportFileMissingFromDisk, t, toNormalizedMarkdown]); const handleContentChange = useCallback((newContent: string) => { contentRef.current = newContent; @@ -375,6 +615,8 @@ const MarkdownEditor: React.FC = ({ showMinimap={true} jumpToLine={jumpToLine} jumpToColumn={jumpToColumn} + isActiveTab={isActiveTab} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} onContentChange={(newContent, dirty) => { contentRef.current = newContent; setContent(newContent); diff --git a/src/web-ui/src/tools/editor/utils/diskFileVersion.ts b/src/web-ui/src/tools/editor/utils/diskFileVersion.ts new file mode 100644 index 00000000..c21ebb32 --- /dev/null +++ b/src/web-ui/src/tools/editor/utils/diskFileVersion.ts @@ -0,0 +1,23 @@ +/** + * Disk file identity for external-change detection (local + remote via get_file_metadata). + */ + +export type DiskFileVersion = { modified: number; size: number }; + +export function diskVersionFromMetadata(fileInfo: unknown): DiskFileVersion | null { + if (!fileInfo || typeof fileInfo !== 'object') { + return null; + } + const o = fileInfo as Record; + if (typeof o.modified !== 'number') { + return null; + } + return { + modified: o.modified, + size: typeof o.size === 'number' ? o.size : 0, + }; +} + +export function diskVersionsDiffer(a: DiskFileVersion, b: DiskFileVersion): boolean { + return a.modified !== b.modified || a.size !== b.size; +} diff --git a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts index 886c94de..62014b00 100644 --- a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts +++ b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts @@ -11,7 +11,7 @@ const log = createLogger('useFileSystem'); const EMPTY_FILE_TREE: FileSystemNode[] = []; /** Polling keeps remote workspaces and lazy-loaded trees in sync when OS/file watch is unreliable. */ -const FILE_TREE_POLL_INTERVAL_MS = 5000; +const FILE_TREE_POLL_INTERVAL_MS = 1000; function findNodeByPath(nodes: FileSystemNode[], targetPath: string): FileSystemNode | undefined { for (const node of nodes) {