diff --git a/packages/electron/README.md b/packages/electron/README.md index 9b971a2b..ac05fabb 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u - Floating Mini Chat windows. - Multiple native windows. - Native notifications. +- User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried. - One-click open/reveal/open-in-app actions. - Desktop host switcher and deep-link imports. - Local and remote instance handling. diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 81d998a4..c5274f08 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -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 = ( 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(null); const addButtonRef = React.useRef(null); @@ -158,6 +162,8 @@ export const DirectoryExplorerDialog: React.FC = ( const [entries, setEntries] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); const [isBrowseDirectoryMissing, setIsBrowseDirectoryMissing] = React.useState(false); + const [browseErrorReason, setBrowseErrorReason] = React.useState(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 = ( 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 = ( .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 = ( 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 = ( 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 = ( }, [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 = ( } finally { setIsOpeningFinder(false); } - }, [finalizeSelection, isDesktop, isOpeningFinder, requestAccess, startAccessing, t, targetPath]); + }, [canRequestAccess, finalizeSelection, isOpeningFinder, requestAccess, startAccessing, t, targetPath]); const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { if (event.key === 'ArrowDown') { @@ -596,6 +614,24 @@ export const DirectoryExplorerDialog: React.FC = (
{t('directoryExplorerDialog.browse.loading')}
+ ) : browseErrorReason && browseErrorReason !== 'not-found' ? ( +
+
+ {browseErrorReason === 'os-permission' + ? t('directoryExplorerDialog.browse.permissionDenied') + : t('directoryExplorerDialog.browse.loadFailed')} +
+
+ {browseErrorReason === 'os-permission' && canRequestAccess ? ( + + ) : null} + +
+
) : rows.length === 0 ? (
{t('directoryExplorerDialog.browse.empty')} @@ -688,7 +724,7 @@ export const DirectoryExplorerDialog: React.FC = ( <> {!isMobile ? footerHints : null}
- {isDesktop ? ( + {canRequestAccess ? ( diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 180dd7f0..8f0dc321 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -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. diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 5fb82420..a4405a0b 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -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 ? ( + + {bootstrapFailure === 'os-permission' + ? t('sessions.sidebar.group.empty.permissionDenied') + : t('sessions.sidebar.group.empty.loadFailed')} + {canGrantBootstrapAccess ? ( + + ) : null} + + + ) : null; + const body = ( {group.isArchivedBucket ? t('sessions.sidebar.group.empty.noArchivedSessions') - : bootstrapState === 'queued' || bootstrapState === 'running' + : bootstrapLoading ? ( {t('sessions.sidebar.group.empty.loadingSessions')} ) - : bootstrapState === 'failed' && bootstrapDirectory - ? ( - - {t('sessions.sidebar.group.empty.loadFailed')} - - - ) + : bootstrapFailureNotice + ? bootstrapFailureNotice : t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
) : null} + {totalSessions > 0 && bootstrapFailureNotice ? ( +
+ {bootstrapFailureNotice} +
+ ) : null} {remainingCount > 0 ? (