fix: improve external file and path handling
Open external context files read-only Preserve leading-dot paths in UI Keep workspace write operations guarded
This commit is contained in:
@@ -37,7 +37,7 @@ export const ChangedFilesList: React.FC<ChangedFilesListProps> = ({ files, curre
|
||||
<>
|
||||
<span
|
||||
className="min-w-0 truncate text-muted-foreground"
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
||||
>
|
||||
{dirPart}
|
||||
</span>
|
||||
|
||||
@@ -271,6 +271,7 @@ const renderReadFilePath = (displayPath: string) => {
|
||||
color: 'var(--tools-description)',
|
||||
direction: 'rtl',
|
||||
textAlign: 'left',
|
||||
unicodeBidi: 'plaintext',
|
||||
}}
|
||||
>
|
||||
{displayDir}
|
||||
|
||||
@@ -1299,7 +1299,7 @@ const renderPathLikeGitChanges = (path: string, grow = true) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('min-w-0 truncate typography-ui-label text-foreground', grow && 'flex-1')}
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
||||
title={path}
|
||||
>
|
||||
{path}
|
||||
@@ -1315,7 +1315,7 @@ const renderPathLikeGitChanges = (path: string, grow = true) => {
|
||||
return (
|
||||
<span className={cn('min-w-0 flex items-baseline overflow-hidden typography-ui-label', grow && 'flex-1')} title={path}>
|
||||
{hasAbsoluteRoot ? <span className="flex-shrink-0 text-muted-foreground">/</span> : null}
|
||||
<span className="min-w-0 truncate text-muted-foreground" style={{ direction: 'rtl', textAlign: 'left' }}>
|
||||
<span className="min-w-0 truncate text-muted-foreground" style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}>
|
||||
{displayDir}
|
||||
</span>
|
||||
<span className="flex-shrink-0">
|
||||
@@ -1360,6 +1360,7 @@ const renderAnimatedPathWithIcon = (path: string, _animate = true, grow = true,
|
||||
color: 'var(--tools-description)',
|
||||
direction: 'rtl',
|
||||
textAlign: 'left',
|
||||
unicodeBidi: 'plaintext',
|
||||
}}
|
||||
>
|
||||
{displayDir}
|
||||
|
||||
@@ -362,7 +362,7 @@ const FileList = React.memo<FileListProps>(({
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate typography-meta"
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
||||
title={file.path}
|
||||
>
|
||||
{file.path}
|
||||
@@ -813,7 +813,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
return (
|
||||
<span
|
||||
className="block min-w-0 truncate typography-ui-label text-foreground"
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
||||
>
|
||||
{file.path}
|
||||
</span>
|
||||
@@ -827,7 +827,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
<span className="flex min-w-0 items-baseline overflow-hidden">
|
||||
<span
|
||||
className="min-w-0 truncate typography-ui-label text-muted-foreground"
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
||||
>
|
||||
{dir}
|
||||
</span>
|
||||
|
||||
@@ -597,6 +597,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]);
|
||||
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
|
||||
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
|
||||
const selectedFilePath = selectedFile?.path ?? '';
|
||||
const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root));
|
||||
const selectedFileReadOptions = React.useMemo(
|
||||
() => ({ allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace }),
|
||||
[mode, selectedFileIsOutsideWorkspace],
|
||||
);
|
||||
|
||||
// Editor tabs horizontal scroll fades
|
||||
const editorTabsScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -1217,13 +1223,17 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
};
|
||||
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
const readFile = React.useCallback(async (path: string): Promise<string> => {
|
||||
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path);
|
||||
const result = await files.readFile(path, options);
|
||||
return result.content ?? '';
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
|
||||
const params = new URLSearchParams({ path });
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
|
||||
@@ -1231,9 +1241,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return response.text();
|
||||
}, [files, t]);
|
||||
|
||||
const readFileStat = React.useCallback(async (path: string): Promise<FileStatSnapshot | null> => {
|
||||
const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise<FileStatSnapshot | null> => {
|
||||
if (files.statFile) {
|
||||
const result = await files.statFile(path);
|
||||
const result = await files.statFile(path, options);
|
||||
return {
|
||||
path: result.path,
|
||||
size: result.size,
|
||||
@@ -1402,14 +1412,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
setFileLoading(true);
|
||||
|
||||
await readFile(node.path)
|
||||
const readOptions = { allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root) };
|
||||
|
||||
await readFile(node.path, readOptions)
|
||||
.then((content) => {
|
||||
setFileContent(content);
|
||||
setDraftContent(content.length > MAX_VIEW_CHARS
|
||||
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
||||
: content);
|
||||
setLoadedFilePath(node.path);
|
||||
void readFileStat(node.path)
|
||||
void readFileStat(node.path, readOptions)
|
||||
.then((stat) => {
|
||||
if (stat) {
|
||||
lastLoadedFileStatRef.current = stat;
|
||||
@@ -1455,7 +1467,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
.finally(() => {
|
||||
setFileLoading(false);
|
||||
});
|
||||
}, [expandPaths, isMobile, loadDirectory, readFile, readFileStat, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
|
||||
}, [expandPaths, isMobile, loadDirectory, mode, readFile, readFileStat, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
|
||||
|
||||
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
|
||||
if (!root) {
|
||||
@@ -1549,7 +1561,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
void readFileStat(selectedFile.path)
|
||||
void readFileStat(selectedFile.path, selectedFileReadOptions)
|
||||
.then((latestStat) => {
|
||||
if (cancelled || !latestStat) {
|
||||
return;
|
||||
@@ -1585,7 +1597,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [loadedFilePath, readFileStat, selectedFile?.path]);
|
||||
}, [loadedFilePath, readFileStat, selectedFile?.path, selectedFileReadOptions]);
|
||||
|
||||
const discardAndContinue = React.useCallback(() => {
|
||||
const nextFile = pendingSelectFileRef.current;
|
||||
@@ -1820,7 +1832,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
|
||||
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
|
||||
const selectedFilePath = selectedFile?.path ?? '';
|
||||
const pendingNavigationTargetPath = React.useMemo(
|
||||
() => normalizePath(pendingFileNavigation?.path ?? ''),
|
||||
[pendingFileNavigation?.path],
|
||||
@@ -1841,7 +1852,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0);
|
||||
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
|
||||
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
|
||||
const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && 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));
|
||||
@@ -2301,7 +2312,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
: desktopImageSrc)
|
||||
: (isSelectedSvg
|
||||
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
|
||||
: `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}`))
|
||||
: `/api/fs/raw?${new URLSearchParams({
|
||||
path: selectedFile.path,
|
||||
...(selectedFileReadOptions.allowOutsideWorkspace ? { allowOutsideWorkspace: 'true' } : {}),
|
||||
}).toString()}`))
|
||||
: '';
|
||||
|
||||
|
||||
@@ -2319,7 +2333,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
setFileError(null);
|
||||
|
||||
const srcPromise = files.readFileBinary
|
||||
? files.readFileBinary(selectedFile.path).then((result) => result.dataUrl)
|
||||
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
|
||||
: Promise.resolve(convertFileSrc(selectedFile.path, 'asset'));
|
||||
|
||||
await srcPromise
|
||||
@@ -2348,7 +2362,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, t]);
|
||||
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]);
|
||||
|
||||
const renderDialogs = () => (
|
||||
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
|
||||
@@ -3004,6 +3018,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
<CodeMirrorEditor
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
readOnly={!canEdit}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
blockWidgets={blockWidgets}
|
||||
@@ -3268,6 +3283,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
<CodeMirrorEditor
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
readOnly={!canEdit}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
onViewReady={(view) => {
|
||||
|
||||
@@ -124,7 +124,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
return (
|
||||
<span
|
||||
className="flex-1 min-w-0 truncate typography-ui-label text-foreground"
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
||||
title={file.path}
|
||||
>
|
||||
{file.path}
|
||||
@@ -137,7 +137,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
<span className="flex-1 min-w-0 flex items-baseline overflow-hidden" title={file.path}>
|
||||
<span
|
||||
className="min-w-0 truncate typography-ui-label text-muted-foreground"
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
||||
>
|
||||
{dir}
|
||||
</span>
|
||||
|
||||
@@ -47,14 +47,14 @@ function withContentCache(files: FilesAPI): FilesAPI {
|
||||
return result;
|
||||
};
|
||||
|
||||
const readFreshFile = async (path: string): Promise<{ content: string; path: string }> => {
|
||||
const readFreshFile = async (path: string, options?: Parameters<NonNullable<FilesAPI['readFile']>>[1]): Promise<{ content: string; path: string }> => {
|
||||
// stat → read → stat to avoid TOCTOU:
|
||||
// if the file changes between read and either stat, metadata won't match and we retry.
|
||||
const statBefore = await files.statFile?.(path).catch(() => null);
|
||||
const statBefore = await files.statFile?.(path, options).catch(() => null);
|
||||
|
||||
const result = await files.readFile!(path);
|
||||
const result = await files.readFile!(path, options);
|
||||
|
||||
const statAfter = await files.statFile?.(path).catch(() => null);
|
||||
const statAfter = await files.statFile?.(path, options).catch(() => null);
|
||||
|
||||
// If both stats are available and agree, the read was atomic with respect to file changes.
|
||||
if (statBefore && statAfter && statBefore.isFile && statAfter.isFile) {
|
||||
@@ -62,9 +62,9 @@ function withContentCache(files: FilesAPI): FilesAPI {
|
||||
return syncCacheEntry(path, result, statAfter);
|
||||
}
|
||||
// File changed during read — discard and re-read once.
|
||||
const retryStatBefore = await files.statFile?.(path).catch(() => null);
|
||||
const retry = await files.readFile!(path);
|
||||
const retryStat = await files.statFile?.(path).catch(() => null);
|
||||
const retryStatBefore = await files.statFile?.(path, options).catch(() => null);
|
||||
const retry = await files.readFile!(path, options);
|
||||
const retryStat = await files.statFile?.(path, options).catch(() => null);
|
||||
// Accept retry only if file was stable across the read.
|
||||
if (retryStatBefore && retryStat && retryStatBefore.isFile && retryStat.isFile
|
||||
&& retryStatBefore.size === retryStat.size && retryStatBefore.mtimeMs === retryStat.mtimeMs) {
|
||||
@@ -78,7 +78,10 @@ function withContentCache(files: FilesAPI): FilesAPI {
|
||||
};
|
||||
|
||||
const cachedReadFile: FilesAPI['readFile'] = files.readFile
|
||||
? async (path: string) => {
|
||||
? async (path: string, options) => {
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
return readFreshFile(path, options);
|
||||
}
|
||||
const hit = cache.get(path);
|
||||
if (hit) {
|
||||
// Validate cached entry is still fresh
|
||||
|
||||
@@ -509,13 +509,17 @@ export interface ListDirectoryOptions {
|
||||
respectGitignore?: boolean;
|
||||
}
|
||||
|
||||
export interface FileReadOptions {
|
||||
allowOutsideWorkspace?: boolean;
|
||||
}
|
||||
|
||||
export interface FilesAPI {
|
||||
listDirectory(path: string, options?: ListDirectoryOptions): Promise<DirectoryListResult>;
|
||||
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
|
||||
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
||||
statFile?(path: string): Promise<{ path: string; isFile: boolean; size: number; mtimeMs?: number }>;
|
||||
readFile?(path: string): Promise<{ content: string; path: string }>;
|
||||
readFileBinary?(path: string): Promise<{ dataUrl: string; path: string }>;
|
||||
statFile?(path: string, options?: FileReadOptions): Promise<{ path: string; isFile: boolean; size: number; mtimeMs?: number }>;
|
||||
readFile?(path: string, options?: FileReadOptions): Promise<{ content: string; path: string }>;
|
||||
readFileBinary?(path: string, options?: FileReadOptions): Promise<{ dataUrl: string; path: string }>;
|
||||
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
|
||||
delete?(path: string): Promise<{ success: boolean }>;
|
||||
rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>;
|
||||
|
||||
@@ -26,11 +26,12 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
||||
|
||||
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path);
|
||||
const result = await files.readFile(path, { allowOutsideWorkspace: true });
|
||||
return result.content ?? '';
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
|
||||
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true' });
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((errorPayload as { error?: string }).error || 'Failed to read file');
|
||||
|
||||
@@ -95,6 +95,27 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
|
||||
});
|
||||
};
|
||||
|
||||
const resolveReadPathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
const normalized = normalizeDirectoryPath(targetPath);
|
||||
if (!normalized || typeof normalized !== 'string') {
|
||||
return { ok: false, error: 'Path is required' };
|
||||
}
|
||||
const resolved = path.resolve(normalized);
|
||||
return { ok: true, base: path.dirname(resolved), resolved };
|
||||
}
|
||||
|
||||
return resolveWorkspacePathFromContext({
|
||||
req,
|
||||
targetPath,
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
};
|
||||
|
||||
const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn, buildAugmentedPath, commandTimeoutMs }) => {
|
||||
return new Promise((resolve) => {
|
||||
let stdout = '';
|
||||
@@ -290,7 +311,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveWorkspacePathFromContext({
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
resolveProjectDirectory,
|
||||
@@ -338,7 +359,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveWorkspacePathFromContext({
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
resolveProjectDirectory,
|
||||
@@ -387,7 +408,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveWorkspacePathFromContext({
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
resolveProjectDirectory,
|
||||
|
||||
@@ -110,9 +110,13 @@ export const createWebFilesAPI = (): FilesAPI => ({
|
||||
};
|
||||
},
|
||||
|
||||
async statFile(path: string): Promise<{ path: string; isFile: boolean; size: number; mtimeMs?: number }> {
|
||||
async statFile(path: string, options): Promise<{ path: string; isFile: boolean; size: number; mtimeMs?: number }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch(`/api/fs/stat?path=${encodeURIComponent(target)}`);
|
||||
const params = new URLSearchParams({ path: target });
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/stat?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
@@ -128,9 +132,13 @@ export const createWebFilesAPI = (): FilesAPI => ({
|
||||
};
|
||||
},
|
||||
|
||||
async readFile(path: string): Promise<{ content: string; path: string }> {
|
||||
async readFile(path: string, options): Promise<{ content: string; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(target)}`);
|
||||
const params = new URLSearchParams({ path: target });
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
|
||||
Reference in New Issue
Block a user