| author | |
| committer | |
| log | 321c6f9d26e9240f6ed3d2a3a8f7fc11344c2f17 |
| tree | 90272f7d0dc38f13fa382c9fdbb10b70d8078d81 |
| parent | 2eda8ac6a3151b70b8ee49b438bfb6fcd835802e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>63 files changed, 3109 insertions(+), 388 deletions(-)
apps/mobile/src/components/ComposerAttachmentStrip.tsx+1-1| ... | ... | @@ -14,7 +14,7 @@ import { |
| 14 | 14 | } from "../lib/composerImages"; |
| 15 | 15 | import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles"; |
| 16 | 16 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 17 | import { type MediaActionsSource } from "../lib/mediaActions"; | |
| 17 | import { type MediaActionsSource } from "../lib/mediaActionsSource"; | |
| 18 | 18 | import { PresentationSource } from "./NativePresentation"; |
| 19 | 19 | import type { FilePreviewSource } from "./FilePreviewModal"; |
| 20 | 20 | import { isPdfFile } from "../lib/filePreview"; |
apps/mobile/src/components/FilePreviewModal.types.ts+1-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | 3 | import type { FileBackedComposerAttachment } from "../lib/composerImages"; |
| 4 | import type { MediaActionsSource } from "../lib/mediaActions"; | |
| 4 | import type { MediaActionsSource } from "../lib/mediaActionsSource"; | |
| 5 | 5 | |
| 6 | 6 | export interface ResolvedFilePreviewSource { |
| 7 | 7 | readonly kind: "image" | "pdf" | "document"; |
apps/mobile/src/components/MediaActionsMenu.tsx+1-1| ... | ... | @@ -2,7 +2,7 @@ import { MenuView } from "@react-native-menu/menu"; |
| 2 | 2 | import type { ReactElement } from "react"; |
| 3 | 3 | import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; |
| 4 | 4 | |
| 5 | import type { useMediaActions } from "../lib/mediaActions"; | |
| 5 | import type { useMediaActions } from "../state/mediaActions"; | |
| 6 | 6 | import { SymbolView } from "./AppSymbol"; |
| 7 | 7 | import { ControlPillMenu } from "./ControlPill"; |
| 8 | 8 |
apps/mobile/src/components/MediaImagePreview.tsx+1-1| ... | ... | @@ -3,7 +3,7 @@ import { Pressable, View } from "react-native"; |
| 3 | 3 | import ImageViewing from "react-native-image-viewing"; |
| 4 | 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 5 | 5 | |
| 6 | import { useMediaActions } from "../lib/mediaActions"; | |
| 6 | import { useMediaActions } from "../state/mediaActions"; | |
| 7 | 7 | import { AppText } from "./AppText"; |
| 8 | 8 | import { SymbolView } from "./AppSymbol"; |
| 9 | 9 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; |
apps/mobile/src/components/MediaVideoPlayer.tsx+2-1| ... | ... | @@ -7,7 +7,8 @@ import { ActivityIndicator, AppState, Pressable, View } from "react-native"; |
| 7 | 7 | import { AppText } from "./AppText"; |
| 8 | 8 | import { SymbolView } from "./AppSymbol"; |
| 9 | 9 | import { VideoThumbnailImage } from "./VideoThumbnailImage"; |
| 10 | import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; | |
| 10 | import { useMediaActions } from "../state/mediaActions"; | |
| 11 | import { type MediaActionsSource } from "../lib/mediaActionsSource"; | |
| 11 | 12 | import { MediaActionsMenu } from "./MediaActionsMenu"; |
| 12 | 13 | |
| 13 | 14 | /** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */ |
apps/mobile/src/components/VideoAttachmentTile.tsx+2-1| ... | ... | @@ -2,7 +2,8 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react |
| 2 | 2 | |
| 3 | 3 | import { cn } from "../lib/cn"; |
| 4 | 4 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; |
| 5 | import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; | |
| 5 | import { useMediaActions } from "../state/mediaActions"; | |
| 6 | import { type MediaActionsSource } from "../lib/mediaActionsSource"; | |
| 6 | 7 | import { SymbolView } from "./AppSymbol"; |
| 7 | 8 | import { AppText } from "./AppText"; |
| 8 | 9 | import { MediaActionsMenu } from "./MediaActionsMenu"; |
apps/mobile/src/components/VideoPreviewModal.tsx+2-1| ... | ... | @@ -5,7 +5,8 @@ import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-nativ |
| 5 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 6 | |
| 7 | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; | |
| 8 | import { useMediaActions } from "../state/mediaActions"; | |
| 9 | import { type MediaActionsSource } from "../lib/mediaActionsSource"; | |
| 9 | 10 | import { |
| 10 | 11 | mediaVideoPreviewUri, |
| 11 | 12 | mediaVideoThumbnailKey, |
apps/mobile/src/dependency-graph.test.ts+1-1| ... | ... | @@ -274,7 +274,7 @@ describe("mobile dependency graph", () => { |
| 274 | 274 | ["native", "features", 8, "native must not add imports from features"], |
| 275 | 275 | // lib -> state: attachment/session plumbing that predates the cycle |
| 276 | 276 | // cleanup; each remaining edge needs a real owner-side seam. |
| 277 | ["lib", "state", 11, "lib must not add imports from state"], | |
| 277 | ["lib", "state", 10, "lib must not add imports from state"], | |
| 278 | 278 | ]; |
| 279 | 279 | |
| 280 | 280 | for (const [from, to, ceiling, message] of ceilings) { |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+55-8| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | |
| 2 | import { environmentSession } from "../../state/session"; | |
| 1 | 3 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | 4 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | 5 | import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; |
| ... | ... | @@ -24,10 +26,12 @@ import { isPdfFile } from "../../lib/filePreview"; |
| 24 | 26 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 25 | 27 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 26 | 28 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; |
| 27 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | |
| 29 | import { useMediaActions } from "../../state/mediaActions"; | |
| 30 | import { type MediaActionsSource } from "../../lib/mediaActionsSource"; | |
| 28 | 31 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 29 | 32 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 30 | 33 | import { useEnvironmentQuery } from "../../state/query"; |
| 34 | import { useEnvironmentPresentation } from "../../state/presentation"; | |
| 31 | 35 | import { projectEnvironment } from "../../state/projects"; |
| 32 | 36 | import type { AssetUrlFailureReason } from "../../state/asset-url-state"; |
| 33 | 37 | import { |
| ... | ... | @@ -370,14 +374,15 @@ function useThreadFilesWorkspace(params: { |
| 370 | 374 | }; |
| 371 | 375 | } |
| 372 | 376 | |
| 373 | function FilesUnavailable() { | |
| 377 | function FilesUnavailable({ | |
| 378 | detail = "This thread does not have an active workspace path.", | |
| 379 | }: { | |
| 380 | detail?: string; | |
| 381 | }) { | |
| 374 | 382 | return ( |
| 375 | 383 | <View className="flex-1 items-center justify-center bg-sheet px-6"> |
| 376 | 384 | <NativeStackScreenOptions options={{ title: "Files" }} /> |
| 377 | <EmptyState | |
| 378 | title="Files unavailable" | |
| 379 | detail="This thread does not have an active workspace path." | |
| 380 | /> | |
| 385 | <EmptyState title="Files unavailable" detail={detail} /> | |
| 381 | 386 | </View> |
| 382 | 387 | ); |
| 383 | 388 | } |
| ... | ... | @@ -420,9 +425,20 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 420 | 425 | props.route.params, |
| 421 | 426 | ); |
| 422 | 427 | const revealedInspectorRef = useRef(false); |
| 428 | const fileAccessSession = useEnvironmentQuery( | |
| 429 | environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, | |
| 430 | ); | |
| 431 | const fileEnvironment = useEnvironmentPresentation(environmentId); | |
| 432 | const fileAccess = resolveFilesystemReadAccess({ | |
| 433 | isCatalogReady: fileEnvironment.isReady, | |
| 434 | connection: fileEnvironment.presentation?.connection ?? null, | |
| 435 | session: fileAccessSession.data, | |
| 436 | sessionError: fileAccessSession.error, | |
| 437 | }); | |
| 438 | const { canReadFiles } = fileAccess; | |
| 423 | 439 | const entriesQuery = useFileTreeEntries({ |
| 424 | 440 | environmentId, |
| 425 | cwd: fileInspector.supported ? null : cwd, | |
| 441 | cwd: !canReadFiles || fileInspector.supported ? null : cwd, | |
| 426 | 442 | searchQuery, |
| 427 | 443 | }); |
| 428 | 444 | const handleReturnToThread = useCallback(() => { |
| ... | ... | @@ -509,6 +525,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 509 | 525 | return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />; |
| 510 | 526 | } |
| 511 | 527 | |
| 528 | if (!canReadFiles) { | |
| 529 | if (fileAccess.isPending) { | |
| 530 | return <LoadingScreen message="Checking file access..." messagePlacement="above-spinner" />; | |
| 531 | } | |
| 532 | return ( | |
| 533 | <FilesUnavailable detail={fileAccess.error ?? "This connection cannot read host files."} /> | |
| 534 | ); | |
| 535 | } | |
| 512 | 536 | if (cwd === null) { |
| 513 | 537 | return <FilesUnavailable />; |
| 514 | 538 | } |
| ... | ... | @@ -656,8 +680,23 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 656 | 680 | !isVideoFile && |
| 657 | 681 | !isAudioFile && |
| 658 | 682 | (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); |
| 683 | const fileAccessSession = useEnvironmentQuery( | |
| 684 | environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, | |
| 685 | ); | |
| 686 | const fileEnvironment = useEnvironmentPresentation(environmentId); | |
| 687 | const fileAccess = resolveFilesystemReadAccess({ | |
| 688 | isCatalogReady: fileEnvironment.isReady, | |
| 689 | connection: fileEnvironment.presentation?.connection ?? null, | |
| 690 | session: fileAccessSession.data, | |
| 691 | sessionError: fileAccessSession.error, | |
| 692 | }); | |
| 693 | const { canReadFiles } = fileAccess; | |
| 659 | 694 | const fileQuery = useEnvironmentQuery( |
| 660 | environmentId !== null && cwd !== null && relativePath !== null && needsFileContents | |
| 695 | canReadFiles && | |
| 696 | environmentId !== null && | |
| 697 | cwd !== null && | |
| 698 | relativePath !== null && | |
| 699 | needsFileContents | |
| 661 | 700 | ? projectEnvironment.readFile({ |
| 662 | 701 | environmentId, |
| 663 | 702 | input: { cwd, relativePath }, |
| ... | ... | @@ -861,6 +900,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 861 | 900 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; |
| 862 | 901 | } |
| 863 | 902 | |
| 903 | if (!canReadFiles) { | |
| 904 | if (fileAccess.isPending) { | |
| 905 | return <LoadingScreen message="Checking file access..." messagePlacement="above-spinner" />; | |
| 906 | } | |
| 907 | return ( | |
| 908 | <FilesUnavailable detail={fileAccess.error ?? "This connection cannot read host files."} /> | |
| 909 | ); | |
| 910 | } | |
| 864 | 911 | if (cwd === null) { |
| 865 | 912 | return <FilesUnavailable />; |
| 866 | 913 | } |
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+2-1| ... | ... | @@ -5,7 +5,8 @@ import { FilePreviewLoading } from "./FilePreviewFeedback"; |
| 5 | 5 | import { EmptyState } from "../../components/EmptyState"; |
| 6 | 6 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 7 | 7 | import { PresentationSource } from "../../components/NativePresentation"; |
| 8 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | |
| 8 | import { useMediaActions } from "../../state/mediaActions"; | |
| 9 | import { type MediaActionsSource } from "../../lib/mediaActionsSource"; | |
| 9 | 10 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; |
| 10 | 11 | |
| 11 | 12 | function ResolvedWorkspaceFileImagePreview(props: { |
apps/mobile/src/features/files/preload-workspace-file.ts+3| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthFilesystemReadScope } from "@t3tools/contracts"; | |
| 2 | import { readEnvironmentScope } from "../../state/session"; | |
| 1 | 3 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 2 | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 5 | import { |
| ... | ... | @@ -30,6 +32,7 @@ export function preloadWorkspaceFileContents(input: { |
| 30 | 32 | readonly theme: ReviewDiffTheme; |
| 31 | 33 | }): void { |
| 32 | 34 | if ( |
| 35 | !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope) || | |
| 33 | 36 | isWorkspaceBrowserPreviewPath(input.relativePath) || |
| 34 | 37 | isWorkspaceImagePreviewPath(input.relativePath) || |
| 35 | 38 | isVideoPreviewFile(input.relativePath) |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+24-3| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | |
| 2 | import { environmentSession } from "../../state/session"; | |
| 1 | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | 5 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| ... | ... | @@ -15,6 +17,8 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App |
| 15 | 17 | import { MaterialFilesHeader } from "./MaterialFilesHeader"; |
| 16 | 18 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 17 | 19 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 20 | import { useEnvironmentQuery } from "../../state/query"; | |
| 21 | import { useEnvironmentPresentation } from "../../state/presentation"; | |
| 18 | 22 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 19 | 23 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 20 | 24 | import { useFileTreeEntries } from "./useFileTreeEntries"; |
| ... | ... | @@ -36,9 +40,20 @@ export function ThreadFileNavigatorPane(props: { |
| 36 | 40 | const foregroundColor = theme["--color-foreground"]; |
| 37 | 41 | const sheetColor = theme["--color-sheet"]; |
| 38 | 42 | const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 43 | const fileAccessSession = useEnvironmentQuery( | |
| 44 | environmentSession.sessionStateAtom(props.environmentId), | |
| 45 | ); | |
| 46 | const fileEnvironment = useEnvironmentPresentation(props.environmentId); | |
| 47 | const fileAccess = resolveFilesystemReadAccess({ | |
| 48 | isCatalogReady: fileEnvironment.isReady, | |
| 49 | connection: fileEnvironment.presentation?.connection ?? null, | |
| 50 | session: fileAccessSession.data, | |
| 51 | sessionError: fileAccessSession.error, | |
| 52 | }); | |
| 53 | const { canReadFiles } = fileAccess; | |
| 39 | 54 | const entriesQuery = useFileTreeEntries({ |
| 40 | 55 | environmentId: props.environmentId, |
| 41 | cwd: props.cwd, | |
| 56 | cwd: canReadFiles ? props.cwd : null, | |
| 42 | 57 | searchQuery, |
| 43 | 58 | }); |
| 44 | 59 | const handlePreviewFile = useCallback( |
| ... | ... | @@ -75,8 +90,14 @@ export function ThreadFileNavigatorPane(props: { |
| 75 | 90 | entries={entriesQuery.entries} |
| 76 | 91 | loadedDirectories={entriesQuery.loadedDirectories} |
| 77 | 92 | onLoadDirectory={entriesQuery.loadDirectory} |
| 78 | error={entriesQuery.error} | |
| 79 | isPending={entriesQuery.isPending} | |
| 93 | error={ | |
| 94 | canReadFiles | |
| 95 | ? entriesQuery.error | |
| 96 | : fileAccess.isPending | |
| 97 | ? null | |
| 98 | : (fileAccess.error ?? "This connection cannot read host files.") | |
| 99 | } | |
| 100 | isPending={fileAccess.isPending || entriesQuery.isPending} | |
| 80 | 101 | searchQuery={searchQuery} |
| 81 | 102 | searchTruncated={entriesQuery.searchTruncated} |
| 82 | 103 | selectedPath={props.selectedPath} |
apps/mobile/src/features/projects/AddProjectScreen.tsx+25-4| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | createBrowseNavigationCoordinator, |
| 33 | 33 | filterFilesystemBrowseEntries, |
| 34 | 34 | getFilesystemBrowsePath, |
| 35 | resolveFilesystemReadAccess, | |
| 35 | 36 | } from "@t3tools/client-runtime/state/filesystem"; |
| 36 | 37 | import { |
| 37 | 38 | appendBrowsePathSegment, |
| ... | ... | @@ -41,6 +42,7 @@ import { |
| 41 | 42 | import { |
| 42 | 43 | AuthOrchestrationOperateScope, |
| 43 | 44 | AuthSourceControlWriteScope, |
| 45 | AuthFilesystemReadScope, | |
| 44 | 46 | CommandId, |
| 45 | 47 | type EnvironmentId, |
| 46 | 48 | type EnvironmentMachineKind, |
| ... | ... | @@ -61,7 +63,8 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit |
| 61 | 63 | import { filesystemEnvironment } from "../../state/filesystem"; |
| 62 | 64 | import { projectEnvironment } from "../../state/projects"; |
| 63 | 65 | import { useEnvironmentQuery } from "../../state/query"; |
| 64 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 66 | import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session"; | |
| 67 | import { useEnvironmentPresentation } from "../../state/presentation"; | |
| 65 | 68 | import { sourceControlEnvironment } from "../../state/sourceControl"; |
| 66 | 69 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 67 | 70 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| ... | ... | @@ -345,7 +348,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto |
| 345 | 348 | setIsBrowseNavigating(true); |
| 346 | 349 | const committed = await browseNavigation.run( |
| 347 | 350 | async () => { |
| 348 | if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { | |
| 351 | if ( | |
| 352 | environment && | |
| 353 | readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) && | |
| 354 | canPreloadBrowsePath(environmentRuntime?.connectionState) | |
| 355 | ) { | |
| 349 | 356 | await loadBrowsePath({ |
| 350 | 357 | environmentId: environment.environmentId, |
| 351 | 358 | input: { partialPath: selectedDirectoryPath }, |
| ... | ... | @@ -877,8 +884,19 @@ function FolderBrowser(props: { |
| 877 | 884 | () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), |
| 878 | 885 | [browsePath.directoryPath], |
| 879 | 886 | ); |
| 887 | const fileAccessSession = useEnvironmentQuery( | |
| 888 | environmentSession.sessionStateAtom(props.environment.environmentId), | |
| 889 | ); | |
| 890 | const fileEnvironment = useEnvironmentPresentation(props.environment.environmentId); | |
| 891 | const fileAccess = resolveFilesystemReadAccess({ | |
| 892 | isCatalogReady: fileEnvironment.isReady, | |
| 893 | connection: fileEnvironment.presentation?.connection ?? null, | |
| 894 | session: fileAccessSession.data, | |
| 895 | sessionError: fileAccessSession.error, | |
| 896 | }); | |
| 897 | const { canReadFiles } = fileAccess; | |
| 880 | 898 | const browseState = useEnvironmentQuery( |
| 881 | browseInput === null | |
| 899 | !canReadFiles || browseInput === null | |
| 882 | 900 | ? null |
| 883 | 901 | : filesystemEnvironment.browse({ |
| 884 | 902 | environmentId: props.environment.environmentId, |
| ... | ... | @@ -900,9 +918,12 @@ function FolderBrowser(props: { |
| 900 | 918 | return ( |
| 901 | 919 | <> |
| 902 | 920 | <SectionTitle>Browse folders</SectionTitle> |
| 921 | {!canReadFiles && !fileAccess.isPending ? ( | |
| 922 | <ErrorBanner message={fileAccess.error ?? "This connection cannot browse host folders."} /> | |
| 923 | ) : null} | |
| 903 | 924 | {browseState.error ? <ErrorBanner message={browseState.error} /> : null} |
| 904 | 925 | <ListSection> |
| 905 | {browseState.isPending && browseState.data === null ? ( | |
| 926 | {fileAccess.isPending || (browseState.isPending && browseState.data === null) ? ( | |
| 906 | 927 | <View className="items-center py-5"> |
| 907 | 928 | <ActivityIndicator colorClassName="accent-icon-muted" /> |
| 908 | 929 | </View> |
apps/mobile/src/features/review/ReviewSheet.tsx+18-16| ... | ... | @@ -825,23 +825,25 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 825 | 825 | > |
| 826 | 826 | {listHeader} |
| 827 | 827 | {!selectedSection ? ( |
| 828 | <View | |
| 829 | className={ | |
| 830 | Platform.OS === "android" | |
| 831 | ? "items-center px-6 py-5" | |
| 832 | : "border-b border-border bg-card px-4 py-5" | |
| 833 | } | |
| 834 | > | |
| 835 | <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text> | |
| 836 | <Text | |
| 837 | className={cn( | |
| 838 | "text-xs leading-normal text-foreground-muted", | |
| 839 | Platform.OS === "android" && "mt-2 text-center", | |
| 840 | )} | |
| 828 | error ? null : ( | |
| 829 | <View | |
| 830 | className={ | |
| 831 | Platform.OS === "android" | |
| 832 | ? "items-center px-6 py-5" | |
| 833 | : "border-b border-border bg-card px-4 py-5" | |
| 834 | } | |
| 841 | 835 | > |
| 842 | This thread has no ready turn diffs and the worktree diff is empty. | |
| 843 | </Text> | |
| 844 | </View> | |
| 836 | <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text> | |
| 837 | <Text | |
| 838 | className={cn( | |
| 839 | "text-xs leading-normal text-foreground-muted", | |
| 840 | Platform.OS === "android" && "mt-2 text-center", | |
| 841 | )} | |
| 842 | > | |
| 843 | This thread has no ready turn diffs and the worktree diff is empty. | |
| 844 | </Text> | |
| 845 | </View> | |
| 846 | ) | |
| 845 | 847 | ) : selectedSection.isLoading && selectedSection.diff === null ? ( |
| 846 | 848 | <View |
| 847 | 849 | className={cn( |
apps/mobile/src/features/review/useReviewSections.test.ts created+210| ... | ... | @@ -0,0 +1,210 @@ |
| 1 | import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; | |
| 2 | import { | |
| 3 | AuthFilesystemReadScope, | |
| 4 | CheckpointRef, | |
| 5 | EnvironmentId, | |
| 6 | MessageId, | |
| 7 | ThreadId, | |
| 8 | RunId, | |
| 9 | type AuthSessionState, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 12 | ||
| 13 | const state = vi.hoisted(() => ({ | |
| 14 | session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null, | |
| 15 | sessionError: null as string | null, | |
| 16 | sessionAtom: {}, | |
| 17 | effects: [] as Array<() => void>, | |
| 18 | checkpoints: [] as ReadonlyArray<ThreadCheckpointSummary>, | |
| 19 | })); | |
| 20 | ||
| 21 | vi.mock("@t3tools/client-runtime/state/thread-checkpoints", () => ({ | |
| 22 | deriveThreadCheckpointSummaries: () => state.checkpoints, | |
| 23 | })); | |
| 24 | vi.mock("react", () => ({ | |
| 25 | useCallback: <A>(callback: A) => callback, | |
| 26 | useEffect: (effect: () => void) => state.effects.push(effect), | |
| 27 | useMemo: <A>(factory: () => A) => factory(), | |
| 28 | })); | |
| 29 | vi.mock("../../state/session", () => ({ | |
| 30 | environmentSession: { sessionStateAtom: () => state.sessionAtom }, | |
| 31 | })); | |
| 32 | vi.mock("../../state/presentation", () => ({ | |
| 33 | useEnvironmentPresentation: () => ({ | |
| 34 | isReady: true, | |
| 35 | presentation: { connection: { phase: "connected", error: null } }, | |
| 36 | }), | |
| 37 | })); | |
| 38 | vi.mock("../../state/use-thread-detail", () => ({ | |
| 39 | useSelectedThreadProjection: () => ({ projection: {} }), | |
| 40 | })); | |
| 41 | vi.mock("../../state/use-selected-thread-worktree", () => ({ | |
| 42 | useSelectedThreadWorktree: () => ({ selectedThreadCwd: "/repo" }), | |
| 43 | })); | |
| 44 | vi.mock("../../state/query", () => ({ | |
| 45 | useEnvironmentQuery: (atom: unknown) => ({ | |
| 46 | data: atom === state.sessionAtom ? state.session : null, | |
| 47 | error: atom === state.sessionAtom ? state.sessionError : null, | |
| 48 | isPending: atom === state.sessionAtom && state.session === null, | |
| 49 | refresh: vi.fn(), | |
| 50 | }), | |
| 51 | })); | |
| 52 | vi.mock("../../state/queries", () => ({ | |
| 53 | useCheckpointDiff: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), | |
| 54 | })); | |
| 55 | vi.mock("../../state/review", () => ({ | |
| 56 | reviewEnvironment: { diffPreview: vi.fn() }, | |
| 57 | })); | |
| 58 | vi.mock("./reviewState", () => ({ | |
| 59 | setReviewAsyncError: vi.fn(), | |
| 60 | setReviewGitSections: vi.fn(), | |
| 61 | setReviewSelectedSectionId: vi.fn(), | |
| 62 | setReviewTurnDiff: vi.fn(), | |
| 63 | setReviewTurnDiffLoading: vi.fn(), | |
| 64 | })); | |
| 65 | ||
| 66 | import { setReviewSelectedSectionId, type ReviewCacheForThread } from "./reviewState"; | |
| 67 | import { useReviewSections } from "./useReviewSections"; | |
| 68 | ||
| 69 | beforeEach(() => { | |
| 70 | vi.clearAllMocks(); | |
| 71 | state.effects = []; | |
| 72 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 73 | state.sessionError = null; | |
| 74 | state.checkpoints = [ | |
| 75 | { | |
| 76 | runId: RunId.make("turn-1"), | |
| 77 | checkpointTurnCount: 1, | |
| 78 | checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread/1"), | |
| 79 | status: "ready", | |
| 80 | files: [], | |
| 81 | assistantMessageId: MessageId.make("message-1"), | |
| 82 | completedAt: "2026-04-01T00:00:00.000Z", | |
| 83 | }, | |
| 84 | ]; | |
| 85 | }); | |
| 86 | ||
| 87 | const checkpointDiff = "diff --git a/checkpoint.ts b/checkpoint.ts"; | |
| 88 | const localDiff = "diff --git a/local.ts b/local.ts"; | |
| 89 | function makeReviewCache( | |
| 90 | kind: "working-tree" | "branch-range" = "working-tree", | |
| 91 | ): ReviewCacheForThread { | |
| 92 | return { | |
| 93 | threadKey: "environment:thread", | |
| 94 | gitSections: [ | |
| 95 | { | |
| 96 | id: kind, | |
| 97 | kind, | |
| 98 | title: "Dirty worktree", | |
| 99 | baseRef: "HEAD", | |
| 100 | headRef: null, | |
| 101 | diff: localDiff, | |
| 102 | diffHash: "cached-local", | |
| 103 | truncated: false, | |
| 104 | }, | |
| 105 | ], | |
| 106 | turnDiffById: { "turn:1": checkpointDiff }, | |
| 107 | selectedSectionId: `git:${kind}`, | |
| 108 | asyncState: { loadingTurnIds: {}, error: null }, | |
| 109 | expandedFileIdsBySection: {}, | |
| 110 | revealedLargeFileIdsBySection: {}, | |
| 111 | viewedFileIdsBySection: {}, | |
| 112 | }; | |
| 113 | } | |
| 114 | ||
| 115 | function makeInput(reviewCache = makeReviewCache()) { | |
| 116 | return { | |
| 117 | environmentId: EnvironmentId.make("environment"), | |
| 118 | threadId: ThreadId.make("thread"), | |
| 119 | reviewCache, | |
| 120 | }; | |
| 121 | } | |
| 122 | ||
| 123 | function renderSections(input: ReturnType<typeof makeInput>) { | |
| 124 | const result = useReviewSections(input); | |
| 125 | const effects = state.effects.splice(0); | |
| 126 | effects.forEach((effect) => effect()); | |
| 127 | return result; | |
| 128 | } | |
| 129 | ||
| 130 | it("hides cached local diffs after file access is lost while retaining checkpoint diffs", () => { | |
| 131 | const input = makeInput(); | |
| 132 | expect(renderSections(input).selectedSection?.diff).toBe(localDiff); | |
| 133 | ||
| 134 | state.session = { authenticated: true, scopes: [] }; | |
| 135 | const denied = renderSections(input); | |
| 136 | expect(denied.reviewSections.map((section) => section.id)).toEqual(["turn:1"]); | |
| 137 | expect(denied.selectedSection?.diff).toBe(checkpointDiff); | |
| 138 | ||
| 139 | expect(setReviewSelectedSectionId).toHaveBeenCalledWith("environment:thread", "turn:1"); | |
| 140 | }); | |
| 141 | ||
| 142 | it.each(["working-tree", "branch-range"] as const)( | |
| 143 | "preserves a cached %s selection while an expired grant reloads", | |
| 144 | (kind) => { | |
| 145 | const input = makeInput(makeReviewCache(kind)); | |
| 146 | expect(renderSections(input).selectedSection?.diff).toBe(localDiff); | |
| 147 | ||
| 148 | state.session = null; | |
| 149 | const pending = renderSections(input); | |
| 150 | expect(pending.selectedSection).toEqual( | |
| 151 | expect.objectContaining({ id: `git:${kind}`, diff: null, isLoading: true }), | |
| 152 | ); | |
| 153 | expect(pending.loadingGitDiffs).toBe(true); | |
| 154 | expect(pending.reviewSections.find((section) => section.id === "turn:1")?.diff).toBe( | |
| 155 | checkpointDiff, | |
| 156 | ); | |
| 157 | expect(setReviewSelectedSectionId).not.toHaveBeenCalled(); | |
| 158 | ||
| 159 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 160 | expect(renderSections(input).selectedSection).toEqual( | |
| 161 | expect.objectContaining({ id: `git:${kind}`, diff: localDiff, isLoading: false }), | |
| 162 | ); | |
| 163 | expect(setReviewSelectedSectionId).not.toHaveBeenCalled(); | |
| 164 | }, | |
| 165 | ); | |
| 166 | ||
| 167 | it("falls back to a checkpoint when a pending grant resolves without file access", () => { | |
| 168 | const input = makeInput(); | |
| 169 | state.session = null; | |
| 170 | renderSections(input); | |
| 171 | expect(setReviewSelectedSectionId).not.toHaveBeenCalled(); | |
| 172 | ||
| 173 | state.session = { authenticated: true, scopes: [] }; | |
| 174 | const denied = renderSections(input); | |
| 175 | expect(denied.reviewSections.map((section) => section.id)).toEqual(["turn:1"]); | |
| 176 | expect(denied.selectedSection?.diff).toBe(checkpointDiff); | |
| 177 | expect(setReviewSelectedSectionId).toHaveBeenCalledWith("environment:thread", "turn:1"); | |
| 178 | }); | |
| 179 | ||
| 180 | it("reports a failed access check when no checkpoint can replace the local review", () => { | |
| 181 | state.checkpoints = []; | |
| 182 | const input = makeInput(); | |
| 183 | expect(renderSections(input).selectedSection?.diff).toBe(localDiff); | |
| 184 | ||
| 185 | state.sessionError = "The session request timed out."; | |
| 186 | const unavailable = renderSections(input); | |
| 187 | expect(unavailable.selectedSection).toBeNull(); | |
| 188 | expect(unavailable.reviewSections).toEqual([]); | |
| 189 | expect(unavailable.error).toBe(state.sessionError); | |
| 190 | }); | |
| 191 | ||
| 192 | it("reports denied file access when no checkpoint can replace the local review", () => { | |
| 193 | state.checkpoints = []; | |
| 194 | const input = makeInput(); | |
| 195 | expect(renderSections(input).selectedSection?.diff).toBe(localDiff); | |
| 196 | ||
| 197 | state.session = { authenticated: true, scopes: [] }; | |
| 198 | const denied = renderSections(input); | |
| 199 | expect(denied.selectedSection).toBeNull(); | |
| 200 | expect(denied.reviewSections).toEqual([]); | |
| 201 | expect(denied.loadingGitDiffs).toBe(false); | |
| 202 | expect(denied.error).toBe("This connection cannot read local diffs."); | |
| 203 | }); | |
| 204 | ||
| 205 | it("keeps a cached checkpoint available when the filesystem access check fails", () => { | |
| 206 | state.sessionError = "The session request timed out."; | |
| 207 | const checkpoint = renderSections(makeInput()); | |
| 208 | expect(checkpoint.selectedSection?.diff).toBe(checkpointDiff); | |
| 209 | expect(checkpoint.error).toBeNull(); | |
| 210 | }); |
apps/mobile/src/features/review/useReviewSections.ts+49-20| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | |
| 2 | import { environmentSession } from "../../state/session"; | |
| 1 | 3 | import { useCallback, useEffect, useMemo } from "react"; |
| 2 | 4 | import * as DateTime from "effect/DateTime"; |
| 3 | 5 | |
| ... | ... | @@ -9,6 +11,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 9 | 11 | |
| 10 | 12 | import { useCheckpointDiff } from "../../state/queries"; |
| 11 | 13 | import { useEnvironmentQuery } from "../../state/query"; |
| 14 | import { useEnvironmentPresentation } from "../../state/presentation"; | |
| 12 | 15 | import { reviewEnvironment } from "../../state/review"; |
| 13 | 16 | import { useSelectedThreadProjection } from "../../state/use-thread-detail"; |
| 14 | 17 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| ... | ... | @@ -35,10 +38,21 @@ export function useReviewSections(input: { |
| 35 | 38 | }) { |
| 36 | 39 | const { environmentId, reviewCache, threadId } = input; |
| 37 | 40 | const enabled = input.enabled ?? true; |
| 41 | const fileAccessSession = useEnvironmentQuery( | |
| 42 | environmentId === undefined ? null : environmentSession.sessionStateAtom(environmentId), | |
| 43 | ); | |
| 44 | const fileEnvironment = useEnvironmentPresentation(environmentId ?? null); | |
| 45 | const fileAccess = resolveFilesystemReadAccess({ | |
| 46 | isCatalogReady: fileEnvironment.isReady, | |
| 47 | connection: fileEnvironment.presentation?.connection ?? null, | |
| 48 | session: fileAccessSession.data, | |
| 49 | sessionError: fileAccessSession.error, | |
| 50 | }); | |
| 51 | const { canReadFiles } = fileAccess; | |
| 38 | 52 | const selectedThread = useSelectedThreadProjection(); |
| 39 | 53 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 40 | 54 | const diffPreview = useEnvironmentQuery( |
| 41 | enabled && environmentId !== undefined && selectedThreadCwd !== null | |
| 55 | canReadFiles && enabled && environmentId !== undefined && selectedThreadCwd !== null | |
| 42 | 56 | ? reviewEnvironment.diffPreview({ |
| 43 | 57 | environmentId, |
| 44 | 58 | input: { cwd: selectedThreadCwd }, |
| ... | ... | @@ -70,24 +84,33 @@ export function useReviewSections(input: { |
| 70 | 84 | ) as Record<string, ThreadCheckpointSummary>, |
| 71 | 85 | [readyCheckpoints], |
| 72 | 86 | ); |
| 73 | const reviewSections = useMemo( | |
| 74 | () => | |
| 75 | buildReviewSectionItems({ | |
| 76 | checkpoints: readyCheckpoints, | |
| 77 | gitSections: diffPreview.data?.sources ?? reviewCache.gitSections, | |
| 78 | turnDiffById: reviewCache.turnDiffById, | |
| 79 | loadingTurnIds, | |
| 80 | loadingGitSections: diffPreview.isPending, | |
| 81 | }), | |
| 82 | [ | |
| 83 | diffPreview.isPending, | |
| 84 | diffPreview.data?.sources, | |
| 87 | const reviewSections = useMemo(() => { | |
| 88 | const sections = buildReviewSectionItems({ | |
| 89 | checkpoints: readyCheckpoints, | |
| 90 | gitSections: | |
| 91 | canReadFiles || fileAccess.isPending | |
| 92 | ? (diffPreview.data?.sources ?? reviewCache.gitSections) | |
| 93 | : [], | |
| 94 | turnDiffById: reviewCache.turnDiffById, | |
| 85 | 95 | loadingTurnIds, |
| 86 | readyCheckpoints, | |
| 87 | reviewCache.gitSections, | |
| 88 | reviewCache.turnDiffById, | |
| 89 | ], | |
| 90 | ); | |
| 96 | loadingGitSections: fileAccess.isPending || diffPreview.isPending, | |
| 97 | }); | |
| 98 | // Keep the selected section while its grant loads, without displaying cached host files. | |
| 99 | return fileAccess.isPending | |
| 100 | ? sections.map((section) => | |
| 101 | section.kind === "turn" ? section : { ...section, diff: null, isLoading: true }, | |
| 102 | ) | |
| 103 | : sections; | |
| 104 | }, [ | |
| 105 | canReadFiles, | |
| 106 | diffPreview.data?.sources, | |
| 107 | diffPreview.isPending, | |
| 108 | fileAccess.isPending, | |
| 109 | loadingTurnIds, | |
| 110 | readyCheckpoints, | |
| 111 | reviewCache.gitSections, | |
| 112 | reviewCache.turnDiffById, | |
| 113 | ]); | |
| 91 | 114 | const selectedSection = useMemo( |
| 92 | 115 | () => |
| 93 | 116 | reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? |
| ... | ... | @@ -181,10 +204,16 @@ export function useReviewSections(input: { |
| 181 | 204 | ); |
| 182 | 205 | |
| 183 | 206 | return { |
| 184 | error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, | |
| 207 | error: | |
| 208 | diffPreview.error ?? | |
| 209 | activeTurnDiff.error ?? | |
| 210 | reviewCache.asyncState.error ?? | |
| 211 | (selectedSection === null && !fileAccess.isPending && !canReadFiles | |
| 212 | ? (fileAccess.error ?? "This connection cannot read local diffs.") | |
| 213 | : null), | |
| 185 | 214 | isSelectedSectionPending: |
| 186 | 215 | selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending, |
| 187 | loadingGitDiffs: diffPreview.isPending, | |
| 216 | loadingGitDiffs: fileAccess.isPending || diffPreview.isPending, | |
| 188 | 217 | diffPreviewRevision: diffPreview.data |
| 189 | 218 | ? DateTime.formatIso(diffPreview.data.generatedAt) |
| 190 | 219 | : undefined, |
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+2-1| ... | ... | @@ -13,7 +13,8 @@ import { AppText as Text } from "../../components/AppText"; |
| 13 | 13 | import type { FilePreviewSource } from "../../components/FilePreviewModal"; |
| 14 | 14 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; |
| 15 | 15 | import { PresentationSource } from "../../components/NativePresentation"; |
| 16 | import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; | |
| 16 | import { useMediaActions } from "../../state/mediaActions"; | |
| 17 | import { type MediaActionsSource } from "../../lib/mediaActionsSource"; | |
| 17 | 18 | import { useAssetUrlState } from "../../state/assets"; |
| 18 | 19 | import { |
| 19 | 20 | MARKDOWN_IMAGE_MAX_WIDTH, |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+20-2| ... | ... | @@ -1,3 +1,6 @@ |
| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | |
| 2 | import { useEnvironmentPresentation } from "../../state/presentation"; | |
| 3 | import { environmentSession } from "../../state/session"; | |
| 1 | 4 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | 5 | import { Alert } from "react-native"; |
| 3 | 6 | |
| ... | ... | @@ -485,8 +488,23 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 485 | 488 | // Default mode until the user picks one explicitly — same resolution web |
| 486 | 489 | // uses for new draft threads: per-project setting, then the repo's |
| 487 | 490 | // checked-in t3.json, then the server's configured default. |
| 488 | const t3ProjectFileQuery = useEnvironmentQuery( | |
| 491 | const fileAccessSession = useEnvironmentQuery( | |
| 489 | 492 | selectedProject !== null && selectedProject.workspaceRoot !== "" |
| 493 | ? environmentSession.sessionStateAtom(selectedProject.environmentId) | |
| 494 | : null, | |
| 495 | ); | |
| 496 | const fileEnvironment = useEnvironmentPresentation(selectedProject?.environmentId ?? null); | |
| 497 | const fileAccess = resolveFilesystemReadAccess({ | |
| 498 | isCatalogReady: fileEnvironment.isReady, | |
| 499 | connection: fileEnvironment.presentation?.connection ?? null, | |
| 500 | session: fileAccessSession.data, | |
| 501 | sessionError: fileAccessSession.error, | |
| 502 | }); | |
| 503 | const { canReadFiles } = fileAccess; | |
| 504 | const fileAccessPending = | |
| 505 | selectedProject !== null && selectedProject.workspaceRoot !== "" && fileAccess.isPending; | |
| 506 | const t3ProjectFileQuery = useEnvironmentQuery( | |
| 507 | canReadFiles && selectedProject !== null && selectedProject.workspaceRoot !== "" | |
| 490 | 508 | ? projectEnvironment.readFile({ |
| 491 | 509 | environmentId: selectedProject.environmentId, |
| 492 | 510 | input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, |
| ... | ... | @@ -527,7 +545,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 527 | 545 | const defaultWorkspaceModeSettled = |
| 528 | 546 | selectedProjectDraft.workspaceSelection?.mode !== undefined || |
| 529 | 547 | projectSettings.sources.defaultThreadEnvMode !== "environment" || |
| 530 | !t3ProjectFileQuery.isPending; | |
| 548 | (!t3ProjectFileQuery.isPending && !fileAccessPending); | |
| 531 | 549 | const workspaceMode = canChooseWorkspace |
| 532 | 550 | ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode) |
| 533 | 551 | : "local"; |
apps/mobile/src/lib/markdownMedia.ts+1-1| ... | ... | @@ -4,7 +4,7 @@ import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links" |
| 4 | 4 | |
| 5 | 5 | import type { FilePreviewSource } from "../components/FilePreviewModal"; |
| 6 | 6 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; |
| 7 | import type { MediaActionsSource } from "./mediaActions"; | |
| 7 | import type { MediaActionsSource } from "./mediaActionsSource"; | |
| 8 | 8 | |
| 9 | 9 | /** Resolves only explicit media references. Ordinary links keep their existing navigation. */ |
| 10 | 10 | export function resolveMarkdownMediaPreview( |
apps/mobile/src/lib/mediaActions.test.ts created+266| ... | ... | @@ -0,0 +1,266 @@ |
| 1 | import { | |
| 2 | AuthFilesystemReadScope, | |
| 3 | EnvironmentId, | |
| 4 | ThreadId, | |
| 5 | type AuthSessionState, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { AsyncResult } from "effect/reactivity"; | |
| 8 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 9 | ||
| 10 | const state = vi.hoisted(() => ({ | |
| 11 | sessions: new Map<string, Pick<AuthSessionState, "authenticated" | "scopes">>(), | |
| 12 | refresh: vi.fn(), | |
| 13 | download: vi.fn(), | |
| 14 | shareLocal: vi.fn(), | |
| 15 | shareDraft: vi.fn(), | |
| 16 | navigate: vi.fn(), | |
| 17 | copy: vi.fn(), | |
| 18 | })); | |
| 19 | ||
| 20 | vi.mock("react", () => ({ | |
| 21 | useEffect: () => {}, | |
| 22 | useRef: <A>(current: A) => ({ current }), | |
| 23 | useState: <A>(initial: A) => [initial, () => {}], | |
| 24 | })); | |
| 25 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); | |
| 26 | vi.mock("@react-navigation/native", () => ({ | |
| 27 | useNavigation: () => ({ navigate: state.navigate }), | |
| 28 | })); | |
| 29 | vi.mock("@t3tools/mobile-markdown-text/links", () => ({ | |
| 30 | normalizeNativeMarkdownUrl: (uri: string) => uri, | |
| 31 | })); | |
| 32 | vi.mock("../state/assets", () => ({ | |
| 33 | useRefreshAssetUrl: (environmentId: string, resource: unknown) => () => | |
| 34 | state.refresh(environmentId, resource), | |
| 35 | })); | |
| 36 | vi.mock("../state/session", () => ({ | |
| 37 | environmentSession: { sessionStateAtom: (environmentId: string) => environmentId }, | |
| 38 | })); | |
| 39 | vi.mock("../state/query", () => ({ | |
| 40 | useEnvironmentQuery: (environmentId: string) => ({ | |
| 41 | data: state.sessions.get(environmentId) ?? null, | |
| 42 | error: null, | |
| 43 | }), | |
| 44 | })); | |
| 45 | vi.mock("../state/atom-registry", () => ({ | |
| 46 | appAtomRegistry: { | |
| 47 | get: (environmentId: string) => { | |
| 48 | const session = state.sessions.get(environmentId); | |
| 49 | return session === undefined ? AsyncResult.initial() : AsyncResult.success(session); | |
| 50 | }, | |
| 51 | }, | |
| 52 | })); | |
| 53 | vi.mock("./attachmentDownload", () => ({ | |
| 54 | downloadAndShareAttachment: state.download, | |
| 55 | shareLocalAttachment: state.shareLocal, | |
| 56 | })); | |
| 57 | vi.mock("./copyTextWithHaptic", () => ({ copyTextWithHaptic: state.copy })); | |
| 58 | vi.mock("./localAttachmentPreview", () => ({ | |
| 59 | loadLocalAttachmentPreview: async () => ({ share: state.shareDraft, dispose: vi.fn() }), | |
| 60 | })); | |
| 61 | ||
| 62 | import { useMediaActions } from "../state/mediaActions"; | |
| 63 | import type { MediaActionsSource } from "./mediaActionsSource"; | |
| 64 | ||
| 65 | const environmentId = EnvironmentId.make("media-environment"); | |
| 66 | const otherEnvironmentId = EnvironmentId.make("other-environment"); | |
| 67 | const threadId = ThreadId.make("media-thread"); | |
| 68 | const granted: Pick<AuthSessionState, "authenticated" | "scopes"> = { | |
| 69 | authenticated: true, | |
| 70 | scopes: [AuthFilesystemReadScope], | |
| 71 | }; | |
| 72 | const denied: Pick<AuthSessionState, "authenticated" | "scopes"> = { | |
| 73 | authenticated: true, | |
| 74 | scopes: [], | |
| 75 | }; | |
| 76 | ||
| 77 | function hostSource(_tag: "workspace-file" | "media-file" = "media-file"): MediaActionsSource { | |
| 78 | return { | |
| 79 | environmentId, | |
| 80 | threadId, | |
| 81 | resource: { _tag, threadId, path: "/repo/image.png" }, | |
| 82 | reference: { kind: "file", path: "/repo/image.png", relativePath: "image.png" }, | |
| 83 | name: "image.png", | |
| 84 | mimeType: "image/png", | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | beforeEach(() => { | |
| 89 | state.sessions.clear(); | |
| 90 | state.sessions.set(environmentId, denied); | |
| 91 | state.refresh.mockReset().mockResolvedValue("https://host.test/image.png"); | |
| 92 | state.download.mockReset().mockResolvedValue(undefined); | |
| 93 | state.shareLocal.mockReset().mockResolvedValue(undefined); | |
| 94 | state.shareDraft.mockReset().mockResolvedValue(undefined); | |
| 95 | state.navigate.mockReset(); | |
| 96 | state.copy.mockReset(); | |
| 97 | }); | |
| 98 | ||
| 99 | it.each(["workspace-file", "media-file"] as const)( | |
| 100 | "waits for the %s grant before enabling host menu actions", | |
| 101 | (_tag) => { | |
| 102 | for (const [session, disabled] of [ | |
| 103 | [null, true], | |
| 104 | [granted, false], | |
| 105 | [denied, true], | |
| 106 | ] as const) { | |
| 107 | if (session === null) state.sessions.delete(environmentId); | |
| 108 | else state.sessions.set(environmentId, session); | |
| 109 | const media = useMediaActions(hostSource(_tag)); | |
| 110 | ||
| 111 | expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(disabled); | |
| 112 | expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(disabled); | |
| 113 | const copyPath = media.actions.find(({ id }) => id === "copy-full-path")!; | |
| 114 | expect(copyPath.disabled).not.toBe(true); | |
| 115 | copyPath.run(); | |
| 116 | } | |
| 117 | expect(state.copy).toHaveBeenCalledTimes(3); | |
| 118 | expect(state.refresh).not.toHaveBeenCalled(); | |
| 119 | expect(state.download).not.toHaveBeenCalled(); | |
| 120 | }, | |
| 121 | ); | |
| 122 | ||
| 123 | it("keeps nonhost menu actions available with pending or denied host grants", () => { | |
| 124 | const sources: MediaActionsSource[] = [ | |
| 125 | { uri: "https://cdn.test/image.png", name: "image.png", mimeType: "image/png" }, | |
| 126 | { uri: "file:///device/image.png", name: "image.png", mimeType: "image/png" }, | |
| 127 | { | |
| 128 | environmentId, | |
| 129 | resource: { _tag: "attachment", attachmentId: "upload" }, | |
| 130 | name: "image.png", | |
| 131 | mimeType: "image/png", | |
| 132 | }, | |
| 133 | { | |
| 134 | attachment: { | |
| 135 | id: "draft", | |
| 136 | type: "file", | |
| 137 | fileUri: "file:///device/image.png", | |
| 138 | name: "image.png", | |
| 139 | mimeType: "image/png", | |
| 140 | sizeBytes: 1, | |
| 141 | }, | |
| 142 | name: "image.png", | |
| 143 | mimeType: "image/png", | |
| 144 | }, | |
| 145 | ]; | |
| 146 | for (const session of [null, denied]) { | |
| 147 | if (session === null) state.sessions.delete(environmentId); | |
| 148 | else state.sessions.set(environmentId, session); | |
| 149 | for (const source of sources) { | |
| 150 | expect(useMediaActions(source).actions.find(({ id }) => id === "save")?.disabled).toBe(false); | |
| 151 | } | |
| 152 | } | |
| 153 | }); | |
| 154 | ||
| 155 | it.each(["workspace-file", "media-file"] as const)( | |
| 156 | "blocks denied %s sharing and file opening while preserving path copying", | |
| 157 | async (_tag) => { | |
| 158 | const media = useMediaActions(hostSource(_tag)); | |
| 159 | await media.share(); | |
| 160 | media.actions.find(({ id }) => id === "open-file")!.run(); | |
| 161 | media.actions.find(({ id }) => id === "copy-full-path")!.run(); | |
| 162 | ||
| 163 | expect(state.refresh).not.toHaveBeenCalled(); | |
| 164 | expect(state.download).not.toHaveBeenCalled(); | |
| 165 | expect(state.navigate).not.toHaveBeenCalled(); | |
| 166 | expect(state.copy).toHaveBeenCalledWith("/repo/image.png"); | |
| 167 | expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(true); | |
| 168 | expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(true); | |
| 169 | }, | |
| 170 | ); | |
| 171 | ||
| 172 | it("rechecks a retained menu action after revocation", async () => { | |
| 173 | state.sessions.set(environmentId, granted); | |
| 174 | const media = useMediaActions(hostSource()); | |
| 175 | state.sessions.set(environmentId, denied); | |
| 176 | ||
| 177 | await media.share(); | |
| 178 | media.actions.find(({ id }) => id === "open-file")!.run(); | |
| 179 | ||
| 180 | expect(state.refresh).not.toHaveBeenCalled(); | |
| 181 | expect(state.navigate).not.toHaveBeenCalled(); | |
| 182 | }); | |
| 183 | ||
| 184 | it("reenables sharing after gaining access while preserving a retained callback", async () => { | |
| 185 | const media = useMediaActions(hostSource()); | |
| 186 | state.sessions.set(environmentId, granted); | |
| 187 | ||
| 188 | await media.share(); | |
| 189 | ||
| 190 | expect(state.download).toHaveBeenCalledOnce(); | |
| 191 | expect(useMediaActions(hostSource()).actions.find(({ id }) => id === "save")?.disabled).toBe( | |
| 192 | false, | |
| 193 | ); | |
| 194 | }); | |
| 195 | ||
| 196 | it.each([false, true])("uses the media environment's grant (allowed: %s)", async (allowed) => { | |
| 197 | state.sessions.set(environmentId, allowed ? granted : denied); | |
| 198 | state.sessions.set(otherEnvironmentId, allowed ? denied : granted); | |
| 199 | ||
| 200 | await useMediaActions(hostSource()).share(); | |
| 201 | ||
| 202 | expect(state.refresh).toHaveBeenCalledTimes(allowed ? 1 : 0); | |
| 203 | expect(state.download).toHaveBeenCalledTimes(allowed ? 1 : 0); | |
| 204 | }); | |
| 205 | ||
| 206 | it("stops before downloading if access is revoked while the URL is refreshed", async () => { | |
| 207 | state.sessions.set(environmentId, granted); | |
| 208 | state.refresh.mockImplementation(async () => { | |
| 209 | state.sessions.set(environmentId, denied); | |
| 210 | return "https://host.test/image.png"; | |
| 211 | }); | |
| 212 | ||
| 213 | await useMediaActions(hostSource()).share(); | |
| 214 | ||
| 215 | expect(state.refresh).toHaveBeenCalledOnce(); | |
| 216 | expect(state.download).not.toHaveBeenCalled(); | |
| 217 | }); | |
| 218 | ||
| 219 | it("lets an unresolved grant be authorized by an explicit server request", async () => { | |
| 220 | state.sessions.delete(environmentId); | |
| 221 | ||
| 222 | await useMediaActions(hostSource()).share(); | |
| 223 | ||
| 224 | expect(state.refresh).toHaveBeenCalledOnce(); | |
| 225 | expect(state.download).toHaveBeenCalledOnce(); | |
| 226 | }); | |
| 227 | ||
| 228 | it("shares uploaded attachments without host filesystem access", async () => { | |
| 229 | await useMediaActions({ | |
| 230 | environmentId, | |
| 231 | resource: { _tag: "attachment", attachmentId: "upload" }, | |
| 232 | name: "image.png", | |
| 233 | mimeType: "image/png", | |
| 234 | }).share(); | |
| 235 | ||
| 236 | expect(state.refresh).toHaveBeenCalledOnce(); | |
| 237 | expect(state.download).toHaveBeenCalledOnce(); | |
| 238 | }); | |
| 239 | ||
| 240 | it.each(["https://cdn.test/image.png", "file:///device/image.png"])( | |
| 241 | "shares direct media without a host grant: %s", | |
| 242 | async (uri) => { | |
| 243 | await useMediaActions({ uri, name: "image.png", mimeType: "image/png" }).share(); | |
| 244 | ||
| 245 | expect(state.refresh).not.toHaveBeenCalled(); | |
| 246 | expect(uri.startsWith("file:") ? state.shareLocal : state.download).toHaveBeenCalledOnce(); | |
| 247 | }, | |
| 248 | ); | |
| 249 | ||
| 250 | it("shares device draft attachments without a host grant", async () => { | |
| 251 | await useMediaActions({ | |
| 252 | attachment: { | |
| 253 | id: "draft", | |
| 254 | type: "file", | |
| 255 | fileUri: "file:///device/image.png", | |
| 256 | name: "image.png", | |
| 257 | mimeType: "image/png", | |
| 258 | sizeBytes: 1, | |
| 259 | }, | |
| 260 | name: "image.png", | |
| 261 | mimeType: "image/png", | |
| 262 | }).share(); | |
| 263 | ||
| 264 | expect(state.refresh).not.toHaveBeenCalled(); | |
| 265 | expect(state.shareDraft).toHaveBeenCalledOnce(); | |
| 266 | }); |
apps/mobile/src/lib/mediaActions.ts deleted-148| ... | ... | @@ -1,148 +0,0 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; | |
| 3 | import type { MediaReference } from "@t3tools/client-runtime/media-reference"; | |
| 4 | import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 5 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; | |
| 6 | import { useEffect, useRef, useState } from "react"; | |
| 7 | import { Alert } from "react-native"; | |
| 8 | ||
| 9 | import { useRefreshAssetUrl } from "../state/assets"; | |
| 10 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; | |
| 11 | import type { FileBackedComposerAttachment } from "./composerImages"; | |
| 12 | import { copyTextWithHaptic } from "./copyTextWithHaptic"; | |
| 13 | import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; | |
| 14 | ||
| 15 | /** Authored source metadata is kept separate from temporary preview/download URLs. */ | |
| 16 | export type MediaActionsSource = { | |
| 17 | readonly reference?: MediaReference; | |
| 18 | readonly name: string; | |
| 19 | readonly mimeType: string; | |
| 20 | /** Anchors the iOS share sheet to the view that opened the menu. */ | |
| 21 | readonly sourceIdentifier?: string; | |
| 22 | } & ( | |
| 23 | | { readonly uri: string } | |
| 24 | | { readonly attachment: FileBackedComposerAttachment } | |
| 25 | | { | |
| 26 | readonly environmentId: EnvironmentId; | |
| 27 | readonly threadId?: ThreadId; | |
| 28 | readonly resource: AssetResource; | |
| 29 | } | |
| 30 | ); | |
| 31 | ||
| 32 | export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { | |
| 33 | const navigation = useNavigation(); | |
| 34 | const refresh = useRefreshAssetUrl( | |
| 35 | source && "environmentId" in source ? source.environmentId : null, | |
| 36 | source && "resource" in source ? source.resource : null, | |
| 37 | ); | |
| 38 | const controller = useRef<AbortController | null>(null); | |
| 39 | const [sharing, setSharing] = useState(false); | |
| 40 | useEffect(() => () => controller.current?.abort(), []); | |
| 41 | ||
| 42 | const share = () => { | |
| 43 | if (!source || controller.current) return; | |
| 44 | const request = new AbortController(); | |
| 45 | controller.current = request; | |
| 46 | setSharing(true); | |
| 47 | void (async () => { | |
| 48 | if ("attachment" in source) { | |
| 49 | const preview = await loadLocalAttachmentPreview(source.attachment, request.signal); | |
| 50 | if (!preview) return; | |
| 51 | try { | |
| 52 | await preview.share(request.signal, source.sourceIdentifier); | |
| 53 | } finally { | |
| 54 | preview.dispose(); | |
| 55 | } | |
| 56 | return; | |
| 57 | } | |
| 58 | const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); | |
| 59 | if (request.signal.aborted) return; | |
| 60 | if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); | |
| 61 | const input = { | |
| 62 | attachment: { name: source.name, mimeType: source.mimeType }, | |
| 63 | signal: request.signal, | |
| 64 | sourceIdentifier: source.sourceIdentifier, | |
| 65 | }; | |
| 66 | if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); | |
| 67 | else await downloadAndShareAttachment({ ...input, url: uri }); | |
| 68 | })() | |
| 69 | .catch((error: unknown) => { | |
| 70 | if (!request.signal.aborted) { | |
| 71 | Alert.alert( | |
| 72 | "Could not share file", | |
| 73 | error instanceof Error ? error.message : "Try again.", | |
| 74 | ); | |
| 75 | } | |
| 76 | }) | |
| 77 | .finally(() => { | |
| 78 | if (controller.current === request) { | |
| 79 | controller.current = null; | |
| 80 | if (!request.signal.aborted) setSharing(false); | |
| 81 | } | |
| 82 | }); | |
| 83 | }; | |
| 84 | ||
| 85 | const reference = source?.reference; | |
| 86 | const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; | |
| 87 | const threadId = source && "threadId" in source ? source.threadId : undefined; | |
| 88 | const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] = | |
| 89 | source | |
| 90 | ? [ | |
| 91 | ...(reference?.kind === "file" | |
| 92 | ? [ | |
| 93 | { | |
| 94 | id: "copy-full-path" as const, | |
| 95 | title: "Copy full path", | |
| 96 | run: () => copyTextWithHaptic(reference.path), | |
| 97 | }, | |
| 98 | ] | |
| 99 | : []), | |
| 100 | ...(relativePath | |
| 101 | ? [ | |
| 102 | { | |
| 103 | id: "copy-relative-path" as const, | |
| 104 | title: "Copy relative path", | |
| 105 | run: () => copyTextWithHaptic(relativePath), | |
| 106 | }, | |
| 107 | ] | |
| 108 | : []), | |
| 109 | ...(reference?.kind === "url" | |
| 110 | ? [ | |
| 111 | { | |
| 112 | id: "copy-url" as const, | |
| 113 | title: "Copy URL", | |
| 114 | run: () => copyTextWithHaptic(reference.url), | |
| 115 | }, | |
| 116 | ] | |
| 117 | : []), | |
| 118 | ...(relativePath && "environmentId" in source && threadId !== undefined | |
| 119 | ? [ | |
| 120 | { | |
| 121 | id: "open-file" as const, | |
| 122 | title: "Open in file viewer", | |
| 123 | run: () => { | |
| 124 | onOpenFile?.(); | |
| 125 | navigation.navigate("ThreadFile", { | |
| 126 | environmentId: String(source.environmentId), | |
| 127 | threadId: String(threadId), | |
| 128 | path: relativePath.split("/"), | |
| 129 | }); | |
| 130 | }, | |
| 131 | }, | |
| 132 | ] | |
| 133 | : []), | |
| 134 | { | |
| 135 | id: "save" as const, | |
| 136 | title: sharing ? "Opening share sheet…" : "Save or share", | |
| 137 | run: share, | |
| 138 | disabled: sharing, | |
| 139 | }, | |
| 140 | ] | |
| 141 | : []; | |
| 142 | return { | |
| 143 | title: reference?.kind === "file" ? reference.path : reference?.url, | |
| 144 | actions, | |
| 145 | sharing, | |
| 146 | share, | |
| 147 | }; | |
| 148 | } |
apps/mobile/src/lib/mediaActionsSource.ts created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | import type { MediaReference } from "@t3tools/client-runtime/media-reference"; | |
| 2 | import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import type { FileBackedComposerAttachment } from "./composerImages"; | |
| 4 | ||
| 5 | /** Authored source metadata is kept separate from temporary preview/download URLs. */ | |
| 6 | export type MediaActionsSource = { | |
| 7 | readonly reference?: MediaReference; | |
| 8 | readonly name: string; | |
| 9 | readonly mimeType: string; | |
| 10 | /** Anchors the iOS share sheet to the view that opened the menu. */ | |
| 11 | readonly sourceIdentifier?: string; | |
| 12 | } & ( | |
| 13 | | { readonly uri: string } | |
| 14 | | { readonly attachment: FileBackedComposerAttachment } | |
| 15 | | { | |
| 16 | readonly environmentId: EnvironmentId; | |
| 17 | readonly threadId?: ThreadId; | |
| 18 | readonly resource: AssetResource; | |
| 19 | } | |
| 20 | ); |
apps/mobile/src/lib/videoPreviewSource.ts+1-1| ... | ... | @@ -2,7 +2,7 @@ import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/ |
| 2 | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | 3 | |
| 4 | 4 | import type { DraftComposerFileAttachment } from "./composerImages"; |
| 5 | import type { MediaActionsSource } from "./mediaActions"; | |
| 5 | import type { MediaActionsSource } from "./mediaActionsSource"; | |
| 6 | 6 | |
| 7 | 7 | export type MediaVideoPreviewSource = { |
| 8 | 8 | readonly type: "media"; |
apps/mobile/src/state/assets.test.ts created+110| ... | ... | @@ -0,0 +1,110 @@ |
| 1 | import { | |
| 2 | AuthFilesystemReadScope, | |
| 3 | EnvironmentAuthorizationError, | |
| 4 | EnvironmentId, | |
| 5 | ThreadId, | |
| 6 | type AuthSessionState, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Cause from "effect/Cause"; | |
| 9 | import { AsyncResult } from "effect/reactivity"; | |
| 10 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | const state = vi.hoisted(() => ({ | |
| 13 | session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null, | |
| 14 | phase: "connected" as "connected" | "offline", | |
| 15 | assetAtom: {}, | |
| 16 | mint: vi.fn(), | |
| 17 | assetQuery: vi.fn(), | |
| 18 | })); | |
| 19 | ||
| 20 | vi.mock("react", () => ({ useCallback: <A>(callback: A) => callback })); | |
| 21 | vi.mock("@effect/atom-react", () => ({ | |
| 22 | useAtomValue: (atom: unknown) => | |
| 23 | atom === state.assetAtom | |
| 24 | ? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }) | |
| 25 | : AsyncResult.initial(false), | |
| 26 | })); | |
| 27 | vi.mock("./session", () => ({ | |
| 28 | environmentSession: { sessionStateAtom: () => ({}) }, | |
| 29 | usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), | |
| 30 | })); | |
| 31 | vi.mock("./presentation", () => ({ | |
| 32 | useEnvironmentPresentation: () => ({ | |
| 33 | isReady: true, | |
| 34 | presentation: { connection: { phase: state.phase, error: null } }, | |
| 35 | }), | |
| 36 | })); | |
| 37 | vi.mock("./query", () => ({ | |
| 38 | useEnvironmentQuery: () => ({ data: state.session, error: null }), | |
| 39 | })); | |
| 40 | vi.mock("./projectClones", () => ({ environmentProjectCloneListAtom: () => null })); | |
| 41 | vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); | |
| 42 | vi.mock("@t3tools/client-runtime/state/assets", async (importOriginal) => ({ | |
| 43 | ...(await importOriginal<typeof import("@t3tools/client-runtime/state/assets")>()), | |
| 44 | createAssetEnvironmentAtoms: () => ({ createUrl: state.assetQuery }), | |
| 45 | })); | |
| 46 | vi.mock("./use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); | |
| 47 | ||
| 48 | import { useRefreshAssetUrl, useAssetUrlState } from "./assets"; | |
| 49 | ||
| 50 | const environmentId = EnvironmentId.make("asset-environment"); | |
| 51 | const threadId = ThreadId.make("asset-thread"); | |
| 52 | const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const; | |
| 53 | ||
| 54 | beforeEach(() => { | |
| 55 | state.session = null; | |
| 56 | state.phase = "connected"; | |
| 57 | state.assetQuery.mockReset().mockReturnValue(state.assetAtom); | |
| 58 | state.mint | |
| 59 | .mockReset() | |
| 60 | .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })); | |
| 61 | }); | |
| 62 | ||
| 63 | it.each(["workspace-file", "media-file"] as const)( | |
| 64 | "keeps %s loading until its file grant resolves", | |
| 65 | (_tag) => { | |
| 66 | expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" }); | |
| 67 | expect(state.assetQuery).not.toHaveBeenCalled(); | |
| 68 | ||
| 69 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 70 | expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ | |
| 71 | _tag: "Success", | |
| 72 | expiresAt: 1, | |
| 73 | url: "https://host.test/api/assets/image.png", | |
| 74 | }); | |
| 75 | }, | |
| 76 | ); | |
| 77 | ||
| 78 | it("hides host assets with a denied grant while preserving attachments", () => { | |
| 79 | state.session = { authenticated: true, scopes: [] }; | |
| 80 | expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure", reason: "failed" }); | |
| 81 | expect(state.assetQuery).not.toHaveBeenCalled(); | |
| 82 | expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ | |
| 83 | _tag: "Success", | |
| 84 | expiresAt: 1, | |
| 85 | url: "https://host.test/api/assets/image.png", | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | it("stops waiting for an unresolved grant when the connection is offline", () => { | |
| 90 | state.phase = "offline"; | |
| 91 | expect(useAssetUrlState(environmentId, resource)).toEqual({ | |
| 92 | _tag: "Failure", | |
| 93 | reason: "disconnected", | |
| 94 | }); | |
| 95 | expect(state.assetQuery).not.toHaveBeenCalled(); | |
| 96 | }); | |
| 97 | ||
| 98 | it("lets the server authorize an explicit refresh before the client grant loads", async () => { | |
| 99 | await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBe( | |
| 100 | "https://host.test/api/assets/image.png", | |
| 101 | ); | |
| 102 | expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } }); | |
| 103 | ||
| 104 | const denied = new EnvironmentAuthorizationError({ | |
| 105 | message: "This connection cannot read host files.", | |
| 106 | requiredScope: AuthFilesystemReadScope, | |
| 107 | }); | |
| 108 | state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied))); | |
| 109 | await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBeNull(); | |
| 110 | }); |
apps/mobile/src/state/assets.ts+28-27| ... | ... | @@ -1,8 +1,5 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | |
| 3 | type EnvironmentConnectionPhase, | |
| 4 | presentConnectionState, | |
| 5 | } from "@t3tools/client-runtime/connection"; | |
| 2 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | |
| 6 | 3 | import { |
| 7 | 4 | assetUrlStateFromResult, |
| 8 | 5 | createAssetEnvironmentAtoms, |
| ... | ... | @@ -10,16 +7,15 @@ import { |
| 10 | 7 | EMPTY_ASSET_URL_ATOM, |
| 11 | 8 | } from "@t3tools/client-runtime/state/assets"; |
| 12 | 9 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 15 | 10 | import { useCallback } from "react"; |
| 16 | 11 | |
| 17 | import { environmentCatalog } from "../connection/catalog"; | |
| 18 | 12 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | 13 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; |
| 20 | 14 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; |
| 21 | 15 | import { environmentProjectCloneListAtom } from "./projectClones"; |
| 22 | 16 | import { environmentSession, usePreparedConnection } from "./session"; |
| 17 | import { useEnvironmentPresentation } from "./presentation"; | |
| 18 | import { useEnvironmentQuery } from "./query"; | |
| 23 | 19 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 24 | 20 | |
| 25 | 21 | export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; |
| ... | ... | @@ -33,35 +29,40 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ |
| 33 | 29 | projectClones: environmentProjectCloneListAtom, |
| 34 | 30 | }); |
| 35 | 31 | |
| 36 | const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | |
| 37 | Atom.withLabel("mobile-asset-connection-state:empty"), | |
| 38 | ); | |
| 39 | ||
| 40 | function useConnectionPhase(environmentId: EnvironmentId | null): EnvironmentConnectionPhase { | |
| 41 | const state = useAtomValue( | |
| 42 | environmentId === null | |
| 43 | ? EMPTY_CONNECTION_STATE_ATOM | |
| 44 | : environmentCatalog.stateAtom(environmentId), | |
| 45 | ); | |
| 46 | const value = Option.getOrNull(AsyncResult.value(state)); | |
| 47 | return value === null ? "available" : presentConnectionState(value).phase; | |
| 48 | } | |
| 49 | ||
| 50 | 32 | export function useAssetUrlState( |
| 51 | 33 | environmentId: EnvironmentId | null, |
| 52 | 34 | resource: AssetResource | null, |
| 53 | 35 | ): AssetUrlState { |
| 36 | const fileAccessSession = useEnvironmentQuery( | |
| 37 | environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), | |
| 38 | ); | |
| 39 | const fileEnvironment = useEnvironmentPresentation(environmentId); | |
| 40 | const fileAccess = resolveFilesystemReadAccess({ | |
| 41 | isCatalogReady: fileEnvironment.isReady, | |
| 42 | connection: fileEnvironment.presentation?.connection ?? null, | |
| 43 | session: fileAccessSession.data, | |
| 44 | sessionError: fileAccessSession.error, | |
| 45 | }); | |
| 46 | const canReadResource = | |
| 47 | fileAccess.canReadFiles || | |
| 48 | (resource?._tag !== "workspace-file" && | |
| 49 | resource?._tag !== "media-file" && | |
| 50 | resource?._tag !== "draft-workspace-file"); | |
| 54 | 51 | const preparedConnection = usePreparedConnection(environmentId); |
| 55 | const connectionPhase = useConnectionPhase(environmentId); | |
| 52 | const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available"; | |
| 56 | 53 | const result = useAtomValue( |
| 57 | environmentId === null || resource === null | |
| 54 | !canReadResource || environmentId === null || resource === null | |
| 58 | 55 | ? EMPTY_ASSET_URL_ATOM |
| 59 | 56 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 60 | 57 | ); |
| 61 | const shared = assetUrlStateFromResult( | |
| 62 | result, | |
| 63 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, | |
| 64 | ); | |
| 58 | const shared = !canReadResource | |
| 59 | ? fileAccess.isPending | |
| 60 | ? { _tag: "Loading" as const } | |
| 61 | : { _tag: "Failure" as const } | |
| 62 | : assetUrlStateFromResult( | |
| 63 | result, | |
| 64 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, | |
| 65 | ); | |
| 65 | 66 | return deriveAssetUrlState({ |
| 66 | 67 | connectionPhase, |
| 67 | 68 | // A failure left over from an outage is re-queried as soon as the |
apps/mobile/src/state/mediaActions.ts created+167| ... | ... | @@ -0,0 +1,167 @@ |
| 1 | import type { MediaActionsSource } from "../lib/mediaActionsSource"; | |
| 2 | import { useNavigation } from "@react-navigation/native"; | |
| 3 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; | |
| 4 | import { | |
| 5 | AuthFilesystemReadScope, | |
| 6 | type AuthSessionState, | |
| 7 | type EnvironmentId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import { AsyncResult } from "effect/reactivity"; | |
| 12 | import { useEffect, useRef, useState } from "react"; | |
| 13 | import { Alert } from "react-native"; | |
| 14 | ||
| 15 | import { useRefreshAssetUrl } from "./assets"; | |
| 16 | import { appAtomRegistry } from "./atom-registry"; | |
| 17 | import { useEnvironmentQuery } from "./query"; | |
| 18 | import { environmentSession } from "./session"; | |
| 19 | import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; | |
| 20 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; | |
| 21 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | |
| 22 | ||
| 23 | /** An explicit action may ask the server while its grant is still unresolved. */ | |
| 24 | function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) { | |
| 25 | return ( | |
| 26 | session === null || | |
| 27 | (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true) | |
| 28 | ); | |
| 29 | } | |
| 30 | ||
| 31 | function canReadHostMedia(environmentId: EnvironmentId | null): boolean { | |
| 32 | if (environmentId === null) return true; | |
| 33 | const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); | |
| 34 | return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result))); | |
| 35 | } | |
| 36 | ||
| 37 | export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { | |
| 38 | const navigation = useNavigation(); | |
| 39 | const hostEnvironmentId = | |
| 40 | source && | |
| 41 | "resource" in source && | |
| 42 | (source.resource._tag === "workspace-file" || source.resource._tag === "media-file") | |
| 43 | ? source.environmentId | |
| 44 | : null; | |
| 45 | const fileSession = useEnvironmentQuery( | |
| 46 | hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId), | |
| 47 | ); | |
| 48 | const canReadMedia = | |
| 49 | hostEnvironmentId === null || | |
| 50 | (fileSession.error === null && fileSession.data !== null && allowsHostMedia(fileSession.data)); | |
| 51 | const refresh = useRefreshAssetUrl( | |
| 52 | source && "environmentId" in source ? source.environmentId : null, | |
| 53 | source && "resource" in source ? source.resource : null, | |
| 54 | ); | |
| 55 | const controller = useRef<AbortController | null>(null); | |
| 56 | const [sharing, setSharing] = useState(false); | |
| 57 | useEffect(() => () => controller.current?.abort(), []); | |
| 58 | ||
| 59 | const share = () => { | |
| 60 | if (!source || controller.current || !canReadHostMedia(hostEnvironmentId)) return; | |
| 61 | const request = new AbortController(); | |
| 62 | controller.current = request; | |
| 63 | setSharing(true); | |
| 64 | return (async () => { | |
| 65 | if ("attachment" in source) { | |
| 66 | const preview = await loadLocalAttachmentPreview(source.attachment, request.signal); | |
| 67 | if (!preview) return; | |
| 68 | try { | |
| 69 | await preview.share(request.signal, source.sourceIdentifier); | |
| 70 | } finally { | |
| 71 | preview.dispose(); | |
| 72 | } | |
| 73 | return; | |
| 74 | } | |
| 75 | const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); | |
| 76 | if (request.signal.aborted || !canReadHostMedia(hostEnvironmentId)) return; | |
| 77 | if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); | |
| 78 | const input = { | |
| 79 | attachment: { name: source.name, mimeType: source.mimeType }, | |
| 80 | signal: request.signal, | |
| 81 | sourceIdentifier: source.sourceIdentifier, | |
| 82 | }; | |
| 83 | if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); | |
| 84 | else await downloadAndShareAttachment({ ...input, url: uri }); | |
| 85 | })() | |
| 86 | .catch((error: unknown) => { | |
| 87 | if (!request.signal.aborted) { | |
| 88 | Alert.alert( | |
| 89 | "Could not share file", | |
| 90 | error instanceof Error ? error.message : "Try again.", | |
| 91 | ); | |
| 92 | } | |
| 93 | }) | |
| 94 | .finally(() => { | |
| 95 | if (controller.current === request) { | |
| 96 | controller.current = null; | |
| 97 | if (!request.signal.aborted) setSharing(false); | |
| 98 | } | |
| 99 | }); | |
| 100 | }; | |
| 101 | ||
| 102 | const reference = source?.reference; | |
| 103 | const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; | |
| 104 | const threadId = source && "threadId" in source ? source.threadId : undefined; | |
| 105 | const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] = | |
| 106 | source | |
| 107 | ? [ | |
| 108 | ...(reference?.kind === "file" | |
| 109 | ? [ | |
| 110 | { | |
| 111 | id: "copy-full-path" as const, | |
| 112 | title: "Copy full path", | |
| 113 | run: () => copyTextWithHaptic(reference.path), | |
| 114 | }, | |
| 115 | ] | |
| 116 | : []), | |
| 117 | ...(relativePath | |
| 118 | ? [ | |
| 119 | { | |
| 120 | id: "copy-relative-path" as const, | |
| 121 | title: "Copy relative path", | |
| 122 | run: () => copyTextWithHaptic(relativePath), | |
| 123 | }, | |
| 124 | ] | |
| 125 | : []), | |
| 126 | ...(reference?.kind === "url" | |
| 127 | ? [ | |
| 128 | { | |
| 129 | id: "copy-url" as const, | |
| 130 | title: "Copy URL", | |
| 131 | run: () => copyTextWithHaptic(reference.url), | |
| 132 | }, | |
| 133 | ] | |
| 134 | : []), | |
| 135 | ...(relativePath && "environmentId" in source && threadId !== undefined | |
| 136 | ? [ | |
| 137 | { | |
| 138 | id: "open-file" as const, | |
| 139 | title: "Open in file viewer", | |
| 140 | disabled: !canReadMedia, | |
| 141 | run: () => { | |
| 142 | if (!canReadHostMedia(hostEnvironmentId)) return; | |
| 143 | onOpenFile?.(); | |
| 144 | navigation.navigate("ThreadFile", { | |
| 145 | environmentId: String(source.environmentId), | |
| 146 | threadId: String(threadId), | |
| 147 | path: relativePath.split("/"), | |
| 148 | }); | |
| 149 | }, | |
| 150 | }, | |
| 151 | ] | |
| 152 | : []), | |
| 153 | { | |
| 154 | id: "save" as const, | |
| 155 | title: sharing ? "Opening share sheet…" : "Save or share", | |
| 156 | run: share, | |
| 157 | disabled: sharing || !canReadMedia, | |
| 158 | }, | |
| 159 | ] | |
| 160 | : []; | |
| 161 | return { | |
| 162 | title: reference?.kind === "file" ? reference.path : reference?.url, | |
| 163 | actions, | |
| 164 | sharing, | |
| 165 | share, | |
| 166 | }; | |
| 167 | } |
apps/mobile/src/state/queries.filesystem.test.ts created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState } from "@t3tools/contracts"; | |
| 2 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | const state = vi.hoisted(() => ({ | |
| 5 | session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null, | |
| 6 | sessionError: null as string | null, | |
| 7 | phase: "connected" as "connected" | "offline", | |
| 8 | sessionAtom: {}, | |
| 9 | searchAtom: {}, | |
| 10 | })); | |
| 11 | ||
| 12 | vi.mock("react", async (importOriginal) => ({ | |
| 13 | ...(await importOriginal<typeof import("react")>()), | |
| 14 | useCallback: <A>(callback: A) => callback, | |
| 15 | useEffect: () => {}, | |
| 16 | useMemo: <A>(factory: () => A) => factory(), | |
| 17 | useState: <A>(value: A) => [value, vi.fn()], | |
| 18 | })); | |
| 19 | vi.mock("./session", () => ({ | |
| 20 | environmentSession: { sessionStateAtom: () => state.sessionAtom }, | |
| 21 | })); | |
| 22 | vi.mock("./presentation", () => ({ | |
| 23 | useEnvironmentPresentation: () => ({ | |
| 24 | isReady: true, | |
| 25 | presentation: { connection: { phase: state.phase, error: null } }, | |
| 26 | }), | |
| 27 | })); | |
| 28 | vi.mock("./pull-requests", () => ({ composerPullRequests: {} })); | |
| 29 | vi.mock("./projects", () => ({ | |
| 30 | projectEnvironment: { searchEntries: () => state.searchAtom }, | |
| 31 | })); | |
| 32 | vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); | |
| 33 | vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); | |
| 34 | vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() })); | |
| 35 | vi.mock("./vcs", () => ({ vcsEnvironment: {} })); | |
| 36 | vi.mock("./query", () => ({ | |
| 37 | useEnvironmentQuery: (atom: unknown) => ({ | |
| 38 | data: | |
| 39 | atom === state.sessionAtom | |
| 40 | ? state.session | |
| 41 | : atom === state.searchAtom | |
| 42 | ? { entries: [{ path: "src/index.ts", kind: "file" }] } | |
| 43 | : null, | |
| 44 | error: atom === state.sessionAtom ? state.sessionError : null, | |
| 45 | isPending: atom === state.sessionAtom && state.session === null && state.sessionError === null, | |
| 46 | refresh: vi.fn(), | |
| 47 | }), | |
| 48 | })); | |
| 49 | ||
| 50 | import { useComposerPathSearch } from "./queries"; | |
| 51 | ||
| 52 | const target = { | |
| 53 | environmentId: EnvironmentId.make("test-environment"), | |
| 54 | cwd: "/repo", | |
| 55 | query: "src", | |
| 56 | }; | |
| 57 | ||
| 58 | beforeEach(() => { | |
| 59 | state.session = null; | |
| 60 | state.sessionError = null; | |
| 61 | state.phase = "connected"; | |
| 62 | }); | |
| 63 | ||
| 64 | it("keeps a file search pending until its grant loads, then shows matches", () => { | |
| 65 | expect(useComposerPathSearch(target)).toMatchObject({ | |
| 66 | entries: [], | |
| 67 | error: null, | |
| 68 | isPending: true, | |
| 69 | }); | |
| 70 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 71 | expect(useComposerPathSearch(target)).toMatchObject({ | |
| 72 | entries: [{ path: "src/index.ts", kind: "file" }], | |
| 73 | error: null, | |
| 74 | isPending: false, | |
| 75 | }); | |
| 76 | }); | |
| 77 | ||
| 78 | it("shows a confirmed denial and a connection failure separately", () => { | |
| 79 | state.session = { authenticated: true, scopes: [] }; | |
| 80 | expect(useComposerPathSearch(target)).toMatchObject({ | |
| 81 | entries: [], | |
| 82 | error: "This connection cannot search host files.", | |
| 83 | isPending: false, | |
| 84 | }); | |
| 85 | state.session = null; | |
| 86 | state.phase = "offline"; | |
| 87 | expect(useComposerPathSearch(target)).toMatchObject({ | |
| 88 | entries: [], | |
| 89 | error: "This environment is not connected.", | |
| 90 | isPending: false, | |
| 91 | }); | |
| 92 | }); | |
| 93 | ||
| 94 | it("leaves an inactive search idle while its grant loads", () => { | |
| 95 | expect(useComposerPathSearch({ ...target, cwd: null, query: null })).toMatchObject({ | |
| 96 | entries: [], | |
| 97 | error: null, | |
| 98 | isPending: false, | |
| 99 | }); | |
| 100 | }); |
apps/mobile/src/state/queries.ts+34-8| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | 1 | import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; |
| 2 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | |
| 3 | import { environmentSession } from "./session"; | |
| 4 | import { useEnvironmentPresentation } from "./presentation"; | |
| 2 | 5 | import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; |
| 3 | 6 | import type { |
| 4 | 7 | EnvironmentId, |
| ... | ... | @@ -309,25 +312,48 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { |
| 309 | 312 | [target.cwd, target.environmentId, target.query], |
| 310 | 313 | ); |
| 311 | 314 | const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); |
| 312 | const result = useEnvironmentQuery( | |
| 315 | const fileAccessSession = useEnvironmentQuery( | |
| 316 | debouncedTarget.environmentId === null | |
| 317 | ? null | |
| 318 | : environmentSession.sessionStateAtom(debouncedTarget.environmentId), | |
| 319 | ); | |
| 320 | const fileEnvironment = useEnvironmentPresentation(debouncedTarget.environmentId); | |
| 321 | const fileAccess = resolveFilesystemReadAccess({ | |
| 322 | isCatalogReady: fileEnvironment.isReady, | |
| 323 | connection: fileEnvironment.presentation?.connection ?? null, | |
| 324 | session: fileAccessSession.data, | |
| 325 | sessionError: fileAccessSession.error, | |
| 326 | }); | |
| 327 | const { canReadFiles } = fileAccess; | |
| 328 | const searchTarget = | |
| 313 | 329 | debouncedTarget.environmentId !== null && |
| 314 | debouncedTarget.cwd !== null && | |
| 315 | debouncedTarget.query.length > 0 | |
| 316 | ? projectEnvironment.searchEntries({ | |
| 330 | debouncedTarget.cwd !== null && | |
| 331 | debouncedTarget.query.length > 0 | |
| 332 | ? { | |
| 317 | 333 | environmentId: debouncedTarget.environmentId, |
| 318 | 334 | input: { |
| 319 | 335 | cwd: debouncedTarget.cwd, |
| 320 | 336 | query: debouncedTarget.query, |
| 321 | 337 | limit: COMPOSER_PATH_SEARCH_LIMIT, |
| 322 | 338 | }, |
| 323 | }) | |
| 324 | : null, | |
| 339 | } | |
| 340 | : null; | |
| 341 | const result = useEnvironmentQuery( | |
| 342 | canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null, | |
| 325 | 343 | ); |
| 344 | const hasTarget = searchTarget !== null; | |
| 326 | 345 | |
| 327 | 346 | return { |
| 328 | 347 | entries: result.data?.entries ?? [], |
| 329 | error: result.error, | |
| 330 | isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, | |
| 348 | error: | |
| 349 | !hasTarget || fileAccess.isPending | |
| 350 | ? null | |
| 351 | : canReadFiles | |
| 352 | ? result.error | |
| 353 | : (fileAccess.error ?? "This connection cannot search host files."), | |
| 354 | isPending: | |
| 355 | normalizedTarget.query !== debouncedTarget.query || | |
| 356 | (hasTarget && (fileAccess.isPending || result.isPending)), | |
| 331 | 357 | refresh: result.refresh, |
| 332 | 358 | }; |
| 333 | 359 | } |
apps/server/src/auth/RpcAuthorization.test.ts+41| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | AuthEnvironmentMaintainScope, |
| 3 | AuthFilesystemReadScope, | |
| 3 | 4 | AuthProvidersManageScope, |
| 4 | 5 | AuthSettingsWriteScope, |
| 5 | 6 | DEFAULT_SERVER_SETTINGS, |
| ... | ... | @@ -301,3 +302,43 @@ it.effect("requires task permission before attaching a prepared worktree to a th |
| 301 | 302 | expect(handled).toBe(false); |
| 302 | 303 | }).pipe(Effect.scoped), |
| 303 | 304 | ); |
| 305 | ||
| 306 | it.effect("separates host file URLs from readable attachment URLs", () => | |
| 307 | Effect.gen(function* () { | |
| 308 | const group = WsRpcGroup.omit( | |
| 309 | ...[...WsRpcGroup.requests.keys()].filter( | |
| 310 | ( | |
| 311 | tag, | |
| 312 | ): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, typeof WS_METHODS.assetsCreateUrl> => | |
| 313 | tag !== WS_METHODS.assetsCreateUrl, | |
| 314 | ), | |
| 315 | ); | |
| 316 | let handled = 0; | |
| 317 | const client = yield* RpcTest.makeClient(group).pipe( | |
| 318 | Effect.provide( | |
| 319 | Layer.mergeAll( | |
| 320 | group.toLayerHandler(WS_METHODS.assetsCreateUrl, () => | |
| 321 | Effect.sync(() => { | |
| 322 | handled++; | |
| 323 | return { relativeUrl: "/api/assets/file", expiresAt: 1 }; | |
| 324 | }), | |
| 325 | ), | |
| 326 | RpcAuthorization.layer([AuthOrchestrationReadScope]), | |
| 327 | ), | |
| 328 | ), | |
| 329 | ); | |
| 330 | yield* client[WS_METHODS.assetsCreateUrl]({ | |
| 331 | resource: { _tag: "attachment", attachmentId: "image" }, | |
| 332 | }); | |
| 333 | for (const resource of [ | |
| 334 | { _tag: "workspace-file", threadId: ThreadId.make("thread"), path: "file.txt" }, | |
| 335 | { _tag: "media-file", threadId: ThreadId.make("thread"), path: "/repo/image.png" }, | |
| 336 | { _tag: "draft-workspace-file", cwd: "/repo", path: "file.txt" }, | |
| 337 | ] as const) { | |
| 338 | expect( | |
| 339 | yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip), | |
| 340 | ).toMatchObject({ requiredScope: AuthFilesystemReadScope }); | |
| 341 | } | |
| 342 | expect(handled).toBe(1); | |
| 343 | }).pipe(Effect.scoped), | |
| 344 | ); |
apps/server/src/auth/RpcAuthorization.ts+21-9| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | CLIENT_GUARDED_RPC_SCOPES, |
| 3 | 3 | type DeviceListInput, |
| 4 | 4 | clientRpcRequiredScopes, |
| 5 | AssetCreateUrlInput, | |
| 5 | 6 | AuthAccessReadScope, |
| 6 | 7 | ServerSettingsPatch, |
| 7 | 8 | ProviderInstanceMutation, |
| ... | ... | @@ -9,11 +10,12 @@ import { |
| 9 | 10 | AuthSettingsWriteScope, |
| 10 | 11 | AuthProvidersManageScope, |
| 11 | 12 | AuthEnvironmentMaintainScope, |
| 13 | AuthFilesystemReadScope, | |
| 14 | AuthFilesystemWriteScope, | |
| 12 | 15 | AuthOrchestrationOperateScope, |
| 13 | 16 | AuthOrchestrationReadScope, |
| 14 | 17 | AuthRelayReadScope, |
| 15 | 18 | AuthRelayWriteScope, |
| 16 | AuthReviewWriteScope, | |
| 17 | 19 | AuthTerminalOperateScope, |
| 18 | 20 | ORCHESTRATION_V2_WS_METHODS, |
| 19 | 21 | type AuthEnvironmentScope, |
| ... | ... | @@ -131,15 +133,15 @@ export const RPC_REQUIRED_SCOPES = { |
| 131 | 133 | [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, |
| 132 | 134 | [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, |
| 133 | 135 | [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, |
| 134 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, | |
| 135 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, | |
| 136 | [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, | |
| 137 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, | |
| 138 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, | |
| 136 | [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, | |
| 137 | [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, | |
| 138 | [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, | |
| 139 | [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope, | |
| 140 | [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope, | |
| 139 | 141 | [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, |
| 140 | 142 | [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, |
| 141 | 143 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| 142 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, | |
| 144 | [WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope, | |
| 143 | 145 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, |
| 144 | 146 | [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, |
| 145 | 147 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, |
| ... | ... | @@ -154,8 +156,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 154 | 156 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, |
| 155 | 157 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, |
| 156 | 158 | [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, |
| 157 | [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, | |
| 158 | [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, | |
| 159 | [WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope, | |
| 160 | [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, | |
| 159 | 161 | [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, |
| 160 | 162 | [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, |
| 161 | 163 | [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, |
| ... | ... | @@ -227,6 +229,16 @@ const requiredScopesForRpcCall = ( |
| 227 | 229 | method: string, |
| 228 | 230 | payload: unknown, |
| 229 | 231 | ): ReadonlyArray<AuthEnvironmentScope> => { |
| 232 | if (method === WS_METHODS.assetsCreateUrl) { | |
| 233 | const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload); | |
| 234 | return [ | |
| 235 | resource._tag === "workspace-file" || | |
| 236 | resource._tag === "media-file" || | |
| 237 | resource._tag === "draft-workspace-file" | |
| 238 | ? AuthFilesystemReadScope | |
| 239 | : AuthOrchestrationReadScope, | |
| 240 | ]; | |
| 241 | } | |
| 230 | 242 | if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); |
| 231 | 243 | const guarded = clientRpcRequiredScopes(method, payload); |
| 232 | 244 | if (guarded.length > 0) return guarded; |
apps/server/src/auth/http.ts+2-24| ... | ... | @@ -2,16 +2,7 @@ import { |
| 2 | 2 | AuthAccessReadScope, |
| 3 | 3 | AuthAccessWriteScope, |
| 4 | 4 | AuthStandardClientScopes, |
| 5 | AuthSettingsWriteScope, | |
| 6 | AuthProvidersManageScope, | |
| 7 | AuthEnvironmentMaintainScope, | |
| 8 | AuthOrchestrationOperateScope, | |
| 9 | AuthOrchestrationReadScope, | |
| 10 | AuthRelayReadScope, | |
| 11 | AuthRelayWriteScope, | |
| 12 | AuthReviewWriteScope, | |
| 13 | AuthSourceControlWriteScope, | |
| 14 | AuthTerminalOperateScope, | |
| 5 | AuthGrantScope, | |
| 15 | 6 | EnvironmentAuthInvalidError, |
| 16 | 7 | type EnvironmentAuthInvalidReason, |
| 17 | 8 | EnvironmentHttpApi, |
| ... | ... | @@ -367,20 +358,7 @@ export const layer = HttpApiBuilder.group( |
| 367 | 358 | ? undefined |
| 368 | 359 | : parseAllowedOAuthScope({ |
| 369 | 360 | value: args.payload.scope, |
| 370 | allowedScopes: new Set<AuthEnvironmentScope>([ | |
| 371 | AuthOrchestrationReadScope, | |
| 372 | AuthOrchestrationOperateScope, | |
| 373 | AuthSettingsWriteScope, | |
| 374 | AuthProvidersManageScope, | |
| 375 | AuthEnvironmentMaintainScope, | |
| 376 | AuthTerminalOperateScope, | |
| 377 | AuthReviewWriteScope, | |
| 378 | AuthSourceControlWriteScope, | |
| 379 | AuthAccessReadScope, | |
| 380 | AuthAccessWriteScope, | |
| 381 | AuthRelayReadScope, | |
| 382 | AuthRelayWriteScope, | |
| 383 | ]), | |
| 361 | allowedScopes: new Set(AuthGrantScope.literals), | |
| 384 | 362 | }); |
| 385 | 363 | if (requestedScopes === null) { |
| 386 | 364 | return yield* failEnvironmentInvalidRequest("invalid_scope"); |
apps/server/src/cli/authScopes.ts+3-3| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | import { AuthEnvironmentScope } from "@t3tools/contracts"; | |
| 1 | import { AuthGrantScope } from "@t3tools/contracts"; | |
| 2 | 2 | import { Flag } from "effect/cli"; |
| 3 | 3 | |
| 4 | export const authScopesFlag = (defaults: ReadonlyArray<AuthEnvironmentScope>) => | |
| 5 | Flag.Literals("scope", AuthEnvironmentScope.literals).pipe( | |
| 4 | export const authScopesFlag = (defaults: ReadonlyArray<AuthGrantScope>) => | |
| 5 | Flag.Literals("scope", AuthGrantScope.literals).pipe( | |
| 6 | 6 | Flag.withDescription( |
| 7 | 7 | `Authorization scope to grant. Repeat for multiple scopes; replaces the default set: ${defaults.join(", ")}.`, |
| 8 | 8 | ), |
apps/web/src/assets/assetUrls.test.ts created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | import { | |
| 2 | AuthFilesystemReadScope, | |
| 3 | EnvironmentAuthorizationError, | |
| 4 | EnvironmentId, | |
| 5 | ThreadId, | |
| 6 | type AuthSessionState, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Cause from "effect/Cause"; | |
| 9 | import { AsyncResult } from "effect/reactivity"; | |
| 10 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | const state = vi.hoisted(() => ({ | |
| 13 | session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null, | |
| 14 | phase: "connected" as "connected" | "offline", | |
| 15 | assetAtom: {}, | |
| 16 | mint: vi.fn(), | |
| 17 | assetQuery: vi.fn(), | |
| 18 | })); | |
| 19 | ||
| 20 | vi.mock("react", () => ({ useCallback: <A>(callback: A) => callback })); | |
| 21 | vi.mock("@effect/atom-react", () => ({ | |
| 22 | useAtomValue: (atom: unknown) => | |
| 23 | atom === state.assetAtom | |
| 24 | ? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }) | |
| 25 | : AsyncResult.initial(false), | |
| 26 | })); | |
| 27 | vi.mock("~/state/session", () => ({ | |
| 28 | usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), | |
| 29 | })); | |
| 30 | vi.mock("~/state/filesystem", async () => { | |
| 31 | const { resolveFilesystemReadAccess } = await import("@t3tools/client-runtime/state/filesystem"); | |
| 32 | return { | |
| 33 | useFilesystemReadAccess: () => | |
| 34 | resolveFilesystemReadAccess({ | |
| 35 | isCatalogReady: true, | |
| 36 | connection: { phase: state.phase, error: null }, | |
| 37 | session: state.session, | |
| 38 | sessionError: null, | |
| 39 | }), | |
| 40 | }; | |
| 41 | }); | |
| 42 | vi.mock("~/state/assets", () => ({ | |
| 43 | assetEnvironment: { createUrl: state.assetQuery }, | |
| 44 | })); | |
| 45 | vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); | |
| 46 | ||
| 47 | import { useAssetUrlRefresh, useAssetUrlState } from "./assetUrls"; | |
| 48 | ||
| 49 | const environmentId = EnvironmentId.make("asset-environment"); | |
| 50 | const threadId = ThreadId.make("asset-thread"); | |
| 51 | const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const; | |
| 52 | ||
| 53 | beforeEach(() => { | |
| 54 | state.session = null; | |
| 55 | state.phase = "connected"; | |
| 56 | state.assetQuery.mockReset().mockReturnValue(state.assetAtom); | |
| 57 | state.mint | |
| 58 | .mockReset() | |
| 59 | .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })); | |
| 60 | }); | |
| 61 | ||
| 62 | it.each(["workspace-file", "media-file"] as const)( | |
| 63 | "keeps %s loading until its file grant resolves", | |
| 64 | (_tag) => { | |
| 65 | expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" }); | |
| 66 | expect(state.assetQuery).not.toHaveBeenCalled(); | |
| 67 | ||
| 68 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 69 | expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ | |
| 70 | _tag: "Success", | |
| 71 | expiresAt: 1, | |
| 72 | url: "https://host.test/api/assets/image.png", | |
| 73 | }); | |
| 74 | }, | |
| 75 | ); | |
| 76 | ||
| 77 | it("hides host assets with a denied grant while preserving attachments", () => { | |
| 78 | state.session = { authenticated: true, scopes: [] }; | |
| 79 | expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" }); | |
| 80 | expect(state.assetQuery).not.toHaveBeenCalled(); | |
| 81 | expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ | |
| 82 | _tag: "Success", | |
| 83 | expiresAt: 1, | |
| 84 | url: "https://host.test/api/assets/image.png", | |
| 85 | }); | |
| 86 | }); | |
| 87 | ||
| 88 | it("stops waiting for an unresolved grant when the connection is offline", () => { | |
| 89 | state.phase = "offline"; | |
| 90 | expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" }); | |
| 91 | expect(state.assetQuery).not.toHaveBeenCalled(); | |
| 92 | }); | |
| 93 | ||
| 94 | it("lets the server authorize an explicit refresh before the client grant loads", async () => { | |
| 95 | await expect(useAssetUrlRefresh(environmentId, resource)()).resolves.toBe( | |
| 96 | "https://host.test/api/assets/image.png", | |
| 97 | ); | |
| 98 | expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } }); | |
| 99 | ||
| 100 | const denied = new EnvironmentAuthorizationError({ | |
| 101 | message: "This connection cannot read host files.", | |
| 102 | requiredScope: AuthFilesystemReadScope, | |
| 103 | }); | |
| 104 | state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied))); | |
| 105 | await expect(useAssetUrlRefresh(environmentId, resource)()).rejects.toBe(denied); | |
| 106 | }); |
apps/web/src/assets/assetUrls.ts+40-13| ... | ... | @@ -11,6 +11,7 @@ import { AsyncResult } from "effect/reactivity"; |
| 11 | 11 | import { useCallback, useMemo } from "react"; |
| 12 | 12 | |
| 13 | 13 | import { assetEnvironment } from "~/state/assets"; |
| 14 | import { useFilesystemReadAccess } from "~/state/filesystem"; | |
| 14 | 15 | import { usePreparedConnection } from "~/state/session"; |
| 15 | 16 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 16 | 17 | |
| ... | ... | @@ -20,12 +21,19 @@ export function useAssetUrlState( |
| 20 | 21 | environmentId: EnvironmentId | null, |
| 21 | 22 | resource: AssetResource | null, |
| 22 | 23 | ): AssetUrlState { |
| 24 | const fileAccess = useFilesystemReadAccess(environmentId); | |
| 25 | const canReadResource = | |
| 26 | fileAccess.canReadFiles || | |
| 27 | (resource?._tag !== "workspace-file" && | |
| 28 | resource?._tag !== "media-file" && | |
| 29 | resource?._tag !== "draft-workspace-file"); | |
| 23 | 30 | const preparedConnection = usePreparedConnection(environmentId); |
| 24 | 31 | const result = useAtomValue( |
| 25 | environmentId === null || resource === null | |
| 32 | !canReadResource || environmentId === null || resource === null | |
| 26 | 33 | ? EMPTY_ASSET_URL_ATOM |
| 27 | 34 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 28 | 35 | ); |
| 36 | if (!canReadResource) return { _tag: fileAccess.isPending ? "Loading" : "Failure" }; | |
| 29 | 37 | return assetUrlStateFromResult( |
| 30 | 38 | result, |
| 31 | 39 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, |
| ... | ... | @@ -55,21 +63,40 @@ export function useAssetUrls( |
| 55 | 63 | resources: ReadonlyArray<AssetResource>, |
| 56 | 64 | ): ReadonlyArray<string | null> { |
| 57 | 65 | const preparedConnection = usePreparedConnection(environmentId); |
| 66 | const { canReadFiles } = useFilesystemReadAccess(environmentId); | |
| 67 | const allowedResources = useMemo( | |
| 68 | () => | |
| 69 | canReadFiles | |
| 70 | ? resources | |
| 71 | : resources.filter( | |
| 72 | (resource) => | |
| 73 | resource._tag !== "workspace-file" && | |
| 74 | resource._tag !== "media-file" && | |
| 75 | resource._tag !== "draft-workspace-file", | |
| 76 | ), | |
| 77 | [canReadFiles, resources], | |
| 78 | ); | |
| 58 | 79 | const results = useAtomValue( |
| 59 | 80 | assetEnvironment.createUrls({ |
| 60 | 81 | environmentId, |
| 61 | resources, | |
| 82 | resources: allowedResources, | |
| 62 | 83 | }), |
| 63 | 84 | ); |
| 64 | return useMemo( | |
| 65 | () => | |
| 66 | preparedConnection._tag === "None" | |
| 67 | ? resources.map(() => null) | |
| 68 | : results.map((result) => | |
| 69 | AsyncResult.isSuccess(result) | |
| 70 | ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) | |
| 71 | : null, | |
| 72 | ), | |
| 73 | [preparedConnection, resources, results], | |
| 74 | ); | |
| 85 | return useMemo(() => { | |
| 86 | if (preparedConnection._tag === "None") return resources.map(() => null); | |
| 87 | let resultIndex = 0; | |
| 88 | return resources.map((resource) => { | |
| 89 | if ( | |
| 90 | !canReadFiles && | |
| 91 | (resource._tag === "workspace-file" || | |
| 92 | resource._tag === "media-file" || | |
| 93 | resource._tag === "draft-workspace-file") | |
| 94 | ) | |
| 95 | return null; | |
| 96 | const result = results[resultIndex++]; | |
| 97 | return result && AsyncResult.isSuccess(result) | |
| 98 | ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) | |
| 99 | : null; | |
| 100 | }); | |
| 101 | }, [canReadFiles, preparedConnection, resources, results]); | |
| 75 | 102 | } |
apps/web/src/components/ChatMarkdown.assets.test.tsx created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { AsyncResult } from "effect/reactivity"; | |
| 3 | import { act, type ComponentProps, type ReactNode } from "react"; | |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 5 | import { expect, it, vi } from "vite-plus/test"; | |
| 6 | ||
| 7 | const mint = vi.hoisted(() => vi.fn()); | |
| 8 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | |
| 9 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | |
| 10 | vi.mock("../hooks/useSettings", async (importOriginal) => { | |
| 11 | const actual = await importOriginal<typeof import("../hooks/useSettings")>(); | |
| 12 | const settings = actual.getClientSettings(); | |
| 13 | return { | |
| 14 | ...actual, | |
| 15 | useClientSettings: (select: (value: typeof settings) => unknown) => select(settings), | |
| 16 | }; | |
| 17 | }); | |
| 18 | vi.mock("./ui/tooltip", async () => { | |
| 19 | const { cloneElement, isValidElement } = await import("react"); | |
| 20 | return { | |
| 21 | Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 22 | TooltipTrigger({ | |
| 23 | render, | |
| 24 | children, | |
| 25 | }: ComponentProps<typeof import("./ui/tooltip").TooltipTrigger>) { | |
| 26 | if (!isValidElement(render)) return <>{children}</>; | |
| 27 | return children === undefined ? render : cloneElement(render, undefined, children); | |
| 28 | }, | |
| 29 | TooltipPopup: () => null, | |
| 30 | }; | |
| 31 | }); | |
| 32 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => mint })); | |
| 33 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | |
| 34 | vi.mock("../state/session", async (importOriginal) => ({ | |
| 35 | ...(await importOriginal<typeof import("../state/session")>()), | |
| 36 | readEnvironmentScope: () => false, | |
| 37 | useEnvironmentScope: () => false, | |
| 38 | usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), | |
| 39 | })); | |
| 40 | vi.mock("../state/entities", () => ({ | |
| 41 | readEnvironmentSupportsServerBrowser: () => false, | |
| 42 | readThreadShell: () => null, | |
| 43 | useProjects: () => [], | |
| 44 | useServerConfigs: () => new Map(), | |
| 45 | })); | |
| 46 | vi.mock("../remoteOpen", () => ({ | |
| 47 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), | |
| 48 | })); | |
| 49 | vi.mock("../editorPreferences", () => ({ | |
| 50 | useOpenInPreferredEditor: () => vi.fn(), | |
| 51 | usePreferredEditor: () => [null, vi.fn()], | |
| 52 | })); | |
| 53 | vi.mock("~/lib/openPullRequestLink", () => ({ | |
| 54 | findProjectForChangeRequest: () => undefined, | |
| 55 | matchesLinkedPullRequestUrl: () => false, | |
| 56 | parseChangeRequestUrl: () => null, | |
| 57 | useOpenChangeRequestLink: () => vi.fn(), | |
| 58 | })); | |
| 59 | ||
| 60 | import ChatMarkdown from "./ChatMarkdown"; | |
| 61 | ||
| 62 | it("opens host media through server authorization before the client grant loads", async () => { | |
| 63 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 64 | mint.mockResolvedValue( | |
| 65 | AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }), | |
| 66 | ); | |
| 67 | const onImageExpand = vi.fn(); | |
| 68 | const threadRef = { | |
| 69 | environmentId: EnvironmentId.make("media-environment"), | |
| 70 | threadId: ThreadId.make("media-thread"), | |
| 71 | }; | |
| 72 | let renderer: ReactTestRenderer | undefined; | |
| 73 | try { | |
| 74 | await act(async () => { | |
| 75 | renderer = create( | |
| 76 | <ChatMarkdown | |
| 77 | cwd="/repo" | |
| 78 | threadRef={threadRef} | |
| 79 | text="[Open image](/tmp/image.png)" | |
| 80 | onImageExpand={onImageExpand} | |
| 81 | />, | |
| 82 | ); | |
| 83 | }); | |
| 84 | await act(async () => { | |
| 85 | const link = renderer!.root | |
| 86 | .findAllByType("a") | |
| 87 | .find((node) => node.props.href === "/tmp/image.png"); | |
| 88 | expect(link).toBeDefined(); | |
| 89 | link!.props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); | |
| 90 | }); | |
| 91 | expect(onImageExpand).toHaveBeenCalledWith( | |
| 92 | expect.objectContaining({ | |
| 93 | images: [expect.objectContaining({ src: "https://host.test/api/assets/image.png" })], | |
| 94 | }), | |
| 95 | ); | |
| 96 | } finally { | |
| 97 | await act(async () => renderer?.unmount()); | |
| 98 | vi.unstubAllGlobals(); | |
| 99 | } | |
| 100 | }); |
apps/web/src/components/ChatMarkdown.tsx+7-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; |
| 2 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 2 | import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 3 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import { |
| 5 | 5 | COMPOSER_CONTEXT_CLIPBOARD_MIME, |
| ... | ... | @@ -2757,7 +2757,12 @@ function useChatMarkdownState({ |
| 2757 | 2757 | ); |
| 2758 | 2758 | const findWorkspaceBasenameMatch = useCallback( |
| 2759 | 2759 | async (workspaceRelativePath: string) => { |
| 2760 | if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { | |
| 2760 | if ( | |
| 2761 | !cwd || | |
| 2762 | environmentId === null || | |
| 2763 | !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || | |
| 2764 | !needsWorkspaceBasenameLookup(workspaceRelativePath) | |
| 2765 | ) { | |
| 2761 | 2766 | return null; |
| 2762 | 2767 | } |
| 2763 | 2768 | const result = await searchProjectEntries({ |
apps/web/src/components/CommandPalette.tsx+28-7| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | import { |
| 33 | 33 | AuthOrchestrationOperateScope, |
| 34 | 34 | AuthSourceControlWriteScope, |
| 35 | AuthFilesystemReadScope, | |
| 35 | 36 | type DesktopWslState, |
| 36 | 37 | type EnvironmentId, |
| 37 | 38 | type EnvironmentMachineKind, |
| ... | ... | @@ -100,7 +101,7 @@ import { |
| 100 | 101 | } from "./settings/ThemePreviewCircles"; |
| 101 | 102 | import { readLocalApi } from "../localApi"; |
| 102 | 103 | import { desktopLocalBackendId } from "../connection/desktopLocal"; |
| 103 | import { filesystemEnvironment } from "../state/filesystem"; | |
| 104 | import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem"; | |
| 104 | 105 | import { projectEnvironment } from "../state/projects"; |
| 105 | 106 | import { useEnvironmentQuery } from "../state/query"; |
| 106 | 107 | import { serverEnvironment } from "../state/server"; |
| ... | ... | @@ -1160,11 +1161,14 @@ function OpenCommandPaletteDialog(props: { |
| 1160 | 1161 | ); |
| 1161 | 1162 | const relativePathNeedsActiveProject = |
| 1162 | 1163 | isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; |
| 1163 | const browseQuery = useEnvironmentQuery( | |
| 1164 | const browseAccess = useFilesystemReadAccess(browseEnvironmentId); | |
| 1165 | const hasBrowseTarget = | |
| 1164 | 1166 | isBrowsing && |
| 1165 | browsePath.directoryPath.length > 0 && | |
| 1166 | browseEnvironmentId !== null && | |
| 1167 | !relativePathNeedsActiveProject | |
| 1167 | browsePath.directoryPath.length > 0 && | |
| 1168 | browseEnvironmentId !== null && | |
| 1169 | !relativePathNeedsActiveProject; | |
| 1170 | const browseQuery = useEnvironmentQuery( | |
| 1171 | browseAccess.canReadFiles && hasBrowseTarget | |
| 1168 | 1172 | ? filesystemEnvironment.browse({ |
| 1169 | 1173 | environmentId: browseEnvironmentId, |
| 1170 | 1174 | input: { |
| ... | ... | @@ -1175,7 +1179,11 @@ function OpenCommandPaletteDialog(props: { |
| 1175 | 1179 | : null, |
| 1176 | 1180 | ); |
| 1177 | 1181 | const browseResult = browseQuery.data; |
| 1178 | const isBrowsePending = browseQuery.isPending; | |
| 1182 | const isBrowsePending = hasBrowseTarget && (browseAccess.isPending || browseQuery.isPending); | |
| 1183 | const browseAccessError = | |
| 1184 | hasBrowseTarget && !browseAccess.isPending && !browseAccess.canReadFiles | |
| 1185 | ? (browseAccess.error ?? "This connection cannot browse host folders.") | |
| 1186 | : null; | |
| 1179 | 1187 | const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; |
| 1180 | 1188 | const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( |
| 1181 | 1189 | () => |
| ... | ... | @@ -1202,7 +1210,10 @@ function OpenCommandPaletteDialog(props: { |
| 1202 | 1210 | const environment = environments.find( |
| 1203 | 1211 | (candidate) => candidate.environmentId === environmentId, |
| 1204 | 1212 | ); |
| 1205 | if (!canPreloadBrowsePath(environment?.connection.phase)) { | |
| 1213 | if ( | |
| 1214 | !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || | |
| 1215 | !canPreloadBrowsePath(environment?.connection.phase) | |
| 1216 | ) { | |
| 1206 | 1217 | return; |
| 1207 | 1218 | } |
| 1208 | 1219 | |
| ... | ... | @@ -2992,6 +3003,7 @@ function OpenCommandPaletteDialog(props: { |
| 2992 | 3003 | const willCreateProjectPath = |
| 2993 | 3004 | canSubmitBrowsePath && |
| 2994 | 3005 | !isBrowsePending && |
| 3006 | browseAccessError === null && | |
| 2995 | 3007 | query.trim().length > 0 && |
| 2996 | 3008 | !hasHighlightedBrowseItem && |
| 2997 | 3009 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); |
| ... | ... | @@ -3512,6 +3524,15 @@ function OpenCommandPaletteDialog(props: { |
| 3512 | 3524 | </div> |
| 3513 | 3525 | </div> |
| 3514 | 3526 | ) : null} |
| 3527 | {browseAccessError ? ( | |
| 3528 | <div role="alert" className="px-4 py-3 text-sm text-muted-foreground"> | |
| 3529 | {browseAccessError} | |
| 3530 | </div> | |
| 3531 | ) : isBrowsePending && browseResult === null ? ( | |
| 3532 | <div role="status" className="px-4 py-3 text-sm text-muted-foreground"> | |
| 3533 | Loading folders... | |
| 3534 | </div> | |
| 3535 | ) : null} | |
| 3515 | 3536 | <CommandPaletteVirtualizedResults |
| 3516 | 3537 | rows={resultRows.rows} |
| 3517 | 3538 | listRef={resultListRef} |
apps/web/src/components/DiffPanel.tsx+22-3| ... | ... | @@ -23,6 +23,7 @@ import * as Schema from "effect/Schema"; |
| 23 | 23 | import * as DateTime from "effect/DateTime"; |
| 24 | 24 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 25 | 25 | import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; |
| 26 | import { useFilesystemReadAccess } from "~/state/filesystem"; | |
| 26 | 27 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 27 | 28 | import { useFileContextMenuHandler } from "../fileContextMenu"; |
| 28 | 29 | import { type DraftId } from "../composerDraftStore"; |
| ... | ... | @@ -223,6 +224,8 @@ export default function DiffPanel({ |
| 223 | 224 | const activeThreadId = routeThreadRef?.threadId ?? null; |
| 224 | 225 | const activeThread = useThreadShell(routeThreadRef); |
| 225 | 226 | const activeThreadProjection = useThreadProjection(routeThreadRef)?.projection ?? null; |
| 227 | const fileAccess = useFilesystemReadAccess(activeThread?.environmentId ?? null); | |
| 228 | const { canReadFiles } = fileAccess; | |
| 226 | 229 | const activeProjectId = activeThread?.projectId ?? null; |
| 227 | 230 | const activeProject = useProject( |
| 228 | 231 | activeThread && activeProjectId |
| ... | ... | @@ -337,7 +340,7 @@ export default function DiffPanel({ |
| 337 | 340 | { enabled: isGitRepo && selectedTurn !== undefined }, |
| 338 | 341 | ); |
| 339 | 342 | const primaryBranchDiffPreview = useEnvironmentQuery( |
| 340 | selectedRunId === null && activeThread && activeCwd | |
| 343 | canReadFiles && selectedRunId === null && activeThread && activeCwd | |
| 341 | 344 | ? reviewEnvironment.diffPreview({ |
| 342 | 345 | environmentId: activeThread.environmentId, |
| 343 | 346 | input: { |
| ... | ... | @@ -354,7 +357,7 @@ export default function DiffPanel({ |
| 354 | 357 | serverConfig?.cwd !== undefined && |
| 355 | 358 | serverConfig.cwd !== activeCwd; |
| 356 | 359 | const fallbackBranchDiffPreview = useEnvironmentQuery( |
| 357 | shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig | |
| 360 | canReadFiles && shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig | |
| 358 | 361 | ? reviewEnvironment.diffPreview({ |
| 359 | 362 | environmentId: activeThread.environmentId, |
| 360 | 363 | input: { |
| ... | ... | @@ -386,6 +389,7 @@ export default function DiffPanel({ |
| 386 | 389 | return undefined; |
| 387 | 390 | } |
| 388 | 391 | |
| 392 | if (!canReadFiles) return undefined; | |
| 389 | 393 | return createGitDiffFileContentsLoader(getDiffFileContents, { |
| 390 | 394 | environmentId: activeThread.environmentId, |
| 391 | 395 | cwd: preview.cwd, |
| ... | ... | @@ -394,7 +398,14 @@ export default function DiffPanel({ |
| 394 | 398 | headRef: selectedGitSource.headRef, |
| 395 | 399 | cacheKey: selectedGitSource.diffHash, |
| 396 | 400 | }); |
| 397 | }, [activeThread, branchDiffPreview.data, getDiffFileContents, selectedGitSource, selectedRunId]); | |
| 401 | }, [ | |
| 402 | activeThread, | |
| 403 | branchDiffPreview.data, | |
| 404 | getDiffFileContents, | |
| 405 | canReadFiles, | |
| 406 | selectedGitSource, | |
| 407 | selectedRunId, | |
| 408 | ]); | |
| 398 | 409 | const loadDiffFilesRef = useRef(currentLoadDiffFiles); |
| 399 | 410 | loadDiffFilesRef.current = currentLoadDiffFiles; |
| 400 | 411 | const loadDiffFiles = useCallback<FileDiffContentsLoader>(async (fileDiff) => { |
| ... | ... | @@ -1051,6 +1062,14 @@ export default function DiffPanel({ |
| 1051 | 1062 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 1052 | 1063 | No completed turns yet. |
| 1053 | 1064 | </div> |
| 1065 | ) : selectedRunId === null && !canReadFiles ? ( | |
| 1066 | fileAccess.isPending ? ( | |
| 1067 | <DiffPanelLoadingState label="Checking file access..." /> | |
| 1068 | ) : ( | |
| 1069 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> | |
| 1070 | {fileAccess.error ?? "This connection cannot read local diffs."} | |
| 1071 | </div> | |
| 1072 | ) | |
| 1054 | 1073 | ) : ( |
| 1055 | 1074 | <> |
| 1056 | 1075 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"> |
apps/web/src/components/chat/ProposedPlanCard.tsx+14-4| ... | ... | @@ -3,7 +3,11 @@ import { |
| 3 | 3 | isAtomCommandInterrupted, |
| 4 | 4 | squashAtomCommandFailure, |
| 5 | 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 6 | import { | |
| 7 | AuthFilesystemWriteScope, | |
| 8 | type EnvironmentId, | |
| 9 | type ScopedThreadRef, | |
| 10 | } from "@t3tools/contracts"; | |
| 7 | 11 | import { |
| 8 | 12 | buildCollapsedProposedPlanPreviewMarkdown, |
| 9 | 13 | buildProposedPlanMarkdownFilename, |
| ... | ... | @@ -32,6 +36,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 32 | 36 | import { projectEnvironment } from "~/state/projects"; |
| 33 | 37 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 34 | 38 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 39 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 35 | 40 | |
| 36 | 41 | export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 37 | 42 | planMarkdown, |
| ... | ... | @@ -47,6 +52,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 47 | 52 | workspaceRoot: string | undefined; |
| 48 | 53 | }) { |
| 49 | 54 | const [expanded, setExpanded] = useState(false); |
| 55 | const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); | |
| 50 | 56 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); |
| 51 | 57 | const [savePath, setSavePath] = useState(""); |
| 52 | 58 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); |
| ... | ... | @@ -85,6 +91,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 85 | 91 | }; |
| 86 | 92 | |
| 87 | 93 | const openSaveDialog = () => { |
| 94 | if (!canWriteFiles) return; | |
| 88 | 95 | if (!workspaceRoot) { |
| 89 | 96 | toastManager.add( |
| 90 | 97 | stackedThreadToast({ |
| ... | ... | @@ -101,7 +108,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 101 | 108 | |
| 102 | 109 | const handleSaveToWorkspace = () => { |
| 103 | 110 | const relativePath = savePath.trim(); |
| 104 | if (!workspaceRoot) { | |
| 111 | if (!workspaceRoot || !readEnvironmentScope(environmentId, AuthFilesystemWriteScope)) { | |
| 105 | 112 | return; |
| 106 | 113 | } |
| 107 | 114 | if (!relativePath) { |
| ... | ... | @@ -165,7 +172,10 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 165 | 172 | {isCopied ? "Copied!" : "Copy to clipboard"} |
| 166 | 173 | </MenuItem> |
| 167 | 174 | <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> |
| 168 | <MenuItem onClick={openSaveDialog} disabled={!workspaceRoot || isSavingToWorkspace}> | |
| 175 | <MenuItem | |
| 176 | onClick={openSaveDialog} | |
| 177 | disabled={!canWriteFiles || !workspaceRoot || isSavingToWorkspace} | |
| 178 | > | |
| 169 | 179 | Save to workspace |
| 170 | 180 | </MenuItem> |
| 171 | 181 | </MenuPopup> |
| ... | ... | @@ -250,7 +260,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 250 | 260 | <Button |
| 251 | 261 | size="sm" |
| 252 | 262 | onClick={() => void handleSaveToWorkspace()} |
| 253 | disabled={isSavingToWorkspace} | |
| 263 | disabled={!canWriteFiles || isSavingToWorkspace} | |
| 254 | 264 | > |
| 255 | 265 | {isSavingToWorkspace ? "Saving..." : "Save"} |
| 256 | 266 | </Button> |
apps/web/src/components/files/FilePreviewPanel.tsx+39-4| ... | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | 6 | ScopedThreadRef, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; |
| 9 | import { AuthFilesystemWriteScope } from "@t3tools/contracts"; | |
| 9 | 10 | import { |
| 10 | 11 | isWorkspaceAudioPreviewPath, |
| 11 | 12 | isWorkspaceImagePreviewPath, |
| ... | ... | @@ -29,6 +30,7 @@ import { |
| 29 | 30 | import type { WorkerPoolManager } from "@pierre/diffs/worker"; |
| 30 | 31 | import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react"; |
| 31 | 32 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; |
| 33 | import { useFilesystemReadAccess } from "~/state/filesystem"; | |
| 32 | 34 | import { |
| 33 | 35 | isAtomCommandInterrupted, |
| 34 | 36 | squashAtomCommandFailure, |
| ... | ... | @@ -63,6 +65,7 @@ import { assetEnvironment } from "~/state/assets"; |
| 63 | 65 | import { usePreviewAvailable } from "~/browser/previewRuntime"; |
| 64 | 66 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; |
| 65 | 67 | import { previewEnvironment } from "~/state/preview"; |
| 68 | import { useEnvironmentScope } from "~/state/session"; | |
| 66 | 69 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 67 | 70 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 68 | 71 | |
| ... | ... | @@ -1038,6 +1041,9 @@ export default function FilePreviewPanel({ |
| 1038 | 1041 | // A file outside the workspace (an absolute path) is shown, never edited. |
| 1039 | 1042 | const isHostFile = |
| 1040 | 1043 | attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath)); |
| 1044 | const fileAccess = useFilesystemReadAccess(environmentId); | |
| 1045 | const { canReadFiles } = fileAccess; | |
| 1046 | const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); | |
| 1041 | 1047 | // Media and PDFs render from their absolute path, so their contents are never |
| 1042 | 1048 | // shown. The read still runs: a folder named `assets.png` is only knowable as a |
| 1043 | 1049 | // folder from the read failure, and the server stats before reading, so a folder |
| ... | ... | @@ -1164,7 +1170,7 @@ export default function FilePreviewPanel({ |
| 1164 | 1170 | }; |
| 1165 | 1171 | |
| 1166 | 1172 | const handleOpenInBrowser = useCallback(() => { |
| 1167 | if (!absolutePath || !environmentHttpBaseUrl) return; | |
| 1173 | if (!canReadFiles || !absolutePath || !environmentHttpBaseUrl) return; | |
| 1168 | 1174 | void (async () => { |
| 1169 | 1175 | const result = await openFileInPreview({ |
| 1170 | 1176 | threadRef, |
| ... | ... | @@ -1186,7 +1192,31 @@ export default function FilePreviewPanel({ |
| 1186 | 1192 | }), |
| 1187 | 1193 | ); |
| 1188 | 1194 | })(); |
| 1189 | }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]); | |
| 1195 | }, [ | |
| 1196 | absolutePath, | |
| 1197 | canReadFiles, | |
| 1198 | createAssetUrl, | |
| 1199 | cwd, | |
| 1200 | environmentHttpBaseUrl, | |
| 1201 | openPreview, | |
| 1202 | threadRef, | |
| 1203 | ]); | |
| 1204 | ||
| 1205 | if (attachment === undefined && !canReadFiles) { | |
| 1206 | if (fileAccess.isPending) { | |
| 1207 | return ( | |
| 1208 | <div className="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"> | |
| 1209 | <Spinner className="size-4" /> | |
| 1210 | Checking file access... | |
| 1211 | </div> | |
| 1212 | ); | |
| 1213 | } | |
| 1214 | return ( | |
| 1215 | <div className="p-4 text-sm text-muted-foreground"> | |
| 1216 | {fileAccess.error ?? "This connection cannot read host files."} | |
| 1217 | </div> | |
| 1218 | ); | |
| 1219 | } | |
| 1190 | 1220 | |
| 1191 | 1221 | return ( |
| 1192 | 1222 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"> |
| ... | ... | @@ -1266,6 +1296,11 @@ export default function FilePreviewPanel({ |
| 1266 | 1296 | ) : null} |
| 1267 | 1297 | </div> |
| 1268 | 1298 | ) : null} |
| 1299 | {relativePath && !attachment && !isHostFile && !canWriteFiles && !fileAccess.isPending ? ( | |
| 1300 | <div className="shrink-0 border-b px-3 py-1.5 text-2xs text-muted-foreground"> | |
| 1301 | Read-only connection. Unsaved edits are kept until write access returns. | |
| 1302 | </div> | |
| 1303 | ) : null} | |
| 1269 | 1304 | {previewPath && |
| 1270 | 1305 | attachment === undefined && |
| 1271 | 1306 | !isMedia && |
| ... | ... | @@ -1368,7 +1403,7 @@ export default function FilePreviewPanel({ |
| 1368 | 1403 | relativePath={relativePath} |
| 1369 | 1404 | threadRef={threadRef} |
| 1370 | 1405 | contents={file.data.contents} |
| 1371 | readOnly={isHostFile} | |
| 1406 | readOnly={isHostFile || !canWriteFiles} | |
| 1372 | 1407 | onPendingChange={onPendingChange} |
| 1373 | 1408 | /> |
| 1374 | 1409 | ) : tableDelimiter && renderTable ? ( |
| ... | ... | @@ -1378,7 +1413,7 @@ export default function FilePreviewPanel({ |
| 1378 | 1413 | text={file.data.contents} |
| 1379 | 1414 | delimiter={tableDelimiter} |
| 1380 | 1415 | /> |
| 1381 | ) : file.data.truncated || isHostFile ? ( | |
| 1416 | ) : file.data.truncated || isHostFile || !canWriteFiles ? ( | |
| 1382 | 1417 | <SourceFilePreview |
| 1383 | 1418 | name={relativePath} |
| 1384 | 1419 | text={file.data.contents} |
apps/web/src/components/files/fileSaveCoordinator.test.ts+25| ... | ... | @@ -93,6 +93,31 @@ describe("FileSaveCoordinator", () => { |
| 93 | 93 | expect(persist).toHaveBeenCalledWith("unsaved"); |
| 94 | 94 | }); |
| 95 | 95 | |
| 96 | it.each([false, true])( | |
| 97 | "keeps an edit pending after write permission is removed (closing=%s)", | |
| 98 | async (closeEditor) => { | |
| 99 | vi.useFakeTimers(); | |
| 100 | let canWrite = true; | |
| 101 | const persist = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); | |
| 102 | const onPendingChange = vi.fn(); | |
| 103 | const coordinator = new FileSaveCoordinator({ | |
| 104 | debounceMs: 500, | |
| 105 | canPersist: () => canWrite, | |
| 106 | persist, | |
| 107 | onPendingChange, | |
| 108 | onConfirmed: vi.fn(), | |
| 109 | }); | |
| 110 | ||
| 111 | coordinator.change("unsaved"); | |
| 112 | canWrite = false; | |
| 113 | if (closeEditor) coordinator.dispose(); | |
| 114 | await vi.runAllTimersAsync(); | |
| 115 | ||
| 116 | expect(persist).not.toHaveBeenCalled(); | |
| 117 | expect(onPendingChange).toHaveBeenLastCalledWith(true); | |
| 118 | }, | |
| 119 | ); | |
| 120 | ||
| 96 | 121 | it("flushes an edit made while a write was in flight when the editor closes", async () => { |
| 97 | 122 | vi.useFakeTimers(); |
| 98 | 123 | const inFlight = deferred(); |
apps/web/src/components/files/fileSaveCoordinator.ts+9-3| ... | ... | @@ -2,9 +2,11 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 2 | 2 | |
| 3 | 3 | export interface FileSaveCoordinatorOptions<A, E> { |
| 4 | 4 | readonly debounceMs: number; |
| 5 | readonly canPersist?: () => boolean; | |
| 5 | 6 | readonly persist: (contents: string) => Promise<AtomCommandResult<A, E>>; |
| 6 | 7 | readonly onPendingChange: (pending: boolean) => void; |
| 7 | readonly onConfirmed: (contents: string) => void; | |
| 8 | /** Return false when another editor has newer unsaved contents. */ | |
| 9 | readonly onConfirmed: (contents: string) => boolean | void; | |
| 8 | 10 | } |
| 9 | 11 | |
| 10 | 12 | export class FileSaveCoordinator<A = unknown, E = unknown> { |
| ... | ... | @@ -49,20 +51,24 @@ export class FileSaveCoordinator<A = unknown, E = unknown> { |
| 49 | 51 | |
| 50 | 52 | private async persistLatest(): Promise<void> { |
| 51 | 53 | if (this.saving || this.latestRevision === this.confirmedRevision) return; |
| 54 | if (this.options.canPersist?.() === false) { | |
| 55 | return; | |
| 56 | } | |
| 52 | 57 | |
| 53 | 58 | this.saving = true; |
| 54 | 59 | const contents = this.latestContents; |
| 55 | 60 | const revision = this.latestRevision; |
| 56 | 61 | const result = await this.options.persist(contents); |
| 57 | 62 | const succeeded = result._tag === "Success"; |
| 63 | let confirmed = false; | |
| 58 | 64 | if (succeeded) { |
| 59 | 65 | this.confirmedRevision = revision; |
| 60 | this.options.onConfirmed(contents); | |
| 66 | confirmed = this.options.onConfirmed(contents) !== false; | |
| 61 | 67 | } |
| 62 | 68 | |
| 63 | 69 | this.saving = false; |
| 64 | 70 | if (revision === this.latestRevision) { |
| 65 | if (succeeded) this.options.onPendingChange(false); | |
| 71 | if (confirmed) this.options.onPendingChange(false); | |
| 66 | 72 | return; |
| 67 | 73 | } |
| 68 | 74 |
apps/web/src/components/files/projectFilesQueryState.test.ts+142| ... | ... | @@ -1,23 +1,165 @@ |
| 1 | 1 | import type { ProjectReadFileResult } from "@t3tools/contracts"; |
| 2 | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { AsyncResult } from "effect/reactivity"; | |
| 3 | 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 5 | |
| 6 | const registryTasks = vi.hoisted(() => new Set<() => void>()); | |
| 7 | ||
| 8 | vi.mock("~/rpc/atomRegistry", async () => { | |
| 9 | const { AtomRegistry } = await import("effect/reactivity"); | |
| 10 | return { | |
| 11 | appAtomRegistry: AtomRegistry.make({ | |
| 12 | scheduleTask: (task) => { | |
| 13 | registryTasks.add(task); | |
| 14 | return () => { | |
| 15 | registryTasks.delete(task); | |
| 16 | }; | |
| 17 | }, | |
| 18 | }), | |
| 19 | }; | |
| 20 | }); | |
| 21 | ||
| 22 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 23 | import { projectEnvironment } from "~/state/projects"; | |
| 24 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; | |
| 5 | 25 | import { |
| 6 | 26 | clearProjectFileQueryData, |
| 7 | 27 | confirmProjectFileQueryData, |
| 8 | 28 | getOptimisticProjectFileQueryData, |
| 29 | getUnsavedProjectFileQueryData, | |
| 9 | 30 | resolveProjectFileQueryData, |
| 10 | 31 | setProjectFileQueryData, |
| 11 | 32 | } from "./projectFilesQueryState"; |
| 12 | 33 | |
| 13 | 34 | const environmentId = EnvironmentId.make("environment-project-files-query-test"); |
| 35 | const optimisticFile = projectEnvironment.optimisticFile({ | |
| 36 | environmentId, | |
| 37 | cwd: "/repo", | |
| 38 | relativePath: "convex.json", | |
| 39 | }); | |
| 40 | ||
| 41 | function drainRegistryTasks(): void { | |
| 42 | while (registryTasks.size > 0) { | |
| 43 | const tasks = [...registryTasks]; | |
| 44 | registryTasks.clear(); | |
| 45 | for (const task of tasks) task(); | |
| 46 | } | |
| 47 | } | |
| 14 | 48 | |
| 15 | 49 | describe("project files queries", () => { |
| 16 | 50 | afterEach(() => { |
| 17 | 51 | clearProjectFileQueryData(environmentId, "/repo", "convex.json"); |
| 52 | drainRegistryTasks(); | |
| 53 | vi.useRealTimers(); | |
| 18 | 54 | vi.unstubAllGlobals(); |
| 19 | 55 | }); |
| 20 | 56 | |
| 57 | it("resumes an unsaved draft after closing the preview and restoring write access", async () => { | |
| 58 | vi.stubGlobal("window", {}); | |
| 59 | vi.useFakeTimers(); | |
| 60 | const closePreview = appAtomRegistry.mount(optimisticFile); | |
| 61 | let canWrite = true; | |
| 62 | const persist = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); | |
| 63 | const onPendingChange = vi.fn(); | |
| 64 | const makeCoordinator = () => | |
| 65 | new FileSaveCoordinator({ | |
| 66 | debounceMs: 500, | |
| 67 | canPersist: () => canWrite, | |
| 68 | persist, | |
| 69 | onPendingChange, | |
| 70 | onConfirmed: (contents) => | |
| 71 | confirmProjectFileQueryData(environmentId, "/repo", "convex.json", contents), | |
| 72 | }); | |
| 73 | const initial = makeCoordinator(); | |
| 74 | setProjectFileQueryData(environmentId, "/repo", "convex.json", "unsaved draft"); | |
| 75 | initial.change("unsaved draft"); | |
| 76 | canWrite = false; | |
| 77 | initial.dispose(); | |
| 78 | closePreview(); | |
| 79 | await vi.runAllTimersAsync(); | |
| 80 | drainRegistryTasks(); | |
| 81 | ||
| 82 | expect(persist).not.toHaveBeenCalled(); | |
| 83 | expect(onPendingChange).toHaveBeenLastCalledWith(true); | |
| 84 | const unsaved = getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json"); | |
| 85 | expect(unsaved?.contents).toBe("unsaved draft"); | |
| 86 | ||
| 87 | canWrite = true; | |
| 88 | const reopened = makeCoordinator(); | |
| 89 | reopened.change(unsaved!.contents); | |
| 90 | await vi.advanceTimersByTimeAsync(500); | |
| 91 | ||
| 92 | expect(persist).toHaveBeenCalledOnce(); | |
| 93 | expect(persist).toHaveBeenCalledWith("unsaved draft"); | |
| 94 | expect(onPendingChange).toHaveBeenLastCalledWith(false); | |
| 95 | expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")).toBeNull(); | |
| 96 | reopened.dispose(); | |
| 97 | drainRegistryTasks(); | |
| 98 | expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); | |
| 99 | }); | |
| 100 | ||
| 101 | it("releases a retained unsaved draft when explicitly cleared", () => { | |
| 102 | setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft"); | |
| 103 | setProjectFileQueryData(environmentId, "/repo", "convex.json", "latest draft"); | |
| 104 | drainRegistryTasks(); | |
| 105 | expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe( | |
| 106 | "latest draft", | |
| 107 | ); | |
| 108 | ||
| 109 | clearProjectFileQueryData(environmentId, "/repo", "convex.json"); | |
| 110 | drainRegistryTasks(); | |
| 111 | expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); | |
| 112 | }); | |
| 113 | ||
| 114 | it("keeps a reopened editor's newer draft pending when the old editor's write finishes", async () => { | |
| 115 | vi.stubGlobal("window", {}); | |
| 116 | vi.useFakeTimers(); | |
| 117 | let canWrite = true; | |
| 118 | const saved = AsyncResult.success(undefined); | |
| 119 | let finishFirstWrite!: (result: typeof saved) => void; | |
| 120 | const firstWrite = new Promise<typeof saved>((resolve) => { | |
| 121 | finishFirstWrite = resolve; | |
| 122 | }); | |
| 123 | const persist = vi.fn().mockReturnValueOnce(firstWrite).mockResolvedValue(saved); | |
| 124 | const onPendingChange = vi.fn(); | |
| 125 | const makeCoordinator = () => | |
| 126 | new FileSaveCoordinator({ | |
| 127 | debounceMs: 500, | |
| 128 | canPersist: () => canWrite, | |
| 129 | persist, | |
| 130 | onPendingChange, | |
| 131 | onConfirmed: (contents) => | |
| 132 | confirmProjectFileQueryData(environmentId, "/repo", "convex.json", contents), | |
| 133 | }); | |
| 134 | ||
| 135 | const initial = makeCoordinator(); | |
| 136 | setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft"); | |
| 137 | initial.change("first draft"); | |
| 138 | await vi.advanceTimersByTimeAsync(500); | |
| 139 | initial.dispose(); | |
| 140 | ||
| 141 | const reopened = makeCoordinator(); | |
| 142 | setProjectFileQueryData(environmentId, "/repo", "convex.json", "newer draft"); | |
| 143 | reopened.change("newer draft"); | |
| 144 | canWrite = false; | |
| 145 | finishFirstWrite(saved); | |
| 146 | await vi.runAllTimersAsync(); | |
| 147 | ||
| 148 | expect(persist).toHaveBeenCalledOnce(); | |
| 149 | expect(onPendingChange).toHaveBeenLastCalledWith(true); | |
| 150 | expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe( | |
| 151 | "newer draft", | |
| 152 | ); | |
| 153 | ||
| 154 | canWrite = true; | |
| 155 | reopened.change("newer draft"); | |
| 156 | await vi.advanceTimersByTimeAsync(500); | |
| 157 | expect(persist).toHaveBeenLastCalledWith("newer draft"); | |
| 158 | expect(onPendingChange).toHaveBeenLastCalledWith(false); | |
| 159 | expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")).toBeNull(); | |
| 160 | reopened.dispose(); | |
| 161 | }); | |
| 162 | ||
| 21 | 163 | it("keeps the latest optimistic draft when an older write finishes", () => { |
| 22 | 164 | vi.stubGlobal("window", {}); |
| 23 | 165 | const initial = { |
apps/web/src/components/files/projectFilesQueryState.test.tsx+191-1| ... | ... | @@ -1,14 +1,35 @@ |
| 1 | 1 | import { |
| 2 | AuthFilesystemReadScope, | |
| 2 | 3 | EnvironmentId, |
| 4 | type AuthSessionState, | |
| 3 | 5 | type ProjectListEntriesResult, |
| 4 | 6 | ProjectReadFileError, |
| 5 | 7 | type ProjectReadFileResult, |
| 6 | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; | |
| 7 | 10 | import * as Effect from "effect/Effect"; |
| 8 | 11 | import * as Schema from "effect/Schema"; |
| 9 | import { Atom, AtomRegistry } from "effect/reactivity"; | |
| 12 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | |
| 10 | 13 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 11 | 14 | |
| 15 | const authorizationMocks = vi.hoisted(() => ({ | |
| 16 | sessionAtom: null as Atom.Atom< | |
| 17 | AsyncResult.AsyncResult<Pick<AuthSessionState, "authenticated" | "scopes">, Error> | |
| 18 | > | null, | |
| 19 | phase: "connected" as "connected" | "offline", | |
| 20 | })); | |
| 21 | ||
| 22 | vi.mock("~/state/session", () => ({ | |
| 23 | environmentSession: { sessionStateAtom: () => authorizationMocks.sessionAtom }, | |
| 24 | })); | |
| 25 | ||
| 26 | vi.mock("~/state/presentation", () => ({ | |
| 27 | useEnvironmentPresentation: () => ({ | |
| 28 | isReady: true, | |
| 29 | presentation: { connection: { phase: authorizationMocks.phase, error: null } }, | |
| 30 | }), | |
| 31 | })); | |
| 32 | ||
| 12 | 33 | const projectMocks = vi.hoisted(() => ({ |
| 13 | 34 | listEntries: vi.fn(), |
| 14 | 35 | optimisticFile: vi.fn(), |
| ... | ... | @@ -64,6 +85,7 @@ vi.mock("react", async (importOriginal) => { |
| 64 | 85 | ...actual, |
| 65 | 86 | useCallback: reactHooks.useCallback, |
| 66 | 87 | useEffect: reactHooks.useEffect, |
| 88 | useMemo: <A,>(factory: () => A) => factory(), | |
| 67 | 89 | useRef: reactHooks.useRef, |
| 68 | 90 | }; |
| 69 | 91 | }); |
| ... | ... | @@ -77,6 +99,7 @@ vi.mock("~/state/queries", () => ({ |
| 77 | 99 | })); |
| 78 | 100 | |
| 79 | 101 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| 102 | import { useT3ProjectFileState } from "~/hooks/useT3ProjectFileScripts"; | |
| 80 | 103 | import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; |
| 81 | 104 | |
| 82 | 105 | const environmentId = EnvironmentId.make("environment-1"); |
| ... | ... | @@ -114,12 +137,179 @@ async function flushEffects(): Promise<void> { |
| 114 | 137 | |
| 115 | 138 | describe("project query refresh", () => { |
| 116 | 139 | beforeEach(() => { |
| 140 | authorizationMocks.sessionAtom = Atom.make( | |
| 141 | AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }), | |
| 142 | ); | |
| 143 | authorizationMocks.phase = "connected"; | |
| 117 | 144 | projectMocks.listEntries.mockReset(); |
| 118 | 145 | projectMocks.optimisticFile.mockReset(); |
| 119 | 146 | projectMocks.readFile.mockReset(); |
| 120 | 147 | reactHooks.reset(); |
| 121 | 148 | }); |
| 122 | 149 | |
| 150 | it("does not query or expose optimistic file contents without read permission", () => { | |
| 151 | authorizationMocks.sessionAtom = Atom.make( | |
| 152 | AsyncResult.success({ authenticated: true, scopes: [] }), | |
| 153 | ); | |
| 154 | const registry = AtomRegistry.make(); | |
| 155 | atomHooks.registry = registry; | |
| 156 | projectMocks.optimisticFile.mockReturnValue(Atom.make({ data: file("cached contents") })); | |
| 157 | try { | |
| 158 | const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); | |
| 159 | expect(projectMocks.readFile).not.toHaveBeenCalled(); | |
| 160 | expect(query.data).toBeNull(); | |
| 161 | expect(query.error).toBe("This connection cannot read host files."); | |
| 162 | expect(query.isPending).toBe(false); | |
| 163 | const entries = useProjectEntriesQuery(environmentId, "/repo"); | |
| 164 | expect(projectMocks.listEntries).not.toHaveBeenCalled(); | |
| 165 | expect(entries.data).toBeNull(); | |
| 166 | expect(entries.error).toBe("This connection cannot read host files."); | |
| 167 | expect(entries.isPending).toBe(false); | |
| 168 | } finally { | |
| 169 | registry.dispose(); | |
| 170 | atomHooks.registry = null; | |
| 171 | } | |
| 172 | }); | |
| 173 | ||
| 174 | it("keeps t3.json and the file tree loading until the file grant arrives", () => { | |
| 175 | authorizationMocks.sessionAtom = Atom.make(AsyncResult.initial()); | |
| 176 | const registry = AtomRegistry.make(); | |
| 177 | atomHooks.registry = registry; | |
| 178 | const config = { | |
| 179 | defaultThreadEnvMode: "worktree", | |
| 180 | scripts: [{ name: "Test", command: "vp test" }], | |
| 181 | }; | |
| 182 | projectMocks.readFile.mockReturnValue( | |
| 183 | Atom.make(AsyncResult.success(file(JSON.stringify(config)))), | |
| 184 | ); | |
| 185 | projectMocks.listEntries.mockReturnValue( | |
| 186 | Atom.make(AsyncResult.success(projectEntries(["t3.json"]))), | |
| 187 | ); | |
| 188 | projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); | |
| 189 | try { | |
| 190 | expect(useProjectFileQuery(environmentId, "/repo", "t3.json")).toMatchObject({ | |
| 191 | data: null, | |
| 192 | error: null, | |
| 193 | isPending: true, | |
| 194 | }); | |
| 195 | expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject({ | |
| 196 | data: null, | |
| 197 | error: null, | |
| 198 | isPending: true, | |
| 199 | }); | |
| 200 | expect(useT3ProjectFileState(environmentId, "/repo").status).toBe("loading"); | |
| 201 | expect(projectMocks.readFile).not.toHaveBeenCalled(); | |
| 202 | expect(projectMocks.listEntries).not.toHaveBeenCalled(); | |
| 203 | ||
| 204 | authorizationMocks.sessionAtom = Atom.make( | |
| 205 | AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }), | |
| 206 | ); | |
| 207 | expect(useT3ProjectFileState(environmentId, "/repo")).toEqual({ | |
| 208 | status: "valid", | |
| 209 | file: config, | |
| 210 | scripts: config.scripts, | |
| 211 | }); | |
| 212 | expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject({ | |
| 213 | data: projectEntries(["t3.json"]), | |
| 214 | error: null, | |
| 215 | isPending: false, | |
| 216 | }); | |
| 217 | } finally { | |
| 218 | registry.dispose(); | |
| 219 | atomHooks.registry = null; | |
| 220 | } | |
| 221 | }); | |
| 222 | ||
| 223 | it.each(["connected", "offline"] as const)( | |
| 224 | "preserves cached t3.json defaults and scripts during a granted refresh while %s", | |
| 225 | (phase) => { | |
| 226 | authorizationMocks.phase = phase; | |
| 227 | authorizationMocks.sessionAtom = Atom.make( | |
| 228 | AsyncResult.success( | |
| 229 | { authenticated: true, scopes: [AuthFilesystemReadScope] }, | |
| 230 | { waiting: true }, | |
| 231 | ), | |
| 232 | ); | |
| 233 | const registry = AtomRegistry.make(); | |
| 234 | atomHooks.registry = registry; | |
| 235 | const config = { | |
| 236 | defaultThreadEnvMode: "worktree", | |
| 237 | scripts: [{ name: "Test", command: "vp test" }], | |
| 238 | }; | |
| 239 | projectMocks.readFile.mockReturnValue( | |
| 240 | Atom.make(AsyncResult.success(file(JSON.stringify(config)), { waiting: true })), | |
| 241 | ); | |
| 242 | projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); | |
| 243 | try { | |
| 244 | expect(useProjectFileQuery(environmentId, "/repo", "t3.json")).toMatchObject({ | |
| 245 | error: null, | |
| 246 | isPending: true, | |
| 247 | }); | |
| 248 | expect(useT3ProjectFileState(environmentId, "/repo")).toEqual({ | |
| 249 | status: "valid", | |
| 250 | file: config, | |
| 251 | scripts: config.scripts, | |
| 252 | }); | |
| 253 | } finally { | |
| 254 | registry.dispose(); | |
| 255 | atomHooks.registry = null; | |
| 256 | } | |
| 257 | }, | |
| 258 | ); | |
| 259 | ||
| 260 | it.each([ | |
| 261 | { phase: "connected", sessionError: "The session request timed out." }, | |
| 262 | { phase: "offline", sessionError: null }, | |
| 263 | ] as const)( | |
| 264 | "reports unavailable file access for $phase connections", | |
| 265 | ({ phase, sessionError }) => { | |
| 266 | authorizationMocks.phase = phase; | |
| 267 | authorizationMocks.sessionAtom = Atom.make( | |
| 268 | sessionError === null | |
| 269 | ? AsyncResult.initial() | |
| 270 | : AsyncResult.failure(Cause.fail(new Error(sessionError))), | |
| 271 | ); | |
| 272 | const registry = AtomRegistry.make(); | |
| 273 | atomHooks.registry = registry; | |
| 274 | projectMocks.optimisticFile.mockReturnValue(Atom.make({ data: file("cached contents") })); | |
| 275 | try { | |
| 276 | const unavailable = { | |
| 277 | data: null, | |
| 278 | error: sessionError ?? "This environment is not connected.", | |
| 279 | isPending: false, | |
| 280 | }; | |
| 281 | expect(useProjectFileQuery(environmentId, "/repo", "src/preview.ts")).toMatchObject( | |
| 282 | unavailable, | |
| 283 | ); | |
| 284 | expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject(unavailable); | |
| 285 | expect(projectMocks.readFile).not.toHaveBeenCalled(); | |
| 286 | expect(projectMocks.listEntries).not.toHaveBeenCalled(); | |
| 287 | } finally { | |
| 288 | registry.dispose(); | |
| 289 | atomHooks.registry = null; | |
| 290 | } | |
| 291 | }, | |
| 292 | ); | |
| 293 | ||
| 294 | it("leaves disabled file queries idle while the file grant loads", () => { | |
| 295 | authorizationMocks.sessionAtom = Atom.make(AsyncResult.initial()); | |
| 296 | const registry = AtomRegistry.make(); | |
| 297 | atomHooks.registry = registry; | |
| 298 | projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); | |
| 299 | try { | |
| 300 | expect(useProjectFileQuery(environmentId, "/repo", "t3.json", false)).toMatchObject({ | |
| 301 | data: null, | |
| 302 | error: null, | |
| 303 | isPending: false, | |
| 304 | }); | |
| 305 | expect(useT3ProjectFileState(environmentId, null).status).toBe("missing"); | |
| 306 | expect(projectMocks.readFile).not.toHaveBeenCalled(); | |
| 307 | } finally { | |
| 308 | registry.dispose(); | |
| 309 | atomHooks.registry = null; | |
| 310 | } | |
| 311 | }); | |
| 312 | ||
| 123 | 313 | it("replaces an in-flight initial read when a workspace mutation arrives", async () => { |
| 124 | 314 | const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = []; |
| 125 | 315 | const readAtom = Atom.make( |
apps/web/src/components/files/projectFilesQueryState.ts+57-11| ... | ... | @@ -12,11 +12,15 @@ import { AsyncResult, Atom } from "effect/reactivity"; |
| 12 | 12 | import { useCallback } from "react"; |
| 13 | 13 | |
| 14 | 14 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 15 | import { useFilesystemReadAccess } from "~/state/filesystem"; | |
| 15 | 16 | import { projectEnvironment } from "~/state/projects"; |
| 16 | 17 | import { useProjectPathSearch } from "~/state/queries"; |
| 17 | 18 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 18 | 19 | |
| 19 | 20 | const EMPTY_PROJECT_FILE_PATH = ""; |
| 21 | const EMPTY_PROJECT_ENTRIES_QUERY_ATOM = Atom.make( | |
| 22 | AsyncResult.initial<ProjectListEntriesResult, never>(false), | |
| 23 | ); | |
| 20 | 24 | const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( |
| 21 | 25 | AsyncResult.initial<ProjectReadFileResult, never>(false), |
| 22 | 26 | ).pipe(Atom.withLabel("project-file-query:empty")); |
| ... | ... | @@ -29,6 +33,15 @@ export function optimisticFileAtom( |
| 29 | 33 | return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); |
| 30 | 34 | } |
| 31 | 35 | |
| 36 | // Dirty contents must survive a closed preview, including failed or unauthorized saves. | |
| 37 | const unsavedFileMounts = new Map<ReturnType<typeof optimisticFileAtom>, () => void>(); | |
| 38 | ||
| 39 | function releaseUnsavedFile(atom: ReturnType<typeof optimisticFileAtom>): void { | |
| 40 | const unmount = unsavedFileMounts.get(atom); | |
| 41 | unsavedFileMounts.delete(atom); | |
| 42 | unmount?.(); | |
| 43 | } | |
| 44 | ||
| 32 | 45 | interface ProjectQueryState<A> { |
| 33 | 46 | readonly data: A | null; |
| 34 | 47 | readonly error: string | null; |
| ... | ... | @@ -70,7 +83,11 @@ export function setProjectFileQueryData( |
| 70 | 83 | relativePath: string, |
| 71 | 84 | contents: string, |
| 72 | 85 | ): void { |
| 73 | appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), { | |
| 86 | const atom = optimisticFileAtom(environmentId, cwd, relativePath); | |
| 87 | if (!unsavedFileMounts.has(atom)) { | |
| 88 | unsavedFileMounts.set(atom, appAtomRegistry.mount(atom)); | |
| 89 | } | |
| 90 | appAtomRegistry.set(atom, { | |
| 74 | 91 | confirmedAgainst: undefined, |
| 75 | 92 | data: { |
| 76 | 93 | relativePath, |
| ... | ... | @@ -101,6 +118,15 @@ export function getProjectFileContents( |
| 101 | 118 | return Option.getOrUndefined(AsyncResult.value(result))?.contents; |
| 102 | 119 | } |
| 103 | 120 | |
| 121 | export function getUnsavedProjectFileQueryData( | |
| 122 | environmentId: EnvironmentId, | |
| 123 | cwd: string, | |
| 124 | relativePath: string, | |
| 125 | ): ProjectReadFileResult | null { | |
| 126 | const optimistic = appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath)); | |
| 127 | return optimistic?.confirmedAgainst === undefined ? (optimistic?.data ?? null) : null; | |
| 128 | } | |
| 129 | ||
| 104 | 130 | export function confirmProjectFileQueryData( |
| 105 | 131 | environmentId: EnvironmentId, |
| 106 | 132 | cwd: string, |
| ... | ... | @@ -117,6 +143,7 @@ export function confirmProjectFileQueryData( |
| 117 | 143 | confirmedAgainst: appAtomRegistry.get(queryAtom), |
| 118 | 144 | }; |
| 119 | 145 | appAtomRegistry.set(atom, confirmed); |
| 146 | releaseUnsavedFile(atom); | |
| 120 | 147 | appAtomRegistry.refresh(queryAtom); |
| 121 | 148 | void executeAtomQuery(appAtomRegistry, queryAtom, { |
| 122 | 149 | reportDefect: false, |
| ... | ... | @@ -144,7 +171,9 @@ export function clearProjectFileQueryData( |
| 144 | 171 | cwd: string, |
| 145 | 172 | relativePath: string, |
| 146 | 173 | ): void { |
| 147 | appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null); | |
| 174 | const atom = optimisticFileAtom(environmentId, cwd, relativePath); | |
| 175 | appAtomRegistry.set(atom, null); | |
| 176 | releaseUnsavedFile(atom); | |
| 148 | 177 | } |
| 149 | 178 | |
| 150 | 179 | function failureCause<A>(result: AsyncResult.AsyncResult<A, unknown>): unknown { |
| ... | ... | @@ -163,14 +192,22 @@ export function useProjectEntriesQuery( |
| 163 | 192 | cwd: string, |
| 164 | 193 | directoryPath?: string, |
| 165 | 194 | ): ProjectQueryState<ProjectListEntriesResult> { |
| 166 | const atom = getProjectEntriesQueryAtom(environmentId, cwd, directoryPath); | |
| 195 | const fileAccess = useFilesystemReadAccess(environmentId); | |
| 196 | const { canReadFiles } = fileAccess; | |
| 197 | const atom = canReadFiles | |
| 198 | ? getProjectEntriesQueryAtom(environmentId, cwd, directoryPath) | |
| 199 | : EMPTY_PROJECT_ENTRIES_QUERY_ATOM; | |
| 167 | 200 | const result = useAtomValue(atom); |
| 168 | 201 | const refreshAtom = useAtomRefresh(atom); |
| 169 | 202 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
| 170 | 203 | return { |
| 171 | 204 | data: Option.getOrNull(AsyncResult.value(result)), |
| 172 | error: errorMessage(failureCause(result)), | |
| 173 | isPending: result.waiting, | |
| 205 | error: fileAccess.isPending | |
| 206 | ? null | |
| 207 | : canReadFiles | |
| 208 | ? errorMessage(failureCause(result)) | |
| 209 | : (fileAccess.error ?? "This connection cannot read host files."), | |
| 210 | isPending: fileAccess.isPending || result.waiting, | |
| 174 | 211 | refresh, |
| 175 | 212 | }; |
| 176 | 213 | } |
| ... | ... | @@ -218,9 +255,13 @@ export function useProjectFileQuery( |
| 218 | 255 | ): ProjectFileQueryState { |
| 219 | 256 | // The caller decides what to read. A media path is not skipped here: a folder |
| 220 | 257 | // named `assets.png` is only knowable as a folder from the read failure. |
| 221 | const atom = enabled | |
| 222 | ? getProjectFileQueryAtom(environmentId, cwd, relativePath) | |
| 223 | : EMPTY_PROJECT_FILE_QUERY_ATOM; | |
| 258 | const fileAccess = useFilesystemReadAccess(environmentId); | |
| 259 | const { canReadFiles } = fileAccess; | |
| 260 | const isQueryEnabled = enabled; | |
| 261 | const atom = | |
| 262 | enabled && canReadFiles | |
| 263 | ? getProjectFileQueryAtom(environmentId, cwd, relativePath) | |
| 264 | : EMPTY_PROJECT_FILE_QUERY_ATOM; | |
| 224 | 265 | const result = useAtomValue(atom); |
| 225 | 266 | const refreshAtom = useAtomRefresh(atom); |
| 226 | 267 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
| ... | ... | @@ -233,11 +274,16 @@ export function useProjectFileQuery( |
| 233 | 274 | const readError = isProjectReadFileError(cause) ? cause : null; |
| 234 | 275 | |
| 235 | 276 | return { |
| 236 | data: optimisticFile?.data ?? data, | |
| 237 | error: errorMessage(cause), | |
| 277 | data: canReadFiles ? (optimisticFile?.data ?? data) : null, | |
| 278 | error: | |
| 279 | !isQueryEnabled || fileAccess.isPending | |
| 280 | ? null | |
| 281 | : canReadFiles | |
| 282 | ? errorMessage(cause) | |
| 283 | : (fileAccess.error ?? "This connection cannot read host files."), | |
| 284 | isPending: isQueryEnabled && (fileAccess.isPending || result.waiting), | |
| 238 | 285 | readError, |
| 239 | 286 | isNotFile: readError?.failure === "path_not_file", |
| 240 | isPending: result.waiting, | |
| 241 | 287 | refresh, |
| 242 | 288 | }; |
| 243 | 289 | } |
apps/web/src/components/files/useFileSaveCoordinator.test.tsx+64-2| ... | ... | @@ -4,13 +4,22 @@ import { act, StrictMode } from "react"; |
| 4 | 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 6 | |
| 7 | const { writeFile, confirmFile } = vi.hoisted(() => ({ | |
| 7 | const { writeFile, confirmFile, readScope, getUnsavedFile } = vi.hoisted(() => ({ | |
| 8 | 8 | writeFile: vi.fn(), |
| 9 | 9 | confirmFile: vi.fn(), |
| 10 | readScope: vi.fn(), | |
| 11 | getUnsavedFile: vi.fn(), | |
| 10 | 12 | })); |
| 11 | 13 | vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } })); |
| 14 | vi.mock("~/state/session", () => ({ | |
| 15 | readEnvironmentScope: readScope, | |
| 16 | useEnvironmentScope: readScope, | |
| 17 | })); | |
| 12 | 18 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile })); |
| 13 | vi.mock("./projectFilesQueryState", () => ({ confirmProjectFileQueryData: confirmFile })); | |
| 19 | vi.mock("./projectFilesQueryState", () => ({ | |
| 20 | confirmProjectFileQueryData: confirmFile, | |
| 21 | getUnsavedProjectFileQueryData: getUnsavedFile, | |
| 22 | })); | |
| 14 | 23 | |
| 15 | 24 | import { setMarkdownTaskChecked } from "./filePreviewMode"; |
| 16 | 25 | import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; |
| ... | ... | @@ -54,6 +63,8 @@ beforeEach(() => { |
| 54 | 63 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 55 | 64 | writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); |
| 56 | 65 | confirmFile.mockReset(); |
| 66 | readScope.mockReset().mockReturnValue(true); | |
| 67 | getUnsavedFile.mockReset().mockReturnValue(null); | |
| 57 | 68 | onPendingChange.mockReset(); |
| 58 | 69 | }); |
| 59 | 70 | |
| ... | ... | @@ -124,6 +135,57 @@ describe("file-save React lifecycle", () => { |
| 124 | 135 | expect(writeFile.mock.calls[0]![0].input.contents).toBe("pending edit"); |
| 125 | 136 | }); |
| 126 | 137 | |
| 138 | it.each([false, true])( | |
| 139 | "keeps edits pending after permission is revoked before a React update (unmount: %s)", | |
| 140 | async (unmount) => { | |
| 141 | mount(); | |
| 142 | changeHandler()("pending edit"); | |
| 143 | readScope.mockReturnValue(false); | |
| 144 | if (unmount) { | |
| 145 | await act(async () => renderer!.unmount()); | |
| 146 | renderer = null; | |
| 147 | } | |
| 148 | await vi.runAllTimersAsync(); | |
| 149 | expect(writeFile).not.toHaveBeenCalled(); | |
| 150 | expect(confirmFile).not.toHaveBeenCalled(); | |
| 151 | expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", true); | |
| 152 | }, | |
| 153 | ); | |
| 154 | ||
| 155 | it("resumes an unsaved draft when write permission returns after effect replay", async () => { | |
| 156 | readScope.mockReturnValue(false); | |
| 157 | getUnsavedFile.mockReturnValue({ contents: "pending draft" }); | |
| 158 | mount(); | |
| 159 | await vi.runAllTimersAsync(); | |
| 160 | expect(writeFile).not.toHaveBeenCalled(); | |
| 161 | ||
| 162 | readScope.mockReturnValue(true); | |
| 163 | act(() => | |
| 164 | renderer!.update( | |
| 165 | <StrictMode> | |
| 166 | <FileSurface {...defaultProps} /> | |
| 167 | </StrictMode>, | |
| 168 | ), | |
| 169 | ); | |
| 170 | await vi.advanceTimersByTimeAsync(500); | |
| 171 | expect(writeFile).toHaveBeenCalledExactlyOnceWith({ | |
| 172 | environmentId, | |
| 173 | input: { cwd: "/workspace", relativePath: "file.txt", contents: "pending draft" }, | |
| 174 | }); | |
| 175 | expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); | |
| 176 | }); | |
| 177 | ||
| 178 | it("recovers an existing draft once after StrictMode setup replay", async () => { | |
| 179 | getUnsavedFile.mockReturnValue({ contents: "reopened draft" }); | |
| 180 | mount(); | |
| 181 | await vi.runAllTimersAsync(); | |
| 182 | expect(writeFile).toHaveBeenCalledExactlyOnceWith({ | |
| 183 | environmentId, | |
| 184 | input: { cwd: "/workspace", relativePath: "file.txt", contents: "reopened draft" }, | |
| 185 | }); | |
| 186 | expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); | |
| 187 | }); | |
| 188 | ||
| 127 | 189 | it.each([ |
| 128 | 190 | { relativePath: "other.txt" }, |
| 129 | 191 | { cwd: "/other-workspace" }, |
apps/web/src/components/files/useFileSaveCoordinator.ts+23-5| ... | ... | @@ -1,11 +1,15 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { AuthFilesystemWriteScope, type EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 2 | import { createRef, useEffect, useMemo } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { projectEnvironment } from "~/state/projects"; |
| 5 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 5 | 6 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 6 | 7 | |
| 7 | 8 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; |
| 8 | import { confirmProjectFileQueryData } from "./projectFilesQueryState"; | |
| 9 | import { | |
| 10 | confirmProjectFileQueryData, | |
| 11 | getUnsavedProjectFileQueryData, | |
| 12 | } from "./projectFilesQueryState"; | |
| 9 | 13 | |
| 10 | 14 | const FILE_SAVE_DEBOUNCE_MS = 500; |
| 11 | 15 | |
| ... | ... | @@ -22,6 +26,7 @@ export function useFileSaveCoordinator({ |
| 22 | 26 | relativePath, |
| 23 | 27 | onPendingChange, |
| 24 | 28 | }: FileSaveOptions): Pick<FileSaveCoordinator, "change"> { |
| 29 | const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); | |
| 25 | 30 | const writeFile = useAtomCommand(projectEnvironment.writeFile); |
| 26 | 31 | const session = useMemo(() => { |
| 27 | 32 | const coordinatorRef = createRef<FileSaveCoordinator>(); |
| ... | ... | @@ -30,15 +35,15 @@ export function useFileSaveCoordinator({ |
| 30 | 35 | setup: () => { |
| 31 | 36 | const coordinator = new FileSaveCoordinator({ |
| 32 | 37 | debounceMs: FILE_SAVE_DEBOUNCE_MS, |
| 38 | canPersist: () => readEnvironmentScope(environmentId, AuthFilesystemWriteScope), | |
| 33 | 39 | onPendingChange: (pending) => onPendingChange(relativePath, pending), |
| 34 | 40 | persist: (nextContents) => |
| 35 | 41 | writeFile({ |
| 36 | 42 | environmentId, |
| 37 | 43 | input: { cwd, relativePath, contents: nextContents }, |
| 38 | 44 | }), |
| 39 | onConfirmed: (confirmedContents) => { | |
| 40 | confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); | |
| 41 | }, | |
| 45 | onConfirmed: (confirmedContents) => | |
| 46 | confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents), | |
| 42 | 47 | }); |
| 43 | 48 | coordinatorRef.current = coordinator; |
| 44 | 49 | return () => { |
| ... | ... | @@ -52,5 +57,18 @@ export function useFileSaveCoordinator({ |
| 52 | 57 | // StrictMode replays effect setup. Retired file sessions stay inert, while the |
| 53 | 58 | // replay gets a fresh coordinator instead of reusing a disposed one. |
| 54 | 59 | useEffect(session.setup, [session]); |
| 60 | useEffect(() => { | |
| 61 | if (!canWriteFiles) return; | |
| 62 | let cancelled = false; | |
| 63 | // Replay must retire the first session before recovery queues a draft to flush. | |
| 64 | queueMicrotask(() => { | |
| 65 | if (cancelled) return; | |
| 66 | const unsaved = getUnsavedProjectFileQueryData(environmentId, cwd, relativePath); | |
| 67 | if (unsaved) session.change(unsaved.contents); | |
| 68 | }); | |
| 69 | return () => { | |
| 70 | cancelled = true; | |
| 71 | }; | |
| 72 | }, [canWriteFiles, cwd, environmentId, relativePath, session]); | |
| 55 | 73 | return session; |
| 56 | 74 | } |
apps/web/src/components/media/MediaActions.test.tsx created+307| ... | ... | @@ -0,0 +1,307 @@ |
| 1 | import { | |
| 2 | AuthFilesystemReadScope, | |
| 3 | EnvironmentId, | |
| 4 | ThreadId, | |
| 5 | type AuthSessionState, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { AsyncResult } from "effect/reactivity"; | |
| 8 | import { createElement, isValidElement, type ReactNode } from "react"; | |
| 9 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 10 | ||
| 11 | function deferred<T>() { | |
| 12 | let resolve!: (value: T | PromiseLike<T>) => void; | |
| 13 | const promise = new Promise<T>((resolvePromise) => { | |
| 14 | resolve = resolvePromise; | |
| 15 | }); | |
| 16 | return { promise, resolve }; | |
| 17 | } | |
| 18 | ||
| 19 | const state = vi.hoisted(() => ({ | |
| 20 | sessions: new Map<string, Pick<AuthSessionState, "authenticated" | "scopes">>(), | |
| 21 | mint: vi.fn(), | |
| 22 | download: vi.fn(), | |
| 23 | png: vi.fn(), | |
| 24 | showMenu: vi.fn(), | |
| 25 | openFile: vi.fn(), | |
| 26 | clipboard: vi.fn(), | |
| 27 | menuFinished: null as (() => void) | null, | |
| 28 | })); | |
| 29 | ||
| 30 | vi.mock("react", async (importOriginal) => ({ | |
| 31 | ...(await importOriginal<typeof import("react")>()), | |
| 32 | useCallback: <A,>(callback: A) => callback, | |
| 33 | useRef: <A,>(current: A) => ({ current }), | |
| 34 | useState: <A,>(initial: A) => [initial, () => {}], | |
| 35 | })); | |
| 36 | vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() })); | |
| 37 | vi.mock("../../localApi", () => ({ | |
| 38 | readLocalApi: () => ({ contextMenu: { show: state.showMenu } }), | |
| 39 | })); | |
| 40 | vi.mock("../../state/assets", () => ({ assetEnvironment: { createUrl: {} } })); | |
| 41 | vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); | |
| 42 | vi.mock("../../state/session", () => ({ | |
| 43 | environmentSession: { sessionStateAtom: (environmentId: string) => environmentId }, | |
| 44 | readPreparedConnection: () => ({ httpBaseUrl: "https://host.test" }), | |
| 45 | })); | |
| 46 | vi.mock("../../state/query", () => ({ | |
| 47 | useEnvironmentQuery: (environmentId: string) => ({ | |
| 48 | data: state.sessions.get(environmentId) ?? null, | |
| 49 | error: null, | |
| 50 | }), | |
| 51 | })); | |
| 52 | vi.mock("../../rpc/atomRegistry", () => ({ | |
| 53 | appAtomRegistry: { | |
| 54 | get: (environmentId: string) => { | |
| 55 | const session = state.sessions.get(environmentId); | |
| 56 | return session === undefined ? AsyncResult.initial() : AsyncResult.success(session); | |
| 57 | }, | |
| 58 | }, | |
| 59 | })); | |
| 60 | vi.mock("./mediaContent", () => ({ downloadMedia: state.download, readMediaPng: state.png })); | |
| 61 | vi.mock("../ui/tooltip", () => ({ | |
| 62 | Tooltip: "Tooltip", | |
| 63 | TooltipTrigger: "TooltipTrigger", | |
| 64 | TooltipPopup: "TooltipPopup", | |
| 65 | })); | |
| 66 | vi.mock("../ui/toast", () => ({ | |
| 67 | stackedThreadToast: <A,>(toast: A) => toast, | |
| 68 | toastManager: { | |
| 69 | add: (toast: { type: string }) => { | |
| 70 | if (toast.type !== "loading") state.menuFinished?.(); | |
| 71 | return "toast"; | |
| 72 | }, | |
| 73 | update: () => state.menuFinished?.(), | |
| 74 | }, | |
| 75 | })); | |
| 76 | ||
| 77 | import { MediaActions, useMediaActions, type MediaActionSource } from "./MediaActions"; | |
| 78 | ||
| 79 | const environmentId = EnvironmentId.make("media-environment"); | |
| 80 | const otherEnvironmentId = EnvironmentId.make("other-environment"); | |
| 81 | const threadId = ThreadId.make("media-thread"); | |
| 82 | const granted: Pick<AuthSessionState, "authenticated" | "scopes"> = { | |
| 83 | authenticated: true, | |
| 84 | scopes: [AuthFilesystemReadScope], | |
| 85 | }; | |
| 86 | const denied: Pick<AuthSessionState, "authenticated" | "scopes"> = { | |
| 87 | authenticated: true, | |
| 88 | scopes: [], | |
| 89 | }; | |
| 90 | ||
| 91 | function hostSource(_tag: "workspace-file" | "media-file" = "media-file"): MediaActionSource { | |
| 92 | return { | |
| 93 | kind: "image", | |
| 94 | name: "image.png", | |
| 95 | src: null, | |
| 96 | asset: { environmentId, resource: { _tag, threadId, path: "/repo/image.png" } }, | |
| 97 | reference: { kind: "file", path: "/repo/image.png", relativePath: "image.png" }, | |
| 98 | onOpenFile: state.openFile, | |
| 99 | }; | |
| 100 | } | |
| 101 | ||
| 102 | function openMenu(source: MediaActionSource) { | |
| 103 | const find = (node: ReactNode): ((event: unknown) => void) | undefined => { | |
| 104 | if (Array.isArray(node)) return node.map(find).find((handler) => handler !== undefined); | |
| 105 | if (!isValidElement<{ children?: ReactNode; onContextMenu?: (event: unknown) => void }>(node)) | |
| 106 | return undefined; | |
| 107 | return node.props.onContextMenu ?? find(node.props.children); | |
| 108 | }; | |
| 109 | const handler = find(MediaActions({ source, children: createElement("img") })); | |
| 110 | if (!handler) throw new Error("Media menu handler missing"); | |
| 111 | handler({ | |
| 112 | defaultPrevented: false, | |
| 113 | preventDefault() {}, | |
| 114 | stopPropagation() {}, | |
| 115 | currentTarget: { getBoundingClientRect: () => ({ left: 0, bottom: 0 }) }, | |
| 116 | clientX: 1, | |
| 117 | clientY: 1, | |
| 118 | }); | |
| 119 | } | |
| 120 | ||
| 121 | beforeEach(() => { | |
| 122 | state.sessions.clear(); | |
| 123 | state.sessions.set(environmentId, denied); | |
| 124 | state.mint | |
| 125 | .mockReset() | |
| 126 | .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })); | |
| 127 | state.download.mockReset().mockResolvedValue(undefined); | |
| 128 | state.png.mockReset().mockResolvedValue(new Blob(["png"], { type: "image/png" })); | |
| 129 | state.showMenu.mockReset().mockResolvedValue(null); | |
| 130 | state.openFile.mockReset(); | |
| 131 | state.menuFinished = null; | |
| 132 | class TestClipboardItem { | |
| 133 | constructor(readonly items: Record<string, Promise<Blob>>) {} | |
| 134 | } | |
| 135 | state.clipboard.mockReset().mockImplementation(async (items: TestClipboardItem[]) => { | |
| 136 | await Promise.all(items.flatMap((item) => Object.values(item.items))); | |
| 137 | }); | |
| 138 | vi.stubGlobal("ClipboardItem", TestClipboardItem); | |
| 139 | vi.stubGlobal("navigator", { clipboard: { write: state.clipboard } }); | |
| 140 | }); | |
| 141 | ||
| 142 | afterEach(() => vi.unstubAllGlobals()); | |
| 143 | ||
| 144 | it.each(["workspace-file", "media-file"] as const)( | |
| 145 | "waits for the %s grant before enabling host menu actions", | |
| 146 | (_tag) => { | |
| 147 | for (const [session, disabled] of [ | |
| 148 | [null, true], | |
| 149 | [granted, false], | |
| 150 | [denied, true], | |
| 151 | ] as const) { | |
| 152 | if (session === null) state.sessions.delete(environmentId); | |
| 153 | else state.sessions.set(environmentId, session); | |
| 154 | openMenu(hostSource(_tag)); | |
| 155 | ||
| 156 | const items = state.showMenu.mock.lastCall![0]; | |
| 157 | expect(items).toEqual( | |
| 158 | expect.arrayContaining([ | |
| 159 | expect.objectContaining({ id: "save", disabled }), | |
| 160 | expect.objectContaining({ id: "copy-image", disabled }), | |
| 161 | expect.objectContaining({ id: "open-file", disabled }), | |
| 162 | ]), | |
| 163 | ); | |
| 164 | expect(items).toContainEqual({ id: "copy-full-path", label: "Copy full path" }); | |
| 165 | } | |
| 166 | expect(state.mint).not.toHaveBeenCalled(); | |
| 167 | expect(state.download).not.toHaveBeenCalled(); | |
| 168 | }, | |
| 169 | ); | |
| 170 | ||
| 171 | it("keeps nonhost menu actions available with pending or denied host grants", () => { | |
| 172 | const sources: MediaActionSource[] = [ | |
| 173 | { kind: "image", name: "image.png", src: "https://cdn.test/image.png" }, | |
| 174 | { kind: "image", name: "image.png", src: "blob:local-image" }, | |
| 175 | { | |
| 176 | kind: "image", | |
| 177 | name: "image.png", | |
| 178 | src: null, | |
| 179 | asset: { environmentId, resource: { _tag: "attachment", attachmentId: "upload" } }, | |
| 180 | }, | |
| 181 | ]; | |
| 182 | for (const session of [null, denied]) { | |
| 183 | if (session === null) state.sessions.delete(environmentId); | |
| 184 | else state.sessions.set(environmentId, session); | |
| 185 | for (const source of sources) { | |
| 186 | openMenu(source); | |
| 187 | expect(state.showMenu.mock.lastCall![0]).toEqual( | |
| 188 | expect.arrayContaining([ | |
| 189 | expect.objectContaining({ id: "save", disabled: false }), | |
| 190 | expect.objectContaining({ id: "copy-image", disabled: false }), | |
| 191 | ]), | |
| 192 | ); | |
| 193 | } | |
| 194 | } | |
| 195 | }); | |
| 196 | ||
| 197 | it.each(["workspace-file", "media-file"] as const)( | |
| 198 | "disables denied %s byte actions and prevents imperative requests", | |
| 199 | async (_tag) => { | |
| 200 | const source = hostSource(_tag); | |
| 201 | openMenu(source); | |
| 202 | expect(state.showMenu).toHaveBeenCalledWith( | |
| 203 | expect.arrayContaining([ | |
| 204 | expect.objectContaining({ id: "save", disabled: true }), | |
| 205 | expect.objectContaining({ id: "copy-image", disabled: true }), | |
| 206 | expect.objectContaining({ id: "open-file", disabled: true }), | |
| 207 | ]), | |
| 208 | { x: 1, y: 1 }, | |
| 209 | ); | |
| 210 | await expect(useMediaActions(source).save()).rejects.toThrow("cannot read host files"); | |
| 211 | await expect(useMediaActions(source).copyImage()).rejects.toThrow("cannot read host files"); | |
| 212 | expect(state.mint).not.toHaveBeenCalled(); | |
| 213 | expect(state.download).not.toHaveBeenCalled(); | |
| 214 | expect(state.clipboard).not.toHaveBeenCalled(); | |
| 215 | }, | |
| 216 | ); | |
| 217 | ||
| 218 | it.each(["save", "copy-image", "open-file"])( | |
| 219 | "rechecks access when %s is selected from an already open native menu", | |
| 220 | async (action) => { | |
| 221 | state.sessions.set(environmentId, granted); | |
| 222 | const choice = deferred<string>(); | |
| 223 | const completed = deferred<void>(); | |
| 224 | state.showMenu.mockReturnValue(choice.promise); | |
| 225 | state.menuFinished = () => completed.resolve(); | |
| 226 | state.openFile.mockImplementation(() => completed.resolve()); | |
| 227 | openMenu(hostSource()); | |
| 228 | ||
| 229 | state.sessions.set(environmentId, denied); | |
| 230 | choice.resolve(action); | |
| 231 | await completed.promise; | |
| 232 | ||
| 233 | expect(state.mint).not.toHaveBeenCalled(); | |
| 234 | expect(state.download).not.toHaveBeenCalled(); | |
| 235 | expect(state.clipboard).not.toHaveBeenCalled(); | |
| 236 | expect(state.openFile).not.toHaveBeenCalled(); | |
| 237 | }, | |
| 238 | ); | |
| 239 | ||
| 240 | it("reenables the menu and a retained action when file access is gained", async () => { | |
| 241 | const actions = useMediaActions(hostSource()); | |
| 242 | state.sessions.set(environmentId, granted); | |
| 243 | openMenu(hostSource()); | |
| 244 | ||
| 245 | await actions.save(); | |
| 246 | ||
| 247 | expect(state.download).toHaveBeenCalledOnce(); | |
| 248 | expect(state.showMenu).toHaveBeenCalledWith( | |
| 249 | expect.arrayContaining([expect.objectContaining({ id: "save", disabled: false })]), | |
| 250 | expect.anything(), | |
| 251 | ); | |
| 252 | }); | |
| 253 | ||
| 254 | it.each([false, true])("uses the media environment's grant (allowed: %s)", async (allowed) => { | |
| 255 | state.sessions.set(environmentId, allowed ? granted : denied); | |
| 256 | state.sessions.set(otherEnvironmentId, allowed ? denied : granted); | |
| 257 | ||
| 258 | await useMediaActions(hostSource()) | |
| 259 | .save() | |
| 260 | .catch(() => {}); | |
| 261 | ||
| 262 | expect(state.mint).toHaveBeenCalledTimes(allowed ? 1 : 0); | |
| 263 | expect(state.download).toHaveBeenCalledTimes(allowed ? 1 : 0); | |
| 264 | }); | |
| 265 | ||
| 266 | it("stops before downloading if access is revoked while minting the URL", async () => { | |
| 267 | state.sessions.set(environmentId, granted); | |
| 268 | state.mint.mockImplementation(async () => { | |
| 269 | state.sessions.set(environmentId, denied); | |
| 270 | return AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }); | |
| 271 | }); | |
| 272 | ||
| 273 | await expect(useMediaActions(hostSource()).save()).rejects.toThrow("cannot read host files"); | |
| 274 | ||
| 275 | expect(state.download).not.toHaveBeenCalled(); | |
| 276 | }); | |
| 277 | ||
| 278 | it("lets the server authorize an explicit action before the grant resolves", async () => { | |
| 279 | state.sessions.delete(environmentId); | |
| 280 | ||
| 281 | await useMediaActions(hostSource()).save(); | |
| 282 | ||
| 283 | expect(state.mint).toHaveBeenCalledOnce(); | |
| 284 | expect(state.download).toHaveBeenCalledOnce(); | |
| 285 | }); | |
| 286 | ||
| 287 | it("saves uploaded attachments without filesystem access", async () => { | |
| 288 | await useMediaActions({ | |
| 289 | kind: "image", | |
| 290 | name: "image.png", | |
| 291 | src: null, | |
| 292 | asset: { environmentId, resource: { _tag: "attachment", attachmentId: "upload" } }, | |
| 293 | }).save(); | |
| 294 | ||
| 295 | expect(state.mint).toHaveBeenCalledOnce(); | |
| 296 | expect(state.download).toHaveBeenCalledOnce(); | |
| 297 | }); | |
| 298 | ||
| 299 | it.each(["https://cdn.test/image.png", "blob:local-image"])( | |
| 300 | "saves direct media without a host grant: %s", | |
| 301 | async (src) => { | |
| 302 | await useMediaActions({ kind: "image", name: "image.png", src }).save(); | |
| 303 | ||
| 304 | expect(state.mint).not.toHaveBeenCalled(); | |
| 305 | expect(state.download).toHaveBeenCalledWith(src, "image.png"); | |
| 306 | }, | |
| 307 | ); |
apps/web/src/components/media/MediaActions.tsx+55-9| ... | ... | @@ -5,13 +5,23 @@ import { |
| 5 | 5 | } from "@t3tools/client-runtime/media-reference"; |
| 6 | 6 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 7 | 7 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 8 | import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts"; | |
| 8 | import { | |
| 9 | AuthFilesystemReadScope, | |
| 10 | type AssetResource, | |
| 11 | type AuthSessionState, | |
| 12 | type ContextMenuItem, | |
| 13 | type EnvironmentId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import { AsyncResult } from "effect/reactivity"; | |
| 9 | 17 | import { useCallback, useRef, useState, type ReactElement } from "react"; |
| 10 | 18 | |
| 11 | 19 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; |
| 12 | 20 | import { readLocalApi } from "../../localApi"; |
| 21 | import { appAtomRegistry } from "../../rpc/atomRegistry"; | |
| 13 | 22 | import { assetEnvironment } from "../../state/assets"; |
| 14 | import { readPreparedConnection } from "../../state/session"; | |
| 23 | import { useEnvironmentQuery } from "../../state/query"; | |
| 24 | import { environmentSession, readPreparedConnection } from "../../state/session"; | |
| 15 | 25 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 16 | 26 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 17 | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -32,13 +42,45 @@ function mediaFileName(source: MediaActionSource): string { |
| 32 | 42 | ); |
| 33 | 43 | } |
| 34 | 44 | |
| 45 | /** An explicit action may ask the server while its grant is still unresolved. */ | |
| 46 | function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) { | |
| 47 | return ( | |
| 48 | session === null || | |
| 49 | (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true) | |
| 50 | ); | |
| 51 | } | |
| 52 | ||
| 53 | function canReadHostMedia(environmentId: EnvironmentId | null): boolean { | |
| 54 | if (environmentId === null) return true; | |
| 55 | const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); | |
| 56 | return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result))); | |
| 57 | } | |
| 58 | ||
| 35 | 59 | /** Explicit byte operations get fresh capabilities without replacing a player's active source. */ |
| 36 | function useMediaActions(source: MediaActionSource) { | |
| 60 | /** Exported for tests that drive the byte operations without the menu. */ | |
| 61 | export function useMediaActions(source: MediaActionSource) { | |
| 62 | const hostEnvironmentId = | |
| 63 | source.asset && | |
| 64 | (source.asset.resource._tag === "workspace-file" || source.asset.resource._tag === "media-file") | |
| 65 | ? source.asset.environmentId | |
| 66 | : null; | |
| 67 | const fileSession = useEnvironmentQuery( | |
| 68 | hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId), | |
| 69 | ); | |
| 70 | const canReadMedia = | |
| 71 | hostEnvironmentId === null || | |
| 72 | (fileSession.error === null && fileSession.data !== null && allowsHostMedia(fileSession.data)); | |
| 73 | const assertCanReadMedia = useCallback(() => { | |
| 74 | if (!canReadHostMedia(hostEnvironmentId)) { | |
| 75 | throw new Error("This connection cannot read host files."); | |
| 76 | } | |
| 77 | }, [hostEnvironmentId]); | |
| 37 | 78 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 38 | 79 | reportFailure: false, |
| 39 | 80 | refresh: true, |
| 40 | 81 | }); |
| 41 | 82 | const actionUrl = useCallback(async () => { |
| 83 | assertCanReadMedia(); | |
| 42 | 84 | if (!source.asset) { |
| 43 | 85 | if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); |
| 44 | 86 | return source.src; |
| ... | ... | @@ -48,14 +90,16 @@ function useMediaActions(source: MediaActionSource) { |
| 48 | 90 | if (!connection) throw new Error("Reconnect to this environment and try again."); |
| 49 | 91 | const result = await createAssetUrl({ environmentId, input: { resource } }); |
| 50 | 92 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 93 | assertCanReadMedia(); | |
| 51 | 94 | const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); |
| 52 | 95 | if (!url) throw new Error("The environment returned an invalid media URL."); |
| 53 | 96 | return url; |
| 54 | }, [source, createAssetUrl]); | |
| 97 | }, [source, createAssetUrl, assertCanReadMedia]); | |
| 55 | 98 | const save = useCallback(async () => { |
| 56 | 99 | await downloadMedia(await actionUrl(), mediaFileName(source)); |
| 57 | 100 | }, [actionUrl, source]); |
| 58 | 101 | const copyImage = useCallback(async () => { |
| 102 | assertCanReadMedia(); | |
| 59 | 103 | if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { |
| 60 | 104 | throw new Error( |
| 61 | 105 | "Image copying is unavailable. Use a secure browser connection or save the image.", |
| ... | ... | @@ -65,8 +109,8 @@ function useMediaActions(source: MediaActionSource) { |
| 65 | 109 | await navigator.clipboard.write([ |
| 66 | 110 | new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), |
| 67 | 111 | ]); |
| 68 | }, [actionUrl]); | |
| 69 | return { save, copyImage }; | |
| 112 | }, [actionUrl, assertCanReadMedia]); | |
| 113 | return { save, copyImage, canReadMedia, assertCanReadMedia }; | |
| 70 | 114 | } |
| 71 | 115 | |
| 72 | 116 | /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ |
| ... | ... | @@ -77,7 +121,7 @@ export function MediaActions({ |
| 77 | 121 | source: MediaActionSource; |
| 78 | 122 | children: ReactElement; |
| 79 | 123 | }) { |
| 80 | const { save, copyImage } = useMediaActions(source); | |
| 124 | const { save, copyImage, canReadMedia, assertCanReadMedia } = useMediaActions(source); | |
| 81 | 125 | const [tooltipOpen, setTooltipOpen] = useState(false); |
| 82 | 126 | const menuOpen = useRef(false); |
| 83 | 127 | const reference = source.reference; |
| ... | ... | @@ -92,7 +136,7 @@ export function MediaActions({ |
| 92 | 136 | let progressToast: ReturnType<typeof toastManager.add> | undefined; |
| 93 | 137 | try { |
| 94 | 138 | const noun = source.kind === "image" ? "image" : "video"; |
| 95 | const unavailable = source.src === null && source.asset === undefined; | |
| 139 | const unavailable = !canReadMedia || (source.src === null && source.asset === undefined); | |
| 96 | 140 | const canCopyImage = |
| 97 | 141 | typeof navigator !== "undefined" && |
| 98 | 142 | Boolean(navigator.clipboard?.write) && |
| ... | ... | @@ -105,7 +149,8 @@ export function MediaActions({ |
| 105 | 149 | } else if (reference?.kind === "url") { |
| 106 | 150 | items.push({ id: "copy-url", label: "Copy URL" }); |
| 107 | 151 | } |
| 108 | if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); | |
| 152 | if (source.onOpenFile) | |
| 153 | items.push({ id: "open-file", label: "Open in file viewer", disabled: !canReadMedia }); | |
| 109 | 154 | items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable }); |
| 110 | 155 | if (source.kind === "image") { |
| 111 | 156 | items.push({ |
| ... | ... | @@ -133,6 +178,7 @@ export function MediaActions({ |
| 133 | 178 | title: action === "copy-url" ? "URL copied" : "Path copied", |
| 134 | 179 | }); |
| 135 | 180 | } else if (action === "open-file") { |
| 181 | assertCanReadMedia(); | |
| 136 | 182 | source.onOpenFile?.(); |
| 137 | 183 | } else if (action === "save" || action === "copy-image") { |
| 138 | 184 | progressToast = toastManager.add({ |
apps/web/src/components/preview/PreviewView.test.tsx+7| ... | ... | @@ -63,6 +63,13 @@ vi.mock("~/state/session", async (importOriginal) => ({ |
| 63 | 63 | readPreparedConnection: mocks.readPreparedConnection, |
| 64 | 64 | })); |
| 65 | 65 | |
| 66 | // File-preview errors share a module with asset hooks. Keep the pure URL resolver | |
| 67 | // without importing those hooks and their environment runtime into chrome tests. | |
| 68 | vi.mock("~/assets/assetUrls", async () => { | |
| 69 | const { resolveAssetUrl } = await import("@t3tools/client-runtime/state/assets"); | |
| 70 | return { resolveAssetUrl }; | |
| 71 | }); | |
| 72 | ||
| 66 | 73 | // Stubbed at the direct dependency rather than letting the real module pull in |
| 67 | 74 | // `useSettings` -> `state/server`, which would drag the whole settings and |
| 68 | 75 | // connection graph into a test that only cares about the browser chrome. |
apps/web/src/components/search/ProjectContentSearchDialog.test.tsx created+131| ... | ... | @@ -0,0 +1,131 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { AuthFilesystemReadScope, EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import type { ReactNode } from "react"; | |
| 4 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 6 | ||
| 7 | const state = vi.hoisted(() => ({ | |
| 8 | canReadFiles: true, | |
| 9 | isCheckingAccess: false, | |
| 10 | error: null as string | null, | |
| 11 | readScope: vi.fn(), | |
| 12 | openFile: vi.fn(), | |
| 13 | })); | |
| 14 | ||
| 15 | const target = { | |
| 16 | environmentId: EnvironmentId.make("content-search-secondary"), | |
| 17 | cwd: "/project", | |
| 18 | projectName: "Project", | |
| 19 | threadRef: scopeThreadRef( | |
| 20 | EnvironmentId.make("content-search-secondary"), | |
| 21 | ThreadId.make("content-search-thread"), | |
| 22 | ), | |
| 23 | }; | |
| 24 | ||
| 25 | vi.mock("~/hooks/useActiveProjectTarget", () => ({ useActiveProjectTarget: () => target })); | |
| 26 | vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); | |
| 27 | vi.mock("~/rightPanelStore", () => ({ | |
| 28 | useRightPanelStore: { getState: () => ({ openFile: state.openFile }) }, | |
| 29 | })); | |
| 30 | vi.mock("~/state/session", () => ({ readEnvironmentScope: state.readScope })); | |
| 31 | vi.mock("~/state/queries", () => ({ | |
| 32 | useProjectContentSearch: ({ query }: { query: string }) => ({ | |
| 33 | canReadFiles: state.canReadFiles, | |
| 34 | isCheckingAccess: state.isCheckingAccess, | |
| 35 | error: state.error, | |
| 36 | isPending: state.isCheckingAccess, | |
| 37 | hasQuery: query.length > 0, | |
| 38 | truncated: false, | |
| 39 | invalidRegex: false, | |
| 40 | matches: | |
| 41 | state.canReadFiles && query.length > 0 | |
| 42 | ? [ | |
| 43 | { | |
| 44 | resultIndex: 0, | |
| 45 | path: "src/index.ts", | |
| 46 | lineNumber: 3, | |
| 47 | lineContent: "match", | |
| 48 | matchRanges: [], | |
| 49 | }, | |
| 50 | ] | |
| 51 | : [], | |
| 52 | }), | |
| 53 | })); | |
| 54 | vi.mock("../CommandPaletteContent", () => ({ CommandPaletteContent: "section" })); | |
| 55 | vi.mock("../chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); | |
| 56 | vi.mock("./HighlightedSearchLine", () => ({ HighlightedSearchLine: () => null })); | |
| 57 | vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); | |
| 58 | vi.mock("../ui/toggle", () => ({ Toggle: "button" })); | |
| 59 | vi.mock("../ui/tooltip", () => ({ | |
| 60 | Tooltip: "div", | |
| 61 | TooltipPopup: "span", | |
| 62 | TooltipTrigger: ({ render }: { render: ReactNode }) => render, | |
| 63 | })); | |
| 64 | ||
| 65 | import { ProjectContentSearchDialog } from "./ProjectContentSearchDialog"; | |
| 66 | ||
| 67 | let renderer: ReactTestRenderer | undefined; | |
| 68 | const onOpenChange = vi.fn(); | |
| 69 | ||
| 70 | beforeEach(() => { | |
| 71 | state.canReadFiles = true; | |
| 72 | state.isCheckingAccess = false; | |
| 73 | state.error = null; | |
| 74 | state.readScope.mockReset().mockReturnValue(true); | |
| 75 | state.openFile.mockClear(); | |
| 76 | onOpenChange.mockClear(); | |
| 77 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 78 | vi.stubGlobal("document", { querySelector: () => null }); | |
| 79 | }); | |
| 80 | ||
| 81 | afterEach(async () => { | |
| 82 | await act(() => renderer?.unmount()); | |
| 83 | vi.unstubAllGlobals(); | |
| 84 | }); | |
| 85 | ||
| 86 | async function openSearch() { | |
| 87 | await act(() => { | |
| 88 | renderer = create(<ProjectContentSearchDialog onOpenChange={onOpenChange} />); | |
| 89 | }); | |
| 90 | return renderer!.root; | |
| 91 | } | |
| 92 | ||
| 93 | it("enables search after access resolves and opens a result in its own environment", async () => { | |
| 94 | state.canReadFiles = false; | |
| 95 | state.isCheckingAccess = true; | |
| 96 | const root = await openSearch(); | |
| 97 | expect(root.findByType("section").props.inputProps.disabled).toBe(true); | |
| 98 | expect( | |
| 99 | root.findAllByType("div").some((node) => node.children.includes("Checking file access…")), | |
| 100 | ).toBe(true); | |
| 101 | ||
| 102 | state.canReadFiles = true; | |
| 103 | state.isCheckingAccess = false; | |
| 104 | await act(() => renderer!.update(<ProjectContentSearchDialog onOpenChange={onOpenChange} />)); | |
| 105 | expect(root.findByType("section").props.inputProps.disabled).toBe(false); | |
| 106 | await act(() => root.findByType("section").props.onValueChange("match")); | |
| 107 | await act(() => root.findByProps({ "data-content-search-result": 0 }).props.onClick()); | |
| 108 | ||
| 109 | expect(state.readScope).toHaveBeenCalledWith(target.environmentId, AuthFilesystemReadScope); | |
| 110 | expect(state.openFile).toHaveBeenCalledWith(target.threadRef, "src/index.ts", 3); | |
| 111 | expect(onOpenChange).toHaveBeenCalledWith(false); | |
| 112 | }); | |
| 113 | ||
| 114 | it.each(["pointer", "keyboard"])( | |
| 115 | "rechecks access before a retained %s action opens a result", | |
| 116 | async (action) => { | |
| 117 | const root = await openSearch(); | |
| 118 | await act(() => root.findByType("section").props.onValueChange("match")); | |
| 119 | const openResult = root.findByProps({ "data-content-search-result": 0 }).props.onClick; | |
| 120 | const onKeyDown = root.findByType("section").props.inputProps.onKeyDown; | |
| 121 | ||
| 122 | state.readScope.mockReturnValue(false); | |
| 123 | await act(() => { | |
| 124 | if (action === "pointer") openResult(); | |
| 125 | else onKeyDown({ key: "Enter", preventDefault() {} }); | |
| 126 | }); | |
| 127 | ||
| 128 | expect(state.openFile).not.toHaveBeenCalled(); | |
| 129 | expect(onOpenChange).not.toHaveBeenCalled(); | |
| 130 | }, | |
| 131 | ); |
apps/web/src/components/search/ProjectContentSearchDialog.tsx+21-7| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | import type { ProjectContentMatch } from "@t3tools/contracts"; | |
| 2 | import { AuthFilesystemReadScope, type ProjectContentMatch } from "@t3tools/contracts"; | |
| 3 | 3 | |
| 4 | 4 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; |
| 5 | 5 | |
| ... | ... | @@ -8,6 +8,7 @@ import { useTheme } from "~/hooks/useTheme"; |
| 8 | 8 | import { cn } from "~/lib/utils"; |
| 9 | 9 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 10 | 10 | import { useProjectContentSearch } from "~/state/queries"; |
| 11 | import { readEnvironmentScope } from "~/state/session"; | |
| 11 | 12 | |
| 12 | 13 | import { PierreEntryIcon } from "../chat/PierreEntryIcon"; |
| 13 | 14 | import { CommandPaletteContent } from "../CommandPaletteContent"; |
| ... | ... | @@ -56,6 +57,7 @@ function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[] |
| 56 | 57 | |
| 57 | 58 | function SearchOptionButton(props: { |
| 58 | 59 | readonly active: boolean; |
| 60 | readonly disabled: boolean; | |
| 59 | 61 | readonly label: string; |
| 60 | 62 | readonly onClick: () => void; |
| 61 | 63 | readonly children: ReactNode; |
| ... | ... | @@ -66,6 +68,7 @@ function SearchOptionButton(props: { |
| 66 | 68 | render={ |
| 67 | 69 | <Toggle |
| 68 | 70 | aria-label={props.label} |
| 71 | disabled={props.disabled} | |
| 69 | 72 | pressed={props.active} |
| 70 | 73 | size="segmented" |
| 71 | 74 | variant="segmented" |
| ... | ... | @@ -121,7 +124,7 @@ function OpenContentSearchDialog(props: { |
| 121 | 124 | wholeWord, |
| 122 | 125 | useRegex, |
| 123 | 126 | }); |
| 124 | const canOpenMatches = !search.isPending; | |
| 127 | const canOpenMatches = search.canReadFiles && !search.isPending && search.error === null; | |
| 125 | 128 | const matches = search.matches; |
| 126 | 129 | const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]); |
| 127 | 130 | const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]); |
| ... | ... | @@ -153,13 +156,16 @@ function OpenContentSearchDialog(props: { |
| 153 | 156 | }, []); |
| 154 | 157 | |
| 155 | 158 | const openMatch = (match: ProjectContentMatch) => { |
| 156 | if (!canOpenMatches) return; | |
| 159 | if (!canOpenMatches || !readEnvironmentScope(target.environmentId, AuthFilesystemReadScope)) { | |
| 160 | return; | |
| 161 | } | |
| 157 | 162 | props.onOpenChange(false); |
| 158 | 163 | useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); |
| 159 | 164 | }; |
| 160 | 165 | const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); |
| 161 | 166 | const showSearchStatus = |
| 162 | search.hasQuery || search.isPending || search.error !== null || search.invalidRegex; | |
| 167 | search.canReadFiles && | |
| 168 | (search.hasQuery || search.isPending || search.error !== null || search.invalidRegex); | |
| 163 | 169 | |
| 164 | 170 | return ( |
| 165 | 171 | <CommandPaletteContent |
| ... | ... | @@ -170,6 +176,7 @@ function OpenContentSearchDialog(props: { |
| 170 | 176 | <div className="absolute inset-e-2.5 top-1/2 flex shrink-0 -translate-y-1/2 items-center gap-0.5 rounded-md border bg-muted/30 p-0.5"> |
| 171 | 177 | <SearchOptionButton |
| 172 | 178 | active={caseSensitive} |
| 179 | disabled={!search.canReadFiles} | |
| 173 | 180 | label="Match case" |
| 174 | 181 | onClick={() => setCaseSensitive((current) => !current)} |
| 175 | 182 | > |
| ... | ... | @@ -177,6 +184,7 @@ function OpenContentSearchDialog(props: { |
| 177 | 184 | </SearchOptionButton> |
| 178 | 185 | <SearchOptionButton |
| 179 | 186 | active={wholeWord} |
| 187 | disabled={!search.canReadFiles} | |
| 180 | 188 | label="Match whole word" |
| 181 | 189 | onClick={() => setWholeWord((current) => !current)} |
| 182 | 190 | > |
| ... | ... | @@ -184,6 +192,7 @@ function OpenContentSearchDialog(props: { |
| 184 | 192 | </SearchOptionButton> |
| 185 | 193 | <SearchOptionButton |
| 186 | 194 | active={useRegex} |
| 195 | disabled={!search.canReadFiles} | |
| 187 | 196 | label="Use regular expression" |
| 188 | 197 | onClick={() => setUseRegex((current) => !current)} |
| 189 | 198 | > |
| ... | ... | @@ -193,6 +202,7 @@ function OpenContentSearchDialog(props: { |
| 193 | 202 | } |
| 194 | 203 | inputProps={{ |
| 195 | 204 | className: "pe-30", |
| 205 | disabled: !search.canReadFiles, | |
| 196 | 206 | placeholder: `Search in ${target.projectName}`, |
| 197 | 207 | onKeyDown: (event) => { |
| 198 | 208 | if (event.key === "ArrowDown" && matches.length > 0) { |
| ... | ... | @@ -241,9 +251,13 @@ function OpenContentSearchDialog(props: { |
| 241 | 251 | |
| 242 | 252 | {matches.length === 0 ? ( |
| 243 | 253 | <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground"> |
| 244 | {search.hasQuery && !search.isPending && !search.error | |
| 245 | ? "No results found." | |
| 246 | : "Type to search across your project."} | |
| 254 | {search.isCheckingAccess | |
| 255 | ? "Checking file access…" | |
| 256 | : !search.canReadFiles | |
| 257 | ? search.error | |
| 258 | : search.hasQuery && !search.isPending && !search.error | |
| 259 | ? "No results found." | |
| 260 | : "Type to search across your project."} | |
| 247 | 261 | </div> |
| 248 | 262 | ) : ( |
| 249 | 263 | <ScrollArea className="min-h-0 flex-1" scrollFade> |
apps/web/src/components/settings/ConnectionsSettings.tsx+19-12| ... | ... | @@ -30,12 +30,14 @@ import { |
| 30 | 30 | AuthOrchestrationReadScope, |
| 31 | 31 | AuthRelayReadScope, |
| 32 | 32 | AuthRelayWriteScope, |
| 33 | AuthReviewWriteScope, | |
| 34 | 33 | AuthSourceControlWriteScope, |
| 34 | AuthFilesystemReadScope, | |
| 35 | AuthFilesystemWriteScope, | |
| 35 | 36 | AuthStandardClientScopes, |
| 36 | 37 | AuthTerminalOperateScope, |
| 37 | 38 | type AuthClientSession, |
| 38 | 39 | type AuthEnvironmentScope, |
| 40 | type AuthGrantScope, | |
| 39 | 41 | type AuthPairingLink, |
| 40 | 42 | type AuthPairingCredentialResult, |
| 41 | 43 | type AdvertisedEndpoint, |
| ... | ... | @@ -226,19 +228,19 @@ function formatAccessTimestamp(value: string): string { |
| 226 | 228 | } |
| 227 | 229 | |
| 228 | 230 | const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ |
| 229 | readonly scope: AuthEnvironmentScope; | |
| 231 | readonly scope: AuthGrantScope; | |
| 230 | 232 | readonly title: string; |
| 231 | 233 | readonly description: string; |
| 232 | 234 | }> = [ |
| 233 | 235 | { |
| 234 | 236 | scope: AuthOrchestrationReadScope, |
| 235 | 237 | title: "View environment", |
| 236 | description: "Read threads, status, diffs, and configuration.", | |
| 238 | description: "Read threads, status, checkpoints, and configuration.", | |
| 237 | 239 | }, |
| 238 | 240 | { |
| 239 | 241 | scope: AuthOrchestrationOperateScope, |
| 240 | 242 | title: "Operate tasks", |
| 241 | description: "Start tasks and perform changes in the environment.", | |
| 243 | description: "Start, update, and stop tasks.", | |
| 242 | 244 | }, |
| 243 | 245 | { |
| 244 | 246 | scope: AuthSettingsWriteScope, |
| ... | ... | @@ -266,9 +268,14 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ |
| 266 | 268 | description: "Commit, push, manage branches and repositories, and change pull requests.", |
| 267 | 269 | }, |
| 268 | 270 | { |
| 269 | scope: AuthReviewWriteScope, | |
| 270 | title: "Write reviews", | |
| 271 | description: "Create comments while reviewing changes.", | |
| 271 | scope: AuthFilesystemReadScope, | |
| 272 | title: "Read files", | |
| 273 | description: "Browse host files, search workspaces, and inspect local changes.", | |
| 274 | }, | |
| 275 | { | |
| 276 | scope: AuthFilesystemWriteScope, | |
| 277 | title: "Write files", | |
| 278 | description: "Edit workspace files and save plans to disk.", | |
| 272 | 279 | }, |
| 273 | 280 | { |
| 274 | 281 | scope: AuthAccessReadScope, |
| ... | ... | @@ -1105,7 +1112,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1105 | 1112 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 1106 | 1113 | const [dialogOpen, setDialogOpen] = useState(false); |
| 1107 | 1114 | const [pairingLabel, setPairingLabel] = useState(""); |
| 1108 | const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([ | |
| 1115 | const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthGrantScope>>([ | |
| 1109 | 1116 | ...AuthStandardClientScopes, |
| 1110 | 1117 | ]); |
| 1111 | 1118 | const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope)); |
| ... | ... | @@ -1145,7 +1152,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1145 | 1152 | } |
| 1146 | 1153 | }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]); |
| 1147 | 1154 | |
| 1148 | const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => { | |
| 1155 | const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => { | |
| 1149 | 1156 | setPairingScopes((current) => |
| 1150 | 1157 | checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope), |
| 1151 | 1158 | ); |
| ... | ... | @@ -1217,9 +1224,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1217 | 1224 | disabled={isCreatingPairingLink} |
| 1218 | 1225 | onClick={() => |
| 1219 | 1226 | setPairingScopes( |
| 1220 | delegatableScopes.includes(AuthOrchestrationReadScope) | |
| 1221 | ? [AuthOrchestrationReadScope] | |
| 1222 | : [], | |
| 1227 | [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) => | |
| 1228 | delegatableScopes.includes(scope), | |
| 1229 | ), | |
| 1223 | 1230 | ) |
| 1224 | 1231 | } |
| 1225 | 1232 | > |
apps/web/src/environments/primary/auth.ts+2-1| ... | ... | @@ -2,6 +2,7 @@ import type { |
| 2 | 2 | AuthBrowserSessionResult, |
| 3 | 3 | AuthClientMetadata, |
| 4 | 4 | AuthEnvironmentScope, |
| 5 | AuthGrantScope, | |
| 5 | 6 | AuthPairingCredentialResult, |
| 6 | 7 | ServerAuthSessionMethod, |
| 7 | 8 | AuthSessionId, |
| ... | ... | @@ -354,7 +355,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 354 | 355 | |
| 355 | 356 | export async function createServerPairingCredential(input?: { |
| 356 | 357 | readonly label?: string; |
| 357 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 358 | readonly scopes?: ReadonlyArray<AuthGrantScope>; | |
| 358 | 359 | }): Promise<AuthPairingCredentialResult> { |
| 359 | 360 | const trimmedLabel = input?.label?.trim(); |
| 360 | 361 | try { |
apps/web/src/state/filesystem.ts+21-1| ... | ... | @@ -1,5 +1,25 @@ |
| 1 | import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; | |
| 1 | import { | |
| 2 | createFilesystemEnvironmentAtoms, | |
| 3 | resolveFilesystemReadAccess, | |
| 4 | } from "@t3tools/client-runtime/state/filesystem"; | |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | import { useEnvironmentPresentation } from "./presentation"; | |
| 9 | import { useEnvironmentQuery } from "./query"; | |
| 10 | import { environmentSession } from "./session"; | |
| 4 | 11 | |
| 5 | 12 | export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); |
| 13 | ||
| 14 | export function useFilesystemReadAccess(environmentId: EnvironmentId | null) { | |
| 15 | const session = useEnvironmentQuery( | |
| 16 | environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), | |
| 17 | ); | |
| 18 | const environment = useEnvironmentPresentation(environmentId); | |
| 19 | return resolveFilesystemReadAccess({ | |
| 20 | isCatalogReady: environment.isReady, | |
| 21 | connection: environment.presentation?.connection ?? null, | |
| 22 | session: session.data, | |
| 23 | sessionError: session.error, | |
| 24 | }); | |
| 25 | } |
apps/web/src/state/queries.filesystem.test.ts created+241| ... | ... | @@ -0,0 +1,241 @@ |
| 1 | import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState } from "@t3tools/contracts"; | |
| 2 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | const state = vi.hoisted(() => ({ | |
| 5 | session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null, | |
| 6 | sessionError: null as string | null, | |
| 7 | sessionWaiting: false, | |
| 8 | phase: "connected" as "connected" | "offline", | |
| 9 | sessionAtom: {}, | |
| 10 | searchAtom: {}, | |
| 11 | contentAtom: {}, | |
| 12 | contentRequests: vi.fn(), | |
| 13 | contentError: null as string | null, | |
| 14 | contentData: { | |
| 15 | matches: [{ path: "src/index.ts", lineNumber: 3, lineContent: "a match", matchRanges: [] }], | |
| 16 | truncated: false, | |
| 17 | }, | |
| 18 | })); | |
| 19 | ||
| 20 | vi.mock("react", async (importOriginal) => ({ | |
| 21 | ...(await importOriginal<typeof import("react")>()), | |
| 22 | useCallback: <A>(callback: A) => callback, | |
| 23 | useEffect: () => {}, | |
| 24 | useMemo: <A>(factory: () => A) => factory(), | |
| 25 | useState: <A>(value: A) => [value, vi.fn()], | |
| 26 | })); | |
| 27 | vi.mock("./session", () => ({ | |
| 28 | environmentSession: { sessionStateAtom: () => state.sessionAtom }, | |
| 29 | })); | |
| 30 | vi.mock("./presentation", () => ({ | |
| 31 | useEnvironmentPresentation: () => ({ | |
| 32 | isReady: true, | |
| 33 | presentation: { connection: { phase: state.phase, error: null } }, | |
| 34 | }), | |
| 35 | })); | |
| 36 | vi.mock("./projects", () => ({ | |
| 37 | projectEnvironment: { searchEntries: () => state.searchAtom }, | |
| 38 | projectContentSearch: (target: unknown) => { | |
| 39 | state.contentRequests(target); | |
| 40 | return state.contentAtom; | |
| 41 | }, | |
| 42 | })); | |
| 43 | vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); | |
| 44 | vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); | |
| 45 | vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() })); | |
| 46 | vi.mock("./vcs", () => ({ vcsEnvironment: {} })); | |
| 47 | vi.mock("./query", () => ({ | |
| 48 | useEnvironmentQuery: (atom: unknown) => ({ | |
| 49 | data: | |
| 50 | atom === state.sessionAtom | |
| 51 | ? state.session | |
| 52 | : atom === state.searchAtom | |
| 53 | ? { entries: [{ path: "src/index.ts", kind: "file" }] } | |
| 54 | : atom === state.contentAtom | |
| 55 | ? state.contentData | |
| 56 | : null, | |
| 57 | error: | |
| 58 | atom === state.sessionAtom | |
| 59 | ? state.sessionError | |
| 60 | : atom === state.contentAtom | |
| 61 | ? state.contentError | |
| 62 | : null, | |
| 63 | isPending: | |
| 64 | atom === state.sessionAtom && | |
| 65 | (state.sessionWaiting || (state.session === null && state.sessionError === null)), | |
| 66 | refresh: vi.fn(), | |
| 67 | }), | |
| 68 | })); | |
| 69 | ||
| 70 | import { useProjectContentSearch, useProjectPathSearch } from "./queries"; | |
| 71 | ||
| 72 | const useComposerPathSearch = (input: Parameters<typeof useProjectPathSearch>[0]) => | |
| 73 | useProjectPathSearch(input, 20); | |
| 74 | ||
| 75 | const target = { | |
| 76 | environmentId: EnvironmentId.make("test-environment"), | |
| 77 | cwd: "/repo", | |
| 78 | query: "src", | |
| 79 | }; | |
| 80 | ||
| 81 | beforeEach(() => { | |
| 82 | state.session = null; | |
| 83 | state.sessionError = null; | |
| 84 | state.sessionWaiting = false; | |
| 85 | state.phase = "connected"; | |
| 86 | state.contentRequests.mockClear(); | |
| 87 | state.contentError = null; | |
| 88 | }); | |
| 89 | ||
| 90 | it("keeps a file search pending until its grant loads, then shows matches", () => { | |
| 91 | expect(useComposerPathSearch(target)).toMatchObject({ | |
| 92 | entries: [], | |
| 93 | error: null, | |
| 94 | isPending: true, | |
| 95 | }); | |
| 96 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 97 | expect(useComposerPathSearch(target)).toMatchObject({ | |
| 98 | entries: [{ path: "src/index.ts", kind: "file" }], | |
| 99 | error: null, | |
| 100 | isPending: false, | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | it("shows a confirmed denial and a connection failure separately", () => { | |
| 105 | state.session = { authenticated: true, scopes: [] }; | |
| 106 | expect(useComposerPathSearch(target)).toMatchObject({ | |
| 107 | entries: [], | |
| 108 | error: "This connection cannot search host files.", | |
| 109 | isPending: false, | |
| 110 | }); | |
| 111 | state.session = null; | |
| 112 | state.phase = "offline"; | |
| 113 | expect(useComposerPathSearch(target)).toMatchObject({ | |
| 114 | entries: [], | |
| 115 | error: "This environment is not connected.", | |
| 116 | isPending: false, | |
| 117 | }); | |
| 118 | }); | |
| 119 | ||
| 120 | it("leaves an inactive search idle while its grant loads", () => { | |
| 121 | expect(useComposerPathSearch({ ...target, cwd: null, query: null })).toMatchObject({ | |
| 122 | entries: [], | |
| 123 | error: null, | |
| 124 | isPending: false, | |
| 125 | }); | |
| 126 | }); | |
| 127 | ||
| 128 | const contentTarget = { | |
| 129 | ...target, | |
| 130 | query: " a match ", | |
| 131 | caseSensitive: true, | |
| 132 | wholeWord: true, | |
| 133 | useRegex: false, | |
| 134 | }; | |
| 135 | ||
| 136 | it("waits for content-search access before issuing a request, including an empty search", () => { | |
| 137 | for (const query of ["", contentTarget.query]) { | |
| 138 | const result = useProjectContentSearch({ ...contentTarget, query }); | |
| 139 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 140 | expect(result).toMatchObject({ | |
| 141 | canReadFiles: false, | |
| 142 | isCheckingAccess: true, | |
| 143 | matches: [], | |
| 144 | error: null, | |
| 145 | isPending: true, | |
| 146 | }); | |
| 147 | } | |
| 148 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 149 | }); | |
| 150 | ||
| 151 | it("shows content-search denial before typing and never issues an unauthorized request", () => { | |
| 152 | state.session = { authenticated: true, scopes: [] }; | |
| 153 | for (const query of ["", contentTarget.query]) { | |
| 154 | const result = useProjectContentSearch({ ...contentTarget, query }); | |
| 155 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 156 | expect(result).toMatchObject({ | |
| 157 | canReadFiles: false, | |
| 158 | isCheckingAccess: false, | |
| 159 | matches: [], | |
| 160 | error: "This connection cannot search host files.", | |
| 161 | isPending: false, | |
| 162 | }); | |
| 163 | } | |
| 164 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 165 | }); | |
| 166 | ||
| 167 | it("preserves content query whitespace and options when access is granted", () => { | |
| 168 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 169 | expect(useProjectContentSearch(contentTarget)).toMatchObject({ | |
| 170 | matches: state.contentData.matches, | |
| 171 | error: null, | |
| 172 | isPending: false, | |
| 173 | }); | |
| 174 | expect(state.contentRequests).toHaveBeenCalledWith({ | |
| 175 | environmentId: contentTarget.environmentId, | |
| 176 | input: { | |
| 177 | cwd: contentTarget.cwd, | |
| 178 | query: contentTarget.query, | |
| 179 | limit: 500, | |
| 180 | caseSensitive: true, | |
| 181 | wholeWord: true, | |
| 182 | useRegex: false, | |
| 183 | }, | |
| 184 | }); | |
| 185 | }); | |
| 186 | ||
| 187 | it("keeps confirmed content access during revalidation and drops results when it is revoked", () => { | |
| 188 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 189 | state.sessionWaiting = true; | |
| 190 | expect(useProjectContentSearch(contentTarget)).toMatchObject({ | |
| 191 | matches: state.contentData.matches, | |
| 192 | isPending: false, | |
| 193 | }); | |
| 194 | ||
| 195 | state.contentRequests.mockClear(); | |
| 196 | state.sessionWaiting = false; | |
| 197 | state.session = { authenticated: true, scopes: [] }; | |
| 198 | const result = useProjectContentSearch(contentTarget); | |
| 199 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 200 | expect(result).toMatchObject({ | |
| 201 | canReadFiles: false, | |
| 202 | matches: [], | |
| 203 | error: "This connection cannot search host files.", | |
| 204 | isPending: false, | |
| 205 | }); | |
| 206 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 207 | }); | |
| 208 | ||
| 209 | it("fails closed after a session check fails, even with a cached grant and matches", () => { | |
| 210 | state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; | |
| 211 | state.sessionError = "The session has expired."; | |
| 212 | const result = useProjectContentSearch(contentTarget); | |
| 213 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 214 | expect(result).toMatchObject({ | |
| 215 | canReadFiles: false, | |
| 216 | matches: [], | |
| 217 | error: state.sessionError, | |
| 218 | isPending: false, | |
| 219 | }); | |
| 220 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 221 | }); | |
| 222 | ||
| 223 | it("keeps inactive content search idle and reports a disconnected target without querying", () => { | |
| 224 | expect( | |
| 225 | useProjectContentSearch({ ...contentTarget, environmentId: null, cwd: null }), | |
| 226 | ).toMatchObject({ | |
| 227 | matches: [], | |
| 228 | error: null, | |
| 229 | isPending: false, | |
| 230 | }); | |
| 231 | state.phase = "offline"; | |
| 232 | const result = useProjectContentSearch(contentTarget); | |
| 233 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 234 | expect(result).toMatchObject({ | |
| 235 | canReadFiles: false, | |
| 236 | matches: [], | |
| 237 | error: "This environment is not connected.", | |
| 238 | isPending: false, | |
| 239 | }); | |
| 240 | expect(state.contentRequests).not.toHaveBeenCalled(); | |
| 241 | }); |
apps/web/src/state/queries.ts+38-12| ... | ... | @@ -25,6 +25,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; |
| 25 | 25 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 26 | 26 | |
| 27 | 27 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 28 | import { useFilesystemReadAccess } from "./filesystem"; | |
| 28 | 29 | import { orchestrationEnvironment } from "./orchestration"; |
| 29 | 30 | import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; |
| 30 | 31 | import { projectContentSearch, projectEnvironment } from "./projects"; |
| ... | ... | @@ -237,12 +238,14 @@ export function useProjectPathSearch( |
| 237 | 238 | [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query], |
| 238 | 239 | ); |
| 239 | 240 | const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); |
| 240 | const result = useEnvironmentQuery( | |
| 241 | const fileAccess = useFilesystemReadAccess(debouncedTarget.environmentId); | |
| 242 | const { canReadFiles } = fileAccess; | |
| 243 | const searchTarget = | |
| 241 | 244 | debouncedTarget.environmentId !== null && |
| 242 | debouncedTarget.cwd !== null && | |
| 243 | debouncedTarget.query !== null && | |
| 244 | (allowEmptyQuery || debouncedTarget.query.length > 0) | |
| 245 | ? projectEnvironment.searchEntries({ | |
| 245 | debouncedTarget.cwd !== null && | |
| 246 | debouncedTarget.query !== null && | |
| 247 | (allowEmptyQuery || debouncedTarget.query.length > 0) | |
| 248 | ? { | |
| 246 | 249 | environmentId: debouncedTarget.environmentId, |
| 247 | 250 | input: { |
| 248 | 251 | cwd: debouncedTarget.cwd, |
| ... | ... | @@ -251,15 +254,24 @@ export function useProjectPathSearch( |
| 251 | 254 | ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), |
| 252 | 255 | ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}), |
| 253 | 256 | }, |
| 254 | }) | |
| 255 | : null, | |
| 257 | } | |
| 258 | : null; | |
| 259 | const result = useEnvironmentQuery( | |
| 260 | canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null, | |
| 256 | 261 | ); |
| 262 | const hasTarget = searchTarget !== null; | |
| 257 | 263 | |
| 258 | 264 | return { |
| 259 | 265 | entries: result.data?.entries ?? EMPTY_PROJECT_ENTRIES, |
| 260 | error: result.error, | |
| 266 | error: | |
| 267 | !hasTarget || fileAccess.isPending | |
| 268 | ? null | |
| 269 | : canReadFiles | |
| 270 | ? result.error | |
| 271 | : (fileAccess.error ?? "This connection cannot search host files."), | |
| 261 | 272 | isPending: |
| 262 | !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, | |
| 273 | !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || | |
| 274 | (hasTarget && (fileAccess.isPending || result.isPending)), | |
| 263 | 275 | searchedQuery: debouncedTarget.query ?? "", |
| 264 | 276 | truncated: result.data?.truncated ?? false, |
| 265 | 277 | refresh: result.refresh, |
| ... | ... | @@ -280,13 +292,18 @@ interface ProjectContentSearchTarget { |
| 280 | 292 | } |
| 281 | 293 | |
| 282 | 294 | export function useProjectContentSearch(target: ProjectContentSearchTarget) { |
| 295 | const hasTarget = target.environmentId !== null && target.cwd !== null; | |
| 296 | const fileAccess = useFilesystemReadAccess(target.environmentId); | |
| 297 | const canReadFiles = hasTarget && fileAccess.canReadFiles; | |
| 298 | const isCheckingAccess = hasTarget && fileAccess.isPending; | |
| 283 | 299 | // Whitespace is significant in content queries; trimming is only used to |
| 284 | 300 | // decide whether the input is blank. |
| 285 | 301 | const query = target.query; |
| 286 | 302 | const hasQuery = query.trim().length > 0; |
| 287 | 303 | const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); |
| 288 | 304 | const result = useEnvironmentQuery( |
| 289 | target.environmentId !== null && | |
| 305 | canReadFiles && | |
| 306 | target.environmentId !== null && | |
| 290 | 307 | target.cwd !== null && |
| 291 | 308 | hasQuery && |
| 292 | 309 | debouncedQuery.trim().length > 0 |
| ... | ... | @@ -305,9 +322,18 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) { |
| 305 | 322 | ); |
| 306 | 323 | |
| 307 | 324 | return { |
| 325 | canReadFiles, | |
| 326 | isCheckingAccess, | |
| 308 | 327 | matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, |
| 309 | error: result.error, | |
| 310 | isPending: hasQuery && (query !== debouncedQuery || result.isPending), | |
| 328 | error: | |
| 329 | !hasTarget || isCheckingAccess | |
| 330 | ? null | |
| 331 | : canReadFiles | |
| 332 | ? result.error | |
| 333 | : (fileAccess.error ?? "This connection cannot search host files."), | |
| 334 | isPending: | |
| 335 | isCheckingAccess || | |
| 336 | (canReadFiles && hasQuery && (query !== debouncedQuery || result.isPending)), | |
| 311 | 337 | hasQuery, |
| 312 | 338 | truncated: result.data?.truncated ?? false, |
| 313 | 339 | invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, |
docs/internals/environment-auth.md+4-2| ... | ... | @@ -92,7 +92,7 @@ after 30 days. |
| 92 | 92 | ## The environment is the filesystem boundary |
| 93 | 93 | |
| 94 | 94 | Projects are organizational boundaries, not filesystem sandboxes. |
| 95 | `orchestration:read` permits reading files the server account can read, including | |
| 95 | `filesystem:read` permits reading files the server account can read, including | |
| 96 | 96 | absolute paths outside a project. This lets clients display artifacts that an |
| 97 | 97 | agent writes in a temporary directory. Relative paths and writes still follow |
| 98 | 98 | the [workspace path rules](../../apps/server/src/workspace/WorkspaceFileSystem.ts). |
| ... | ... | @@ -104,7 +104,9 @@ file's identity when serving it, so atomic replacement requires a new URL while |
| 104 | 104 | editing the same file in place does not. An HTML file authorized this way cannot |
| 105 | 105 | load sibling assets; directory-scoped workspace previews are a separate grant. |
| 106 | 106 | Clients should share the authored file reference so they do not disclose the |
| 107 | temporary URL's credential. | |
| 107 | temporary URL's credential. `filesystem:read` is checked when the URL is minted, | |
| 108 | not when it is served: a URL issued before the grant was revoked keeps working | |
| 109 | until it expires, and it is not bound to the session that minted it. | |
| 108 | 110 | |
| 109 | 111 | Host videos can change in place. Their [HTTP |
| 110 | 112 | responses](../../apps/server/src/http.ts) omit cache validators because file |
docs/user/remote-access.md+4| ... | ... | @@ -293,6 +293,10 @@ permissions. Existing clients keep their original grants after an update; to |
| 293 | 293 | receive newly separated permissions, pair the client again with the scopes it |
| 294 | 294 | needs. Reconnecting or refreshing a session does not expand its grant. |
| 295 | 295 | |
| 296 | `filesystem:read` allows browsing host files, opening workspace files, and viewing | |
| 297 | local changes. Add `filesystem:write` to allow editing files or saving plans to | |
| 298 | the workspace. These scopes control direct file access from the client. | |
| 299 | ||
| 296 | 300 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 297 | 301 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 298 | 302 | revokes its cloud access and frees its host space even when the environment is |
packages/client-runtime/src/state/filesystem.test.ts+107| ... | ... | @@ -1,12 +1,119 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | canPreloadBrowsePath, |
| 5 | 6 | createBrowseNavigationCoordinator, |
| 6 | 7 | filterFilesystemBrowseEntries, |
| 7 | 8 | getFilesystemBrowsePath, |
| 9 | resolveFilesystemReadAccess, | |
| 8 | 10 | } from "./filesystem.ts"; |
| 9 | 11 | |
| 12 | describe("filesystem read access", () => { | |
| 13 | it("waits for the initial catalog before declaring a missing environment disconnected", () => { | |
| 14 | expect( | |
| 15 | resolveFilesystemReadAccess({ | |
| 16 | isCatalogReady: false, | |
| 17 | connection: null, | |
| 18 | session: null, | |
| 19 | sessionError: null, | |
| 20 | }), | |
| 21 | ).toEqual({ canReadFiles: false, isPending: true, error: null }); | |
| 22 | }); | |
| 23 | ||
| 24 | it("stops waiting when the loaded catalog has no matching environment", () => { | |
| 25 | expect( | |
| 26 | resolveFilesystemReadAccess({ | |
| 27 | isCatalogReady: true, | |
| 28 | connection: null, | |
| 29 | session: null, | |
| 30 | sessionError: null, | |
| 31 | }), | |
| 32 | ).toEqual({ | |
| 33 | canReadFiles: false, | |
| 34 | isPending: false, | |
| 35 | error: "This environment is not connected.", | |
| 36 | }); | |
| 37 | }); | |
| 38 | ||
| 39 | it.each(["available", "offline", "error"] as const)( | |
| 40 | "stops waiting for an unresolved session when the connection is %s", | |
| 41 | (phase) => { | |
| 42 | expect( | |
| 43 | resolveFilesystemReadAccess({ | |
| 44 | isCatalogReady: true, | |
| 45 | connection: { phase, error: null }, | |
| 46 | session: null, | |
| 47 | sessionError: null, | |
| 48 | }), | |
| 49 | ).toEqual({ | |
| 50 | canReadFiles: false, | |
| 51 | isPending: false, | |
| 52 | error: "This environment is not connected.", | |
| 53 | }); | |
| 54 | }, | |
| 55 | ); | |
| 56 | ||
| 57 | it.each(["connected", "connecting", "reconnecting"] as const)( | |
| 58 | "waits for the session check while %s", | |
| 59 | (phase) => { | |
| 60 | expect( | |
| 61 | resolveFilesystemReadAccess({ | |
| 62 | isCatalogReady: true, | |
| 63 | connection: { phase, error: null }, | |
| 64 | session: null, | |
| 65 | sessionError: null, | |
| 66 | }), | |
| 67 | ).toEqual({ canReadFiles: false, isPending: true, error: null }); | |
| 68 | }, | |
| 69 | ); | |
| 70 | ||
| 71 | it("reports the transport failure when the session cannot be checked", () => { | |
| 72 | expect( | |
| 73 | resolveFilesystemReadAccess({ | |
| 74 | isCatalogReady: true, | |
| 75 | connection: { phase: "error", error: "The relay is unavailable." }, | |
| 76 | session: null, | |
| 77 | sessionError: null, | |
| 78 | }), | |
| 79 | ).toEqual({ canReadFiles: false, isPending: false, error: "The relay is unavailable." }); | |
| 80 | }); | |
| 81 | ||
| 82 | it.each([false, true])( | |
| 83 | "preserves a cached file grant offline with catalog ready=%s", | |
| 84 | (isCatalogReady) => { | |
| 85 | const input = { | |
| 86 | isCatalogReady, | |
| 87 | connection: { phase: "offline", error: null }, | |
| 88 | session: { authenticated: true, scopes: [AuthFilesystemReadScope] }, | |
| 89 | sessionError: null, | |
| 90 | } as const; | |
| 91 | expect(resolveFilesystemReadAccess(input)).toEqual({ | |
| 92 | canReadFiles: true, | |
| 93 | isPending: false, | |
| 94 | error: null, | |
| 95 | }); | |
| 96 | expect( | |
| 97 | resolveFilesystemReadAccess({ ...input, sessionError: "The session has expired." }), | |
| 98 | ).toEqual({ canReadFiles: false, isPending: false, error: "The session has expired." }); | |
| 99 | }, | |
| 100 | ); | |
| 101 | ||
| 102 | it.each([ | |
| 103 | { authenticated: true, scopes: [AuthOrchestrationReadScope] }, | |
| 104 | { authenticated: false, scopes: [AuthFilesystemReadScope] }, | |
| 105 | ] as const)("does not infer file access from an ungranted session", (session) => { | |
| 106 | expect( | |
| 107 | resolveFilesystemReadAccess({ | |
| 108 | isCatalogReady: true, | |
| 109 | connection: { phase: "connected", error: null }, | |
| 110 | session, | |
| 111 | sessionError: null, | |
| 112 | }), | |
| 113 | ).toEqual({ canReadFiles: false, isPending: false, error: null }); | |
| 114 | }); | |
| 115 | }); | |
| 116 | ||
| 10 | 117 | describe("filesystem browse model", () => { |
| 11 | 118 | it("derives the browse target and navigation state", () => { |
| 12 | 119 | expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({ |
packages/client-runtime/src/state/filesystem.ts+42-2| ... | ... | @@ -1,7 +1,15 @@ |
| 1 | import { type FilesystemBrowseEntry, WS_METHODS } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | AuthFilesystemReadScope, | |
| 3 | type AuthSessionState, | |
| 4 | type FilesystemBrowseEntry, | |
| 5 | WS_METHODS, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { Atom } from "effect/reactivity"; |
| 3 | 8 | |
| 4 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; | |
| 9 | import type { | |
| 10 | EnvironmentConnectionPhase, | |
| 11 | EnvironmentConnectionPresentation, | |
| 12 | } from "../connection/presentation.ts"; | |
| 5 | 13 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 6 | 14 | import { |
| 7 | 15 | canNavigateUp, |
| ... | ... | @@ -13,6 +21,38 @@ import { |
| 13 | 21 | } from "./projects.ts"; |
| 14 | 22 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; |
| 15 | 23 | |
| 24 | export function resolveFilesystemReadAccess(input: { | |
| 25 | readonly isCatalogReady: boolean; | |
| 26 | readonly connection: Pick<EnvironmentConnectionPresentation, "phase" | "error"> | null; | |
| 27 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 28 | readonly sessionError: string | null; | |
| 29 | }) { | |
| 30 | if (input.sessionError !== null) { | |
| 31 | return { canReadFiles: false, isPending: false, error: input.sessionError }; | |
| 32 | } | |
| 33 | if (input.session === null) { | |
| 34 | // Wait for the catalog before interpreting a missing presentation as offline. | |
| 35 | // Once ready, an offline environment cannot finish its session check. | |
| 36 | const isPending = | |
| 37 | !input.isCatalogReady || | |
| 38 | input.connection?.phase === "connected" || | |
| 39 | input.connection?.phase === "connecting" || | |
| 40 | input.connection?.phase === "reconnecting"; | |
| 41 | return { | |
| 42 | canReadFiles: false, | |
| 43 | isPending, | |
| 44 | error: isPending ? null : (input.connection?.error ?? "This environment is not connected."), | |
| 45 | }; | |
| 46 | } | |
| 47 | return { | |
| 48 | canReadFiles: | |
| 49 | input.session.authenticated && | |
| 50 | input.session.scopes?.includes(AuthFilesystemReadScope) === true, | |
| 51 | isPending: false, | |
| 52 | error: null, | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 16 | 56 | export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { |
| 17 | 57 | const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); |
| 18 | 58 | const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; |
packages/contracts/src/auth.test.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts"; | |
| 5 | ||
| 6 | describe("authorization grants", () => { | |
| 7 | it("decodes legacy review credentials without offering them in new grants", () => { | |
| 8 | expect(Schema.decodeUnknownSync(AuthEnvironmentScopes)(["review:write"])).toEqual([ | |
| 9 | "review:write", | |
| 10 | ]); | |
| 11 | expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow(); | |
| 12 | expect(AuthStandardClientScopes).not.toContain("review:write"); | |
| 13 | }); | |
| 14 | }); |
packages/contracts/src/auth.ts+16-3| ... | ... | @@ -85,7 +85,10 @@ export const AuthProvidersManageScope = "providers:manage" as const; |
| 85 | 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; |
| 86 | 86 | export const AuthTerminalOperateScope = "terminal:operate" as const; |
| 87 | 87 | export const AuthSourceControlWriteScope = "source-control:write" as const; |
| 88 | export const AuthReviewWriteScope = "review:write" as const; | |
| 88 | export const AuthFilesystemReadScope = "filesystem:read" as const; | |
| 89 | export const AuthFilesystemWriteScope = "filesystem:write" as const; | |
| 90 | /** Retained for decoding existing credentials; grants no current RPC access. */ | |
| 91 | const AuthReviewWriteScope = "review:write" as const; | |
| 89 | 92 | export const AuthAccessReadScope = "access:read" as const; |
| 90 | 93 | export const AuthAccessWriteScope = "access:write" as const; |
| 91 | 94 | export const AuthRelayReadScope = "relay:read" as const; |
| ... | ... | @@ -97,6 +100,8 @@ export const AuthEnvironmentScope = Schema.Literals([ |
| 97 | 100 | AuthProvidersManageScope, |
| 98 | 101 | AuthEnvironmentMaintainScope, |
| 99 | 102 | AuthTerminalOperateScope, |
| 103 | AuthFilesystemReadScope, | |
| 104 | AuthFilesystemWriteScope, | |
| 100 | 105 | AuthReviewWriteScope, |
| 101 | 106 | AuthSourceControlWriteScope, |
| 102 | 107 | AuthAccessReadScope, |
| ... | ... | @@ -108,6 +113,13 @@ export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type; |
| 108 | 113 | export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope); |
| 109 | 114 | export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; |
| 110 | 115 | |
| 116 | export const AuthGrantScope = Schema.Literals( | |
| 117 | AuthEnvironmentScope.literals.filter((scope) => scope !== AuthReviewWriteScope), | |
| 118 | ); | |
| 119 | export type AuthGrantScope = typeof AuthGrantScope.Type; | |
| 120 | export const AuthGrantScopes = Schema.Array(AuthGrantScope); | |
| 121 | export type AuthGrantScopes = typeof AuthGrantScopes.Type; | |
| 122 | ||
| 111 | 123 | export const AuthStandardClientScopes = [ |
| 112 | 124 | AuthOrchestrationReadScope, |
| 113 | 125 | AuthOrchestrationOperateScope, |
| ... | ... | @@ -115,8 +127,9 @@ export const AuthStandardClientScopes = [ |
| 115 | 127 | AuthProvidersManageScope, |
| 116 | 128 | AuthEnvironmentMaintainScope, |
| 117 | 129 | AuthTerminalOperateScope, |
| 118 | AuthReviewWriteScope, | |
| 119 | 130 | AuthSourceControlWriteScope, |
| 131 | AuthFilesystemReadScope, | |
| 132 | AuthFilesystemWriteScope, | |
| 120 | 133 | AuthRelayReadScope, |
| 121 | 134 | ] as const; |
| 122 | 135 | export const AuthAdministrativeScopes = [ |
| ... | ... | @@ -355,7 +368,7 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T |
| 355 | 368 | |
| 356 | 369 | export const AuthCreatePairingCredentialInput = Schema.Struct({ |
| 357 | 370 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 358 | scopes: Schema.optionalKey(AuthEnvironmentScopes), | |
| 371 | scopes: Schema.optionalKey(AuthGrantScopes), | |
| 359 | 372 | }); |
| 360 | 373 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; |
| 361 | 374 |