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:
committed by
GitHub
co-authored by
Konstantin Zolin
parent
c5862cc6ee
commit
af25edd3f7
@@ -224,6 +224,47 @@ const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
|
|||||||
return <span className="size-2 rounded-full" style={{ backgroundColor: color }} />;
|
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 shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name);
|
||||||
|
|
||||||
const shouldIgnorePath = (path: string): boolean => {
|
const shouldIgnorePath = (path: string): boolean => {
|
||||||
@@ -237,6 +278,15 @@ const isDirectoryReadError = (error: unknown): boolean => {
|
|||||||
return normalized.includes('is a directory') || normalized.includes('eisdir');
|
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 MAX_VIEW_CHARS = 200_000;
|
||||||
const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled';
|
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;
|
return null;
|
||||||
}, [files]);
|
}, [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(() =>
|
const displayedContent = React.useMemo(() =>
|
||||||
fileContent.length > MAX_VIEW_CHARS
|
fileContent.length > MAX_VIEW_CHARS
|
||||||
? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
||||||
@@ -1592,6 +1668,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isFileMissingError(error)) {
|
||||||
|
if (root) {
|
||||||
|
removeOpenPathsByPrefix(root, node.path);
|
||||||
|
}
|
||||||
|
setFileContent('');
|
||||||
|
setDraftContent('');
|
||||||
|
setFileError(null);
|
||||||
|
lastLoadedFileStatRef.current = null;
|
||||||
|
if (isMobile) {
|
||||||
|
setShowMobilePageContent(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFileContent('');
|
setFileContent('');
|
||||||
setDraftContent('');
|
setDraftContent('');
|
||||||
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
||||||
@@ -1602,7 +1691,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
setFileLoading(false);
|
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) => {
|
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
|
||||||
if (!root) {
|
if (!root) {
|
||||||
@@ -2889,11 +2978,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
aria-label={t('filesView.editor.openFilesAria')}
|
aria-label={t('filesView.editor.openFilesAria')}
|
||||||
>
|
>
|
||||||
<FileTypeIcon filePath={selectedFile.path} extension={selectedFile.extension} className="size-3.5 flex-shrink-0" />
|
<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" />
|
<Icon name="arrow-down-s" className="size-4 flex-shrink-0 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</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) => {
|
{openFiles.map((file) => {
|
||||||
const isActive = selectedFile?.path === file.path;
|
const isActive = selectedFile?.path === file.path;
|
||||||
return (
|
return (
|
||||||
@@ -2910,13 +2999,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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)]'
|
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" />
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2930,7 +3019,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleCloseFile(file.path);
|
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 })}
|
aria-label={t('filesView.editor.closeFileAria', { name: file.name })}
|
||||||
>
|
>
|
||||||
<Icon name="close" className="size-3.5" />
|
<Icon name="close" className="size-3.5" />
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ import {
|
|||||||
function withContentCache(files: FilesAPI): FilesAPI {
|
function withContentCache(files: FilesAPI): FilesAPI {
|
||||||
const cache = new Map<string, { content: string; path: string; size?: number; mtimeMs?: number }>();
|
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. */
|
/** Whether cached metadata still matches the file on disk. */
|
||||||
const statMatches = (
|
const statMatches = (
|
||||||
cached: { size?: number; mtimeMs?: number },
|
cached: { size?: number; mtimeMs?: number },
|
||||||
@@ -86,10 +100,12 @@ function withContentCache(files: FilesAPI): FilesAPI {
|
|||||||
if (hit) {
|
if (hit) {
|
||||||
// Validate cached entry is still fresh
|
// Validate cached entry is still fresh
|
||||||
if (files.statFile) {
|
if (files.statFile) {
|
||||||
const latest = await files.statFile(path).catch(() => null);
|
const latest = await files.statFile(path).catch(() => {
|
||||||
if (latest && !statMatches(hit, latest)) {
|
removeCacheEntry(path);
|
||||||
cache.delete(path);
|
return null;
|
||||||
removeContentBytes(path);
|
});
|
||||||
|
if (!latest || !statMatches(hit, latest)) {
|
||||||
|
removeCacheEntry(path);
|
||||||
return readFreshFile(path);
|
return readFreshFile(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,26 +120,22 @@ function withContentCache(files: FilesAPI): FilesAPI {
|
|||||||
// Invalidate cache on writes, deletes, renames
|
// Invalidate cache on writes, deletes, renames
|
||||||
const cachedWriteFile: FilesAPI['writeFile'] = files.writeFile
|
const cachedWriteFile: FilesAPI['writeFile'] = files.writeFile
|
||||||
? async (path, content) => {
|
? async (path, content) => {
|
||||||
cache.delete(path);
|
removeCacheEntry(path);
|
||||||
removeContentBytes(path);
|
|
||||||
return files.writeFile!(path, content);
|
return files.writeFile!(path, content);
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const cachedDelete: FilesAPI['delete'] = files.delete
|
const cachedDelete: FilesAPI['delete'] = files.delete
|
||||||
? async (path) => {
|
? async (path) => {
|
||||||
cache.delete(path);
|
removeCacheEntriesByPrefix(path);
|
||||||
removeContentBytes(path);
|
|
||||||
return files.delete!(path);
|
return files.delete!(path);
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const cachedRename: FilesAPI['rename'] = files.rename
|
const cachedRename: FilesAPI['rename'] = files.rename
|
||||||
? async (oldPath, newPath) => {
|
? async (oldPath, newPath) => {
|
||||||
cache.delete(oldPath);
|
removeCacheEntriesByPrefix(oldPath);
|
||||||
removeContentBytes(oldPath);
|
removeCacheEntriesByPrefix(newPath);
|
||||||
cache.delete(newPath);
|
|
||||||
removeContentBytes(newPath);
|
|
||||||
return files.rename!(oldPath, newPath);
|
return files.rename!(oldPath, newPath);
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -1442,6 +1442,23 @@ input[aria-label="Terminal input"] {
|
|||||||
100% { transform: translateX(-100%); }
|
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 {
|
.marquee-text {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1459,11 +1476,30 @@ input[aria-label="Terminal input"] {
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.marquee-text--auto,
|
.marquee-text--auto,
|
||||||
|
.open-file-name-marquee-track,
|
||||||
.group:hover .marquee-text--active,
|
.group:hover .marquee-text--active,
|
||||||
.marquee-text--active:hover {
|
.marquee-text--active:hover {
|
||||||
animation: none;
|
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 {
|
.animate-busy-pulse {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user