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"
|
||||
|
||||
Reference in New Issue
Block a user