fix: stop stale file focus requests from stealing context panel selection

This commit is contained in:
Bohdan Triapitsyn
2026-07-20 19:25:12 +03:00
parent b7a5f44b7c
commit d04712ce89
+154 -157
View File
@@ -811,10 +811,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null));
const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath);
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
const removeExpandedPathsByPrefix = useFilesViewTabsStore((state) => state.removeExpandedPathsByPrefix);
const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath);
const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath);
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
const removeExpandedPathsByPrefix = useFilesViewTabsStore((state) => state.removeExpandedPathsByPrefix);
const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath);
const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath);
const expandPaths = useFilesViewTabsStore((state) => state.expandPaths);
@@ -1204,22 +1204,22 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
const message = error instanceof Error ? error.message : String(error ?? '');
if (message === 'Directory not found' && root && normalizedDir !== root) {
removeExpandedPathsByPrefix(root, normalizedDir);
setLoadErrorsByDir((prev) => {
if (!prev[normalizedDir]) return prev;
const next = { ...prev };
delete next[normalizedDir];
return next;
});
return;
}
console.error('Failed to load files directory:', error);
setLoadErrorsByDir((prev) => ({
...prev,
[normalizedDir]: message,
}));
const message = error instanceof Error ? error.message : String(error ?? '');
if (message === 'Directory not found' && root && normalizedDir !== root) {
removeExpandedPathsByPrefix(root, normalizedDir);
setLoadErrorsByDir((prev) => {
if (!prev[normalizedDir]) return prev;
const next = { ...prev };
delete next[normalizedDir];
return next;
});
return;
}
console.error('Failed to load files directory:', error);
setLoadErrorsByDir((prev) => ({
...prev,
[normalizedDir]: message,
}));
})
.finally(() => {
if (!isCurrentRequest()) {
@@ -1231,7 +1231,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
});
}, [files, mapDirectoryEntries, removeExpandedPathsByPrefix, root]);
}, [files, mapDirectoryEntries, removeExpandedPathsByPrefix, root]);
const refreshRoot = React.useCallback(async () => {
if (!root) {
@@ -2322,14 +2322,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && !isSelectedPdf && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path));
const isTextFile = Boolean(selectedFile && !isSelectedImage && !isSelectedPdf);
const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview');
const isEditingFile = (isMarkdown && mdViewMode === 'edit')
|| (isHtml && htmlViewMode === 'edit')
|| (isJson && jsonViewMode === 'text')
|| (!isMarkdown && !isHtml && !isJson && textViewMode === 'edit');
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path));
const isTextFile = Boolean(selectedFile && !isSelectedImage && !isSelectedPdf);
const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview');
const isEditingFile = (isMarkdown && mdViewMode === 'edit')
|| (isHtml && htmlViewMode === 'edit')
|| (isJson && jsonViewMode === 'text')
|| (!isMarkdown && !isHtml && !isJson && textViewMode === 'edit');
const staticLanguageExtension = React.useMemo(
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
[selectedFilePath],
@@ -2373,7 +2373,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
setTextViewMode(textViewModeByPathRef.current[selectedPath] ?? 'edit');
setTextViewMode(textViewModeByPathRef.current[selectedPath] ?? 'edit');
// Respect per-type localStorage preference when available,
// falling back to the setting-derived default when nothing is stored.
@@ -2398,7 +2398,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
// Ignore localStorage errors
}
setHtmlViewMode(htmlViewModeByPathRef.current[selectedPath] ?? htmlDefault);
setDrawioViewMode(drawioViewModeByPathRef.current[selectedPath] ?? (settingsDefaultFileViewerPreview ? 'preview' : 'edit'));
setDrawioViewMode(drawioViewModeByPathRef.current[selectedPath] ?? (settingsDefaultFileViewerPreview ? 'preview' : 'edit'));
let jsonDefault: 'tree' | 'text' = settingsDefaultFileViewerPreview ? 'tree' : 'text';
try {
@@ -2562,28 +2562,28 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}, [htmlViewMode]);
React.useEffect(() => {
const applyDefaultFileViewerMode = (enabled: boolean) => {
const previewMode: PreviewViewMode = enabled ? 'preview' : 'edit';
const nextJsonMode: 'tree' | 'text' = enabled ? 'tree' : 'text';
for (const path of openPaths) {
textViewModeByPathRef.current[path] = 'edit';
if (isMarkdownFile(path)) {
mdViewModeByPathRef.current[path] = previewMode;
}
if (isHtmlFile(path)) {
htmlViewModeByPathRef.current[path] = previewMode;
}
if (isDrawioFile(path)) {
drawioViewModeByPathRef.current[path] = previewMode;
}
}
setTextViewMode('edit');
setMdViewMode(previewMode);
setHtmlViewMode(previewMode);
setDrawioViewMode(previewMode);
setJsonViewMode(nextJsonMode);
const applyDefaultFileViewerMode = (enabled: boolean) => {
const previewMode: PreviewViewMode = enabled ? 'preview' : 'edit';
const nextJsonMode: 'tree' | 'text' = enabled ? 'tree' : 'text';
for (const path of openPaths) {
textViewModeByPathRef.current[path] = 'edit';
if (isMarkdownFile(path)) {
mdViewModeByPathRef.current[path] = previewMode;
}
if (isHtmlFile(path)) {
htmlViewModeByPathRef.current[path] = previewMode;
}
if (isDrawioFile(path)) {
drawioViewModeByPathRef.current[path] = previewMode;
}
}
setTextViewMode('edit');
setMdViewMode(previewMode);
setHtmlViewMode(previewMode);
setDrawioViewMode(previewMode);
setJsonViewMode(nextJsonMode);
try {
localStorage.setItem(MD_VIEWER_MODE_KEY, previewMode);
@@ -2762,32 +2762,30 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
if (selectedFile?.path !== targetPath) {
if (confirmDiscardOpen) {
return;
}
void handleSelectFile(toFileNode(targetPath));
// Selection is owned by the tab sync / user. A pending focus request must
// not steal selection back (e.g. after the user switched to another tab
// while this file was still loading). Wait; clear once it loads or the
// request is superseded.
return;
}
if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage || isSelectedPdf) {
if (fileLoading || loadedFilePath !== targetPath) {
return;
}
if (canEdit && textViewMode === 'edit') {
const view = editorViewRef.current;
if (!view) {
return;
}
view.focus();
// Best-effort focus: preview renderers (markdown/html preview, drawio,
// JSON tree, images, PDFs) never mount a CodeMirror editor, so the request
// must clear regardless — otherwise it lingers and replays on every
// dependency change.
if (!fileError && !isSelectedImage && !isSelectedPdf && canEdit && textViewMode === 'edit') {
editorViewRef.current?.focus();
}
setPendingFileFocusPath(null);
}, [
canEdit,
confirmDiscardOpen,
fileError,
fileLoading,
handleSelectFile,
isSelectedImage,
isSelectedPdf,
loadedFilePath,
@@ -2796,7 +2794,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
selectedFile?.path,
setPendingFileFocusPath,
textViewMode,
toFileNode,
]);
const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => {
@@ -3158,7 +3155,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return (
<div className={wrapperCls}>
{canEdit && isEditingFile && (
{canEdit && isEditingFile && (
<>
{isSaving ? (
<span className="flex items-center gap-1 px-1 text-muted-foreground typography-meta">
@@ -3308,35 +3305,35 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
)}
{isMarkdown && (
withTooltip(
t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode'),
<Button
variant="ghost"
size="sm"
onClick={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
className={cn(
'size-6 p-0 transition-colors hover:bg-[var(--interactive-hover)] focus-visible:bg-[var(--interactive-hover)] active:bg-[var(--interactive-hover)]',
getMdViewMode() === 'preview'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)] hover:bg-[var(--interactive-selection)] focus-visible:bg-[var(--interactive-selection)] active:bg-[var(--interactive-selection)]'
: 'text-muted-foreground opacity-65 hover:opacity-100'
)}
title={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
aria-label={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
>
<Icon name={getMdViewMode() === 'preview' ? 'eye' : 'eye-off'} className="size-4" />
</Button>
)
)}
{isHtmlFile(selectedFile?.path ?? '') && (
<PreviewToggleButton
currentMode={getHtmlViewMode()}
onToggle={() => {
saveHtmlViewMode(getHtmlViewMode() === 'preview' ? 'edit' : 'preview');
}}
/>
)}
{isMarkdown && (
withTooltip(
t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode'),
<Button
variant="ghost"
size="sm"
onClick={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
className={cn(
'size-6 p-0 transition-colors hover:bg-[var(--interactive-hover)] focus-visible:bg-[var(--interactive-hover)] active:bg-[var(--interactive-hover)]',
getMdViewMode() === 'preview'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)] hover:bg-[var(--interactive-selection)] focus-visible:bg-[var(--interactive-selection)] active:bg-[var(--interactive-selection)]'
: 'text-muted-foreground opacity-65 hover:opacity-100'
)}
title={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
aria-label={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
>
<Icon name={getMdViewMode() === 'preview' ? 'eye' : 'eye-off'} className="size-4" />
</Button>
)
)}
{isHtmlFile(selectedFile?.path ?? '') && (
<PreviewToggleButton
currentMode={getHtmlViewMode()}
onToggle={() => {
saveHtmlViewMode(getHtmlViewMode() === 'preview' ? 'edit' : 'preview');
}}
/>
)}
{isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && (
<Tooltip>
@@ -3736,68 +3733,68 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<div className="flex-1 min-h-0 min-w-0 relative">
{selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar && !isMobile) && (
<div
ref={floatingToolbarRef}
className="absolute right-3 top-3 z-30"
onMouseLeave={() => {
if (toolbarDropdownOpenCountRef.current > 0) return;
setIsFloatingToolbarOpen(false);
<div
ref={floatingToolbarRef}
className="absolute right-3 top-3 z-30"
onMouseLeave={() => {
if (toolbarDropdownOpenCountRef.current > 0) return;
setIsFloatingToolbarOpen(false);
}}
>
{isFloatingToolbarOpen ? (
renderFloatingFileControls()
) : (
<div className="flex items-center gap-1">
{isMarkdown ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="sm"
onClick={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
className={cn(
'size-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 shadow-sm transition-colors',
getMdViewMode() === 'preview'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)] hover:bg-[var(--interactive-selection)]'
: 'text-muted-foreground hover:text-foreground'
)}
aria-label={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
title={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
>
<Icon name={getMdViewMode() === 'preview' ? 'eye' : 'eye-off'} className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex"
onMouseEnter={() => setIsFloatingToolbarOpen(true)}
>
<Button
variant="ghost"
size="sm"
onClick={() => setIsFloatingToolbarOpen(true)}
className="size-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 text-muted-foreground shadow-sm hover:text-foreground"
aria-label={t('filesView.editor.showControlsAria')}
title={t('filesView.editor.controlsTitle')}
>
<Icon name="more-2-fill" className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>{t('filesView.editor.controlsTitle')}</TooltipContent>
</Tooltip>
</div>
)}
</div>
)}
{isFloatingToolbarOpen ? (
renderFloatingFileControls()
) : (
<div className="flex items-center gap-1">
{isMarkdown ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="sm"
onClick={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
className={cn(
'size-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 shadow-sm transition-colors',
getMdViewMode() === 'preview'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)] hover:bg-[var(--interactive-selection)]'
: 'text-muted-foreground hover:text-foreground'
)}
aria-label={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
title={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
>
<Icon name={getMdViewMode() === 'preview' ? 'eye' : 'eye-off'} className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex"
onMouseEnter={() => setIsFloatingToolbarOpen(true)}
>
<Button
variant="ghost"
size="sm"
onClick={() => setIsFloatingToolbarOpen(true)}
className="size-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 text-muted-foreground shadow-sm hover:text-foreground"
aria-label={t('filesView.editor.showControlsAria')}
title={t('filesView.editor.controlsTitle')}
>
<Icon name="more-2-fill" className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>{t('filesView.editor.controlsTitle')}</TooltipContent>
</Tooltip>
</div>
)}
</div>
)}
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>