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