fix: stop stale file focus requests from stealing context panel selection
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user