fix(desktop): recover from macOS directory permission failures
This commit is contained in:
@@ -22,6 +22,10 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
isFilesystemError,
|
||||
type FilesystemErrorReason,
|
||||
} from '@/lib/api/files-errors';
|
||||
|
||||
interface DirectoryExplorerDialogProps {
|
||||
open: boolean;
|
||||
@@ -148,7 +152,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const loadGitIdentityProfiles = useGitIdentitiesStore((s) => s.loadProfiles);
|
||||
const loadGlobalGitIdentity = useGitIdentitiesStore((s) => s.loadGlobalIdentity);
|
||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
|
||||
const { canRequestAccess, requestAccess, startAccessing } = useFileSystemAccess();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const addButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
@@ -158,6 +162,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const [entries, setEntries] = React.useState<BrowseEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isBrowseDirectoryMissing, setIsBrowseDirectoryMissing] = React.useState(false);
|
||||
const [browseErrorReason, setBrowseErrorReason] = React.useState<FilesystemErrorReason | null>(null);
|
||||
const [browseReloadKey, setBrowseReloadKey] = React.useState(0);
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
|
||||
const [isConfirming, setIsConfirming] = React.useState(false);
|
||||
const [isOpeningFinder, setIsOpeningFinder] = React.useState(false);
|
||||
@@ -250,16 +256,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
React.useEffect(() => {
|
||||
if (!open || !browseDirectoryAbsolutePath) {
|
||||
setEntries([]);
|
||||
setBrowseErrorReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setIsBrowseDirectoryMissing(false);
|
||||
setBrowseErrorReason(null);
|
||||
opencodeClient.listLocalDirectory(browseDirectoryAbsolutePath)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setIsBrowseDirectoryMissing(false);
|
||||
setBrowseErrorReason(null);
|
||||
const nextEntries = result
|
||||
.filter((entry) => entry.isDirectory)
|
||||
.map((entry) => ({
|
||||
@@ -269,10 +278,12 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
setEntries(nextEntries);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setEntries([]);
|
||||
setIsBrowseDirectoryMissing(true);
|
||||
const reason = isFilesystemError(error) ? error.reason : 'unknown';
|
||||
setBrowseErrorReason(reason);
|
||||
setIsBrowseDirectoryMissing(reason === 'not-found');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -282,7 +293,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [browseDirectoryAbsolutePath, open]);
|
||||
}, [browseDirectoryAbsolutePath, browseReloadKey, open]);
|
||||
|
||||
const filteredEntries = React.useMemo(() => {
|
||||
const lowerFilter = browseFilterQuery.toLowerCase();
|
||||
@@ -327,12 +338,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const shouldCreateTarget = Boolean(
|
||||
targetPath
|
||||
&& !isAlreadyAdded
|
||||
&& (browseErrorReason === null || browseErrorReason === 'not-found')
|
||||
&& (
|
||||
(hasTrailingPathSeparator(query) && isBrowseDirectoryMissing)
|
||||
|| (!hasTrailingPathSeparator(query) && browseFilterQuery.trim().length > 0 && exactEntry === null)
|
||||
)
|
||||
);
|
||||
const canAddProject = !isConfirming && !isOpeningFinder && !isAlreadyAdded && Boolean(targetPath);
|
||||
const canAddProject = !isConfirming
|
||||
&& !isOpeningFinder
|
||||
&& !isAlreadyAdded
|
||||
&& browseErrorReason !== 'os-permission'
|
||||
&& browseErrorReason !== 'invalid-response'
|
||||
&& browseErrorReason !== 'unknown'
|
||||
&& Boolean(targetPath);
|
||||
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
|
||||
const highlightedRow = rows[highlightedIndex] ?? null;
|
||||
const hasHighlightedBrowseItem = Boolean(
|
||||
@@ -459,7 +477,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
}, [browseToDisplayPath, browseToEntry]);
|
||||
|
||||
const handleOpenInFinder = React.useCallback(async () => {
|
||||
if (!isDesktop || isOpeningFinder) return;
|
||||
if (!canRequestAccess || isOpeningFinder) return;
|
||||
setIsOpeningFinder(true);
|
||||
try {
|
||||
const result = await requestAccess(targetPath);
|
||||
@@ -488,7 +506,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
} finally {
|
||||
setIsOpeningFinder(false);
|
||||
}
|
||||
}, [finalizeSelection, isDesktop, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
|
||||
}, [canRequestAccess, finalizeSelection, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
|
||||
|
||||
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
@@ -596,6 +614,24 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
<div className="py-10 text-center typography-ui-label text-muted-foreground">
|
||||
{t('directoryExplorerDialog.browse.loading')}
|
||||
</div>
|
||||
) : browseErrorReason && browseErrorReason !== 'not-found' ? (
|
||||
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
|
||||
<div className="typography-ui-label text-[var(--status-error-foreground)]">
|
||||
{browseErrorReason === 'os-permission'
|
||||
? t('directoryExplorerDialog.browse.permissionDenied')
|
||||
: t('directoryExplorerDialog.browse.loadFailed')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{browseErrorReason === 'os-permission' && canRequestAccess ? (
|
||||
<Button size="xs" onClick={() => void handleOpenInFinder()} disabled={isOpeningFinder}>
|
||||
{t('directoryExplorerDialog.browse.grantAccess')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="xs" onClick={() => setBrowseReloadKey((key) => key + 1)}>
|
||||
{t('directoryExplorerDialog.browse.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="py-10 text-center typography-ui-label text-muted-foreground">
|
||||
{t('directoryExplorerDialog.browse.empty')}
|
||||
@@ -688,7 +724,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
<>
|
||||
{!isMobile ? footerHints : null}
|
||||
<div className={cn('flex w-full flex-row justify-end gap-2 sm:w-auto', isMobile && 'justify-stretch')}>
|
||||
{isDesktop ? (
|
||||
{canRequestAccess ? (
|
||||
<Button variant="ghost" size="xs" onClick={handleOpenInFinder} disabled={isConfirming || isOpeningFinder || isCloneMode}>
|
||||
{isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')}
|
||||
</Button>
|
||||
|
||||
@@ -78,4 +78,5 @@
|
||||
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
|
||||
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
||||
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
|
||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
||||
|
||||
@@ -9,6 +9,7 @@ const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
|
||||
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
||||
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -32,6 +33,7 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
|
||||
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
|
||||
import {
|
||||
getSessionNodesActivityState,
|
||||
@@ -348,18 +350,57 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
const groupPrSummary = usePrVisualSummary(groupPrKey);
|
||||
const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined;
|
||||
const childStores = useChildStoreManager();
|
||||
const bootstrapDirectory = normalizePath(group.directory ?? null);
|
||||
const bootstrapState = React.useSyncExternalStore(
|
||||
const bootstrapDirectories = React.useMemo(() => {
|
||||
const directories = group.folderScopes?.map((scope) => normalizePath(scope.directory))
|
||||
?? [normalizePath(group.directory ?? null)];
|
||||
return [...new Set(directories.filter((directory): directory is string => Boolean(directory)))];
|
||||
}, [group.directory, group.folderScopes]);
|
||||
React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(notify) => bootstrapDirectory ? childStores.subscribeBootstrap(notify) : () => undefined,
|
||||
[bootstrapDirectory, childStores],
|
||||
(notify) => bootstrapDirectories.length > 0 ? childStores.subscribeBootstrap(notify) : () => undefined,
|
||||
[bootstrapDirectories.length, childStores],
|
||||
),
|
||||
React.useCallback(
|
||||
() => bootstrapDirectory ? childStores.getBootstrapState(bootstrapDirectory) : undefined,
|
||||
[bootstrapDirectory, childStores],
|
||||
() => bootstrapDirectories.map((directory) => (
|
||||
`${directory}\u0000${childStores.getBootstrapState(directory) ?? ''}\u0000${childStores.getBootstrapFailure(directory) ?? ''}`
|
||||
)).join('\u0001'),
|
||||
[bootstrapDirectories, childStores],
|
||||
),
|
||||
React.useCallback(() => undefined, []),
|
||||
React.useCallback(() => '', []),
|
||||
);
|
||||
const bootstrapLoading = bootstrapDirectories.some((directory) => {
|
||||
const state = childStores.getBootstrapState(directory);
|
||||
return state === 'queued' || state === 'running';
|
||||
});
|
||||
const failedBootstrapDirectory = bootstrapDirectories.find(
|
||||
(directory) => childStores.getBootstrapState(directory) === 'failed',
|
||||
) ?? null;
|
||||
const bootstrapFailure = failedBootstrapDirectory
|
||||
? childStores.getBootstrapFailure(failedBootstrapDirectory)
|
||||
: undefined;
|
||||
const canGrantBootstrapAccess = bootstrapFailure === 'os-permission' && canRequestNativeDirectoryAccess();
|
||||
const [isRequestingBootstrapAccess, setIsRequestingBootstrapAccess] = React.useState(false);
|
||||
|
||||
const retryFailedBootstrap = React.useCallback(() => {
|
||||
if (!failedBootstrapDirectory) return;
|
||||
childStores.requestBootstrap({
|
||||
directory: failedBootstrapDirectory,
|
||||
priority: isCollapsed ? 'visible' : 'expanded',
|
||||
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
|
||||
force: true,
|
||||
});
|
||||
}, [childStores, failedBootstrapDirectory, group.isMain, isCollapsed]);
|
||||
|
||||
const grantFailedBootstrapAccess = React.useCallback(async () => {
|
||||
if (!failedBootstrapDirectory || !canGrantBootstrapAccess || isRequestingBootstrapAccess) return;
|
||||
setIsRequestingBootstrapAccess(true);
|
||||
try {
|
||||
const result = await requestDirectoryAccess(failedBootstrapDirectory);
|
||||
if (result.success) retryFailedBootstrap();
|
||||
} finally {
|
||||
setIsRequestingBootstrapAccess(false);
|
||||
}
|
||||
}, [canGrantBootstrapAccess, failedBootstrapDirectory, isRequestingBootstrapAccess, retryFailedBootstrap]);
|
||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
|
||||
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
||||
@@ -879,6 +920,33 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7');
|
||||
|
||||
const bootstrapFailureNotice = failedBootstrapDirectory ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
{bootstrapFailure === 'os-permission'
|
||||
? t('sessions.sidebar.group.empty.permissionDenied')
|
||||
: t('sessions.sidebar.group.empty.loadFailed')}
|
||||
{canGrantBootstrapAccess ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="h-auto p-0 typography-micro"
|
||||
disabled={isRequestingBootstrapAccess}
|
||||
onClick={() => void grantFailedBootstrapAccess()}
|
||||
>
|
||||
{t('sessions.sidebar.group.empty.grantAccess')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="h-auto p-0 typography-micro"
|
||||
onClick={retryFailedBootstrap}
|
||||
>
|
||||
{t('sessions.sidebar.group.empty.retry')}
|
||||
</Button>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const body = (
|
||||
<SessionFolderDndScope
|
||||
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
||||
@@ -971,34 +1039,23 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<div className="py-1 pl-[26px] text-left typography-micro text-muted-foreground">
|
||||
{group.isArchivedBucket
|
||||
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
||||
: bootstrapState === 'queued' || bootstrapState === 'running'
|
||||
: bootstrapLoading
|
||||
? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
||||
{t('sessions.sidebar.group.empty.loadingSessions')}
|
||||
</span>
|
||||
)
|
||||
: bootstrapState === 'failed' && bootstrapDirectory
|
||||
? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('sessions.sidebar.group.empty.loadFailed')}
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground hover:underline"
|
||||
onClick={() => childStores.requestBootstrap({
|
||||
directory: bootstrapDirectory,
|
||||
priority: isCollapsed ? 'visible' : 'expanded',
|
||||
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
|
||||
force: true,
|
||||
})}
|
||||
>
|
||||
{t('sessions.sidebar.group.empty.retry')}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
: bootstrapFailureNotice
|
||||
? bootstrapFailureNotice
|
||||
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
|
||||
</div>
|
||||
) : null}
|
||||
{totalSessions > 0 && bootstrapFailureNotice ? (
|
||||
<div className="py-1 pl-[26px] text-left typography-micro text-[var(--status-error-foreground)]">
|
||||
{bootstrapFailureNotice}
|
||||
</div>
|
||||
) : null}
|
||||
{remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { isDesktopShell, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
|
||||
import {
|
||||
canRequestNativeDirectoryAccess,
|
||||
isDesktopShell,
|
||||
requestDirectoryAccess,
|
||||
startAccessingDirectory,
|
||||
stopAccessingDirectory,
|
||||
} from '@/lib/desktop';
|
||||
|
||||
export const useFileSystemAccess = () => {
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
const [canRequestAccess, setCanRequestAccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDesktop(isDesktopShell());
|
||||
setCanRequestAccess(canRequestNativeDirectoryAccess());
|
||||
}, []);
|
||||
|
||||
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||
@@ -34,6 +42,7 @@ export const useFileSystemAccess = () => {
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
canRequestAccess,
|
||||
requestAccess,
|
||||
startAccessing,
|
||||
stopAccessing
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
FilesystemError,
|
||||
isFilesystemError,
|
||||
parseFilesystemErrorReason,
|
||||
} from './files-errors';
|
||||
|
||||
describe('FilesystemError', () => {
|
||||
test('retains a stable reason and HTTP status', () => {
|
||||
const error = new FilesystemError('Access denied', {
|
||||
reason: 'os-permission',
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(isFilesystemError(error)).toBe(true);
|
||||
expect(error.name).toBe('FilesystemError');
|
||||
expect(error.message).toBe('Access denied');
|
||||
expect(error.reason).toBe('os-permission');
|
||||
expect(error.status).toBe(403);
|
||||
});
|
||||
|
||||
test('normalizes unsupported response reasons to unknown', () => {
|
||||
expect(parseFilesystemErrorReason('os-permission')).toBe('os-permission');
|
||||
expect(parseFilesystemErrorReason('made-up')).toBe('unknown');
|
||||
expect(parseFilesystemErrorReason(undefined)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export type FilesystemErrorReason =
|
||||
| 'os-permission'
|
||||
| 'not-found'
|
||||
| 'not-directory'
|
||||
| 'invalid-response'
|
||||
| 'unknown';
|
||||
|
||||
export class FilesystemError extends Error {
|
||||
readonly reason: FilesystemErrorReason;
|
||||
readonly status?: number;
|
||||
|
||||
constructor(message: string, options: { reason?: FilesystemErrorReason; status?: number } = {}) {
|
||||
super(message);
|
||||
this.name = 'FilesystemError';
|
||||
this.reason = options.reason ?? 'unknown';
|
||||
this.status = options.status;
|
||||
}
|
||||
}
|
||||
|
||||
export const isFilesystemError = (error: unknown): error is FilesystemError => (
|
||||
error instanceof FilesystemError
|
||||
|| Boolean(
|
||||
error
|
||||
&& typeof error === 'object'
|
||||
&& 'reason' in error
|
||||
&& typeof (error as { reason?: unknown }).reason === 'string'
|
||||
)
|
||||
);
|
||||
|
||||
export const parseFilesystemErrorReason = (value: unknown): FilesystemErrorReason => {
|
||||
switch (value) {
|
||||
case 'os-permission':
|
||||
case 'not-found':
|
||||
case 'not-directory':
|
||||
case 'invalid-response':
|
||||
return value;
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
@@ -531,6 +531,10 @@ export const isDesktopShell = (): boolean => {
|
||||
return isElectronShell();
|
||||
};
|
||||
|
||||
export const canRequestNativeDirectoryAccess = (): boolean => (
|
||||
isDesktopShell() && hasDesktopInvoke() && isDesktopLocalOriginActive()
|
||||
);
|
||||
|
||||
export const startDesktopWindowDrag = async (): Promise<boolean> => {
|
||||
if (!isDesktopShell()) {
|
||||
return false;
|
||||
@@ -586,12 +590,13 @@ export const requestDirectoryAccess = async (
|
||||
directoryPath: string
|
||||
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||
// Desktop shell on local instance: use native folder picker.
|
||||
if (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
|
||||
if (canRequestNativeDirectoryAccess()) {
|
||||
try {
|
||||
const selected = await getDesktopBridge()?.openDialog?.({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: 'Select Working Directory',
|
||||
...(directoryPath ? { defaultPath: directoryPath } : {}),
|
||||
});
|
||||
if (!selected || typeof selected !== 'string') {
|
||||
return { success: false, error: 'Directory selection cancelled' };
|
||||
@@ -603,7 +608,7 @@ export const requestDirectoryAccess = async (
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, path: directoryPath };
|
||||
return { success: false, error: 'Native directory picker not available' };
|
||||
};
|
||||
|
||||
const isDesktopFileGrantResult = (
|
||||
|
||||
@@ -1499,6 +1499,10 @@ export const dict = {
|
||||
'directoryExplorerDialog.browse.directories': 'Verzeichnisse',
|
||||
'directoryExplorerDialog.browse.loading': 'Lade Verzeichnisse...',
|
||||
'directoryExplorerDialog.browse.empty': 'Keine passenden Verzeichnisse.',
|
||||
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber benötigt Zugriff auf diesen Ordner.',
|
||||
'directoryExplorerDialog.browse.loadFailed': 'Dieser Ordner konnte nicht geladen werden.',
|
||||
'directoryExplorerDialog.browse.grantAccess': 'Zugriff gewähren',
|
||||
'directoryExplorerDialog.browse.retry': 'Erneut versuchen',
|
||||
'directoryExplorerDialog.browse.parentDirectory': 'Übergeordnetes Verzeichnis',
|
||||
'directoryExplorerDialog.browse.addedBadge': 'Hinzugefügt',
|
||||
'directoryExplorerDialog.browse.quickAdd': 'Hinzufügen',
|
||||
@@ -2924,6 +2928,8 @@ export const dict = {
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden',
|
||||
'sessions.sidebar.group.empty.retry': 'Erneut versuchen',
|
||||
'sessions.sidebar.group.empty.permissionDenied': 'Ordnerzugriff ist erforderlich.',
|
||||
'sessions.sidebar.group.empty.grantAccess': 'Zugriff gewähren',
|
||||
'chat.messageBody.actions.pinContext': 'Kontext anheften',
|
||||
'chat.messageBody.actions.unpinContext': 'Kontext lösen',
|
||||
'chat.messageBody.actions.contextPinFailed': 'Kontext konnte nicht angeheftet werden',
|
||||
|
||||
@@ -1656,6 +1656,10 @@ export const dict = {
|
||||
'directoryExplorerDialog.browse.directories': 'Directories',
|
||||
'directoryExplorerDialog.browse.loading': 'Loading directories...',
|
||||
'directoryExplorerDialog.browse.empty': 'No matching directories.',
|
||||
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber needs access to this folder.',
|
||||
'directoryExplorerDialog.browse.loadFailed': 'Could not load this folder.',
|
||||
'directoryExplorerDialog.browse.grantAccess': 'Grant access',
|
||||
'directoryExplorerDialog.browse.retry': 'Try again',
|
||||
'directoryExplorerDialog.browse.parentDirectory': 'Parent directory',
|
||||
'directoryExplorerDialog.browse.addedBadge': 'Added',
|
||||
'directoryExplorerDialog.browse.quickAdd': 'Add',
|
||||
@@ -2007,6 +2011,8 @@ export const dict = {
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
|
||||
'sessions.sidebar.group.empty.retry': 'Try again',
|
||||
'sessions.sidebar.group.empty.permissionDenied': 'Folder access is required.',
|
||||
'sessions.sidebar.group.empty.grantAccess': 'Grant access',
|
||||
'chat.unifiedControls.title': 'Controls',
|
||||
'chat.unifiedControls.model.title': 'Model',
|
||||
'chat.unifiedControls.model.noRecent': 'No recent models',
|
||||
|
||||
@@ -1634,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"directoryExplorerDialog.browse.directories": "Directorios",
|
||||
"directoryExplorerDialog.browse.loading": "Cargando directorios...",
|
||||
"directoryExplorerDialog.browse.empty": "No hay directorios coincidentes.",
|
||||
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber necesita acceso a esta carpeta.",
|
||||
"directoryExplorerDialog.browse.loadFailed": "No se pudo cargar esta carpeta.",
|
||||
"directoryExplorerDialog.browse.grantAccess": "Permitir acceso",
|
||||
"directoryExplorerDialog.browse.retry": "Reintentar",
|
||||
"directoryExplorerDialog.browse.parentDirectory": "Directorio padre",
|
||||
"directoryExplorerDialog.browse.addedBadge": "Añadido",
|
||||
"directoryExplorerDialog.browse.quickAdd": "Añadir",
|
||||
@@ -1985,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
|
||||
"sessions.sidebar.group.empty.retry": "Reintentar",
|
||||
"sessions.sidebar.group.empty.permissionDenied": "Se requiere acceso a la carpeta.",
|
||||
"sessions.sidebar.group.empty.grantAccess": "Permitir acceso",
|
||||
"chat.unifiedControls.title": "Controles",
|
||||
"chat.unifiedControls.model.title": "Modelo",
|
||||
"chat.unifiedControls.model.noRecent": "No hay modelos recientes",
|
||||
|
||||
@@ -1469,6 +1469,10 @@ export const dict = {
|
||||
'directoryExplorerDialog.browse.directories': 'Annuaires',
|
||||
'directoryExplorerDialog.browse.loading': 'Chargement des répertoires...',
|
||||
'directoryExplorerDialog.browse.empty': 'Aucun répertoire correspondant.',
|
||||
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber doit accéder à ce dossier.',
|
||||
'directoryExplorerDialog.browse.loadFailed': 'Impossible de charger ce dossier.',
|
||||
'directoryExplorerDialog.browse.grantAccess': 'Autoriser l’accès',
|
||||
'directoryExplorerDialog.browse.retry': 'Réessayer',
|
||||
'directoryExplorerDialog.browse.parentDirectory': 'Annuaire parent',
|
||||
'directoryExplorerDialog.browse.addedBadge': 'Ajouté',
|
||||
'directoryExplorerDialog.browse.quickAdd': 'Ajouter',
|
||||
@@ -1794,6 +1798,8 @@ export const dict = {
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Impossible d’actualiser les sessions.',
|
||||
'sessions.sidebar.group.empty.retry': 'Réessayer',
|
||||
'sessions.sidebar.group.empty.permissionDenied': 'L’accès au dossier est requis.',
|
||||
'sessions.sidebar.group.empty.grantAccess': 'Autoriser l’accès',
|
||||
'chat.unifiedControls.title': 'Contrôles',
|
||||
'chat.unifiedControls.model.title': 'Modèle',
|
||||
'chat.unifiedControls.model.noRecent': 'Aucun modèle récent',
|
||||
|
||||
@@ -1652,6 +1652,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'directoryExplorerDialog.browse.directories': 'ディレクトリ',
|
||||
'directoryExplorerDialog.browse.loading': 'ディレクトリを読み込み中...',
|
||||
'directoryExplorerDialog.browse.empty': '一致するディレクトリがありません。',
|
||||
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber がこのフォルダにアクセスする必要があります。',
|
||||
'directoryExplorerDialog.browse.loadFailed': 'このフォルダを読み込めませんでした。',
|
||||
'directoryExplorerDialog.browse.grantAccess': 'アクセスを許可',
|
||||
'directoryExplorerDialog.browse.retry': '再試行',
|
||||
'directoryExplorerDialog.browse.parentDirectory': '親ディレクトリ',
|
||||
'directoryExplorerDialog.browse.addedBadge': '追加済み',
|
||||
'directoryExplorerDialog.browse.quickAdd': '追加',
|
||||
@@ -2003,6 +2007,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
|
||||
'sessions.sidebar.group.empty.retry': '再試行',
|
||||
'sessions.sidebar.group.empty.permissionDenied': 'フォルダへのアクセスが必要です。',
|
||||
'sessions.sidebar.group.empty.grantAccess': 'アクセスを許可',
|
||||
'chat.unifiedControls.title': 'コントロール',
|
||||
'chat.unifiedControls.model.title': 'モデル',
|
||||
'chat.unifiedControls.model.noRecent': '最近のモデルはありません',
|
||||
|
||||
@@ -1658,6 +1658,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'directoryExplorerDialog.browse.directories': '디렉터리',
|
||||
'directoryExplorerDialog.browse.loading': '디렉터리 로드 중...',
|
||||
'directoryExplorerDialog.browse.empty': '일치하는 디렉터리가 없습니다.',
|
||||
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber에서 이 폴더에 접근해야 합니다.',
|
||||
'directoryExplorerDialog.browse.loadFailed': '이 폴더를 불러올 수 없습니다.',
|
||||
'directoryExplorerDialog.browse.grantAccess': '접근 허용',
|
||||
'directoryExplorerDialog.browse.retry': '다시 시도',
|
||||
'directoryExplorerDialog.browse.parentDirectory': '상위 디렉터리',
|
||||
'directoryExplorerDialog.browse.addedBadge': '추가됨',
|
||||
'directoryExplorerDialog.browse.quickAdd': '추가',
|
||||
@@ -2009,6 +2013,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
|
||||
'sessions.sidebar.group.empty.retry': '다시 시도',
|
||||
'sessions.sidebar.group.empty.permissionDenied': '폴더 접근이 필요합니다.',
|
||||
'sessions.sidebar.group.empty.grantAccess': '접근 허용',
|
||||
'chat.unifiedControls.title': '컨트롤',
|
||||
'chat.unifiedControls.model.title': '모델',
|
||||
'chat.unifiedControls.model.noRecent': '최근 모델 없음',
|
||||
|
||||
@@ -811,6 +811,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
|
||||
'sessions.sidebar.group.empty.retry': 'Spróbuj ponownie',
|
||||
'sessions.sidebar.group.empty.permissionDenied': 'Wymagany jest dostęp do folderu.',
|
||||
'sessions.sidebar.group.empty.grantAccess': 'Przyznaj dostęp',
|
||||
'chat.unifiedControls.title': 'Kontrolki',
|
||||
'chat.unifiedControls.model.title': 'Model',
|
||||
'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli',
|
||||
@@ -1738,6 +1740,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'directoryExplorerDialog.browse.quickAdd': 'Dodaj',
|
||||
'directoryExplorerDialog.browse.directories': 'Katalogi',
|
||||
'directoryExplorerDialog.browse.empty': 'Brak pasujących katalogów.',
|
||||
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber potrzebuje dostępu do tego folderu.',
|
||||
'directoryExplorerDialog.browse.loadFailed': 'Nie udało się wczytać tego folderu.',
|
||||
'directoryExplorerDialog.browse.grantAccess': 'Przyznaj dostęp',
|
||||
'directoryExplorerDialog.browse.retry': 'Spróbuj ponownie',
|
||||
'directoryExplorerDialog.browse.loading': 'Ładowanie katalogów...',
|
||||
'directoryExplorerDialog.browse.parentDirectory': 'Katalog nadrzędny',
|
||||
'directoryExplorerDialog.description': 'Wybierz folder, który chcesz dodać jako projekt.',
|
||||
|
||||
@@ -1634,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"directoryExplorerDialog.browse.directories": "Diretórios",
|
||||
"directoryExplorerDialog.browse.loading": "Carregando diretórios...",
|
||||
"directoryExplorerDialog.browse.empty": "Nenhum diretório correspondente.",
|
||||
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber precisa acessar esta pasta.",
|
||||
"directoryExplorerDialog.browse.loadFailed": "Não foi possível carregar esta pasta.",
|
||||
"directoryExplorerDialog.browse.grantAccess": "Conceder acesso",
|
||||
"directoryExplorerDialog.browse.retry": "Tentar novamente",
|
||||
"directoryExplorerDialog.browse.parentDirectory": "Diretório pai",
|
||||
"directoryExplorerDialog.browse.addedBadge": "Adicionado",
|
||||
"directoryExplorerDialog.browse.quickAdd": "Adicionar",
|
||||
@@ -1985,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
|
||||
"sessions.sidebar.group.empty.retry": "Tentar novamente",
|
||||
"sessions.sidebar.group.empty.permissionDenied": "É necessário acesso à pasta.",
|
||||
"sessions.sidebar.group.empty.grantAccess": "Conceder acesso",
|
||||
"chat.unifiedControls.title": "Controles",
|
||||
"chat.unifiedControls.model.title": "Modelo",
|
||||
"chat.unifiedControls.model.noRecent": "Não há modelos recentes",
|
||||
|
||||
@@ -1634,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"directoryExplorerDialog.browse.directories": "Каталоги",
|
||||
"directoryExplorerDialog.browse.loading": "Завантаження каталогів...",
|
||||
"directoryExplorerDialog.browse.empty": "Немає відповідних каталогів.",
|
||||
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber потрібен доступ до цієї папки.",
|
||||
"directoryExplorerDialog.browse.loadFailed": "Не вдалося завантажити цю папку.",
|
||||
"directoryExplorerDialog.browse.grantAccess": "Надати доступ",
|
||||
"directoryExplorerDialog.browse.retry": "Спробувати знову",
|
||||
"directoryExplorerDialog.browse.parentDirectory": "Батьківський каталог",
|
||||
"directoryExplorerDialog.browse.addedBadge": "Додано",
|
||||
"directoryExplorerDialog.browse.quickAdd": "Додати",
|
||||
@@ -1985,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
|
||||
"sessions.sidebar.group.empty.retry": "Спробувати знову",
|
||||
"sessions.sidebar.group.empty.permissionDenied": "Потрібен доступ до папки.",
|
||||
"sessions.sidebar.group.empty.grantAccess": "Надати доступ",
|
||||
"chat.unifiedControls.title": "Елементи керування",
|
||||
"chat.unifiedControls.model.title": "Модель",
|
||||
"chat.unifiedControls.model.noRecent": "Немає останніх моделей",
|
||||
|
||||
@@ -1622,6 +1622,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'directoryExplorerDialog.browse.directories': '目录',
|
||||
'directoryExplorerDialog.browse.loading': '正在加载目录...',
|
||||
'directoryExplorerDialog.browse.empty': '没有匹配的目录。',
|
||||
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber 需要访问此文件夹。',
|
||||
'directoryExplorerDialog.browse.loadFailed': '无法加载此文件夹。',
|
||||
'directoryExplorerDialog.browse.grantAccess': '授予访问权限',
|
||||
'directoryExplorerDialog.browse.retry': '重试',
|
||||
'directoryExplorerDialog.browse.parentDirectory': '上级目录',
|
||||
'directoryExplorerDialog.browse.addedBadge': '已添加',
|
||||
'directoryExplorerDialog.browse.quickAdd': '添加',
|
||||
@@ -1973,6 +1977,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。',
|
||||
'sessions.sidebar.group.empty.retry': '重试',
|
||||
'sessions.sidebar.group.empty.permissionDenied': '需要文件夹访问权限。',
|
||||
'sessions.sidebar.group.empty.grantAccess': '授予访问权限',
|
||||
'chat.unifiedControls.title': '控制',
|
||||
'chat.unifiedControls.model.title': '模型',
|
||||
'chat.unifiedControls.model.noRecent': '没有最近使用的模型',
|
||||
|
||||
@@ -1626,6 +1626,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'directoryExplorerDialog.browse.directories': '目錄',
|
||||
'directoryExplorerDialog.browse.loading': '正在載入目錄...',
|
||||
'directoryExplorerDialog.browse.empty': '沒有符合的目錄。',
|
||||
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber 需要存取此資料夾。',
|
||||
'directoryExplorerDialog.browse.loadFailed': '無法載入此資料夾。',
|
||||
'directoryExplorerDialog.browse.grantAccess': '授予存取權限',
|
||||
'directoryExplorerDialog.browse.retry': '再試一次',
|
||||
'directoryExplorerDialog.browse.parentDirectory': '上層目錄',
|
||||
'directoryExplorerDialog.browse.addedBadge': '已新增',
|
||||
'directoryExplorerDialog.browse.quickAdd': '添加',
|
||||
@@ -1977,6 +1981,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。',
|
||||
'sessions.sidebar.group.empty.retry': '再試一次',
|
||||
'sessions.sidebar.group.empty.permissionDenied': '需要資料夾存取權限。',
|
||||
'sessions.sidebar.group.empty.grantAccess': '授予存取權限',
|
||||
'chat.unifiedControls.title': '控制',
|
||||
'chat.unifiedControls.model.title': '模型',
|
||||
'chat.unifiedControls.model.noRecent': '沒有最近使用的模型',
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
FilePartInput,
|
||||
} from "@opencode-ai/sdk/v2";
|
||||
import { isAmbiguousTransportFailure, markAmbiguousTransportFailure } from "@/lib/relay/transport-error";
|
||||
import { FilesystemError, parseFilesystemErrorReason } from "@/lib/api/files-errors";
|
||||
import type { PermissionRequest } from "@/types/permission";
|
||||
import type { QuestionRequest } from "@/types/question";
|
||||
|
||||
@@ -1751,20 +1752,55 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const desktopFiles = getDesktopFilesApi();
|
||||
if (desktopFiles) {
|
||||
const desktopFiles = getDesktopFilesApi();
|
||||
try {
|
||||
const result = await desktopFiles.listDirectory(directoryPath || '', options);
|
||||
if (!result || !Array.isArray(result.entries)) {
|
||||
return [];
|
||||
if (desktopFiles) {
|
||||
const result = await desktopFiles.listDirectory(directoryPath || '', options);
|
||||
if (!result || !Array.isArray(result.entries)) {
|
||||
throw new FilesystemError('Directory listing returned an invalid response', {
|
||||
reason: 'invalid-response',
|
||||
});
|
||||
}
|
||||
const entries = result.entries.map<FilesystemEntry>((entry) => ({
|
||||
name: entry.name,
|
||||
path: normalizeFsPath(entry.path),
|
||||
isDirectory: !!entry.isDirectory,
|
||||
isFile: !entry.isDirectory,
|
||||
isSymbolicLink: false,
|
||||
}));
|
||||
this.listDirectoryCache.set(cacheKey, {
|
||||
entries,
|
||||
expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS,
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
const entries = result.entries.map<FilesystemEntry>((entry) => ({
|
||||
name: entry.name,
|
||||
path: normalizeFsPath(entry.path),
|
||||
isDirectory: !!entry.isDirectory,
|
||||
isFile: !entry.isDirectory,
|
||||
isSymbolicLink: false,
|
||||
}));
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (directoryPath && directoryPath.trim().length > 0) {
|
||||
params.set('path', directoryPath);
|
||||
}
|
||||
if (options?.respectGitignore) {
|
||||
params.set('respectGitignore', 'true');
|
||||
}
|
||||
const query = params.toString();
|
||||
const response = await runtimeFetch(`${this.baseUrl}/fs/list${query ? `?${query}` : ''}`);
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
const message = typeof error.error === 'string' ? error.error : 'Failed to list directory';
|
||||
throw new FilesystemError(message, {
|
||||
reason: parseFilesystemErrorReason((error as { reason?: unknown }).reason),
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result || !Array.isArray(result.entries)) {
|
||||
throw new FilesystemError('Directory listing returned an invalid response', {
|
||||
reason: 'invalid-response',
|
||||
});
|
||||
}
|
||||
|
||||
const entries = result.entries as FilesystemEntry[];
|
||||
this.listDirectoryCache.set(cacheKey, {
|
||||
entries,
|
||||
expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS,
|
||||
@@ -1774,39 +1810,6 @@ class OpencodeService {
|
||||
console.error('Failed to list directory contents:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (directoryPath && directoryPath.trim().length > 0) {
|
||||
params.set('path', directoryPath);
|
||||
}
|
||||
if (options?.respectGitignore) {
|
||||
params.set('respectGitignore', 'true');
|
||||
}
|
||||
const query = params.toString();
|
||||
const response = await runtimeFetch(`${this.baseUrl}/fs/list${query ? `?${query}` : ''}`);
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
const message = typeof error.error === 'string' ? error.error : 'Failed to list directory';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result || !Array.isArray(result.entries)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = result.entries as FilesystemEntry[];
|
||||
this.listDirectoryCache.set(cacheKey, {
|
||||
entries,
|
||||
expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS,
|
||||
});
|
||||
return entries;
|
||||
} catch (error) {
|
||||
console.error('Failed to list directory contents:', error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
const trackedTask = task.finally(() => {
|
||||
|
||||
@@ -75,6 +75,7 @@ The composer compares normalized attachment MIME types with the selected model's
|
||||
- A mounted directory-store consumer pins that store for its lifetime. Eviction may dispose only unmounted directories, so optimistic actions and realtime events cannot move to a replacement store while visible React consumers remain subscribed to an older identity.
|
||||
- Reconfiguration and runtime switching invalidate stale generations. A stale completion must not publish state into the new runtime.
|
||||
- Failure is recorded as `failed`; it is not converted into a successful empty snapshot. Forced demand can retry failed or completed work.
|
||||
- A failed bootstrap is classified as `os-permission` only when the owning runtime filesystem API independently confirms `EPERM`/`EACCES` for that exact directory. OpenCode/proxy error text is never used as permission evidence. The scheduler retains the directory-scoped reason so local Desktop can offer native folder selection before a forced retry.
|
||||
|
||||
Bootstrap remains stale-while-revalidate: a directory store may paint persisted sessions immediately, but only a successful authoritative fetch may replace that cached list.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
setSyncPerformanceDiagnosticsEnabled,
|
||||
} from './performance-diagnostics';
|
||||
import { DIR_IDLE_TTL_MS } from './types';
|
||||
import { FilesystemError } from '@/lib/api/files-errors';
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
@@ -414,6 +415,40 @@ describe('ChildStoreManager directory bootstrap scheduler', () => {
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('records os-permission failures and clears them on forced retry', async () => {
|
||||
const manager = new ChildStoreManager();
|
||||
let denied = true;
|
||||
const cleanup = manager.configure({
|
||||
onBootstrap: () => {
|
||||
if (denied) {
|
||||
throw new FilesystemError('Access denied', { reason: 'os-permission', status: 403 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
manager.requestBootstrap({ directory: '/protected', priority: 'selected', reason: 'current-directory' });
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(manager.getBootstrapState('/protected')).toBe('failed');
|
||||
expect(manager.getBootstrapFailure('/protected')).toBe('os-permission');
|
||||
|
||||
denied = false;
|
||||
manager.requestBootstrap({
|
||||
directory: '/protected',
|
||||
priority: 'selected',
|
||||
reason: 'action-demand',
|
||||
force: true,
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(manager.getBootstrapState('/protected')).toBe('complete');
|
||||
expect(manager.getBootstrapFailure('/protected')).toBe(undefined);
|
||||
cleanup();
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('continues after a synchronous bootstrap failure', async () => {
|
||||
const manager = new ChildStoreManager();
|
||||
const started: string[] = [];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessi
|
||||
import { normalizePath } from "@/lib/pathNormalization"
|
||||
import { startSessionLoadPerformanceEvent } from "./session-load-performance"
|
||||
import { countSyncPerformance } from "./performance-diagnostics"
|
||||
import { isFilesystemError } from "@/lib/api/files-errors"
|
||||
|
||||
export type DirectoryStore = State & {
|
||||
/** Apply a partial state update */
|
||||
@@ -216,6 +217,7 @@ export type DirectoryBootstrapDemand = {
|
||||
}
|
||||
|
||||
export type DirectoryBootstrapState = "queued" | "running" | "complete" | "failed"
|
||||
export type DirectoryBootstrapFailureReason = "os-permission" | "generic"
|
||||
|
||||
export type DirectoryBootstrapContext = DirectoryBootstrapDemand & {
|
||||
generation: number
|
||||
@@ -296,6 +298,7 @@ export class ChildStoreManager {
|
||||
private readonly bootstrapQueue = new Map<string, QueuedBootstrap>()
|
||||
private readonly runningBootstraps = new Map<string, RunningBootstrap>()
|
||||
private readonly bootstrapStates = new Map<string, DirectoryBootstrapState>()
|
||||
private readonly bootstrapFailures = new Map<string, DirectoryBootstrapFailureReason>()
|
||||
|
||||
private onBootstrap?: (context: DirectoryBootstrapContext) => Promise<void> | void
|
||||
private onDispose?: (directory: string) => void
|
||||
@@ -479,6 +482,11 @@ export class ChildStoreManager {
|
||||
return normalizedDirectory ? this.bootstrapStates.get(normalizedDirectory) : undefined
|
||||
}
|
||||
|
||||
getBootstrapFailure(directory: string): DirectoryBootstrapFailureReason | undefined {
|
||||
const normalizedDirectory = normalizePath(directory)
|
||||
return normalizedDirectory ? this.bootstrapFailures.get(normalizedDirectory) : undefined
|
||||
}
|
||||
|
||||
subscribeBootstrap(listener: () => void): () => void {
|
||||
this.bootstrapSubscribers.add(listener)
|
||||
return () => this.bootstrapSubscribers.delete(listener)
|
||||
@@ -530,6 +538,7 @@ export class ChildStoreManager {
|
||||
if (demand.force) running.rerunRequested = true
|
||||
return false
|
||||
}
|
||||
this.bootstrapFailures.delete(directory)
|
||||
const existing = this.bootstrapQueue.get(directory)
|
||||
const next: QueuedBootstrap = existing
|
||||
? {
|
||||
@@ -603,14 +612,19 @@ export class ChildStoreManager {
|
||||
.then(() => {
|
||||
if (isCurrent()) {
|
||||
this.bootstrapStates.set(next.directory, "complete")
|
||||
this.bootstrapFailures.delete(next.directory)
|
||||
finishPerformanceEvent("complete")
|
||||
} else {
|
||||
finishPerformanceEvent("stale")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (isCurrent()) {
|
||||
this.bootstrapStates.set(next.directory, "failed")
|
||||
this.bootstrapFailures.set(
|
||||
next.directory,
|
||||
isFilesystemError(error) && error.reason === "os-permission" ? "os-permission" : "generic",
|
||||
)
|
||||
finishPerformanceEvent("error")
|
||||
} else {
|
||||
finishPerformanceEvent("stale")
|
||||
@@ -658,6 +672,7 @@ export class ChildStoreManager {
|
||||
this.bootstrapQueue.delete(directory)
|
||||
this.manualBootstrapDemands.delete(directory)
|
||||
this.bootstrapStates.delete(directory)
|
||||
this.bootstrapFailures.delete(directory)
|
||||
for (const demands of this.bootstrapDemandsByOwner.values()) demands.delete(directory)
|
||||
this.children.delete(directory)
|
||||
this.notifyRegistrySubscribers()
|
||||
@@ -719,6 +734,7 @@ export class ChildStoreManager {
|
||||
this.bootstrapQueue.clear()
|
||||
this.runningBootstraps.clear()
|
||||
this.bootstrapStates.clear()
|
||||
this.bootstrapFailures.clear()
|
||||
this.bootstrapDemandsByOwner.clear()
|
||||
this.manualBootstrapDemands.clear()
|
||||
this.notifyBootstrapSubscribers()
|
||||
|
||||
@@ -68,6 +68,7 @@ import { getPermissionToastKey, showPermissionNeededToast } from "./permission-t
|
||||
import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
||||
import { isFilesystemError } from "@/lib/api/files-errors"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||
@@ -2068,7 +2069,21 @@ export function SyncProvider(props: {
|
||||
}
|
||||
|
||||
const result = await runBootstrap(0)
|
||||
if (result === "failed") throw new Error(`Directory bootstrap failed for ${directory}`)
|
||||
if (result === "failed") {
|
||||
// OpenCode can mask the underlying errno while initializing an
|
||||
// inaccessible workspace. Probe the exact directory through the
|
||||
// owning runtime filesystem API so only an authoritative local
|
||||
// EPERM/EACCES becomes an actionable grant-access failure.
|
||||
const files = getRegisteredRuntimeAPIs()?.files
|
||||
if (files) {
|
||||
try {
|
||||
await files.listDirectory(directory)
|
||||
} catch (error) {
|
||||
if (isFilesystemError(error) && error.reason === "os-permission") throw error
|
||||
}
|
||||
}
|
||||
throw new Error(`Directory bootstrap failed for ${directory}`)
|
||||
}
|
||||
|
||||
// Selecting a session whose directory this client had not indexed yet
|
||||
// routes it through the active directory as a documented guess. This is
|
||||
|
||||
Reference in New Issue
Block a user