| 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,7 +14,7 @@ import { |
| 14 | } from "../lib/composerImages"; | 14 | } from "../lib/composerImages"; |
| 15 | import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles"; | 15 | import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles"; |
| 16 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; | 16 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 17 | import { type MediaActionsSource } from "../lib/mediaActions"; | 17 | import { type MediaActionsSource } from "../lib/mediaActionsSource"; |
| 18 | import { PresentationSource } from "./NativePresentation"; | 18 | import { PresentationSource } from "./NativePresentation"; |
| 19 | import type { FilePreviewSource } from "./FilePreviewModal"; | 19 | import type { FilePreviewSource } from "./FilePreviewModal"; |
| 20 | import { isPdfFile } from "../lib/filePreview"; | 20 | import { isPdfFile } from "../lib/filePreview"; |
apps/mobile/src/components/FilePreviewModal.types.ts+1-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import type { FileBackedComposerAttachment } from "../lib/composerImages"; | 3 | import type { FileBackedComposerAttachment } from "../lib/composerImages"; |
| 4 | import type { MediaActionsSource } from "../lib/mediaActions"; | 4 | import type { MediaActionsSource } from "../lib/mediaActionsSource"; |
| 5 | 5 | ||
| 6 | export interface ResolvedFilePreviewSource { | 6 | export interface ResolvedFilePreviewSource { |
| 7 | readonly kind: "image" | "pdf" | "document"; | 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,7 +2,7 @@ import { MenuView } from "@react-native-menu/menu"; |
| 2 | import type { ReactElement } from "react"; | 2 | import type { ReactElement } from "react"; |
| 3 | import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; | 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 | import { SymbolView } from "./AppSymbol"; | 6 | import { SymbolView } from "./AppSymbol"; |
| 7 | import { ControlPillMenu } from "./ControlPill"; | 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,7 +3,7 @@ import { Pressable, View } from "react-native"; |
| 3 | import ImageViewing from "react-native-image-viewing"; | 3 | import ImageViewing from "react-native-image-viewing"; |
| 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 5 | 5 | ||
| 6 | import { useMediaActions } from "../lib/mediaActions"; | 6 | import { useMediaActions } from "../state/mediaActions"; |
| 7 | import { AppText } from "./AppText"; | 7 | import { AppText } from "./AppText"; |
| 8 | import { SymbolView } from "./AppSymbol"; | 8 | import { SymbolView } from "./AppSymbol"; |
| 9 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; | 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 +7,8 @@ import { ActivityIndicator, AppState, Pressable, View } from "react-native"; |
| 7 | import { AppText } from "./AppText"; | 7 | import { AppText } from "./AppText"; |
| 8 | import { SymbolView } from "./AppSymbol"; | 8 | import { SymbolView } from "./AppSymbol"; |
| 9 | import { VideoThumbnailImage } from "./VideoThumbnailImage"; | 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 | import { MediaActionsMenu } from "./MediaActionsMenu"; | 12 | import { MediaActionsMenu } from "./MediaActionsMenu"; |
| 12 | 13 | ||
| 13 | /** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */ | 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,7 +2,8 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react |
| 2 | 2 | ||
| 3 | import { cn } from "../lib/cn"; | 3 | import { cn } from "../lib/cn"; |
| 4 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | 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 | import { SymbolView } from "./AppSymbol"; | 7 | import { SymbolView } from "./AppSymbol"; |
| 7 | import { AppText } from "./AppText"; | 8 | import { AppText } from "./AppText"; |
| 8 | import { MediaActionsMenu } from "./MediaActionsMenu"; | 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,7 +5,8 @@ import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-nativ |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 6 | ||
| 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | 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 | import { | 10 | import { |
| 10 | mediaVideoPreviewUri, | 11 | mediaVideoPreviewUri, |
| 11 | mediaVideoThumbnailKey, | 12 | mediaVideoThumbnailKey, |
apps/mobile/src/dependency-graph.test.ts+1-1| ... | @@ -274,7 +274,7 @@ describe("mobile dependency graph", () => { | ... | @@ -274,7 +274,7 @@ describe("mobile dependency graph", () => { |
| 274 | ["native", "features", 8, "native must not add imports from features"], | 274 | ["native", "features", 8, "native must not add imports from features"], |
| 275 | // lib -> state: attachment/session plumbing that predates the cycle | 275 | // lib -> state: attachment/session plumbing that predates the cycle |
| 276 | // cleanup; each remaining edge needs a real owner-side seam. | 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 | for (const [from, to, ceiling, message] of ceilings) { | 280 | for (const [from, to, ceiling, message] of ceilings) { |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+55-8| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | ||
| 2 | import { environmentSession } from "../../state/session"; | ||
| 1 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 3 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 4 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; | 5 | import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; |
| ... | @@ -24,10 +26,12 @@ import { isPdfFile } from "../../lib/filePreview"; | ... | @@ -24,10 +26,12 @@ import { isPdfFile } from "../../lib/filePreview"; |
| 24 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 26 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 25 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 27 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 26 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; | 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 | import { useThreadSelection } from "../../state/use-thread-selection"; | 31 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 29 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | 32 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 30 | import { useEnvironmentQuery } from "../../state/query"; | 33 | import { useEnvironmentQuery } from "../../state/query"; |
| 34 | import { useEnvironmentPresentation } from "../../state/presentation"; | ||
| 31 | import { projectEnvironment } from "../../state/projects"; | 35 | import { projectEnvironment } from "../../state/projects"; |
| 32 | import type { AssetUrlFailureReason } from "../../state/asset-url-state"; | 36 | import type { AssetUrlFailureReason } from "../../state/asset-url-state"; |
| 33 | import { | 37 | import { |
| ... | @@ -370,14 +374,15 @@ function useThreadFilesWorkspace(params: { | ... | @@ -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 | return ( | 382 | return ( |
| 375 | <View className="flex-1 items-center justify-center bg-sheet px-6"> | 383 | <View className="flex-1 items-center justify-center bg-sheet px-6"> |
| 376 | <NativeStackScreenOptions options={{ title: "Files" }} /> | 384 | <NativeStackScreenOptions options={{ title: "Files" }} /> |
| 377 | <EmptyState | 385 | <EmptyState title="Files unavailable" detail={detail} /> |
| 378 | title="Files unavailable" | ||
| 379 | detail="This thread does not have an active workspace path." | ||
| 380 | /> | ||
| 381 | </View> | 386 | </View> |
| 382 | ); | 387 | ); |
| 383 | } | 388 | } |
| ... | @@ -420,9 +425,20 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -420,9 +425,20 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 420 | props.route.params, | 425 | props.route.params, |
| 421 | ); | 426 | ); |
| 422 | const revealedInspectorRef = useRef(false); | 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 | const entriesQuery = useFileTreeEntries({ | 439 | const entriesQuery = useFileTreeEntries({ |
| 424 | environmentId, | 440 | environmentId, |
| 425 | cwd: fileInspector.supported ? null : cwd, | 441 | cwd: !canReadFiles || fileInspector.supported ? null : cwd, |
| 426 | searchQuery, | 442 | searchQuery, |
| 427 | }); | 443 | }); |
| 428 | const handleReturnToThread = useCallback(() => { | 444 | const handleReturnToThread = useCallback(() => { |
| ... | @@ -509,6 +525,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -509,6 +525,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 509 | return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />; | 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 | if (cwd === null) { | 536 | if (cwd === null) { |
| 513 | return <FilesUnavailable />; | 537 | return <FilesUnavailable />; |
| 514 | } | 538 | } |
| ... | @@ -656,8 +680,23 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -656,8 +680,23 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 656 | !isVideoFile && | 680 | !isVideoFile && |
| 657 | !isAudioFile && | 681 | !isAudioFile && |
| 658 | (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); | 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 | const fileQuery = useEnvironmentQuery( | 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 | ? projectEnvironment.readFile({ | 700 | ? projectEnvironment.readFile({ |
| 662 | environmentId, | 701 | environmentId, |
| 663 | input: { cwd, relativePath }, | 702 | input: { cwd, relativePath }, |
| ... | @@ -861,6 +900,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -861,6 +900,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 861 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; | 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 | if (cwd === null) { | 911 | if (cwd === null) { |
| 865 | return <FilesUnavailable />; | 912 | return <FilesUnavailable />; |
| 866 | } | 913 | } |
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+2-1| ... | @@ -5,7 +5,8 @@ import { FilePreviewLoading } from "./FilePreviewFeedback"; | ... | @@ -5,7 +5,8 @@ import { FilePreviewLoading } from "./FilePreviewFeedback"; |
| 5 | import { EmptyState } from "../../components/EmptyState"; | 5 | import { EmptyState } from "../../components/EmptyState"; |
| 6 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 6 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 7 | import { PresentationSource } from "../../components/NativePresentation"; | 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 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; | 10 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; |
| 10 | 11 | ||
| 11 | function ResolvedWorkspaceFileImagePreview(props: { | 12 | function ResolvedWorkspaceFileImagePreview(props: { |
apps/mobile/src/features/files/preload-workspace-file.ts+3| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthFilesystemReadScope } from "@t3tools/contracts"; | ||
| 2 | import { readEnvironmentScope } from "../../state/session"; | ||
| 1 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | 3 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { | 5 | import { |
| ... | @@ -30,6 +32,7 @@ export function preloadWorkspaceFileContents(input: { | ... | @@ -30,6 +32,7 @@ export function preloadWorkspaceFileContents(input: { |
| 30 | readonly theme: ReviewDiffTheme; | 32 | readonly theme: ReviewDiffTheme; |
| 31 | }): void { | 33 | }): void { |
| 32 | if ( | 34 | if ( |
| 35 | !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope) || | ||
| 33 | isWorkspaceBrowserPreviewPath(input.relativePath) || | 36 | isWorkspaceBrowserPreviewPath(input.relativePath) || |
| 34 | isWorkspaceImagePreviewPath(input.relativePath) || | 37 | isWorkspaceImagePreviewPath(input.relativePath) || |
| 35 | isVideoPreviewFile(input.relativePath) | 38 | isVideoPreviewFile(input.relativePath) |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+24-3| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | ||
| 2 | import { environmentSession } from "../../state/session"; | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { SymbolView } from "../../components/AppSymbol"; | 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | 5 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| ... | @@ -15,6 +17,8 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App | ... | @@ -15,6 +17,8 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App |
| 15 | import { MaterialFilesHeader } from "./MaterialFilesHeader"; | 17 | import { MaterialFilesHeader } from "./MaterialFilesHeader"; |
| 16 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | 18 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 17 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 19 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 20 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 21 | import { useEnvironmentPresentation } from "../../state/presentation"; | ||
| 18 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 22 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 19 | import { FileTreeBrowser } from "./FileTreeBrowser"; | 23 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 20 | import { useFileTreeEntries } from "./useFileTreeEntries"; | 24 | import { useFileTreeEntries } from "./useFileTreeEntries"; |
| ... | @@ -36,9 +40,20 @@ export function ThreadFileNavigatorPane(props: { | ... | @@ -36,9 +40,20 @@ export function ThreadFileNavigatorPane(props: { |
| 36 | const foregroundColor = theme["--color-foreground"]; | 40 | const foregroundColor = theme["--color-foreground"]; |
| 37 | const sheetColor = theme["--color-sheet"]; | 41 | const sheetColor = theme["--color-sheet"]; |
| 38 | const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | 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 | const entriesQuery = useFileTreeEntries({ | 54 | const entriesQuery = useFileTreeEntries({ |
| 40 | environmentId: props.environmentId, | 55 | environmentId: props.environmentId, |
| 41 | cwd: props.cwd, | 56 | cwd: canReadFiles ? props.cwd : null, |
| 42 | searchQuery, | 57 | searchQuery, |
| 43 | }); | 58 | }); |
| 44 | const handlePreviewFile = useCallback( | 59 | const handlePreviewFile = useCallback( |
| ... | @@ -75,8 +90,14 @@ export function ThreadFileNavigatorPane(props: { | ... | @@ -75,8 +90,14 @@ export function ThreadFileNavigatorPane(props: { |
| 75 | entries={entriesQuery.entries} | 90 | entries={entriesQuery.entries} |
| 76 | loadedDirectories={entriesQuery.loadedDirectories} | 91 | loadedDirectories={entriesQuery.loadedDirectories} |
| 77 | onLoadDirectory={entriesQuery.loadDirectory} | 92 | onLoadDirectory={entriesQuery.loadDirectory} |
| 78 | error={entriesQuery.error} | 93 | error={ |
| 79 | isPending={entriesQuery.isPending} | 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 | searchQuery={searchQuery} | 101 | searchQuery={searchQuery} |
| 81 | searchTruncated={entriesQuery.searchTruncated} | 102 | searchTruncated={entriesQuery.searchTruncated} |
| 82 | selectedPath={props.selectedPath} | 103 | selectedPath={props.selectedPath} |
apps/mobile/src/features/projects/AddProjectScreen.tsx+25-4| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | createBrowseNavigationCoordinator, | 32 | createBrowseNavigationCoordinator, |
| 33 | filterFilesystemBrowseEntries, | 33 | filterFilesystemBrowseEntries, |
| 34 | getFilesystemBrowsePath, | 34 | getFilesystemBrowsePath, |
| 35 | resolveFilesystemReadAccess, | ||
| 35 | } from "@t3tools/client-runtime/state/filesystem"; | 36 | } from "@t3tools/client-runtime/state/filesystem"; |
| 36 | import { | 37 | import { |
| 37 | appendBrowsePathSegment, | 38 | appendBrowsePathSegment, |
| ... | @@ -41,6 +42,7 @@ import { | ... | @@ -41,6 +42,7 @@ import { |
| 41 | import { | 42 | import { |
| 42 | AuthOrchestrationOperateScope, | 43 | AuthOrchestrationOperateScope, |
| 43 | AuthSourceControlWriteScope, | 44 | AuthSourceControlWriteScope, |
| 45 | AuthFilesystemReadScope, | ||
| 44 | CommandId, | 46 | CommandId, |
| 45 | type EnvironmentId, | 47 | type EnvironmentId, |
| 46 | type EnvironmentMachineKind, | 48 | type EnvironmentMachineKind, |
| ... | @@ -61,7 +63,8 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit | ... | @@ -61,7 +63,8 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit |
| 61 | import { filesystemEnvironment } from "../../state/filesystem"; | 63 | import { filesystemEnvironment } from "../../state/filesystem"; |
| 62 | import { projectEnvironment } from "../../state/projects"; | 64 | import { projectEnvironment } from "../../state/projects"; |
| 63 | import { useEnvironmentQuery } from "../../state/query"; | 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 | import { sourceControlEnvironment } from "../../state/sourceControl"; | 68 | import { sourceControlEnvironment } from "../../state/sourceControl"; |
| 66 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 69 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 67 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 70 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| ... | @@ -345,7 +348,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto | ... | @@ -345,7 +348,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto |
| 345 | setIsBrowseNavigating(true); | 348 | setIsBrowseNavigating(true); |
| 346 | const committed = await browseNavigation.run( | 349 | const committed = await browseNavigation.run( |
| 347 | async () => { | 350 | async () => { |
| 348 | if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { | 351 | if ( |
| 352 | environment && | ||
| 353 | readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) && | ||
| 354 | canPreloadBrowsePath(environmentRuntime?.connectionState) | ||
| 355 | ) { | ||
| 349 | await loadBrowsePath({ | 356 | await loadBrowsePath({ |
| 350 | environmentId: environment.environmentId, | 357 | environmentId: environment.environmentId, |
| 351 | input: { partialPath: selectedDirectoryPath }, | 358 | input: { partialPath: selectedDirectoryPath }, |
| ... | @@ -877,8 +884,19 @@ function FolderBrowser(props: { | ... | @@ -877,8 +884,19 @@ function FolderBrowser(props: { |
| 877 | () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), | 884 | () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), |
| 878 | [browsePath.directoryPath], | 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 | const browseState = useEnvironmentQuery( | 898 | const browseState = useEnvironmentQuery( |
| 881 | browseInput === null | 899 | !canReadFiles || browseInput === null |
| 882 | ? null | 900 | ? null |
| 883 | : filesystemEnvironment.browse({ | 901 | : filesystemEnvironment.browse({ |
| 884 | environmentId: props.environment.environmentId, | 902 | environmentId: props.environment.environmentId, |
| ... | @@ -900,9 +918,12 @@ function FolderBrowser(props: { | ... | @@ -900,9 +918,12 @@ function FolderBrowser(props: { |
| 900 | return ( | 918 | return ( |
| 901 | <> | 919 | <> |
| 902 | <SectionTitle>Browse folders</SectionTitle> | 920 | <SectionTitle>Browse folders</SectionTitle> |
| 921 | {!canReadFiles && !fileAccess.isPending ? ( | ||
| 922 | <ErrorBanner message={fileAccess.error ?? "This connection cannot browse host folders."} /> | ||
| 923 | ) : null} | ||
| 903 | {browseState.error ? <ErrorBanner message={browseState.error} /> : null} | 924 | {browseState.error ? <ErrorBanner message={browseState.error} /> : null} |
| 904 | <ListSection> | 925 | <ListSection> |
| 905 | {browseState.isPending && browseState.data === null ? ( | 926 | {fileAccess.isPending || (browseState.isPending && browseState.data === null) ? ( |
| 906 | <View className="items-center py-5"> | 927 | <View className="items-center py-5"> |
| 907 | <ActivityIndicator colorClassName="accent-icon-muted" /> | 928 | <ActivityIndicator colorClassName="accent-icon-muted" /> |
| 908 | </View> | 929 | </View> |
apps/mobile/src/features/review/ReviewSheet.tsx+18-16| ... | @@ -825,23 +825,25 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -825,23 +825,25 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 825 | > | 825 | > |
| 826 | {listHeader} | 826 | {listHeader} |
| 827 | {!selectedSection ? ( | 827 | {!selectedSection ? ( |
| 828 | <View | 828 | error ? null : ( |
| 829 | className={ | 829 | <View |
| 830 | Platform.OS === "android" | 830 | className={ |
| 831 | ? "items-center px-6 py-5" | 831 | Platform.OS === "android" |
| 832 | : "border-b border-border bg-card px-4 py-5" | 832 | ? "items-center px-6 py-5" |
| 833 | } | 833 | : "border-b border-border bg-card px-4 py-5" |
| 834 | > | 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 | )} | ||
| 841 | > | 835 | > |
| 842 | This thread has no ready turn diffs and the worktree diff is empty. | 836 | <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text> |
| 843 | </Text> | 837 | <Text |
| 844 | </View> | 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 | ) : selectedSection.isLoading && selectedSection.diff === null ? ( | 847 | ) : selectedSection.isLoading && selectedSection.diff === null ? ( |
| 846 | <View | 848 | <View |
| 847 | className={cn( | 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,3 +1,5 @@ |
| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | ||
| 2 | import { environmentSession } from "../../state/session"; | ||
| 1 | import { useCallback, useEffect, useMemo } from "react"; | 3 | import { useCallback, useEffect, useMemo } from "react"; |
| 2 | import * as DateTime from "effect/DateTime"; | 4 | import * as DateTime from "effect/DateTime"; |
| 3 | 5 | ||
| ... | @@ -9,6 +11,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ... | @@ -9,6 +11,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 9 | 11 | ||
| 10 | import { useCheckpointDiff } from "../../state/queries"; | 12 | import { useCheckpointDiff } from "../../state/queries"; |
| 11 | import { useEnvironmentQuery } from "../../state/query"; | 13 | import { useEnvironmentQuery } from "../../state/query"; |
| 14 | import { useEnvironmentPresentation } from "../../state/presentation"; | ||
| 12 | import { reviewEnvironment } from "../../state/review"; | 15 | import { reviewEnvironment } from "../../state/review"; |
| 13 | import { useSelectedThreadProjection } from "../../state/use-thread-detail"; | 16 | import { useSelectedThreadProjection } from "../../state/use-thread-detail"; |
| 14 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | 17 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| ... | @@ -35,10 +38,21 @@ export function useReviewSections(input: { | ... | @@ -35,10 +38,21 @@ export function useReviewSections(input: { |
| 35 | }) { | 38 | }) { |
| 36 | const { environmentId, reviewCache, threadId } = input; | 39 | const { environmentId, reviewCache, threadId } = input; |
| 37 | const enabled = input.enabled ?? true; | 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 | const selectedThread = useSelectedThreadProjection(); | 52 | const selectedThread = useSelectedThreadProjection(); |
| 39 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | 53 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 40 | const diffPreview = useEnvironmentQuery( | 54 | const diffPreview = useEnvironmentQuery( |
| 41 | enabled && environmentId !== undefined && selectedThreadCwd !== null | 55 | canReadFiles && enabled && environmentId !== undefined && selectedThreadCwd !== null |
| 42 | ? reviewEnvironment.diffPreview({ | 56 | ? reviewEnvironment.diffPreview({ |
| 43 | environmentId, | 57 | environmentId, |
| 44 | input: { cwd: selectedThreadCwd }, | 58 | input: { cwd: selectedThreadCwd }, |
| ... | @@ -70,24 +84,33 @@ export function useReviewSections(input: { | ... | @@ -70,24 +84,33 @@ export function useReviewSections(input: { |
| 70 | ) as Record<string, ThreadCheckpointSummary>, | 84 | ) as Record<string, ThreadCheckpointSummary>, |
| 71 | [readyCheckpoints], | 85 | [readyCheckpoints], |
| 72 | ); | 86 | ); |
| 73 | const reviewSections = useMemo( | 87 | const reviewSections = useMemo(() => { |
| 74 | () => | 88 | const sections = buildReviewSectionItems({ |
| 75 | buildReviewSectionItems({ | 89 | checkpoints: readyCheckpoints, |
| 76 | checkpoints: readyCheckpoints, | 90 | gitSections: |
| 77 | gitSections: diffPreview.data?.sources ?? reviewCache.gitSections, | 91 | canReadFiles || fileAccess.isPending |
| 78 | turnDiffById: reviewCache.turnDiffById, | 92 | ? (diffPreview.data?.sources ?? reviewCache.gitSections) |
| 79 | loadingTurnIds, | 93 | : [], |
| 80 | loadingGitSections: diffPreview.isPending, | 94 | turnDiffById: reviewCache.turnDiffById, |
| 81 | }), | ||
| 82 | [ | ||
| 83 | diffPreview.isPending, | ||
| 84 | diffPreview.data?.sources, | ||
| 85 | loadingTurnIds, | 95 | loadingTurnIds, |
| 86 | readyCheckpoints, | 96 | loadingGitSections: fileAccess.isPending || diffPreview.isPending, |
| 87 | reviewCache.gitSections, | 97 | }); |
| 88 | reviewCache.turnDiffById, | 98 | // Keep the selected section while its grant loads, without displaying cached host files. |
| 89 | ], | 99 | return fileAccess.isPending |
| 90 | ); | 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 | const selectedSection = useMemo( | 114 | const selectedSection = useMemo( |
| 92 | () => | 115 | () => |
| 93 | reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? | 116 | reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? |
| ... | @@ -181,10 +204,16 @@ export function useReviewSections(input: { | ... | @@ -181,10 +204,16 @@ export function useReviewSections(input: { |
| 181 | ); | 204 | ); |
| 182 | 205 | ||
| 183 | return { | 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 | isSelectedSectionPending: | 214 | isSelectedSectionPending: |
| 186 | selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending, | 215 | selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending, |
| 187 | loadingGitDiffs: diffPreview.isPending, | 216 | loadingGitDiffs: fileAccess.isPending || diffPreview.isPending, |
| 188 | diffPreviewRevision: diffPreview.data | 217 | diffPreviewRevision: diffPreview.data |
| 189 | ? DateTime.formatIso(diffPreview.data.generatedAt) | 218 | ? DateTime.formatIso(diffPreview.data.generatedAt) |
| 190 | : undefined, | 219 | : undefined, |
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+2-1| ... | @@ -13,7 +13,8 @@ import { AppText as Text } from "../../components/AppText"; | ... | @@ -13,7 +13,8 @@ import { AppText as Text } from "../../components/AppText"; |
| 13 | import type { FilePreviewSource } from "../../components/FilePreviewModal"; | 13 | import type { FilePreviewSource } from "../../components/FilePreviewModal"; |
| 14 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; | 14 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; |
| 15 | import { PresentationSource } from "../../components/NativePresentation"; | 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 | import { useAssetUrlState } from "../../state/assets"; | 18 | import { useAssetUrlState } from "../../state/assets"; |
| 18 | import { | 19 | import { |
| 19 | MARKDOWN_IMAGE_MAX_WIDTH, | 20 | MARKDOWN_IMAGE_MAX_WIDTH, |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+20-2| ... | @@ -1,3 +1,6 @@ | ... | @@ -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 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { Alert } from "react-native"; | 5 | import { Alert } from "react-native"; |
| 3 | 6 | ||
| ... | @@ -485,8 +488,23 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -485,8 +488,23 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 485 | // Default mode until the user picks one explicitly — same resolution web | 488 | // Default mode until the user picks one explicitly — same resolution web |
| 486 | // uses for new draft threads: per-project setting, then the repo's | 489 | // uses for new draft threads: per-project setting, then the repo's |
| 487 | // checked-in t3.json, then the server's configured default. | 490 | // checked-in t3.json, then the server's configured default. |
| 488 | const t3ProjectFileQuery = useEnvironmentQuery( | 491 | const fileAccessSession = useEnvironmentQuery( |
| 489 | selectedProject !== null && selectedProject.workspaceRoot !== "" | 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 | ? projectEnvironment.readFile({ | 508 | ? projectEnvironment.readFile({ |
| 491 | environmentId: selectedProject.environmentId, | 509 | environmentId: selectedProject.environmentId, |
| 492 | input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, | 510 | input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, |
| ... | @@ -527,7 +545,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -527,7 +545,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 527 | const defaultWorkspaceModeSettled = | 545 | const defaultWorkspaceModeSettled = |
| 528 | selectedProjectDraft.workspaceSelection?.mode !== undefined || | 546 | selectedProjectDraft.workspaceSelection?.mode !== undefined || |
| 529 | projectSettings.sources.defaultThreadEnvMode !== "environment" || | 547 | projectSettings.sources.defaultThreadEnvMode !== "environment" || |
| 530 | !t3ProjectFileQuery.isPending; | 548 | (!t3ProjectFileQuery.isPending && !fileAccessPending); |
| 531 | const workspaceMode = canChooseWorkspace | 549 | const workspaceMode = canChooseWorkspace |
| 532 | ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode) | 550 | ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode) |
| 533 | : "local"; | 551 | : "local"; |
apps/mobile/src/lib/markdownMedia.ts+1-1| ... | @@ -4,7 +4,7 @@ import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links" | ... | @@ -4,7 +4,7 @@ import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links" |
| 4 | 4 | ||
| 5 | import type { FilePreviewSource } from "../components/FilePreviewModal"; | 5 | import type { FilePreviewSource } from "../components/FilePreviewModal"; |
| 6 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; | 6 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; |
| 7 | import type { MediaActionsSource } from "./mediaActions"; | 7 | import type { MediaActionsSource } from "./mediaActionsSource"; |
| 8 | 8 | ||
| 9 | /** Resolves only explicit media references. Ordinary links keep their existing navigation. */ | 9 | /** Resolves only explicit media references. Ordinary links keep their existing navigation. */ |
| 10 | export function resolveMarkdownMediaPreview( | 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,7 +2,7 @@ import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/ |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | 3 | ||
| 4 | import type { DraftComposerFileAttachment } from "./composerImages"; | 4 | import type { DraftComposerFileAttachment } from "./composerImages"; |
| 5 | import type { MediaActionsSource } from "./mediaActions"; | 5 | import type { MediaActionsSource } from "./mediaActionsSource"; |
| 6 | 6 | ||
| 7 | export type MediaVideoPreviewSource = { | 7 | export type MediaVideoPreviewSource = { |
| 8 | readonly type: "media"; | 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,8 +1,5 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; |
| 3 | type EnvironmentConnectionPhase, | ||
| 4 | presentConnectionState, | ||
| 5 | } from "@t3tools/client-runtime/connection"; | ||
| 6 | import { | 3 | import { |
| 7 | assetUrlStateFromResult, | 4 | assetUrlStateFromResult, |
| 8 | createAssetEnvironmentAtoms, | 5 | createAssetEnvironmentAtoms, |
| ... | @@ -10,16 +7,15 @@ import { | ... | @@ -10,16 +7,15 @@ import { |
| 10 | EMPTY_ASSET_URL_ATOM, | 7 | EMPTY_ASSET_URL_ATOM, |
| 11 | } from "@t3tools/client-runtime/state/assets"; | 8 | } from "@t3tools/client-runtime/state/assets"; |
| 12 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | 9 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import { AsyncResult, Atom } from "effect/reactivity"; | ||
| 15 | import { useCallback } from "react"; | 10 | import { useCallback } from "react"; |
| 16 | 11 | ||
| 17 | import { environmentCatalog } from "../connection/catalog"; | ||
| 18 | import { connectionAtomRuntime } from "../connection/runtime"; | 12 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; | 13 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; |
| 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; | 14 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; |
| 21 | import { environmentProjectCloneListAtom } from "./projectClones"; | 15 | import { environmentProjectCloneListAtom } from "./projectClones"; |
| 22 | import { environmentSession, usePreparedConnection } from "./session"; | 16 | import { environmentSession, usePreparedConnection } from "./session"; |
| 17 | import { useEnvironmentPresentation } from "./presentation"; | ||
| 18 | import { useEnvironmentQuery } from "./query"; | ||
| 23 | import { useAtomQueryRunner } from "./use-atom-query-runner"; | 19 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 24 | 20 | ||
| 25 | export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; | 21 | export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; |
| ... | @@ -33,35 +29,40 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ | ... | @@ -33,35 +29,40 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ |
| 33 | projectClones: environmentProjectCloneListAtom, | 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 | export function useAssetUrlState( | 32 | export function useAssetUrlState( |
| 51 | environmentId: EnvironmentId | null, | 33 | environmentId: EnvironmentId | null, |
| 52 | resource: AssetResource | null, | 34 | resource: AssetResource | null, |
| 53 | ): AssetUrlState { | 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 | const preparedConnection = usePreparedConnection(environmentId); | 51 | const preparedConnection = usePreparedConnection(environmentId); |
| 55 | const connectionPhase = useConnectionPhase(environmentId); | 52 | const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available"; |
| 56 | const result = useAtomValue( | 53 | const result = useAtomValue( |
| 57 | environmentId === null || resource === null | 54 | !canReadResource || environmentId === null || resource === null |
| 58 | ? EMPTY_ASSET_URL_ATOM | 55 | ? EMPTY_ASSET_URL_ATOM |
| 59 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), | 56 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 60 | ); | 57 | ); |
| 61 | const shared = assetUrlStateFromResult( | 58 | const shared = !canReadResource |
| 62 | result, | 59 | ? fileAccess.isPending |
| 63 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, | 60 | ? { _tag: "Loading" as const } |
| 64 | ); | 61 | : { _tag: "Failure" as const } |
| 62 | : assetUrlStateFromResult( | ||
| 63 | result, | ||
| 64 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, | ||
| 65 | ); | ||
| 65 | return deriveAssetUrlState({ | 66 | return deriveAssetUrlState({ |
| 66 | connectionPhase, | 67 | connectionPhase, |
| 67 | // A failure left over from an outage is re-queried as soon as the | 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,4 +1,7 @@ |
| 1 | import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; | 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 | import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; | 5 | import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; |
| 3 | import type { | 6 | import type { |
| 4 | EnvironmentId, | 7 | EnvironmentId, |
| ... | @@ -309,25 +312,48 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { | ... | @@ -309,25 +312,48 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { |
| 309 | [target.cwd, target.environmentId, target.query], | 312 | [target.cwd, target.environmentId, target.query], |
| 310 | ); | 313 | ); |
| 311 | const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); | 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 | debouncedTarget.environmentId !== null && | 329 | debouncedTarget.environmentId !== null && |
| 314 | debouncedTarget.cwd !== null && | 330 | debouncedTarget.cwd !== null && |
| 315 | debouncedTarget.query.length > 0 | 331 | debouncedTarget.query.length > 0 |
| 316 | ? projectEnvironment.searchEntries({ | 332 | ? { |
| 317 | environmentId: debouncedTarget.environmentId, | 333 | environmentId: debouncedTarget.environmentId, |
| 318 | input: { | 334 | input: { |
| 319 | cwd: debouncedTarget.cwd, | 335 | cwd: debouncedTarget.cwd, |
| 320 | query: debouncedTarget.query, | 336 | query: debouncedTarget.query, |
| 321 | limit: COMPOSER_PATH_SEARCH_LIMIT, | 337 | limit: COMPOSER_PATH_SEARCH_LIMIT, |
| 322 | }, | 338 | }, |
| 323 | }) | 339 | } |
| 324 | : null, | 340 | : null; |
| 341 | const result = useEnvironmentQuery( | ||
| 342 | canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null, | ||
| 325 | ); | 343 | ); |
| 344 | const hasTarget = searchTarget !== null; | ||
| 326 | 345 | ||
| 327 | return { | 346 | return { |
| 328 | entries: result.data?.entries ?? [], | 347 | entries: result.data?.entries ?? [], |
| 329 | error: result.error, | 348 | error: |
| 330 | isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, | 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 | refresh: result.refresh, | 357 | refresh: result.refresh, |
| 332 | }; | 358 | }; |
| 333 | } | 359 | } |
apps/server/src/auth/RpcAuthorization.test.ts+41| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthEnvironmentMaintainScope, | 2 | AuthEnvironmentMaintainScope, |
| 3 | AuthFilesystemReadScope, | ||
| 3 | AuthProvidersManageScope, | 4 | AuthProvidersManageScope, |
| 4 | AuthSettingsWriteScope, | 5 | AuthSettingsWriteScope, |
| 5 | DEFAULT_SERVER_SETTINGS, | 6 | DEFAULT_SERVER_SETTINGS, |
| ... | @@ -301,3 +302,43 @@ it.effect("requires task permission before attaching a prepared worktree to a th | ... | @@ -301,3 +302,43 @@ it.effect("requires task permission before attaching a prepared worktree to a th |
| 301 | expect(handled).toBe(false); | 302 | expect(handled).toBe(false); |
| 302 | }).pipe(Effect.scoped), | 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,6 +2,7 @@ import { |
| 2 | CLIENT_GUARDED_RPC_SCOPES, | 2 | CLIENT_GUARDED_RPC_SCOPES, |
| 3 | type DeviceListInput, | 3 | type DeviceListInput, |
| 4 | clientRpcRequiredScopes, | 4 | clientRpcRequiredScopes, |
| 5 | AssetCreateUrlInput, | ||
| 5 | AuthAccessReadScope, | 6 | AuthAccessReadScope, |
| 6 | ServerSettingsPatch, | 7 | ServerSettingsPatch, |
| 7 | ProviderInstanceMutation, | 8 | ProviderInstanceMutation, |
| ... | @@ -9,11 +10,12 @@ import { | ... | @@ -9,11 +10,12 @@ import { |
| 9 | AuthSettingsWriteScope, | 10 | AuthSettingsWriteScope, |
| 10 | AuthProvidersManageScope, | 11 | AuthProvidersManageScope, |
| 11 | AuthEnvironmentMaintainScope, | 12 | AuthEnvironmentMaintainScope, |
| 13 | AuthFilesystemReadScope, | ||
| 14 | AuthFilesystemWriteScope, | ||
| 12 | AuthOrchestrationOperateScope, | 15 | AuthOrchestrationOperateScope, |
| 13 | AuthOrchestrationReadScope, | 16 | AuthOrchestrationReadScope, |
| 14 | AuthRelayReadScope, | 17 | AuthRelayReadScope, |
| 15 | AuthRelayWriteScope, | 18 | AuthRelayWriteScope, |
| 16 | AuthReviewWriteScope, | ||
| 17 | AuthTerminalOperateScope, | 19 | AuthTerminalOperateScope, |
| 18 | ORCHESTRATION_V2_WS_METHODS, | 20 | ORCHESTRATION_V2_WS_METHODS, |
| 19 | type AuthEnvironmentScope, | 21 | type AuthEnvironmentScope, |
| ... | @@ -131,15 +133,15 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -131,15 +133,15 @@ export const RPC_REQUIRED_SCOPES = { |
| 131 | [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, | 133 | [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, |
| 132 | [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, | 134 | [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, |
| 133 | [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, | 135 | [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, |
| 134 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, | 136 | [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, |
| 135 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, | 137 | [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, |
| 136 | [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, | 138 | [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, |
| 137 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, | 139 | [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope, |
| 138 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, | 140 | [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope, |
| 139 | [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, | 141 | [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, |
| 140 | [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, | 142 | [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, |
| 141 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, | 143 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| 142 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, | 144 | [WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope, |
| 143 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, | 145 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, |
| 144 | [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, | 146 | [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, |
| 145 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, | 147 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, |
| ... | @@ -154,8 +156,8 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -154,8 +156,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 154 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, | 156 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, |
| 155 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, | 157 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, |
| 156 | [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, | 158 | [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, |
| 157 | [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, | 159 | [WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope, |
| 158 | [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, | 160 | [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, |
| 159 | [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, | 161 | [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, |
| 160 | [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, | 162 | [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, |
| 161 | [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, | 163 | [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, |
| ... | @@ -227,6 +229,16 @@ const requiredScopesForRpcCall = ( | ... | @@ -227,6 +229,16 @@ const requiredScopesForRpcCall = ( |
| 227 | method: string, | 229 | method: string, |
| 228 | payload: unknown, | 230 | payload: unknown, |
| 229 | ): ReadonlyArray<AuthEnvironmentScope> => { | 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 | if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); | 242 | if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); |
| 231 | const guarded = clientRpcRequiredScopes(method, payload); | 243 | const guarded = clientRpcRequiredScopes(method, payload); |
| 232 | if (guarded.length > 0) return guarded; | 244 | if (guarded.length > 0) return guarded; |
apps/server/src/auth/http.ts+2-24| ... | @@ -2,16 +2,7 @@ import { | ... | @@ -2,16 +2,7 @@ import { |
| 2 | AuthAccessReadScope, | 2 | AuthAccessReadScope, |
| 3 | AuthAccessWriteScope, | 3 | AuthAccessWriteScope, |
| 4 | AuthStandardClientScopes, | 4 | AuthStandardClientScopes, |
| 5 | AuthSettingsWriteScope, | 5 | AuthGrantScope, |
| 6 | AuthProvidersManageScope, | ||
| 7 | AuthEnvironmentMaintainScope, | ||
| 8 | AuthOrchestrationOperateScope, | ||
| 9 | AuthOrchestrationReadScope, | ||
| 10 | AuthRelayReadScope, | ||
| 11 | AuthRelayWriteScope, | ||
| 12 | AuthReviewWriteScope, | ||
| 13 | AuthSourceControlWriteScope, | ||
| 14 | AuthTerminalOperateScope, | ||
| 15 | EnvironmentAuthInvalidError, | 6 | EnvironmentAuthInvalidError, |
| 16 | type EnvironmentAuthInvalidReason, | 7 | type EnvironmentAuthInvalidReason, |
| 17 | EnvironmentHttpApi, | 8 | EnvironmentHttpApi, |
| ... | @@ -367,20 +358,7 @@ export const layer = HttpApiBuilder.group( | ... | @@ -367,20 +358,7 @@ export const layer = HttpApiBuilder.group( |
| 367 | ? undefined | 358 | ? undefined |
| 368 | : parseAllowedOAuthScope({ | 359 | : parseAllowedOAuthScope({ |
| 369 | value: args.payload.scope, | 360 | value: args.payload.scope, |
| 370 | allowedScopes: new Set<AuthEnvironmentScope>([ | 361 | allowedScopes: new Set(AuthGrantScope.literals), |
| 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 | ]), | ||
| 384 | }); | 362 | }); |
| 385 | if (requestedScopes === null) { | 363 | if (requestedScopes === null) { |
| 386 | return yield* failEnvironmentInvalidRequest("invalid_scope"); | 364 | return yield* failEnvironmentInvalidRequest("invalid_scope"); |
apps/server/src/cli/authScopes.ts+3-3| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { AuthEnvironmentScope } from "@t3tools/contracts"; | 1 | import { AuthGrantScope } from "@t3tools/contracts"; |
| 2 | import { Flag } from "effect/cli"; | 2 | import { Flag } from "effect/cli"; |
| 3 | 3 | ||
| 4 | export const authScopesFlag = (defaults: ReadonlyArray<AuthEnvironmentScope>) => | 4 | export const authScopesFlag = (defaults: ReadonlyArray<AuthGrantScope>) => |
| 5 | Flag.Literals("scope", AuthEnvironmentScope.literals).pipe( | 5 | Flag.Literals("scope", AuthGrantScope.literals).pipe( |
| 6 | Flag.withDescription( | 6 | Flag.withDescription( |
| 7 | `Authorization scope to grant. Repeat for multiple scopes; replaces the default set: ${defaults.join(", ")}.`, | 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,6 +11,7 @@ import { AsyncResult } from "effect/reactivity"; |
| 11 | import { useCallback, useMemo } from "react"; | 11 | import { useCallback, useMemo } from "react"; |
| 12 | 12 | ||
| 13 | import { assetEnvironment } from "~/state/assets"; | 13 | import { assetEnvironment } from "~/state/assets"; |
| 14 | import { useFilesystemReadAccess } from "~/state/filesystem"; | ||
| 14 | import { usePreparedConnection } from "~/state/session"; | 15 | import { usePreparedConnection } from "~/state/session"; |
| 15 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | 16 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 16 | 17 | ||
| ... | @@ -20,12 +21,19 @@ export function useAssetUrlState( | ... | @@ -20,12 +21,19 @@ export function useAssetUrlState( |
| 20 | environmentId: EnvironmentId | null, | 21 | environmentId: EnvironmentId | null, |
| 21 | resource: AssetResource | null, | 22 | resource: AssetResource | null, |
| 22 | ): AssetUrlState { | 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 | const preparedConnection = usePreparedConnection(environmentId); | 30 | const preparedConnection = usePreparedConnection(environmentId); |
| 24 | const result = useAtomValue( | 31 | const result = useAtomValue( |
| 25 | environmentId === null || resource === null | 32 | !canReadResource || environmentId === null || resource === null |
| 26 | ? EMPTY_ASSET_URL_ATOM | 33 | ? EMPTY_ASSET_URL_ATOM |
| 27 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), | 34 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 28 | ); | 35 | ); |
| 36 | if (!canReadResource) return { _tag: fileAccess.isPending ? "Loading" : "Failure" }; | ||
| 29 | return assetUrlStateFromResult( | 37 | return assetUrlStateFromResult( |
| 30 | result, | 38 | result, |
| 31 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, | 39 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, |
| ... | @@ -55,21 +63,40 @@ export function useAssetUrls( | ... | @@ -55,21 +63,40 @@ export function useAssetUrls( |
| 55 | resources: ReadonlyArray<AssetResource>, | 63 | resources: ReadonlyArray<AssetResource>, |
| 56 | ): ReadonlyArray<string | null> { | 64 | ): ReadonlyArray<string | null> { |
| 57 | const preparedConnection = usePreparedConnection(environmentId); | 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 | const results = useAtomValue( | 79 | const results = useAtomValue( |
| 59 | assetEnvironment.createUrls({ | 80 | assetEnvironment.createUrls({ |
| 60 | environmentId, | 81 | environmentId, |
| 61 | resources, | 82 | resources: allowedResources, |
| 62 | }), | 83 | }), |
| 63 | ); | 84 | ); |
| 64 | return useMemo( | 85 | return useMemo(() => { |
| 65 | () => | 86 | if (preparedConnection._tag === "None") return resources.map(() => null); |
| 66 | preparedConnection._tag === "None" | 87 | let resultIndex = 0; |
| 67 | ? resources.map(() => null) | 88 | return resources.map((resource) => { |
| 68 | : results.map((result) => | 89 | if ( |
| 69 | AsyncResult.isSuccess(result) | 90 | !canReadFiles && |
| 70 | ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) | 91 | (resource._tag === "workspace-file" || |
| 71 | : null, | 92 | resource._tag === "media-file" || |
| 72 | ), | 93 | resource._tag === "draft-workspace-file") |
| 73 | [preparedConnection, resources, results], | 94 | ) |
| 74 | ); | 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,5 +1,5 @@ |
| 1 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; | 1 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; |
| 2 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | 2 | import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; |
| 3 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | import { | 4 | import { |
| 5 | COMPOSER_CONTEXT_CLIPBOARD_MIME, | 5 | COMPOSER_CONTEXT_CLIPBOARD_MIME, |
| ... | @@ -2757,7 +2757,12 @@ function useChatMarkdownState({ | ... | @@ -2757,7 +2757,12 @@ function useChatMarkdownState({ |
| 2757 | ); | 2757 | ); |
| 2758 | const findWorkspaceBasenameMatch = useCallback( | 2758 | const findWorkspaceBasenameMatch = useCallback( |
| 2759 | async (workspaceRelativePath: string) => { | 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 | return null; | 2766 | return null; |
| 2762 | } | 2767 | } |
| 2763 | const result = await searchProjectEntries({ | 2768 | const result = await searchProjectEntries({ |
apps/web/src/components/CommandPalette.tsx+28-7| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | import { | 32 | import { |
| 33 | AuthOrchestrationOperateScope, | 33 | AuthOrchestrationOperateScope, |
| 34 | AuthSourceControlWriteScope, | 34 | AuthSourceControlWriteScope, |
| 35 | AuthFilesystemReadScope, | ||
| 35 | type DesktopWslState, | 36 | type DesktopWslState, |
| 36 | type EnvironmentId, | 37 | type EnvironmentId, |
| 37 | type EnvironmentMachineKind, | 38 | type EnvironmentMachineKind, |
| ... | @@ -100,7 +101,7 @@ import { | ... | @@ -100,7 +101,7 @@ import { |
| 100 | } from "./settings/ThemePreviewCircles"; | 101 | } from "./settings/ThemePreviewCircles"; |
| 101 | import { readLocalApi } from "../localApi"; | 102 | import { readLocalApi } from "../localApi"; |
| 102 | import { desktopLocalBackendId } from "../connection/desktopLocal"; | 103 | import { desktopLocalBackendId } from "../connection/desktopLocal"; |
| 103 | import { filesystemEnvironment } from "../state/filesystem"; | 104 | import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem"; |
| 104 | import { projectEnvironment } from "../state/projects"; | 105 | import { projectEnvironment } from "../state/projects"; |
| 105 | import { useEnvironmentQuery } from "../state/query"; | 106 | import { useEnvironmentQuery } from "../state/query"; |
| 106 | import { serverEnvironment } from "../state/server"; | 107 | import { serverEnvironment } from "../state/server"; |
| ... | @@ -1160,11 +1161,14 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1160,11 +1161,14 @@ function OpenCommandPaletteDialog(props: { |
| 1160 | ); | 1161 | ); |
| 1161 | const relativePathNeedsActiveProject = | 1162 | const relativePathNeedsActiveProject = |
| 1162 | isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; | 1163 | isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; |
| 1163 | const browseQuery = useEnvironmentQuery( | 1164 | const browseAccess = useFilesystemReadAccess(browseEnvironmentId); |
| 1165 | const hasBrowseTarget = | ||
| 1164 | isBrowsing && | 1166 | isBrowsing && |
| 1165 | browsePath.directoryPath.length > 0 && | 1167 | browsePath.directoryPath.length > 0 && |
| 1166 | browseEnvironmentId !== null && | 1168 | browseEnvironmentId !== null && |
| 1167 | !relativePathNeedsActiveProject | 1169 | !relativePathNeedsActiveProject; |
| 1170 | const browseQuery = useEnvironmentQuery( | ||
| 1171 | browseAccess.canReadFiles && hasBrowseTarget | ||
| 1168 | ? filesystemEnvironment.browse({ | 1172 | ? filesystemEnvironment.browse({ |
| 1169 | environmentId: browseEnvironmentId, | 1173 | environmentId: browseEnvironmentId, |
| 1170 | input: { | 1174 | input: { |
| ... | @@ -1175,7 +1179,11 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1175,7 +1179,11 @@ function OpenCommandPaletteDialog(props: { |
| 1175 | : null, | 1179 | : null, |
| 1176 | ); | 1180 | ); |
| 1177 | const browseResult = browseQuery.data; | 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 | const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; | 1187 | const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; |
| 1180 | const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( | 1188 | const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( |
| 1181 | () => | 1189 | () => |
| ... | @@ -1202,7 +1210,10 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1202,7 +1210,10 @@ function OpenCommandPaletteDialog(props: { |
| 1202 | const environment = environments.find( | 1210 | const environment = environments.find( |
| 1203 | (candidate) => candidate.environmentId === environmentId, | 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 | return; | 1217 | return; |
| 1207 | } | 1218 | } |
| 1208 | 1219 | ||
| ... | @@ -2992,6 +3003,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2992,6 +3003,7 @@ function OpenCommandPaletteDialog(props: { |
| 2992 | const willCreateProjectPath = | 3003 | const willCreateProjectPath = |
| 2993 | canSubmitBrowsePath && | 3004 | canSubmitBrowsePath && |
| 2994 | !isBrowsePending && | 3005 | !isBrowsePending && |
| 3006 | browseAccessError === null && | ||
| 2995 | query.trim().length > 0 && | 3007 | query.trim().length > 0 && |
| 2996 | !hasHighlightedBrowseItem && | 3008 | !hasHighlightedBrowseItem && |
| 2997 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); | 3009 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); |
| ... | @@ -3512,6 +3524,15 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3512,6 +3524,15 @@ function OpenCommandPaletteDialog(props: { |
| 3512 | </div> | 3524 | </div> |
| 3513 | </div> | 3525 | </div> |
| 3514 | ) : null} | 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 | <CommandPaletteVirtualizedResults | 3536 | <CommandPaletteVirtualizedResults |
| 3516 | rows={resultRows.rows} | 3537 | rows={resultRows.rows} |
| 3517 | listRef={resultListRef} | 3538 | listRef={resultListRef} |
apps/web/src/components/DiffPanel.tsx+22-3| ... | @@ -23,6 +23,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -23,6 +23,7 @@ import * as Schema from "effect/Schema"; |
| 23 | import * as DateTime from "effect/DateTime"; | 23 | import * as DateTime from "effect/DateTime"; |
| 24 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 24 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 25 | import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; | 25 | import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; |
| 26 | import { useFilesystemReadAccess } from "~/state/filesystem"; | ||
| 26 | import { useOpenInPreferredEditor } from "../editorPreferences"; | 27 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 27 | import { useFileContextMenuHandler } from "../fileContextMenu"; | 28 | import { useFileContextMenuHandler } from "../fileContextMenu"; |
| 28 | import { type DraftId } from "../composerDraftStore"; | 29 | import { type DraftId } from "../composerDraftStore"; |
| ... | @@ -223,6 +224,8 @@ export default function DiffPanel({ | ... | @@ -223,6 +224,8 @@ export default function DiffPanel({ |
| 223 | const activeThreadId = routeThreadRef?.threadId ?? null; | 224 | const activeThreadId = routeThreadRef?.threadId ?? null; |
| 224 | const activeThread = useThreadShell(routeThreadRef); | 225 | const activeThread = useThreadShell(routeThreadRef); |
| 225 | const activeThreadProjection = useThreadProjection(routeThreadRef)?.projection ?? null; | 226 | const activeThreadProjection = useThreadProjection(routeThreadRef)?.projection ?? null; |
| 227 | const fileAccess = useFilesystemReadAccess(activeThread?.environmentId ?? null); | ||
| 228 | const { canReadFiles } = fileAccess; | ||
| 226 | const activeProjectId = activeThread?.projectId ?? null; | 229 | const activeProjectId = activeThread?.projectId ?? null; |
| 227 | const activeProject = useProject( | 230 | const activeProject = useProject( |
| 228 | activeThread && activeProjectId | 231 | activeThread && activeProjectId |
| ... | @@ -337,7 +340,7 @@ export default function DiffPanel({ | ... | @@ -337,7 +340,7 @@ export default function DiffPanel({ |
| 337 | { enabled: isGitRepo && selectedTurn !== undefined }, | 340 | { enabled: isGitRepo && selectedTurn !== undefined }, |
| 338 | ); | 341 | ); |
| 339 | const primaryBranchDiffPreview = useEnvironmentQuery( | 342 | const primaryBranchDiffPreview = useEnvironmentQuery( |
| 340 | selectedRunId === null && activeThread && activeCwd | 343 | canReadFiles && selectedRunId === null && activeThread && activeCwd |
| 341 | ? reviewEnvironment.diffPreview({ | 344 | ? reviewEnvironment.diffPreview({ |
| 342 | environmentId: activeThread.environmentId, | 345 | environmentId: activeThread.environmentId, |
| 343 | input: { | 346 | input: { |
| ... | @@ -354,7 +357,7 @@ export default function DiffPanel({ | ... | @@ -354,7 +357,7 @@ export default function DiffPanel({ |
| 354 | serverConfig?.cwd !== undefined && | 357 | serverConfig?.cwd !== undefined && |
| 355 | serverConfig.cwd !== activeCwd; | 358 | serverConfig.cwd !== activeCwd; |
| 356 | const fallbackBranchDiffPreview = useEnvironmentQuery( | 359 | const fallbackBranchDiffPreview = useEnvironmentQuery( |
| 357 | shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig | 360 | canReadFiles && shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig |
| 358 | ? reviewEnvironment.diffPreview({ | 361 | ? reviewEnvironment.diffPreview({ |
| 359 | environmentId: activeThread.environmentId, | 362 | environmentId: activeThread.environmentId, |
| 360 | input: { | 363 | input: { |
| ... | @@ -386,6 +389,7 @@ export default function DiffPanel({ | ... | @@ -386,6 +389,7 @@ export default function DiffPanel({ |
| 386 | return undefined; | 389 | return undefined; |
| 387 | } | 390 | } |
| 388 | 391 | ||
| 392 | if (!canReadFiles) return undefined; | ||
| 389 | return createGitDiffFileContentsLoader(getDiffFileContents, { | 393 | return createGitDiffFileContentsLoader(getDiffFileContents, { |
| 390 | environmentId: activeThread.environmentId, | 394 | environmentId: activeThread.environmentId, |
| 391 | cwd: preview.cwd, | 395 | cwd: preview.cwd, |
| ... | @@ -394,7 +398,14 @@ export default function DiffPanel({ | ... | @@ -394,7 +398,14 @@ export default function DiffPanel({ |
| 394 | headRef: selectedGitSource.headRef, | 398 | headRef: selectedGitSource.headRef, |
| 395 | cacheKey: selectedGitSource.diffHash, | 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 | const loadDiffFilesRef = useRef(currentLoadDiffFiles); | 409 | const loadDiffFilesRef = useRef(currentLoadDiffFiles); |
| 399 | loadDiffFilesRef.current = currentLoadDiffFiles; | 410 | loadDiffFilesRef.current = currentLoadDiffFiles; |
| 400 | const loadDiffFiles = useCallback<FileDiffContentsLoader>(async (fileDiff) => { | 411 | const loadDiffFiles = useCallback<FileDiffContentsLoader>(async (fileDiff) => { |
| ... | @@ -1051,6 +1062,14 @@ export default function DiffPanel({ | ... | @@ -1051,6 +1062,14 @@ export default function DiffPanel({ |
| 1051 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> | 1062 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 1052 | No completed turns yet. | 1063 | No completed turns yet. |
| 1053 | </div> | 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 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"> | 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,7 +3,11 @@ import { |
| 3 | isAtomCommandInterrupted, | 3 | isAtomCommandInterrupted, |
| 4 | squashAtomCommandFailure, | 4 | squashAtomCommandFailure, |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | 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 | import { | 11 | import { |
| 8 | buildCollapsedProposedPlanPreviewMarkdown, | 12 | buildCollapsedProposedPlanPreviewMarkdown, |
| 9 | buildProposedPlanMarkdownFilename, | 13 | buildProposedPlanMarkdownFilename, |
| ... | @@ -32,6 +36,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; | ... | @@ -32,6 +36,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 32 | import { projectEnvironment } from "~/state/projects"; | 36 | import { projectEnvironment } from "~/state/projects"; |
| 33 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 37 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | 38 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 39 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ||
| 35 | 40 | ||
| 36 | export const ProposedPlanCard = memo(function ProposedPlanCard({ | 41 | export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 37 | planMarkdown, | 42 | planMarkdown, |
| ... | @@ -47,6 +52,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -47,6 +52,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 47 | workspaceRoot: string | undefined; | 52 | workspaceRoot: string | undefined; |
| 48 | }) { | 53 | }) { |
| 49 | const [expanded, setExpanded] = useState(false); | 54 | const [expanded, setExpanded] = useState(false); |
| 55 | const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); | ||
| 50 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); | 56 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); |
| 51 | const [savePath, setSavePath] = useState(""); | 57 | const [savePath, setSavePath] = useState(""); |
| 52 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); | 58 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); |
| ... | @@ -85,6 +91,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -85,6 +91,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 85 | }; | 91 | }; |
| 86 | 92 | ||
| 87 | const openSaveDialog = () => { | 93 | const openSaveDialog = () => { |
| 94 | if (!canWriteFiles) return; | ||
| 88 | if (!workspaceRoot) { | 95 | if (!workspaceRoot) { |
| 89 | toastManager.add( | 96 | toastManager.add( |
| 90 | stackedThreadToast({ | 97 | stackedThreadToast({ |
| ... | @@ -101,7 +108,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -101,7 +108,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 101 | 108 | ||
| 102 | const handleSaveToWorkspace = () => { | 109 | const handleSaveToWorkspace = () => { |
| 103 | const relativePath = savePath.trim(); | 110 | const relativePath = savePath.trim(); |
| 104 | if (!workspaceRoot) { | 111 | if (!workspaceRoot || !readEnvironmentScope(environmentId, AuthFilesystemWriteScope)) { |
| 105 | return; | 112 | return; |
| 106 | } | 113 | } |
| 107 | if (!relativePath) { | 114 | if (!relativePath) { |
| ... | @@ -165,7 +172,10 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -165,7 +172,10 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 165 | {isCopied ? "Copied!" : "Copy to clipboard"} | 172 | {isCopied ? "Copied!" : "Copy to clipboard"} |
| 166 | </MenuItem> | 173 | </MenuItem> |
| 167 | <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> | 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 | Save to workspace | 179 | Save to workspace |
| 170 | </MenuItem> | 180 | </MenuItem> |
| 171 | </MenuPopup> | 181 | </MenuPopup> |
| ... | @@ -250,7 +260,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -250,7 +260,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 250 | <Button | 260 | <Button |
| 251 | size="sm" | 261 | size="sm" |
| 252 | onClick={() => void handleSaveToWorkspace()} | 262 | onClick={() => void handleSaveToWorkspace()} |
| 253 | disabled={isSavingToWorkspace} | 263 | disabled={!canWriteFiles || isSavingToWorkspace} |
| 254 | > | 264 | > |
| 255 | {isSavingToWorkspace ? "Saving..." : "Save"} | 265 | {isSavingToWorkspace ? "Saving..." : "Save"} |
| 256 | </Button> | 266 | </Button> |
apps/web/src/components/files/FilePreviewPanel.tsx+39-4| ... | @@ -6,6 +6,7 @@ import type { | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | ScopedThreadRef, | 6 | ScopedThreadRef, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; | 8 | import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; |
| 9 | import { AuthFilesystemWriteScope } from "@t3tools/contracts"; | ||
| 9 | import { | 10 | import { |
| 10 | isWorkspaceAudioPreviewPath, | 11 | isWorkspaceAudioPreviewPath, |
| 11 | isWorkspaceImagePreviewPath, | 12 | isWorkspaceImagePreviewPath, |
| ... | @@ -29,6 +30,7 @@ import { | ... | @@ -29,6 +30,7 @@ import { |
| 29 | import type { WorkerPoolManager } from "@pierre/diffs/worker"; | 30 | import type { WorkerPoolManager } from "@pierre/diffs/worker"; |
| 30 | import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react"; | 31 | import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react"; |
| 31 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | 32 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; |
| 33 | import { useFilesystemReadAccess } from "~/state/filesystem"; | ||
| 32 | import { | 34 | import { |
| 33 | isAtomCommandInterrupted, | 35 | isAtomCommandInterrupted, |
| 34 | squashAtomCommandFailure, | 36 | squashAtomCommandFailure, |
| ... | @@ -63,6 +65,7 @@ import { assetEnvironment } from "~/state/assets"; | ... | @@ -63,6 +65,7 @@ import { assetEnvironment } from "~/state/assets"; |
| 63 | import { usePreviewAvailable } from "~/browser/previewRuntime"; | 65 | import { usePreviewAvailable } from "~/browser/previewRuntime"; |
| 64 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; | 66 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; |
| 65 | import { previewEnvironment } from "~/state/preview"; | 67 | import { previewEnvironment } from "~/state/preview"; |
| 68 | import { useEnvironmentScope } from "~/state/session"; | ||
| 66 | import { useAtomCommand } from "~/state/use-atom-command"; | 69 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 67 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | 70 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 68 | 71 | ||
| ... | @@ -1038,6 +1041,9 @@ export default function FilePreviewPanel({ | ... | @@ -1038,6 +1041,9 @@ export default function FilePreviewPanel({ |
| 1038 | // A file outside the workspace (an absolute path) is shown, never edited. | 1041 | // A file outside the workspace (an absolute path) is shown, never edited. |
| 1039 | const isHostFile = | 1042 | const isHostFile = |
| 1040 | attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath)); | 1043 | attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath)); |
| 1044 | const fileAccess = useFilesystemReadAccess(environmentId); | ||
| 1045 | const { canReadFiles } = fileAccess; | ||
| 1046 | const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); | ||
| 1041 | // Media and PDFs render from their absolute path, so their contents are never | 1047 | // Media and PDFs render from their absolute path, so their contents are never |
| 1042 | // shown. The read still runs: a folder named `assets.png` is only knowable as a | 1048 | // shown. The read still runs: a folder named `assets.png` is only knowable as a |
| 1043 | // folder from the read failure, and the server stats before reading, so a folder | 1049 | // folder from the read failure, and the server stats before reading, so a folder |
| ... | @@ -1164,7 +1170,7 @@ export default function FilePreviewPanel({ | ... | @@ -1164,7 +1170,7 @@ export default function FilePreviewPanel({ |
| 1164 | }; | 1170 | }; |
| 1165 | 1171 | ||
| 1166 | const handleOpenInBrowser = useCallback(() => { | 1172 | const handleOpenInBrowser = useCallback(() => { |
| 1167 | if (!absolutePath || !environmentHttpBaseUrl) return; | 1173 | if (!canReadFiles || !absolutePath || !environmentHttpBaseUrl) return; |
| 1168 | void (async () => { | 1174 | void (async () => { |
| 1169 | const result = await openFileInPreview({ | 1175 | const result = await openFileInPreview({ |
| 1170 | threadRef, | 1176 | threadRef, |
| ... | @@ -1186,7 +1192,31 @@ export default function FilePreviewPanel({ | ... | @@ -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 | return ( | 1221 | return ( |
| 1192 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"> | 1222 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"> |
| ... | @@ -1266,6 +1296,11 @@ export default function FilePreviewPanel({ | ... | @@ -1266,6 +1296,11 @@ export default function FilePreviewPanel({ |
| 1266 | ) : null} | 1296 | ) : null} |
| 1267 | </div> | 1297 | </div> |
| 1268 | ) : null} | 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 | {previewPath && | 1304 | {previewPath && |
| 1270 | attachment === undefined && | 1305 | attachment === undefined && |
| 1271 | !isMedia && | 1306 | !isMedia && |
| ... | @@ -1368,7 +1403,7 @@ export default function FilePreviewPanel({ | ... | @@ -1368,7 +1403,7 @@ export default function FilePreviewPanel({ |
| 1368 | relativePath={relativePath} | 1403 | relativePath={relativePath} |
| 1369 | threadRef={threadRef} | 1404 | threadRef={threadRef} |
| 1370 | contents={file.data.contents} | 1405 | contents={file.data.contents} |
| 1371 | readOnly={isHostFile} | 1406 | readOnly={isHostFile || !canWriteFiles} |
| 1372 | onPendingChange={onPendingChange} | 1407 | onPendingChange={onPendingChange} |
| 1373 | /> | 1408 | /> |
| 1374 | ) : tableDelimiter && renderTable ? ( | 1409 | ) : tableDelimiter && renderTable ? ( |
| ... | @@ -1378,7 +1413,7 @@ export default function FilePreviewPanel({ | ... | @@ -1378,7 +1413,7 @@ export default function FilePreviewPanel({ |
| 1378 | text={file.data.contents} | 1413 | text={file.data.contents} |
| 1379 | delimiter={tableDelimiter} | 1414 | delimiter={tableDelimiter} |
| 1380 | /> | 1415 | /> |
| 1381 | ) : file.data.truncated || isHostFile ? ( | 1416 | ) : file.data.truncated || isHostFile || !canWriteFiles ? ( |
| 1382 | <SourceFilePreview | 1417 | <SourceFilePreview |
| 1383 | name={relativePath} | 1418 | name={relativePath} |
| 1384 | text={file.data.contents} | 1419 | text={file.data.contents} |
apps/web/src/components/files/fileSaveCoordinator.test.ts+25| ... | @@ -93,6 +93,31 @@ describe("FileSaveCoordinator", () => { | ... | @@ -93,6 +93,31 @@ describe("FileSaveCoordinator", () => { |
| 93 | expect(persist).toHaveBeenCalledWith("unsaved"); | 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 | it("flushes an edit made while a write was in flight when the editor closes", async () => { | 121 | it("flushes an edit made while a write was in flight when the editor closes", async () => { |
| 97 | vi.useFakeTimers(); | 122 | vi.useFakeTimers(); |
| 98 | const inFlight = deferred(); | 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,9 +2,11 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 2 | 2 | ||
| 3 | export interface FileSaveCoordinatorOptions<A, E> { | 3 | export interface FileSaveCoordinatorOptions<A, E> { |
| 4 | readonly debounceMs: number; | 4 | readonly debounceMs: number; |
| 5 | readonly canPersist?: () => boolean; | ||
| 5 | readonly persist: (contents: string) => Promise<AtomCommandResult<A, E>>; | 6 | readonly persist: (contents: string) => Promise<AtomCommandResult<A, E>>; |
| 6 | readonly onPendingChange: (pending: boolean) => void; | 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 | export class FileSaveCoordinator<A = unknown, E = unknown> { | 12 | export class FileSaveCoordinator<A = unknown, E = unknown> { |
| ... | @@ -49,20 +51,24 @@ export class FileSaveCoordinator<A = unknown, E = unknown> { | ... | @@ -49,20 +51,24 @@ export class FileSaveCoordinator<A = unknown, E = unknown> { |
| 49 | 51 | ||
| 50 | private async persistLatest(): Promise<void> { | 52 | private async persistLatest(): Promise<void> { |
| 51 | if (this.saving || this.latestRevision === this.confirmedRevision) return; | 53 | if (this.saving || this.latestRevision === this.confirmedRevision) return; |
| 54 | if (this.options.canPersist?.() === false) { | ||
| 55 | return; | ||
| 56 | } | ||
| 52 | 57 | ||
| 53 | this.saving = true; | 58 | this.saving = true; |
| 54 | const contents = this.latestContents; | 59 | const contents = this.latestContents; |
| 55 | const revision = this.latestRevision; | 60 | const revision = this.latestRevision; |
| 56 | const result = await this.options.persist(contents); | 61 | const result = await this.options.persist(contents); |
| 57 | const succeeded = result._tag === "Success"; | 62 | const succeeded = result._tag === "Success"; |
| 63 | let confirmed = false; | ||
| 58 | if (succeeded) { | 64 | if (succeeded) { |
| 59 | this.confirmedRevision = revision; | 65 | this.confirmedRevision = revision; |
| 60 | this.options.onConfirmed(contents); | 66 | confirmed = this.options.onConfirmed(contents) !== false; |
| 61 | } | 67 | } |
| 62 | 68 | ||
| 63 | this.saving = false; | 69 | this.saving = false; |
| 64 | if (revision === this.latestRevision) { | 70 | if (revision === this.latestRevision) { |
| 65 | if (succeeded) this.options.onPendingChange(false); | 71 | if (confirmed) this.options.onPendingChange(false); |
| 66 | return; | 72 | return; |
| 67 | } | 73 | } |
| 68 | 74 |
apps/web/src/components/files/projectFilesQueryState.test.ts+142| ... | @@ -1,23 +1,165 @@ | ... | @@ -1,23 +1,165 @@ |
| 1 | import type { ProjectReadFileResult } from "@t3tools/contracts"; | 1 | import type { ProjectReadFileResult } from "@t3tools/contracts"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { AsyncResult } from "effect/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 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 | import { | 25 | import { |
| 6 | clearProjectFileQueryData, | 26 | clearProjectFileQueryData, |
| 7 | confirmProjectFileQueryData, | 27 | confirmProjectFileQueryData, |
| 8 | getOptimisticProjectFileQueryData, | 28 | getOptimisticProjectFileQueryData, |
| 29 | getUnsavedProjectFileQueryData, | ||
| 9 | resolveProjectFileQueryData, | 30 | resolveProjectFileQueryData, |
| 10 | setProjectFileQueryData, | 31 | setProjectFileQueryData, |
| 11 | } from "./projectFilesQueryState"; | 32 | } from "./projectFilesQueryState"; |
| 12 | 33 | ||
| 13 | const environmentId = EnvironmentId.make("environment-project-files-query-test"); | 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 | describe("project files queries", () => { | 49 | describe("project files queries", () => { |
| 16 | afterEach(() => { | 50 | afterEach(() => { |
| 17 | clearProjectFileQueryData(environmentId, "/repo", "convex.json"); | 51 | clearProjectFileQueryData(environmentId, "/repo", "convex.json"); |
| 52 | drainRegistryTasks(); | ||
| 53 | vi.useRealTimers(); | ||
| 18 | vi.unstubAllGlobals(); | 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 | it("keeps the latest optimistic draft when an older write finishes", () => { | 163 | it("keeps the latest optimistic draft when an older write finishes", () => { |
| 22 | vi.stubGlobal("window", {}); | 164 | vi.stubGlobal("window", {}); |
| 23 | const initial = { | 165 | const initial = { |
apps/web/src/components/files/projectFilesQueryState.test.tsx+191-1| ... | @@ -1,14 +1,35 @@ | ... | @@ -1,14 +1,35 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthFilesystemReadScope, | ||
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | type AuthSessionState, | ||
| 3 | type ProjectListEntriesResult, | 5 | type ProjectListEntriesResult, |
| 4 | ProjectReadFileError, | 6 | ProjectReadFileError, |
| 5 | type ProjectReadFileResult, | 7 | type ProjectReadFileResult, |
| 6 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; | ||
| 7 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 8 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 9 | import { Atom, AtomRegistry } from "effect/reactivity"; | 12 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 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 | const projectMocks = vi.hoisted(() => ({ | 33 | const projectMocks = vi.hoisted(() => ({ |
| 13 | listEntries: vi.fn(), | 34 | listEntries: vi.fn(), |
| 14 | optimisticFile: vi.fn(), | 35 | optimisticFile: vi.fn(), |
| ... | @@ -64,6 +85,7 @@ vi.mock("react", async (importOriginal) => { | ... | @@ -64,6 +85,7 @@ vi.mock("react", async (importOriginal) => { |
| 64 | ...actual, | 85 | ...actual, |
| 65 | useCallback: reactHooks.useCallback, | 86 | useCallback: reactHooks.useCallback, |
| 66 | useEffect: reactHooks.useEffect, | 87 | useEffect: reactHooks.useEffect, |
| 88 | useMemo: <A,>(factory: () => A) => factory(), | ||
| 67 | useRef: reactHooks.useRef, | 89 | useRef: reactHooks.useRef, |
| 68 | }; | 90 | }; |
| 69 | }); | 91 | }); |
| ... | @@ -77,6 +99,7 @@ vi.mock("~/state/queries", () => ({ | ... | @@ -77,6 +99,7 @@ vi.mock("~/state/queries", () => ({ |
| 77 | })); | 99 | })); |
| 78 | 100 | ||
| 79 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | 101 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| 102 | import { useT3ProjectFileState } from "~/hooks/useT3ProjectFileScripts"; | ||
| 80 | import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; | 103 | import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; |
| 81 | 104 | ||
| 82 | const environmentId = EnvironmentId.make("environment-1"); | 105 | const environmentId = EnvironmentId.make("environment-1"); |
| ... | @@ -114,12 +137,179 @@ async function flushEffects(): Promise<void> { | ... | @@ -114,12 +137,179 @@ async function flushEffects(): Promise<void> { |
| 114 | 137 | ||
| 115 | describe("project query refresh", () => { | 138 | describe("project query refresh", () => { |
| 116 | beforeEach(() => { | 139 | beforeEach(() => { |
| 140 | authorizationMocks.sessionAtom = Atom.make( | ||
| 141 | AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }), | ||
| 142 | ); | ||
| 143 | authorizationMocks.phase = "connected"; | ||
| 117 | projectMocks.listEntries.mockReset(); | 144 | projectMocks.listEntries.mockReset(); |
| 118 | projectMocks.optimisticFile.mockReset(); | 145 | projectMocks.optimisticFile.mockReset(); |
| 119 | projectMocks.readFile.mockReset(); | 146 | projectMocks.readFile.mockReset(); |
| 120 | reactHooks.reset(); | 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 | it("replaces an in-flight initial read when a workspace mutation arrives", async () => { | 313 | it("replaces an in-flight initial read when a workspace mutation arrives", async () => { |
| 124 | const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = []; | 314 | const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = []; |
| 125 | const readAtom = Atom.make( | 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,11 +12,15 @@ import { AsyncResult, Atom } from "effect/reactivity"; |
| 12 | import { useCallback } from "react"; | 12 | import { useCallback } from "react"; |
| 13 | 13 | ||
| 14 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | 14 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 15 | import { useFilesystemReadAccess } from "~/state/filesystem"; | ||
| 15 | import { projectEnvironment } from "~/state/projects"; | 16 | import { projectEnvironment } from "~/state/projects"; |
| 16 | import { useProjectPathSearch } from "~/state/queries"; | 17 | import { useProjectPathSearch } from "~/state/queries"; |
| 17 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | 18 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 18 | 19 | ||
| 19 | const EMPTY_PROJECT_FILE_PATH = ""; | 20 | const EMPTY_PROJECT_FILE_PATH = ""; |
| 21 | const EMPTY_PROJECT_ENTRIES_QUERY_ATOM = Atom.make( | ||
| 22 | AsyncResult.initial<ProjectListEntriesResult, never>(false), | ||
| 23 | ); | ||
| 20 | const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( | 24 | const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( |
| 21 | AsyncResult.initial<ProjectReadFileResult, never>(false), | 25 | AsyncResult.initial<ProjectReadFileResult, never>(false), |
| 22 | ).pipe(Atom.withLabel("project-file-query:empty")); | 26 | ).pipe(Atom.withLabel("project-file-query:empty")); |
| ... | @@ -29,6 +33,15 @@ export function optimisticFileAtom( | ... | @@ -29,6 +33,15 @@ export function optimisticFileAtom( |
| 29 | return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); | 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 | interface ProjectQueryState<A> { | 45 | interface ProjectQueryState<A> { |
| 33 | readonly data: A | null; | 46 | readonly data: A | null; |
| 34 | readonly error: string | null; | 47 | readonly error: string | null; |
| ... | @@ -70,7 +83,11 @@ export function setProjectFileQueryData( | ... | @@ -70,7 +83,11 @@ export function setProjectFileQueryData( |
| 70 | relativePath: string, | 83 | relativePath: string, |
| 71 | contents: string, | 84 | contents: string, |
| 72 | ): void { | 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 | confirmedAgainst: undefined, | 91 | confirmedAgainst: undefined, |
| 75 | data: { | 92 | data: { |
| 76 | relativePath, | 93 | relativePath, |
| ... | @@ -101,6 +118,15 @@ export function getProjectFileContents( | ... | @@ -101,6 +118,15 @@ export function getProjectFileContents( |
| 101 | return Option.getOrUndefined(AsyncResult.value(result))?.contents; | 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 | export function confirmProjectFileQueryData( | 130 | export function confirmProjectFileQueryData( |
| 105 | environmentId: EnvironmentId, | 131 | environmentId: EnvironmentId, |
| 106 | cwd: string, | 132 | cwd: string, |
| ... | @@ -117,6 +143,7 @@ export function confirmProjectFileQueryData( | ... | @@ -117,6 +143,7 @@ export function confirmProjectFileQueryData( |
| 117 | confirmedAgainst: appAtomRegistry.get(queryAtom), | 143 | confirmedAgainst: appAtomRegistry.get(queryAtom), |
| 118 | }; | 144 | }; |
| 119 | appAtomRegistry.set(atom, confirmed); | 145 | appAtomRegistry.set(atom, confirmed); |
| 146 | releaseUnsavedFile(atom); | ||
| 120 | appAtomRegistry.refresh(queryAtom); | 147 | appAtomRegistry.refresh(queryAtom); |
| 121 | void executeAtomQuery(appAtomRegistry, queryAtom, { | 148 | void executeAtomQuery(appAtomRegistry, queryAtom, { |
| 122 | reportDefect: false, | 149 | reportDefect: false, |
| ... | @@ -144,7 +171,9 @@ export function clearProjectFileQueryData( | ... | @@ -144,7 +171,9 @@ export function clearProjectFileQueryData( |
| 144 | cwd: string, | 171 | cwd: string, |
| 145 | relativePath: string, | 172 | relativePath: string, |
| 146 | ): void { | 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 | function failureCause<A>(result: AsyncResult.AsyncResult<A, unknown>): unknown { | 179 | function failureCause<A>(result: AsyncResult.AsyncResult<A, unknown>): unknown { |
| ... | @@ -163,14 +192,22 @@ export function useProjectEntriesQuery( | ... | @@ -163,14 +192,22 @@ export function useProjectEntriesQuery( |
| 163 | cwd: string, | 192 | cwd: string, |
| 164 | directoryPath?: string, | 193 | directoryPath?: string, |
| 165 | ): ProjectQueryState<ProjectListEntriesResult> { | 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 | const result = useAtomValue(atom); | 200 | const result = useAtomValue(atom); |
| 168 | const refreshAtom = useAtomRefresh(atom); | 201 | const refreshAtom = useAtomRefresh(atom); |
| 169 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); | 202 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
| 170 | return { | 203 | return { |
| 171 | data: Option.getOrNull(AsyncResult.value(result)), | 204 | data: Option.getOrNull(AsyncResult.value(result)), |
| 172 | error: errorMessage(failureCause(result)), | 205 | error: fileAccess.isPending |
| 173 | isPending: result.waiting, | 206 | ? null |
| 207 | : canReadFiles | ||
| 208 | ? errorMessage(failureCause(result)) | ||
| 209 | : (fileAccess.error ?? "This connection cannot read host files."), | ||
| 210 | isPending: fileAccess.isPending || result.waiting, | ||
| 174 | refresh, | 211 | refresh, |
| 175 | }; | 212 | }; |
| 176 | } | 213 | } |
| ... | @@ -218,9 +255,13 @@ export function useProjectFileQuery( | ... | @@ -218,9 +255,13 @@ export function useProjectFileQuery( |
| 218 | ): ProjectFileQueryState { | 255 | ): ProjectFileQueryState { |
| 219 | // The caller decides what to read. A media path is not skipped here: a folder | 256 | // The caller decides what to read. A media path is not skipped here: a folder |
| 220 | // named `assets.png` is only knowable as a folder from the read failure. | 257 | // named `assets.png` is only knowable as a folder from the read failure. |
| 221 | const atom = enabled | 258 | const fileAccess = useFilesystemReadAccess(environmentId); |
| 222 | ? getProjectFileQueryAtom(environmentId, cwd, relativePath) | 259 | const { canReadFiles } = fileAccess; |
| 223 | : EMPTY_PROJECT_FILE_QUERY_ATOM; | 260 | const isQueryEnabled = enabled; |
| 261 | const atom = | ||
| 262 | enabled && canReadFiles | ||
| 263 | ? getProjectFileQueryAtom(environmentId, cwd, relativePath) | ||
| 264 | : EMPTY_PROJECT_FILE_QUERY_ATOM; | ||
| 224 | const result = useAtomValue(atom); | 265 | const result = useAtomValue(atom); |
| 225 | const refreshAtom = useAtomRefresh(atom); | 266 | const refreshAtom = useAtomRefresh(atom); |
| 226 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); | 267 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
| ... | @@ -233,11 +274,16 @@ export function useProjectFileQuery( | ... | @@ -233,11 +274,16 @@ export function useProjectFileQuery( |
| 233 | const readError = isProjectReadFileError(cause) ? cause : null; | 274 | const readError = isProjectReadFileError(cause) ? cause : null; |
| 234 | 275 | ||
| 235 | return { | 276 | return { |
| 236 | data: optimisticFile?.data ?? data, | 277 | data: canReadFiles ? (optimisticFile?.data ?? data) : null, |
| 237 | error: errorMessage(cause), | 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 | readError, | 285 | readError, |
| 239 | isNotFile: readError?.failure === "path_not_file", | 286 | isNotFile: readError?.failure === "path_not_file", |
| 240 | isPending: result.waiting, | ||
| 241 | refresh, | 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,13 +4,22 @@ import { act, StrictMode } from "react"; |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 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 | writeFile: vi.fn(), | 8 | writeFile: vi.fn(), |
| 9 | confirmFile: vi.fn(), | 9 | confirmFile: vi.fn(), |
| 10 | readScope: vi.fn(), | ||
| 11 | getUnsavedFile: vi.fn(), | ||
| 10 | })); | 12 | })); |
| 11 | vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } })); | 13 | vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } })); |
| 14 | vi.mock("~/state/session", () => ({ | ||
| 15 | readEnvironmentScope: readScope, | ||
| 16 | useEnvironmentScope: readScope, | ||
| 17 | })); | ||
| 12 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile })); | 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 | import { setMarkdownTaskChecked } from "./filePreviewMode"; | 24 | import { setMarkdownTaskChecked } from "./filePreviewMode"; |
| 16 | import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; | 25 | import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; |
| ... | @@ -54,6 +63,8 @@ beforeEach(() => { | ... | @@ -54,6 +63,8 @@ beforeEach(() => { |
| 54 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | 63 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 55 | writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | 64 | writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); |
| 56 | confirmFile.mockReset(); | 65 | confirmFile.mockReset(); |
| 66 | readScope.mockReset().mockReturnValue(true); | ||
| 67 | getUnsavedFile.mockReset().mockReturnValue(null); | ||
| 57 | onPendingChange.mockReset(); | 68 | onPendingChange.mockReset(); |
| 58 | }); | 69 | }); |
| 59 | 70 | ||
| ... | @@ -124,6 +135,57 @@ describe("file-save React lifecycle", () => { | ... | @@ -124,6 +135,57 @@ describe("file-save React lifecycle", () => { |
| 124 | expect(writeFile.mock.calls[0]![0].input.contents).toBe("pending edit"); | 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 | it.each([ | 189 | it.each([ |
| 128 | { relativePath: "other.txt" }, | 190 | { relativePath: "other.txt" }, |
| 129 | { cwd: "/other-workspace" }, | 191 | { cwd: "/other-workspace" }, |
apps/web/src/components/files/useFileSaveCoordinator.ts+23-5| ... | @@ -1,11 +1,15 @@ | ... | @@ -1,11 +1,15 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import { AuthFilesystemWriteScope, type EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { createRef, useEffect, useMemo } from "react"; | 2 | import { createRef, useEffect, useMemo } from "react"; |
| 3 | 3 | ||
| 4 | import { projectEnvironment } from "~/state/projects"; | 4 | import { projectEnvironment } from "~/state/projects"; |
| 5 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ||
| 5 | import { useAtomCommand } from "~/state/use-atom-command"; | 6 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 6 | 7 | ||
| 7 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; | 8 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; |
| 8 | import { confirmProjectFileQueryData } from "./projectFilesQueryState"; | 9 | import { |
| 10 | confirmProjectFileQueryData, | ||
| 11 | getUnsavedProjectFileQueryData, | ||
| 12 | } from "./projectFilesQueryState"; | ||
| 9 | 13 | ||
| 10 | const FILE_SAVE_DEBOUNCE_MS = 500; | 14 | const FILE_SAVE_DEBOUNCE_MS = 500; |
| 11 | 15 | ||
| ... | @@ -22,6 +26,7 @@ export function useFileSaveCoordinator({ | ... | @@ -22,6 +26,7 @@ export function useFileSaveCoordinator({ |
| 22 | relativePath, | 26 | relativePath, |
| 23 | onPendingChange, | 27 | onPendingChange, |
| 24 | }: FileSaveOptions): Pick<FileSaveCoordinator, "change"> { | 28 | }: FileSaveOptions): Pick<FileSaveCoordinator, "change"> { |
| 29 | const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); | ||
| 25 | const writeFile = useAtomCommand(projectEnvironment.writeFile); | 30 | const writeFile = useAtomCommand(projectEnvironment.writeFile); |
| 26 | const session = useMemo(() => { | 31 | const session = useMemo(() => { |
| 27 | const coordinatorRef = createRef<FileSaveCoordinator>(); | 32 | const coordinatorRef = createRef<FileSaveCoordinator>(); |
| ... | @@ -30,15 +35,15 @@ export function useFileSaveCoordinator({ | ... | @@ -30,15 +35,15 @@ export function useFileSaveCoordinator({ |
| 30 | setup: () => { | 35 | setup: () => { |
| 31 | const coordinator = new FileSaveCoordinator({ | 36 | const coordinator = new FileSaveCoordinator({ |
| 32 | debounceMs: FILE_SAVE_DEBOUNCE_MS, | 37 | debounceMs: FILE_SAVE_DEBOUNCE_MS, |
| 38 | canPersist: () => readEnvironmentScope(environmentId, AuthFilesystemWriteScope), | ||
| 33 | onPendingChange: (pending) => onPendingChange(relativePath, pending), | 39 | onPendingChange: (pending) => onPendingChange(relativePath, pending), |
| 34 | persist: (nextContents) => | 40 | persist: (nextContents) => |
| 35 | writeFile({ | 41 | writeFile({ |
| 36 | environmentId, | 42 | environmentId, |
| 37 | input: { cwd, relativePath, contents: nextContents }, | 43 | input: { cwd, relativePath, contents: nextContents }, |
| 38 | }), | 44 | }), |
| 39 | onConfirmed: (confirmedContents) => { | 45 | onConfirmed: (confirmedContents) => |
| 40 | confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); | 46 | confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents), |
| 41 | }, | ||
| 42 | }); | 47 | }); |
| 43 | coordinatorRef.current = coordinator; | 48 | coordinatorRef.current = coordinator; |
| 44 | return () => { | 49 | return () => { |
| ... | @@ -52,5 +57,18 @@ export function useFileSaveCoordinator({ | ... | @@ -52,5 +57,18 @@ export function useFileSaveCoordinator({ |
| 52 | // StrictMode replays effect setup. Retired file sessions stay inert, while the | 57 | // StrictMode replays effect setup. Retired file sessions stay inert, while the |
| 53 | // replay gets a fresh coordinator instead of reusing a disposed one. | 58 | // replay gets a fresh coordinator instead of reusing a disposed one. |
| 54 | useEffect(session.setup, [session]); | 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 | return session; | 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,13 +5,23 @@ import { |
| 5 | } from "@t3tools/client-runtime/media-reference"; | 5 | } from "@t3tools/client-runtime/media-reference"; |
| 6 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 6 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 7 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 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 | import { useCallback, useRef, useState, type ReactElement } from "react"; | 17 | import { useCallback, useRef, useState, type ReactElement } from "react"; |
| 10 | 18 | ||
| 11 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; | 19 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; |
| 12 | import { readLocalApi } from "../../localApi"; | 20 | import { readLocalApi } from "../../localApi"; |
| 21 | import { appAtomRegistry } from "../../rpc/atomRegistry"; | ||
| 13 | import { assetEnvironment } from "../../state/assets"; | 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 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | 25 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 16 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 26 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 17 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | @@ -32,13 +42,45 @@ function mediaFileName(source: MediaActionSource): string { | ... | @@ -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 | /** Explicit byte operations get fresh capabilities without replacing a player's active source. */ | 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 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | 78 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 38 | reportFailure: false, | 79 | reportFailure: false, |
| 39 | refresh: true, | 80 | refresh: true, |
| 40 | }); | 81 | }); |
| 41 | const actionUrl = useCallback(async () => { | 82 | const actionUrl = useCallback(async () => { |
| 83 | assertCanReadMedia(); | ||
| 42 | if (!source.asset) { | 84 | if (!source.asset) { |
| 43 | if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); | 85 | if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); |
| 44 | return source.src; | 86 | return source.src; |
| ... | @@ -48,14 +90,16 @@ function useMediaActions(source: MediaActionSource) { | ... | @@ -48,14 +90,16 @@ function useMediaActions(source: MediaActionSource) { |
| 48 | if (!connection) throw new Error("Reconnect to this environment and try again."); | 90 | if (!connection) throw new Error("Reconnect to this environment and try again."); |
| 49 | const result = await createAssetUrl({ environmentId, input: { resource } }); | 91 | const result = await createAssetUrl({ environmentId, input: { resource } }); |
| 50 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | 92 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 93 | assertCanReadMedia(); | ||
| 51 | const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); | 94 | const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); |
| 52 | if (!url) throw new Error("The environment returned an invalid media URL."); | 95 | if (!url) throw new Error("The environment returned an invalid media URL."); |
| 53 | return url; | 96 | return url; |
| 54 | }, [source, createAssetUrl]); | 97 | }, [source, createAssetUrl, assertCanReadMedia]); |
| 55 | const save = useCallback(async () => { | 98 | const save = useCallback(async () => { |
| 56 | await downloadMedia(await actionUrl(), mediaFileName(source)); | 99 | await downloadMedia(await actionUrl(), mediaFileName(source)); |
| 57 | }, [actionUrl, source]); | 100 | }, [actionUrl, source]); |
| 58 | const copyImage = useCallback(async () => { | 101 | const copyImage = useCallback(async () => { |
| 102 | assertCanReadMedia(); | ||
| 59 | if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { | 103 | if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { |
| 60 | throw new Error( | 104 | throw new Error( |
| 61 | "Image copying is unavailable. Use a secure browser connection or save the image.", | 105 | "Image copying is unavailable. Use a secure browser connection or save the image.", |
| ... | @@ -65,8 +109,8 @@ function useMediaActions(source: MediaActionSource) { | ... | @@ -65,8 +109,8 @@ function useMediaActions(source: MediaActionSource) { |
| 65 | await navigator.clipboard.write([ | 109 | await navigator.clipboard.write([ |
| 66 | new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), | 110 | new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), |
| 67 | ]); | 111 | ]); |
| 68 | }, [actionUrl]); | 112 | }, [actionUrl, assertCanReadMedia]); |
| 69 | return { save, copyImage }; | 113 | return { save, copyImage, canReadMedia, assertCanReadMedia }; |
| 70 | } | 114 | } |
| 71 | 115 | ||
| 72 | /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ | 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,7 +121,7 @@ export function MediaActions({ |
| 77 | source: MediaActionSource; | 121 | source: MediaActionSource; |
| 78 | children: ReactElement; | 122 | children: ReactElement; |
| 79 | }) { | 123 | }) { |
| 80 | const { save, copyImage } = useMediaActions(source); | 124 | const { save, copyImage, canReadMedia, assertCanReadMedia } = useMediaActions(source); |
| 81 | const [tooltipOpen, setTooltipOpen] = useState(false); | 125 | const [tooltipOpen, setTooltipOpen] = useState(false); |
| 82 | const menuOpen = useRef(false); | 126 | const menuOpen = useRef(false); |
| 83 | const reference = source.reference; | 127 | const reference = source.reference; |
| ... | @@ -92,7 +136,7 @@ export function MediaActions({ | ... | @@ -92,7 +136,7 @@ export function MediaActions({ |
| 92 | let progressToast: ReturnType<typeof toastManager.add> | undefined; | 136 | let progressToast: ReturnType<typeof toastManager.add> | undefined; |
| 93 | try { | 137 | try { |
| 94 | const noun = source.kind === "image" ? "image" : "video"; | 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 | const canCopyImage = | 140 | const canCopyImage = |
| 97 | typeof navigator !== "undefined" && | 141 | typeof navigator !== "undefined" && |
| 98 | Boolean(navigator.clipboard?.write) && | 142 | Boolean(navigator.clipboard?.write) && |
| ... | @@ -105,7 +149,8 @@ export function MediaActions({ | ... | @@ -105,7 +149,8 @@ export function MediaActions({ |
| 105 | } else if (reference?.kind === "url") { | 149 | } else if (reference?.kind === "url") { |
| 106 | items.push({ id: "copy-url", label: "Copy URL" }); | 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 | items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable }); | 154 | items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable }); |
| 110 | if (source.kind === "image") { | 155 | if (source.kind === "image") { |
| 111 | items.push({ | 156 | items.push({ |
| ... | @@ -133,6 +178,7 @@ export function MediaActions({ | ... | @@ -133,6 +178,7 @@ export function MediaActions({ |
| 133 | title: action === "copy-url" ? "URL copied" : "Path copied", | 178 | title: action === "copy-url" ? "URL copied" : "Path copied", |
| 134 | }); | 179 | }); |
| 135 | } else if (action === "open-file") { | 180 | } else if (action === "open-file") { |
| 181 | assertCanReadMedia(); | ||
| 136 | source.onOpenFile?.(); | 182 | source.onOpenFile?.(); |
| 137 | } else if (action === "save" || action === "copy-image") { | 183 | } else if (action === "save" || action === "copy-image") { |
| 138 | progressToast = toastManager.add({ | 184 | progressToast = toastManager.add({ |
apps/web/src/components/preview/PreviewView.test.tsx+7| ... | @@ -63,6 +63,13 @@ vi.mock("~/state/session", async (importOriginal) => ({ | ... | @@ -63,6 +63,13 @@ vi.mock("~/state/session", async (importOriginal) => ({ |
| 63 | readPreparedConnection: mocks.readPreparedConnection, | 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 | // Stubbed at the direct dependency rather than letting the real module pull in | 73 | // Stubbed at the direct dependency rather than letting the real module pull in |
| 67 | // `useSettings` -> `state/server`, which would drag the whole settings and | 74 | // `useSettings` -> `state/server`, which would drag the whole settings and |
| 68 | // connection graph into a test that only cares about the browser chrome. | 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,5 +1,5 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | 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 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; | 4 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; |
| 5 | 5 | ||
| ... | @@ -8,6 +8,7 @@ import { useTheme } from "~/hooks/useTheme"; | ... | @@ -8,6 +8,7 @@ import { useTheme } from "~/hooks/useTheme"; |
| 8 | import { cn } from "~/lib/utils"; | 8 | import { cn } from "~/lib/utils"; |
| 9 | import { useRightPanelStore } from "~/rightPanelStore"; | 9 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 10 | import { useProjectContentSearch } from "~/state/queries"; | 10 | import { useProjectContentSearch } from "~/state/queries"; |
| 11 | import { readEnvironmentScope } from "~/state/session"; | ||
| 11 | 12 | ||
| 12 | import { PierreEntryIcon } from "../chat/PierreEntryIcon"; | 13 | import { PierreEntryIcon } from "../chat/PierreEntryIcon"; |
| 13 | import { CommandPaletteContent } from "../CommandPaletteContent"; | 14 | import { CommandPaletteContent } from "../CommandPaletteContent"; |
| ... | @@ -56,6 +57,7 @@ function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[] | ... | @@ -56,6 +57,7 @@ function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[] |
| 56 | 57 | ||
| 57 | function SearchOptionButton(props: { | 58 | function SearchOptionButton(props: { |
| 58 | readonly active: boolean; | 59 | readonly active: boolean; |
| 60 | readonly disabled: boolean; | ||
| 59 | readonly label: string; | 61 | readonly label: string; |
| 60 | readonly onClick: () => void; | 62 | readonly onClick: () => void; |
| 61 | readonly children: ReactNode; | 63 | readonly children: ReactNode; |
| ... | @@ -66,6 +68,7 @@ function SearchOptionButton(props: { | ... | @@ -66,6 +68,7 @@ function SearchOptionButton(props: { |
| 66 | render={ | 68 | render={ |
| 67 | <Toggle | 69 | <Toggle |
| 68 | aria-label={props.label} | 70 | aria-label={props.label} |
| 71 | disabled={props.disabled} | ||
| 69 | pressed={props.active} | 72 | pressed={props.active} |
| 70 | size="segmented" | 73 | size="segmented" |
| 71 | variant="segmented" | 74 | variant="segmented" |
| ... | @@ -121,7 +124,7 @@ function OpenContentSearchDialog(props: { | ... | @@ -121,7 +124,7 @@ function OpenContentSearchDialog(props: { |
| 121 | wholeWord, | 124 | wholeWord, |
| 122 | useRegex, | 125 | useRegex, |
| 123 | }); | 126 | }); |
| 124 | const canOpenMatches = !search.isPending; | 127 | const canOpenMatches = search.canReadFiles && !search.isPending && search.error === null; |
| 125 | const matches = search.matches; | 128 | const matches = search.matches; |
| 126 | const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]); | 129 | const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]); |
| 127 | const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]); | 130 | const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]); |
| ... | @@ -153,13 +156,16 @@ function OpenContentSearchDialog(props: { | ... | @@ -153,13 +156,16 @@ function OpenContentSearchDialog(props: { |
| 153 | }, []); | 156 | }, []); |
| 154 | 157 | ||
| 155 | const openMatch = (match: ProjectContentMatch) => { | 158 | const openMatch = (match: ProjectContentMatch) => { |
| 156 | if (!canOpenMatches) return; | 159 | if (!canOpenMatches || !readEnvironmentScope(target.environmentId, AuthFilesystemReadScope)) { |
| 160 | return; | ||
| 161 | } | ||
| 157 | props.onOpenChange(false); | 162 | props.onOpenChange(false); |
| 158 | useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); | 163 | useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); |
| 159 | }; | 164 | }; |
| 160 | const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); | 165 | const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); |
| 161 | const showSearchStatus = | 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 | return ( | 170 | return ( |
| 165 | <CommandPaletteContent | 171 | <CommandPaletteContent |
| ... | @@ -170,6 +176,7 @@ function OpenContentSearchDialog(props: { | ... | @@ -170,6 +176,7 @@ function OpenContentSearchDialog(props: { |
| 170 | <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"> | 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 | <SearchOptionButton | 177 | <SearchOptionButton |
| 172 | active={caseSensitive} | 178 | active={caseSensitive} |
| 179 | disabled={!search.canReadFiles} | ||
| 173 | label="Match case" | 180 | label="Match case" |
| 174 | onClick={() => setCaseSensitive((current) => !current)} | 181 | onClick={() => setCaseSensitive((current) => !current)} |
| 175 | > | 182 | > |
| ... | @@ -177,6 +184,7 @@ function OpenContentSearchDialog(props: { | ... | @@ -177,6 +184,7 @@ function OpenContentSearchDialog(props: { |
| 177 | </SearchOptionButton> | 184 | </SearchOptionButton> |
| 178 | <SearchOptionButton | 185 | <SearchOptionButton |
| 179 | active={wholeWord} | 186 | active={wholeWord} |
| 187 | disabled={!search.canReadFiles} | ||
| 180 | label="Match whole word" | 188 | label="Match whole word" |
| 181 | onClick={() => setWholeWord((current) => !current)} | 189 | onClick={() => setWholeWord((current) => !current)} |
| 182 | > | 190 | > |
| ... | @@ -184,6 +192,7 @@ function OpenContentSearchDialog(props: { | ... | @@ -184,6 +192,7 @@ function OpenContentSearchDialog(props: { |
| 184 | </SearchOptionButton> | 192 | </SearchOptionButton> |
| 185 | <SearchOptionButton | 193 | <SearchOptionButton |
| 186 | active={useRegex} | 194 | active={useRegex} |
| 195 | disabled={!search.canReadFiles} | ||
| 187 | label="Use regular expression" | 196 | label="Use regular expression" |
| 188 | onClick={() => setUseRegex((current) => !current)} | 197 | onClick={() => setUseRegex((current) => !current)} |
| 189 | > | 198 | > |
| ... | @@ -193,6 +202,7 @@ function OpenContentSearchDialog(props: { | ... | @@ -193,6 +202,7 @@ function OpenContentSearchDialog(props: { |
| 193 | } | 202 | } |
| 194 | inputProps={{ | 203 | inputProps={{ |
| 195 | className: "pe-30", | 204 | className: "pe-30", |
| 205 | disabled: !search.canReadFiles, | ||
| 196 | placeholder: `Search in ${target.projectName}`, | 206 | placeholder: `Search in ${target.projectName}`, |
| 197 | onKeyDown: (event) => { | 207 | onKeyDown: (event) => { |
| 198 | if (event.key === "ArrowDown" && matches.length > 0) { | 208 | if (event.key === "ArrowDown" && matches.length > 0) { |
| ... | @@ -241,9 +251,13 @@ function OpenContentSearchDialog(props: { | ... | @@ -241,9 +251,13 @@ function OpenContentSearchDialog(props: { |
| 241 | 251 | ||
| 242 | {matches.length === 0 ? ( | 252 | {matches.length === 0 ? ( |
| 243 | <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground"> | 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 | 254 | {search.isCheckingAccess |
| 245 | ? "No results found." | 255 | ? "Checking file access…" |
| 246 | : "Type to search across your project."} | 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 | </div> | 261 | </div> |
| 248 | ) : ( | 262 | ) : ( |
| 249 | <ScrollArea className="min-h-0 flex-1" scrollFade> | 263 | <ScrollArea className="min-h-0 flex-1" scrollFade> |
apps/web/src/components/settings/ConnectionsSettings.tsx+19-12| ... | @@ -30,12 +30,14 @@ import { | ... | @@ -30,12 +30,14 @@ import { |
| 30 | AuthOrchestrationReadScope, | 30 | AuthOrchestrationReadScope, |
| 31 | AuthRelayReadScope, | 31 | AuthRelayReadScope, |
| 32 | AuthRelayWriteScope, | 32 | AuthRelayWriteScope, |
| 33 | AuthReviewWriteScope, | ||
| 34 | AuthSourceControlWriteScope, | 33 | AuthSourceControlWriteScope, |
| 34 | AuthFilesystemReadScope, | ||
| 35 | AuthFilesystemWriteScope, | ||
| 35 | AuthStandardClientScopes, | 36 | AuthStandardClientScopes, |
| 36 | AuthTerminalOperateScope, | 37 | AuthTerminalOperateScope, |
| 37 | type AuthClientSession, | 38 | type AuthClientSession, |
| 38 | type AuthEnvironmentScope, | 39 | type AuthEnvironmentScope, |
| 40 | type AuthGrantScope, | ||
| 39 | type AuthPairingLink, | 41 | type AuthPairingLink, |
| 40 | type AuthPairingCredentialResult, | 42 | type AuthPairingCredentialResult, |
| 41 | type AdvertisedEndpoint, | 43 | type AdvertisedEndpoint, |
| ... | @@ -226,19 +228,19 @@ function formatAccessTimestamp(value: string): string { | ... | @@ -226,19 +228,19 @@ function formatAccessTimestamp(value: string): string { |
| 226 | } | 228 | } |
| 227 | 229 | ||
| 228 | const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ | 230 | const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ |
| 229 | readonly scope: AuthEnvironmentScope; | 231 | readonly scope: AuthGrantScope; |
| 230 | readonly title: string; | 232 | readonly title: string; |
| 231 | readonly description: string; | 233 | readonly description: string; |
| 232 | }> = [ | 234 | }> = [ |
| 233 | { | 235 | { |
| 234 | scope: AuthOrchestrationReadScope, | 236 | scope: AuthOrchestrationReadScope, |
| 235 | title: "View environment", | 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 | scope: AuthOrchestrationOperateScope, | 241 | scope: AuthOrchestrationOperateScope, |
| 240 | title: "Operate tasks", | 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 | scope: AuthSettingsWriteScope, | 246 | scope: AuthSettingsWriteScope, |
| ... | @@ -266,9 +268,14 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ | ... | @@ -266,9 +268,14 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ |
| 266 | description: "Commit, push, manage branches and repositories, and change pull requests.", | 268 | description: "Commit, push, manage branches and repositories, and change pull requests.", |
| 267 | }, | 269 | }, |
| 268 | { | 270 | { |
| 269 | scope: AuthReviewWriteScope, | 271 | scope: AuthFilesystemReadScope, |
| 270 | title: "Write reviews", | 272 | title: "Read files", |
| 271 | description: "Create comments while reviewing changes.", | 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 | scope: AuthAccessReadScope, | 281 | scope: AuthAccessReadScope, |
| ... | @@ -1105,7 +1112,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1105,7 +1112,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1105 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 1112 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 1106 | const [dialogOpen, setDialogOpen] = useState(false); | 1113 | const [dialogOpen, setDialogOpen] = useState(false); |
| 1107 | const [pairingLabel, setPairingLabel] = useState(""); | 1114 | const [pairingLabel, setPairingLabel] = useState(""); |
| 1108 | const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([ | 1115 | const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthGrantScope>>([ |
| 1109 | ...AuthStandardClientScopes, | 1116 | ...AuthStandardClientScopes, |
| 1110 | ]); | 1117 | ]); |
| 1111 | const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope)); | 1118 | const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope)); |
| ... | @@ -1145,7 +1152,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1145,7 +1152,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1145 | } | 1152 | } |
| 1146 | }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]); | 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 | setPairingScopes((current) => | 1156 | setPairingScopes((current) => |
| 1150 | checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope), | 1157 | checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope), |
| 1151 | ); | 1158 | ); |
| ... | @@ -1217,9 +1224,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1217,9 +1224,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1217 | disabled={isCreatingPairingLink} | 1224 | disabled={isCreatingPairingLink} |
| 1218 | onClick={() => | 1225 | onClick={() => |
| 1219 | setPairingScopes( | 1226 | setPairingScopes( |
| 1220 | delegatableScopes.includes(AuthOrchestrationReadScope) | 1227 | [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) => |
| 1221 | ? [AuthOrchestrationReadScope] | 1228 | delegatableScopes.includes(scope), |
| 1222 | : [], | 1229 | ), |
| 1223 | ) | 1230 | ) |
| 1224 | } | 1231 | } |
| 1225 | > | 1232 | > |
apps/web/src/environments/primary/auth.ts+2-1| ... | @@ -2,6 +2,7 @@ import type { | ... | @@ -2,6 +2,7 @@ import type { |
| 2 | AuthBrowserSessionResult, | 2 | AuthBrowserSessionResult, |
| 3 | AuthClientMetadata, | 3 | AuthClientMetadata, |
| 4 | AuthEnvironmentScope, | 4 | AuthEnvironmentScope, |
| 5 | AuthGrantScope, | ||
| 5 | AuthPairingCredentialResult, | 6 | AuthPairingCredentialResult, |
| 6 | ServerAuthSessionMethod, | 7 | ServerAuthSessionMethod, |
| 7 | AuthSessionId, | 8 | AuthSessionId, |
| ... | @@ -354,7 +355,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo | ... | @@ -354,7 +355,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 354 | 355 | ||
| 355 | export async function createServerPairingCredential(input?: { | 356 | export async function createServerPairingCredential(input?: { |
| 356 | readonly label?: string; | 357 | readonly label?: string; |
| 357 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 358 | readonly scopes?: ReadonlyArray<AuthGrantScope>; |
| 358 | }): Promise<AuthPairingCredentialResult> { | 359 | }): Promise<AuthPairingCredentialResult> { |
| 359 | const trimmedLabel = input?.label?.trim(); | 360 | const trimmedLabel = input?.label?.trim(); |
| 360 | try { | 361 | try { |
apps/web/src/state/filesystem.ts+21-1| ... | @@ -1,5 +1,25 @@ | ... | @@ -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 | import { connectionAtomRuntime } from "../connection/runtime"; | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | import { useEnvironmentPresentation } from "./presentation"; | ||
| 9 | import { useEnvironmentQuery } from "./query"; | ||
| 10 | import { environmentSession } from "./session"; | ||
| 4 | 11 | ||
| 5 | export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); | 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,6 +25,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; |
| 25 | import { useCallback, useEffect, useMemo, useState } from "react"; | 25 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 26 | 26 | ||
| 27 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 27 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 28 | import { useFilesystemReadAccess } from "./filesystem"; | ||
| 28 | import { orchestrationEnvironment } from "./orchestration"; | 29 | import { orchestrationEnvironment } from "./orchestration"; |
| 29 | import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; | 30 | import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; |
| 30 | import { projectContentSearch, projectEnvironment } from "./projects"; | 31 | import { projectContentSearch, projectEnvironment } from "./projects"; |
| ... | @@ -237,12 +238,14 @@ export function useProjectPathSearch( | ... | @@ -237,12 +238,14 @@ export function useProjectPathSearch( |
| 237 | [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query], | 238 | [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query], |
| 238 | ); | 239 | ); |
| 239 | const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); | 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 | debouncedTarget.environmentId !== null && | 244 | debouncedTarget.environmentId !== null && |
| 242 | debouncedTarget.cwd !== null && | 245 | debouncedTarget.cwd !== null && |
| 243 | debouncedTarget.query !== null && | 246 | debouncedTarget.query !== null && |
| 244 | (allowEmptyQuery || debouncedTarget.query.length > 0) | 247 | (allowEmptyQuery || debouncedTarget.query.length > 0) |
| 245 | ? projectEnvironment.searchEntries({ | 248 | ? { |
| 246 | environmentId: debouncedTarget.environmentId, | 249 | environmentId: debouncedTarget.environmentId, |
| 247 | input: { | 250 | input: { |
| 248 | cwd: debouncedTarget.cwd, | 251 | cwd: debouncedTarget.cwd, |
| ... | @@ -251,15 +254,24 @@ export function useProjectPathSearch( | ... | @@ -251,15 +254,24 @@ export function useProjectPathSearch( |
| 251 | ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), | 254 | ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), |
| 252 | ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}), | 255 | ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}), |
| 253 | }, | 256 | }, |
| 254 | }) | 257 | } |
| 255 | : null, | 258 | : null; |
| 259 | const result = useEnvironmentQuery( | ||
| 260 | canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null, | ||
| 256 | ); | 261 | ); |
| 262 | const hasTarget = searchTarget !== null; | ||
| 257 | 263 | ||
| 258 | return { | 264 | return { |
| 259 | entries: result.data?.entries ?? EMPTY_PROJECT_ENTRIES, | 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 | isPending: | 272 | isPending: |
| 262 | !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, | 273 | !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || |
| 274 | (hasTarget && (fileAccess.isPending || result.isPending)), | ||
| 263 | searchedQuery: debouncedTarget.query ?? "", | 275 | searchedQuery: debouncedTarget.query ?? "", |
| 264 | truncated: result.data?.truncated ?? false, | 276 | truncated: result.data?.truncated ?? false, |
| 265 | refresh: result.refresh, | 277 | refresh: result.refresh, |
| ... | @@ -280,13 +292,18 @@ interface ProjectContentSearchTarget { | ... | @@ -280,13 +292,18 @@ interface ProjectContentSearchTarget { |
| 280 | } | 292 | } |
| 281 | 293 | ||
| 282 | export function useProjectContentSearch(target: ProjectContentSearchTarget) { | 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 | // Whitespace is significant in content queries; trimming is only used to | 299 | // Whitespace is significant in content queries; trimming is only used to |
| 284 | // decide whether the input is blank. | 300 | // decide whether the input is blank. |
| 285 | const query = target.query; | 301 | const query = target.query; |
| 286 | const hasQuery = query.trim().length > 0; | 302 | const hasQuery = query.trim().length > 0; |
| 287 | const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); | 303 | const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); |
| 288 | const result = useEnvironmentQuery( | 304 | const result = useEnvironmentQuery( |
| 289 | target.environmentId !== null && | 305 | canReadFiles && |
| 306 | target.environmentId !== null && | ||
| 290 | target.cwd !== null && | 307 | target.cwd !== null && |
| 291 | hasQuery && | 308 | hasQuery && |
| 292 | debouncedQuery.trim().length > 0 | 309 | debouncedQuery.trim().length > 0 |
| ... | @@ -305,9 +322,18 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) { | ... | @@ -305,9 +322,18 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) { |
| 305 | ); | 322 | ); |
| 306 | 323 | ||
| 307 | return { | 324 | return { |
| 325 | canReadFiles, | ||
| 326 | isCheckingAccess, | ||
| 308 | matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, | 327 | matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, |
| 309 | error: result.error, | 328 | error: |
| 310 | isPending: hasQuery && (query !== debouncedQuery || result.isPending), | 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 | hasQuery, | 337 | hasQuery, |
| 312 | truncated: result.data?.truncated ?? false, | 338 | truncated: result.data?.truncated ?? false, |
| 313 | invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, | 339 | invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, |
docs/internals/environment-auth.md+4-2| ... | @@ -92,7 +92,7 @@ after 30 days. | ... | @@ -92,7 +92,7 @@ after 30 days. |
| 92 | ## The environment is the filesystem boundary | 92 | ## The environment is the filesystem boundary |
| 93 | 93 | ||
| 94 | Projects are organizational boundaries, not filesystem sandboxes. | 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 | absolute paths outside a project. This lets clients display artifacts that an | 96 | absolute paths outside a project. This lets clients display artifacts that an |
| 97 | agent writes in a temporary directory. Relative paths and writes still follow | 97 | agent writes in a temporary directory. Relative paths and writes still follow |
| 98 | the [workspace path rules](../../apps/server/src/workspace/WorkspaceFileSystem.ts). | 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,7 +104,9 @@ file's identity when serving it, so atomic replacement requires a new URL while |
| 104 | editing the same file in place does not. An HTML file authorized this way cannot | 104 | editing the same file in place does not. An HTML file authorized this way cannot |
| 105 | load sibling assets; directory-scoped workspace previews are a separate grant. | 105 | load sibling assets; directory-scoped workspace previews are a separate grant. |
| 106 | Clients should share the authored file reference so they do not disclose the | 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 | Host videos can change in place. Their [HTTP | 111 | Host videos can change in place. Their [HTTP |
| 110 | responses](../../apps/server/src/http.ts) omit cache validators because file | 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,6 +293,10 @@ permissions. Existing clients keep their original grants after an update; to |
| 293 | receive newly separated permissions, pair the client again with the scopes it | 293 | receive newly separated permissions, pair the client again with the scopes it |
| 294 | needs. Reconnecting or refreshing a session does not expand its grant. | 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 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** | 300 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 297 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This | 301 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 298 | revokes its cloud access and frees its host space even when the environment is | 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,12 +1,119 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 4 | canPreloadBrowsePath, | 5 | canPreloadBrowsePath, |
| 5 | createBrowseNavigationCoordinator, | 6 | createBrowseNavigationCoordinator, |
| 6 | filterFilesystemBrowseEntries, | 7 | filterFilesystemBrowseEntries, |
| 7 | getFilesystemBrowsePath, | 8 | getFilesystemBrowsePath, |
| 9 | resolveFilesystemReadAccess, | ||
| 8 | } from "./filesystem.ts"; | 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 | describe("filesystem browse model", () => { | 117 | describe("filesystem browse model", () => { |
| 11 | it("derives the browse target and navigation state", () => { | 118 | it("derives the browse target and navigation state", () => { |
| 12 | expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({ | 119 | expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({ |
packages/client-runtime/src/state/filesystem.ts+42-2| ... | @@ -1,7 +1,15 @@ | ... | @@ -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 | import { Atom } from "effect/reactivity"; | 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 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 13 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 6 | import { | 14 | import { |
| 7 | canNavigateUp, | 15 | canNavigateUp, |
| ... | @@ -13,6 +21,38 @@ import { | ... | @@ -13,6 +21,38 @@ import { |
| 13 | } from "./projects.ts"; | 21 | } from "./projects.ts"; |
| 14 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | 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 | export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { | 56 | export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { |
| 17 | const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); | 57 | const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); |
| 18 | const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; | 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,7 +85,10 @@ export const AuthProvidersManageScope = "providers:manage" as const; |
| 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; | 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; |
| 86 | export const AuthTerminalOperateScope = "terminal:operate" as const; | 86 | export const AuthTerminalOperateScope = "terminal:operate" as const; |
| 87 | export const AuthSourceControlWriteScope = "source-control:write" as const; | 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 | export const AuthAccessReadScope = "access:read" as const; | 92 | export const AuthAccessReadScope = "access:read" as const; |
| 90 | export const AuthAccessWriteScope = "access:write" as const; | 93 | export const AuthAccessWriteScope = "access:write" as const; |
| 91 | export const AuthRelayReadScope = "relay:read" as const; | 94 | export const AuthRelayReadScope = "relay:read" as const; |
| ... | @@ -97,6 +100,8 @@ export const AuthEnvironmentScope = Schema.Literals([ | ... | @@ -97,6 +100,8 @@ export const AuthEnvironmentScope = Schema.Literals([ |
| 97 | AuthProvidersManageScope, | 100 | AuthProvidersManageScope, |
| 98 | AuthEnvironmentMaintainScope, | 101 | AuthEnvironmentMaintainScope, |
| 99 | AuthTerminalOperateScope, | 102 | AuthTerminalOperateScope, |
| 103 | AuthFilesystemReadScope, | ||
| 104 | AuthFilesystemWriteScope, | ||
| 100 | AuthReviewWriteScope, | 105 | AuthReviewWriteScope, |
| 101 | AuthSourceControlWriteScope, | 106 | AuthSourceControlWriteScope, |
| 102 | AuthAccessReadScope, | 107 | AuthAccessReadScope, |
| ... | @@ -108,6 +113,13 @@ export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type; | ... | @@ -108,6 +113,13 @@ export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type; |
| 108 | export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope); | 113 | export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope); |
| 109 | export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; | 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 | export const AuthStandardClientScopes = [ | 123 | export const AuthStandardClientScopes = [ |
| 112 | AuthOrchestrationReadScope, | 124 | AuthOrchestrationReadScope, |
| 113 | AuthOrchestrationOperateScope, | 125 | AuthOrchestrationOperateScope, |
| ... | @@ -115,8 +127,9 @@ export const AuthStandardClientScopes = [ | ... | @@ -115,8 +127,9 @@ export const AuthStandardClientScopes = [ |
| 115 | AuthProvidersManageScope, | 127 | AuthProvidersManageScope, |
| 116 | AuthEnvironmentMaintainScope, | 128 | AuthEnvironmentMaintainScope, |
| 117 | AuthTerminalOperateScope, | 129 | AuthTerminalOperateScope, |
| 118 | AuthReviewWriteScope, | ||
| 119 | AuthSourceControlWriteScope, | 130 | AuthSourceControlWriteScope, |
| 131 | AuthFilesystemReadScope, | ||
| 132 | AuthFilesystemWriteScope, | ||
| 120 | AuthRelayReadScope, | 133 | AuthRelayReadScope, |
| 121 | ] as const; | 134 | ] as const; |
| 122 | export const AuthAdministrativeScopes = [ | 135 | export const AuthAdministrativeScopes = [ |
| ... | @@ -355,7 +368,7 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T | ... | @@ -355,7 +368,7 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T |
| 355 | 368 | ||
| 356 | export const AuthCreatePairingCredentialInput = Schema.Struct({ | 369 | export const AuthCreatePairingCredentialInput = Schema.Struct({ |
| 357 | label: Schema.optionalKey(TrimmedNonEmptyString), | 370 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 358 | scopes: Schema.optionalKey(AuthEnvironmentScopes), | 371 | scopes: Schema.optionalKey(AuthGrantScopes), |
| 359 | }); | 372 | }); |
| 360 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; | 373 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; |
| 361 | 374 |