Fix mobile open file list behavior for deleted and long-named files (#1391)

* Fix mobile open file list cleanup and long names

- Remove deleted files from persisted open file tabs
- Invalidate cached file content when stat/delete/rename affects paths
- Keep mobile open-file close buttons visible for long filenames
- Add marquee scrolling for overflowing file names

* Fix bot comments

---------

Co-authored-by: Konstantin Zolin <zolin_ka@vk.com>
This commit is contained in:
kostazol
2026-05-24 15:49:22 +03:00
committed by GitHub
co-authored by Konstantin Zolin
parent c5862cc6ee
commit af25edd3f7
3 changed files with 156 additions and 19 deletions
+96 -7
View File
@@ -224,6 +224,47 @@ const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
return <span className="size-2 rounded-full" style={{ backgroundColor: color }} />;
};
const ScrollingFileName: React.FC<{ name: string }> = ({ name }) => {
const containerRef = React.useRef<HTMLSpanElement | null>(null);
const textRef = React.useRef<HTMLSpanElement | null>(null);
const [overflowing, setOverflowing] = React.useState(false);
React.useLayoutEffect(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) {
return;
}
const updateOverflow = () => {
setOverflowing(text.scrollWidth > container.clientWidth + 1);
};
updateOverflow();
const resizeObserver = new ResizeObserver(updateOverflow);
resizeObserver.observe(container);
resizeObserver.observe(text);
return () => {
resizeObserver.disconnect();
};
}, [name]);
return (
<span ref={containerRef} className="relative block min-w-0 flex-1 overflow-hidden whitespace-nowrap">
<span ref={textRef} aria-hidden="true" className="invisible absolute whitespace-nowrap">{name}</span>
{overflowing ? (
<span className="open-file-name-marquee-track">
<span className="open-file-name-marquee-item">{name}</span>
<span className="open-file-name-marquee-item" aria-hidden="true">{name}</span>
</span>
) : (
<span className="block min-w-0 truncate">{name}</span>
)}
</span>
);
};
const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name);
const shouldIgnorePath = (path: string): boolean => {
@@ -237,6 +278,15 @@ const isDirectoryReadError = (error: unknown): boolean => {
return normalized.includes('is a directory') || normalized.includes('eisdir');
};
const isFileMissingError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error ?? '');
const normalized = message.toLowerCase();
return normalized.includes('file not found')
|| normalized.includes('enoent')
|| normalized.includes('no such file')
|| normalized.includes('does not exist');
};
const MAX_VIEW_CHARS = 200_000;
const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled';
@@ -1347,6 +1397,32 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return null;
}, [files]);
React.useEffect(() => {
if (!root || !files.statFile || openPaths.length === 0) {
return;
}
let cancelled = false;
const paths = [...openPaths];
void Promise.all(paths.map(async (path) => {
try {
const stat = await files.statFile?.(path);
if (!cancelled && stat && !stat.isFile) {
removeOpenPathsByPrefix(root, path);
}
} catch (error) {
if (!cancelled && isFileMissingError(error)) {
removeOpenPathsByPrefix(root, path);
}
}
}));
return () => {
cancelled = true;
};
}, [files, openPaths, removeOpenPathsByPrefix, root]);
const displayedContent = React.useMemo(() =>
fileContent.length > MAX_VIEW_CHARS
? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
@@ -1592,6 +1668,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
return;
}
if (isFileMissingError(error)) {
if (root) {
removeOpenPathsByPrefix(root, node.path);
}
setFileContent('');
setDraftContent('');
setFileError(null);
lastLoadedFileStatRef.current = null;
if (isMobile) {
setShowMobilePageContent(false);
}
return;
}
setFileContent('');
setDraftContent('');
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
@@ -1602,7 +1691,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setFileLoading(false);
}
});
}, [expandPaths, isMobile, loadDirectory, mode, readFile, readFileStat, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
}, [expandPaths, isMobile, loadDirectory, mode, readFile, readFileStat, removeOpenPathsByPrefix, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
if (!root) {
@@ -2889,11 +2978,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
aria-label={t('filesView.editor.openFilesAria')}
>
<FileTypeIcon filePath={selectedFile.path} extension={selectedFile.extension} className="size-3.5 flex-shrink-0" />
<span className="min-w-0 flex-1 truncate">{selectedFile.name}</span>
<ScrollingFileName name={selectedFile.name} />
<Icon name="arrow-down-s" className="size-4 flex-shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[16rem]">
<DropdownMenuContent align="start" className="w-[min(24rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)]">
{openFiles.map((file) => {
const isActive = selectedFile?.path === file.path;
return (
@@ -2910,13 +2999,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
}}
className={cn(
'flex items-center justify-between gap-2',
'flex min-w-0 items-center justify-between gap-2 overflow-hidden',
isActive && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]'
)}
>
<span className="flex min-w-0 flex-1 items-center gap-2 truncate">
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<FileTypeIcon filePath={file.path} extension={file.extension} className="size-3.5 flex-shrink-0" />
<span className="min-w-0 flex-1 truncate">{file.name}</span>
<ScrollingFileName name={file.name} />
</span>
<button
type="button"
@@ -2930,7 +3019,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
event.stopPropagation();
handleCloseFile(file.path);
}}
className="inline-flex size-6 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]"
aria-label={t('filesView.editor.closeFileAria', { name: file.name })}
>
<Icon name="close" className="size-3.5" />
+24 -12
View File
@@ -13,6 +13,20 @@ import {
function withContentCache(files: FilesAPI): FilesAPI {
const cache = new Map<string, { content: string; path: string; size?: number; mtimeMs?: number }>();
const removeCacheEntry = (path: string) => {
cache.delete(path);
removeContentBytes(path);
};
const removeCacheEntriesByPrefix = (path: string) => {
const prefix = path.endsWith('/') ? path : `${path}/`;
for (const key of cache.keys()) {
if (key === path || key.startsWith(prefix)) {
removeCacheEntry(key);
}
}
};
/** Whether cached metadata still matches the file on disk. */
const statMatches = (
cached: { size?: number; mtimeMs?: number },
@@ -86,10 +100,12 @@ function withContentCache(files: FilesAPI): FilesAPI {
if (hit) {
// Validate cached entry is still fresh
if (files.statFile) {
const latest = await files.statFile(path).catch(() => null);
if (latest && !statMatches(hit, latest)) {
cache.delete(path);
removeContentBytes(path);
const latest = await files.statFile(path).catch(() => {
removeCacheEntry(path);
return null;
});
if (!latest || !statMatches(hit, latest)) {
removeCacheEntry(path);
return readFreshFile(path);
}
}
@@ -104,26 +120,22 @@ function withContentCache(files: FilesAPI): FilesAPI {
// Invalidate cache on writes, deletes, renames
const cachedWriteFile: FilesAPI['writeFile'] = files.writeFile
? async (path, content) => {
cache.delete(path);
removeContentBytes(path);
removeCacheEntry(path);
return files.writeFile!(path, content);
}
: undefined;
const cachedDelete: FilesAPI['delete'] = files.delete
? async (path) => {
cache.delete(path);
removeContentBytes(path);
removeCacheEntriesByPrefix(path);
return files.delete!(path);
}
: undefined;
const cachedRename: FilesAPI['rename'] = files.rename
? async (oldPath, newPath) => {
cache.delete(oldPath);
removeContentBytes(oldPath);
cache.delete(newPath);
removeContentBytes(newPath);
removeCacheEntriesByPrefix(oldPath);
removeCacheEntriesByPrefix(newPath);
return files.rename!(oldPath, newPath);
}
: undefined;
+36
View File
@@ -1442,6 +1442,23 @@ input[aria-label="Terminal input"] {
100% { transform: translateX(-100%); }
}
@keyframes open-file-name-marquee-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.open-file-name-marquee-track {
display: inline-flex;
min-width: max-content;
animation: open-file-name-marquee-scroll var(--open-file-name-marquee-duration, 14s) linear infinite;
will-change: transform;
}
.open-file-name-marquee-item {
flex-shrink: 0;
padding-right: 2rem;
}
.marquee-text {
display: inline-block;
white-space: nowrap;
@@ -1459,11 +1476,30 @@ input[aria-label="Terminal input"] {
@media (prefers-reduced-motion: reduce) {
.marquee-text--auto,
.open-file-name-marquee-track,
.group:hover .marquee-text--active,
.marquee-text--active:hover {
animation: none;
}
.open-file-name-marquee-track {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
will-change: auto;
}
.open-file-name-marquee-track .open-file-name-marquee-item {
display: inline;
padding-right: 0;
}
.open-file-name-marquee-track .open-file-name-marquee-item[aria-hidden="true"] {
display: none;
}
.animate-busy-pulse {
animation: none;
}