fix(desktop): recover from macOS directory permission failures

This commit is contained in:
deatheros
2026-08-07 01:49:44 +03:00
parent 7e0e22f6e2
commit d8518bf053
30 changed files with 497 additions and 106 deletions
@@ -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"