Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExtendedFlexiblePanelProps> = memo(({
Expand All @@ -101,6 +103,7 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
onBeforeClose,
onDirtyStateChange,
isActive = true,
onFileMissingFromDiskChange,
}) => {
const { t } = useI18n('components');

Expand Down Expand Up @@ -273,6 +276,8 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
readOnly={markdownEditorData.readOnly || false}
jumpToLine={markdownJumpToLine}
jumpToColumn={markdownJumpToColumn}
isActiveTab={isActive}
onFileMissingFromDiskChange={onFileMissingFromDiskChange}
onContentChange={(_newContent, hasChanges) => {
if (onDirtyStateChange) {
onDirtyStateChange(hasChanges);
Expand Down Expand Up @@ -407,6 +412,8 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
showMinimap={true}
theme="vs-dark"
className={fileViewerClass}
isActiveTab={isActive}
onFileMissingFromDiskChange={onFileMissingFromDiskChange}
/>
</div>
);
Expand Down Expand Up @@ -442,6 +449,8 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
showMinimap={true}
theme="vs-dark"
onContentChange={codeData.onContentChange}
isActiveTab={isActive}
onFileMissingFromDiskChange={onFileMissingFromDiskChange}
/>
</div>
</div>
Expand All @@ -467,6 +476,8 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
jumpToLine={editorData.jumpToLine}
jumpToColumn={editorData.jumpToColumn}
jumpToRange={editorData.jumpToRange}
isActiveTab={isActive}
onFileMissingFromDiskChange={onFileMissingFromDiskChange}
onContentChange={(newContent, hasChanges) => {
if (onContentChange) {
onContentChange({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
setActiveGroup,
updateTabContent,
setTabDirty,
setTabFileDeletedFromDisk,
} = useCanvasStore();

const handleTabClick = useCallback((groupId: EditorGroupId) => (tabId: string) => {
Expand Down Expand Up @@ -110,6 +111,13 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
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) => (
<EditorGroup
groupId={groupId}
Expand All @@ -130,6 +138,7 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
onGroupFocus={handleGroupFocus(groupId)}
onContentChange={handleContentChange(groupId)}
onDirtyStateChange={handleDirtyStateChange(groupId)}
onTabFileDeletedFromDiskChange={handleTabFileDeletedFromDiskChange(groupId)}
onOpenMissionControl={groupId === 'primary' ? onOpenMissionControl : undefined}
onCloseAllTabs={handleCloseAllTabs(groupId)}
onInteraction={onInteraction}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void;
onInteraction?: (itemId: string, userInput: string) => Promise<void>;
Expand All @@ -61,6 +62,7 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({
onGroupFocus,
onContentChange,
onDirtyStateChange,
onTabFileDeletedFromDiskChange,
onOpenMissionControl,
onCloseAllTabs,
onInteraction,
Expand Down Expand Up @@ -163,6 +165,11 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({
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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ export const ThumbnailCard: React.FC<ThumbnailCardProps> = ({
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();
Expand Down Expand Up @@ -135,7 +140,7 @@ export const ThumbnailCard: React.FC<ThumbnailCardProps> = ({

return (
<div
className={`canvas-thumbnail-card ${isActive ? 'is-active' : ''} ${stateClass} ${tab.isDirty ? 'is-dirty' : ''}`}
className={`canvas-thumbnail-card ${isActive ? 'is-active' : ''} ${stateClass} ${tab.isDirty ? 'is-dirty' : ''} ${tab.fileDeletedFromDisk ? 'is-file-deleted' : ''}`}
onClick={onClick}
onContextMenu={handleContextMenu}
draggable
Expand All @@ -150,7 +155,7 @@ export const ThumbnailCard: React.FC<ThumbnailCardProps> = ({
<div className="canvas-thumbnail-card__title">
{tab.state === 'pinned' && <Pin size={10} className="canvas-thumbnail-card__pin-icon" />}
<span className={tab.state === 'preview' ? 'is-preview' : ''}>
{tab.title}
{titleWithDeleted}
</span>
{tab.isDirty && <span className="canvas-thumbnail-card__dirty">●</span>}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -641,6 +644,16 @@ const createCanvasStoreHook = () => create<CanvasStore>()(
}
});
},

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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ export const Tab: React.FC<TabProps> = ({

// 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) => {
Expand Down Expand Up @@ -113,6 +115,7 @@ export const Tab: React.FC<TabProps> = ({
'canvas-tab',
isActive && 'is-active',
tab.isDirty && 'is-dirty',
tab.fileDeletedFromDisk && 'is-file-deleted',
isDragging && 'is-dragging',
getStateClassName(tab.state),
isTaskDetail && 'is-task-detail',
Expand Down Expand Up @@ -153,7 +156,7 @@ export const Tab: React.FC<TabProps> = ({

{/* Title */}
<span className="canvas-tab__title">
{tab.title}
{titleDisplay}
</span>

{/* Dirty state indicator */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TabBarProps> = ({
tabs,
groupId,
Expand Down Expand Up @@ -108,7 +111,10 @@ export const TabBar: React.FC<TabBarProps> = ({
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 => {
Expand All @@ -118,8 +124,8 @@ export const TabBar: React.FC<TabBarProps> = ({
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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,17 +189,20 @@ export const TabOverflowMenu: React.FC<TabOverflowMenuProps> = ({

{/* Overflow tab list */}
<div className="canvas-tab-overflow-menu__list">
{overflowTabs.map((tab) => (
{overflowTabs.map((tab) => {
const deletedSuffix = tab.fileDeletedFromDisk ? ` - ${t('tabs.fileDeleted')}` : '';
const titleWithDeleted = `${tab.title}${deletedSuffix}`;
return (
<div
key={tab.id}
className={`canvas-tab-overflow-menu__item ${
activeTabId === tab.id ? 'is-active' : ''
} ${tab.isDirty ? 'is-dirty' : ''}`}
} ${tab.isDirty ? 'is-dirty' : ''} ${tab.fileDeletedFromDisk ? 'is-file-deleted' : ''}`}
onClick={() => handleTabClick(tab.id)}
>
<span className="canvas-tab-overflow-menu__item-title">
{tab.state === 'preview' && <em>{tab.title}</em>}
{tab.state !== 'preview' && tab.title}
{tab.state === 'preview' && <em>{titleWithDeleted}</em>}
{tab.state !== 'preview' && titleWithDeleted}
</span>

{tab.isDirty && (
Expand All @@ -213,7 +216,8 @@ export const TabOverflowMenu: React.FC<TabOverflowMenuProps> = ({
<X size={12} />
</button>
</div>
))}
);
})}
</div>
</div>,
document.body
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/en-US/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down
10 changes: 9 additions & 1 deletion src/web-ui/src/locales/en-US/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-CN/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@
"pin": "固定标签",
"unpin": "取消固定",
"unsaved": "未保存",
"fileDeleted": "已删除",
"missionControl": "全景模式",
"hiddenTabsCount": "{{count}} 个隐藏标签",
"confirmCloseWithDirty": "文件 \"{{title}}\" 有未保存的更改。\n\n是否放弃更改并关闭?",
Expand Down
10 changes: 9 additions & 1 deletion src/web-ui/src/locales/zh-CN/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,15 @@
"loadingFile": "正在加载文件...",
"saving": "正在保存...",
"initFailedWithMessage": "编辑器初始化失败: {{message}}",
"externalModifiedConfirm": "该文件已被外部程序修改。\n\n您当前有未保存的修改,是否要放弃修改并重新加载文件?"
"externalModifiedConfirm": "该文件已被外部程序修改。\n\n您当前有未保存的修改,是否要放弃修改并重新加载文件?",
"externalModifiedTitle": "磁盘上的文件已变更",
"externalModifiedDetail": "检测到该文件在磁盘上的版本(修改时间/大小)与当前编辑器所依据的版本不一致。您有未保存的本地修改。若重新加载,本地未保存内容将丢失。",
"discardAndReload": "放弃修改并重新加载",
"keepLocalEdits": "保留本地编辑",
"saveConflictTitle": "保存冲突",
"saveConflictDetail": "保存前检测到磁盘上的文件已被其他程序修改。您可以选择用当前编辑覆盖磁盘,或放弃本地修改并从磁盘重新加载。",
"overwriteSave": "覆盖保存",
"reloadFromDisk": "从磁盘重新加载"
},
"diffEditor": {
"loading": "正在加载差异编辑器...",
Expand Down
23 changes: 23 additions & 0 deletions src/web-ui/src/shared/utils/fsErrorUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null | undefined): boolean {
if (!fileInfo || typeof fileInfo !== 'object') {
return true;
}
return fileInfo.is_file !== true;
}
Loading
Loading