authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:11-07:00
log321c6f9d26e9240f6ed3d2a3a8f7fc11344c2f17
tree90272f7d0dc38f13fa382c9fdbb10b70d8078d81
parent2eda8ac6a3151b70b8ee49b438bfb6fcd835802e
signature Signed by PGP key B5690EEEBB952194

feat(auth): separate filesystem read and write permissions (#9788)

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";
15import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";15import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";
16import { VideoAttachmentTile } from "./VideoAttachmentTile";16import { VideoAttachmentTile } from "./VideoAttachmentTile";
17import { type MediaActionsSource } from "../lib/mediaActions";17import { type MediaActionsSource } from "../lib/mediaActionsSource";
18import { PresentationSource } from "./NativePresentation";18import { PresentationSource } from "./NativePresentation";
19import type { FilePreviewSource } from "./FilePreviewModal";19import type { FilePreviewSource } from "./FilePreviewModal";
20import { isPdfFile } from "../lib/filePreview";20import { isPdfFile } from "../lib/filePreview";
apps/mobile/src/components/FilePreviewModal.types.ts+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
22
3import type { FileBackedComposerAttachment } from "../lib/composerImages";3import type { FileBackedComposerAttachment } from "../lib/composerImages";
4import type { MediaActionsSource } from "../lib/mediaActions";4import type { MediaActionsSource } from "../lib/mediaActionsSource";
55
6export interface ResolvedFilePreviewSource {6export 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";
2import type { ReactElement } from "react";2import type { ReactElement } from "react";
3import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";3import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
44
5import type { useMediaActions } from "../lib/mediaActions";5import type { useMediaActions } from "../state/mediaActions";
6import { SymbolView } from "./AppSymbol";6import { SymbolView } from "./AppSymbol";
7import { ControlPillMenu } from "./ControlPill";7import { ControlPillMenu } from "./ControlPill";
88
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";
3import ImageViewing from "react-native-image-viewing";3import ImageViewing from "react-native-image-viewing";
4import { useSafeAreaInsets } from "react-native-safe-area-context";4import { useSafeAreaInsets } from "react-native-safe-area-context";
55
6import { useMediaActions } from "../lib/mediaActions";6import { useMediaActions } from "../state/mediaActions";
7import { AppText } from "./AppText";7import { AppText } from "./AppText";
8import { SymbolView } from "./AppSymbol";8import { SymbolView } from "./AppSymbol";
9import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types";9import 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";
7import { AppText } from "./AppText";7import { AppText } from "./AppText";
8import { SymbolView } from "./AppSymbol";8import { SymbolView } from "./AppSymbol";
9import { VideoThumbnailImage } from "./VideoThumbnailImage";9import { VideoThumbnailImage } from "./VideoThumbnailImage";
10import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";10import { useMediaActions } from "../state/mediaActions";
11import { type MediaActionsSource } from "../lib/mediaActionsSource";
11import { MediaActionsMenu } from "./MediaActionsMenu";12import { MediaActionsMenu } from "./MediaActionsMenu";
1213
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
22
3import { cn } from "../lib/cn";3import { cn } from "../lib/cn";
4import type { DraftComposerFileAttachment } from "../lib/composerImages";4import type { DraftComposerFileAttachment } from "../lib/composerImages";
5import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";5import { useMediaActions } from "../state/mediaActions";
6import { type MediaActionsSource } from "../lib/mediaActionsSource";
6import { SymbolView } from "./AppSymbol";7import { SymbolView } from "./AppSymbol";
7import { AppText } from "./AppText";8import { AppText } from "./AppText";
8import { MediaActionsMenu } from "./MediaActionsMenu";9import { 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
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
66
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";8import { useMediaActions } from "../state/mediaActions";
9import { type MediaActionsSource } from "../lib/mediaActionsSource";
9import {10import {
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 cycle275 // 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 ];
279279
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 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
1import { NativeStackScreenOptions } from "../../native/StackHeader";3import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";4import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";5import { 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";
24import { tryOpenExternalUrl } from "../../lib/openExternalUrl";26import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
25import { useUniwindTheme } from "../../lib/useUniwindTheme";27import { useUniwindTheme } from "../../lib/useUniwindTheme";
26import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";28import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
27import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";29import { useMediaActions } from "../../state/mediaActions";
30import { type MediaActionsSource } from "../../lib/mediaActionsSource";
28import { useThreadSelection } from "../../state/use-thread-selection";31import { useThreadSelection } from "../../state/use-thread-selection";
29import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";32import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
30import { useEnvironmentQuery } from "../../state/query";33import { useEnvironmentQuery } from "../../state/query";
34import { useEnvironmentPresentation } from "../../state/presentation";
31import { projectEnvironment } from "../../state/projects";35import { projectEnvironment } from "../../state/projects";
32import type { AssetUrlFailureReason } from "../../state/asset-url-state";36import type { AssetUrlFailureReason } from "../../state/asset-url-state";
33import {37import {
...@@ -370,14 +374,15 @@ function useThreadFilesWorkspace(params: {...@@ -370,14 +374,15 @@ function useThreadFilesWorkspace(params: {
370 };374 };
371}375}
372376
373function FilesUnavailable() {377function 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 <EmptyState385 <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 }
511527
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 && needsFileContents695 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 }
863902
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";
5import { EmptyState } from "../../components/EmptyState";5import { EmptyState } from "../../components/EmptyState";
6import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";6import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
7import { PresentationSource } from "../../components/NativePresentation";7import { PresentationSource } from "../../components/NativePresentation";
8import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";8import { useMediaActions } from "../../state/mediaActions";
9import { type MediaActionsSource } from "../../lib/mediaActionsSource";
9import { MediaActionsMenu } from "../../components/MediaActionsMenu";10import { MediaActionsMenu } from "../../components/MediaActionsMenu";
1011
11function ResolvedWorkspaceFileImagePreview(props: {12function ResolvedWorkspaceFileImagePreview(props: {
apps/mobile/src/features/files/preload-workspace-file.ts+3
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthFilesystemReadScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
1import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";3import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
3import {5import {
...@@ -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 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
1import type { EnvironmentId } from "@t3tools/contracts";3import type { EnvironmentId } from "@t3tools/contracts";
2import { SymbolView } from "../../components/AppSymbol";4import { SymbolView } from "../../components/AppSymbol";
3import { MaterialScreenContent } from "../../components/MaterialScreenContent";5import { 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
15import { MaterialFilesHeader } from "./MaterialFilesHeader";17import { MaterialFilesHeader } from "./MaterialFilesHeader";
16import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";18import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
17import { useUniwindTheme } from "../../lib/useUniwindTheme";19import { useUniwindTheme } from "../../lib/useUniwindTheme";
20import { useEnvironmentQuery } from "../../state/query";
21import { useEnvironmentPresentation } from "../../state/presentation";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";22import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
19import { FileTreeBrowser } from "./FileTreeBrowser";23import { FileTreeBrowser } from "./FileTreeBrowser";
20import { useFileTreeEntries } from "./useFileTreeEntries";24import { 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";
36import {37import {
37 appendBrowsePathSegment,38 appendBrowsePathSegment,
...@@ -41,6 +42,7 @@ import {...@@ -41,6 +42,7 @@ import {
41import {42import {
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
61import { filesystemEnvironment } from "../../state/filesystem";63import { filesystemEnvironment } from "../../state/filesystem";
62import { projectEnvironment } from "../../state/projects";64import { projectEnvironment } from "../../state/projects";
63import { useEnvironmentQuery } from "../../state/query";65import { useEnvironmentQuery } from "../../state/query";
64import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";66import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
67import { useEnvironmentPresentation } from "../../state/presentation";
65import { sourceControlEnvironment } from "../../state/sourceControl";68import { sourceControlEnvironment } from "../../state/sourceControl";
66import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";69import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
67import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";70import { 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 === null899 !canReadFiles || browseInput === null
882 ? null900 ? 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 <View828 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 <View848 <View
847 className={cn(849 className={cn(
apps/mobile/src/features/review/useReviewSections.test.ts created+210
...@@ -0,0 +1,210 @@
1import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
2import {
3 AuthFilesystemReadScope,
4 CheckpointRef,
5 EnvironmentId,
6 MessageId,
7 ThreadId,
8 RunId,
9 type AuthSessionState,
10} from "@t3tools/contracts";
11import { beforeEach, expect, it, vi } from "vite-plus/test";
12
13const 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
21vi.mock("@t3tools/client-runtime/state/thread-checkpoints", () => ({
22 deriveThreadCheckpointSummaries: () => state.checkpoints,
23}));
24vi.mock("react", () => ({
25 useCallback: <A>(callback: A) => callback,
26 useEffect: (effect: () => void) => state.effects.push(effect),
27 useMemo: <A>(factory: () => A) => factory(),
28}));
29vi.mock("../../state/session", () => ({
30 environmentSession: { sessionStateAtom: () => state.sessionAtom },
31}));
32vi.mock("../../state/presentation", () => ({
33 useEnvironmentPresentation: () => ({
34 isReady: true,
35 presentation: { connection: { phase: "connected", error: null } },
36 }),
37}));
38vi.mock("../../state/use-thread-detail", () => ({
39 useSelectedThreadProjection: () => ({ projection: {} }),
40}));
41vi.mock("../../state/use-selected-thread-worktree", () => ({
42 useSelectedThreadWorktree: () => ({ selectedThreadCwd: "/repo" }),
43}));
44vi.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}));
52vi.mock("../../state/queries", () => ({
53 useCheckpointDiff: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }),
54}));
55vi.mock("../../state/review", () => ({
56 reviewEnvironment: { diffPreview: vi.fn() },
57}));
58vi.mock("./reviewState", () => ({
59 setReviewAsyncError: vi.fn(),
60 setReviewGitSections: vi.fn(),
61 setReviewSelectedSectionId: vi.fn(),
62 setReviewTurnDiff: vi.fn(),
63 setReviewTurnDiffLoading: vi.fn(),
64}));
65
66import { setReviewSelectedSectionId, type ReviewCacheForThread } from "./reviewState";
67import { useReviewSections } from "./useReviewSections";
68
69beforeEach(() => {
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
87const checkpointDiff = "diff --git a/checkpoint.ts b/checkpoint.ts";
88const localDiff = "diff --git a/local.ts b/local.ts";
89function 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
115function makeInput(reviewCache = makeReviewCache()) {
116 return {
117 environmentId: EnvironmentId.make("environment"),
118 threadId: ThreadId.make("thread"),
119 reviewCache,
120 };
121}
122
123function 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
130it("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
142it.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
167it("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
180it("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
192it("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
205it("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 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
1import { useCallback, useEffect, useMemo } from "react";3import { useCallback, useEffect, useMemo } from "react";
2import * as DateTime from "effect/DateTime";4import * as DateTime from "effect/DateTime";
35
...@@ -9,6 +11,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts";...@@ -9,6 +11,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
911
10import { useCheckpointDiff } from "../../state/queries";12import { useCheckpointDiff } from "../../state/queries";
11import { useEnvironmentQuery } from "../../state/query";13import { useEnvironmentQuery } from "../../state/query";
14import { useEnvironmentPresentation } from "../../state/presentation";
12import { reviewEnvironment } from "../../state/review";15import { reviewEnvironment } from "../../state/review";
13import { useSelectedThreadProjection } from "../../state/use-thread-detail";16import { useSelectedThreadProjection } from "../../state/use-thread-detail";
14import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";17import { 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 !== null55 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 );
182205
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.data217 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";
13import type { FilePreviewSource } from "../../components/FilePreviewModal";13import type { FilePreviewSource } from "../../components/FilePreviewModal";
14import { MediaActionsMenu } from "../../components/MediaActionsMenu";14import { MediaActionsMenu } from "../../components/MediaActionsMenu";
15import { PresentationSource } from "../../components/NativePresentation";15import { PresentationSource } from "../../components/NativePresentation";
16import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";16import { useMediaActions } from "../../state/mediaActions";
17import { type MediaActionsSource } from "../../lib/mediaActionsSource";
17import { useAssetUrlState } from "../../state/assets";18import { useAssetUrlState } from "../../state/assets";
18import {19import {
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 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { useEnvironmentPresentation } from "../../state/presentation";
3import { environmentSession } from "../../state/session";
1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";4import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { Alert } from "react-native";5import { Alert } from "react-native";
36
...@@ -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 web488 // Default mode until the user picks one explicitly — same resolution web
486 // uses for new draft threads: per-project setting, then the repo's489 // 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 = canChooseWorkspace549 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"
44
5import type { FilePreviewSource } from "../components/FilePreviewModal";5import type { FilePreviewSource } from "../components/FilePreviewModal";
6import type { MediaVideoPreviewSource } from "./videoPreviewSource";6import type { MediaVideoPreviewSource } from "./videoPreviewSource";
7import type { MediaActionsSource } from "./mediaActions";7import type { MediaActionsSource } from "./mediaActionsSource";
88
9/** Resolves only explicit media references. Ordinary links keep their existing navigation. */9/** Resolves only explicit media references. Ordinary links keep their existing navigation. */
10export function resolveMarkdownMediaPreview(10export function resolveMarkdownMediaPreview(
apps/mobile/src/lib/mediaActions.test.ts created+266
...@@ -0,0 +1,266 @@
1import {
2 AuthFilesystemReadScope,
3 EnvironmentId,
4 ThreadId,
5 type AuthSessionState,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { beforeEach, expect, it, vi } from "vite-plus/test";
9
10const 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
20vi.mock("react", () => ({
21 useEffect: () => {},
22 useRef: <A>(current: A) => ({ current }),
23 useState: <A>(initial: A) => [initial, () => {}],
24}));
25vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
26vi.mock("@react-navigation/native", () => ({
27 useNavigation: () => ({ navigate: state.navigate }),
28}));
29vi.mock("@t3tools/mobile-markdown-text/links", () => ({
30 normalizeNativeMarkdownUrl: (uri: string) => uri,
31}));
32vi.mock("../state/assets", () => ({
33 useRefreshAssetUrl: (environmentId: string, resource: unknown) => () =>
34 state.refresh(environmentId, resource),
35}));
36vi.mock("../state/session", () => ({
37 environmentSession: { sessionStateAtom: (environmentId: string) => environmentId },
38}));
39vi.mock("../state/query", () => ({
40 useEnvironmentQuery: (environmentId: string) => ({
41 data: state.sessions.get(environmentId) ?? null,
42 error: null,
43 }),
44}));
45vi.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}));
53vi.mock("./attachmentDownload", () => ({
54 downloadAndShareAttachment: state.download,
55 shareLocalAttachment: state.shareLocal,
56}));
57vi.mock("./copyTextWithHaptic", () => ({ copyTextWithHaptic: state.copy }));
58vi.mock("./localAttachmentPreview", () => ({
59 loadLocalAttachmentPreview: async () => ({ share: state.shareDraft, dispose: vi.fn() }),
60}));
61
62import { useMediaActions } from "../state/mediaActions";
63import type { MediaActionsSource } from "./mediaActionsSource";
64
65const environmentId = EnvironmentId.make("media-environment");
66const otherEnvironmentId = EnvironmentId.make("other-environment");
67const threadId = ThreadId.make("media-thread");
68const granted: Pick<AuthSessionState, "authenticated" | "scopes"> = {
69 authenticated: true,
70 scopes: [AuthFilesystemReadScope],
71};
72const denied: Pick<AuthSessionState, "authenticated" | "scopes"> = {
73 authenticated: true,
74 scopes: [],
75};
76
77function 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
88beforeEach(() => {
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
99it.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
123it("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
155it.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
172it("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
184it("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
196it.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
206it("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
219it("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
228it("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
240it.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
250it("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 @@
1import { useNavigation } from "@react-navigation/native";
2import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
3import type { MediaReference } from "@t3tools/client-runtime/media-reference";
4import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts";
5import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
6import { useEffect, useRef, useState } from "react";
7import { Alert } from "react-native";
8
9import { useRefreshAssetUrl } from "../state/assets";
10import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload";
11import type { FileBackedComposerAttachment } from "./composerImages";
12import { copyTextWithHaptic } from "./copyTextWithHaptic";
13import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
14
15/** Authored source metadata is kept separate from temporary preview/download URLs. */
16export 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
32export 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 @@
1import type { MediaReference } from "@t3tools/client-runtime/media-reference";
2import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts";
3import type { FileBackedComposerAttachment } from "./composerImages";
4
5/** Authored source metadata is kept separate from temporary preview/download URLs. */
6export 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/
2import { videoMimeType } from "@t3tools/shared/video";2import { videoMimeType } from "@t3tools/shared/video";
33
4import type { DraftComposerFileAttachment } from "./composerImages";4import type { DraftComposerFileAttachment } from "./composerImages";
5import type { MediaActionsSource } from "./mediaActions";5import type { MediaActionsSource } from "./mediaActionsSource";
66
7export type MediaVideoPreviewSource = {7export type MediaVideoPreviewSource = {
8 readonly type: "media";8 readonly type: "media";
apps/mobile/src/state/assets.test.ts created+110
...@@ -0,0 +1,110 @@
1import {
2 AuthFilesystemReadScope,
3 EnvironmentAuthorizationError,
4 EnvironmentId,
5 ThreadId,
6 type AuthSessionState,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/reactivity";
10import { beforeEach, expect, it, vi } from "vite-plus/test";
11
12const 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
20vi.mock("react", () => ({ useCallback: <A>(callback: A) => callback }));
21vi.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}));
27vi.mock("./session", () => ({
28 environmentSession: { sessionStateAtom: () => ({}) },
29 usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }),
30}));
31vi.mock("./presentation", () => ({
32 useEnvironmentPresentation: () => ({
33 isReady: true,
34 presentation: { connection: { phase: state.phase, error: null } },
35 }),
36}));
37vi.mock("./query", () => ({
38 useEnvironmentQuery: () => ({ data: state.session, error: null }),
39}));
40vi.mock("./projectClones", () => ({ environmentProjectCloneListAtom: () => null }));
41vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));
42vi.mock("@t3tools/client-runtime/state/assets", async (importOriginal) => ({
43 ...(await importOriginal<typeof import("@t3tools/client-runtime/state/assets")>()),
44 createAssetEnvironmentAtoms: () => ({ createUrl: state.assetQuery }),
45}));
46vi.mock("./use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint }));
47
48import { useRefreshAssetUrl, useAssetUrlState } from "./assets";
49
50const environmentId = EnvironmentId.make("asset-environment");
51const threadId = ThreadId.make("asset-thread");
52const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const;
53
54beforeEach(() => {
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
63it.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
78it("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
89it("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
98it("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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
3 type EnvironmentConnectionPhase,
4 presentConnectionState,
5} from "@t3tools/client-runtime/connection";
6import {3import {
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";
12import type { AssetResource, EnvironmentId } from "@t3tools/contracts";9import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
13import * as Option from "effect/Option";
14import { AsyncResult, Atom } from "effect/reactivity";
15import { useCallback } from "react";10import { useCallback } from "react";
1611
17import { environmentCatalog } from "../connection/catalog";
18import { connectionAtomRuntime } from "../connection/runtime";12import { connectionAtomRuntime } from "../connection/runtime";
19import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";13import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
20import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";14import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
21import { environmentProjectCloneListAtom } from "./projectClones";15import { environmentProjectCloneListAtom } from "./projectClones";
22import { environmentSession, usePreparedConnection } from "./session";16import { environmentSession, usePreparedConnection } from "./session";
17import { useEnvironmentPresentation } from "./presentation";
18import { useEnvironmentQuery } from "./query";
23import { useAtomQueryRunner } from "./use-atom-query-runner";19import { useAtomQueryRunner } from "./use-atom-query-runner";
2420
25export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";21export 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});
3531
36const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
37 Atom.withLabel("mobile-asset-connection-state:empty"),
38);
39
40function 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
50export function useAssetUrlState(32export 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 === null54 !canReadResource || environmentId === null || resource === null
58 ? EMPTY_ASSET_URL_ATOM55 ? 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 the68 // 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 @@
1import type { MediaActionsSource } from "../lib/mediaActionsSource";
2import { useNavigation } from "@react-navigation/native";
3import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
4import {
5 AuthFilesystemReadScope,
6 type AuthSessionState,
7 type EnvironmentId,
8} from "@t3tools/contracts";
9import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";
10import * as Option from "effect/Option";
11import { AsyncResult } from "effect/reactivity";
12import { useEffect, useRef, useState } from "react";
13import { Alert } from "react-native";
14
15import { useRefreshAssetUrl } from "./assets";
16import { appAtomRegistry } from "./atom-registry";
17import { useEnvironmentQuery } from "./query";
18import { environmentSession } from "./session";
19import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
20import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
21import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
22
23/** An explicit action may ask the server while its grant is still unresolved. */
24function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) {
25 return (
26 session === null ||
27 (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
28 );
29}
30
31function 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
37export 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 @@
1import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState } from "@t3tools/contracts";
2import { beforeEach, expect, it, vi } from "vite-plus/test";
3
4const 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
12vi.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}));
19vi.mock("./session", () => ({
20 environmentSession: { sessionStateAtom: () => state.sessionAtom },
21}));
22vi.mock("./presentation", () => ({
23 useEnvironmentPresentation: () => ({
24 isReady: true,
25 presentation: { connection: { phase: state.phase, error: null } },
26 }),
27}));
28vi.mock("./pull-requests", () => ({ composerPullRequests: {} }));
29vi.mock("./projects", () => ({
30 projectEnvironment: { searchEntries: () => state.searchAtom },
31}));
32vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
33vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} }));
34vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() }));
35vi.mock("./vcs", () => ({ vcsEnvironment: {} }));
36vi.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
50import { useComposerPathSearch } from "./queries";
51
52const target = {
53 environmentId: EnvironmentId.make("test-environment"),
54 cwd: "/repo",
55 query: "src",
56};
57
58beforeEach(() => {
59 state.session = null;
60 state.sessionError = null;
61 state.phase = "connected";
62});
63
64it("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
78it("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
94it("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 @@
1import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";1import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";
2import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
3import { environmentSession } from "./session";
4import { useEnvironmentPresentation } from "./presentation";
2import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";5import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
3import type {6import 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 > 0331 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;
326345
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 @@
1import {1import {
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
306it.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 ? undefined358 ? 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 @@
1import { AuthEnvironmentScope } from "@t3tools/contracts";1import { AuthGrantScope } from "@t3tools/contracts";
2import { Flag } from "effect/cli";2import { Flag } from "effect/cli";
33
4export const authScopesFlag = (defaults: ReadonlyArray<AuthEnvironmentScope>) =>4export 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 @@
1import {
2 AuthFilesystemReadScope,
3 EnvironmentAuthorizationError,
4 EnvironmentId,
5 ThreadId,
6 type AuthSessionState,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/reactivity";
10import { beforeEach, expect, it, vi } from "vite-plus/test";
11
12const 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
20vi.mock("react", () => ({ useCallback: <A>(callback: A) => callback }));
21vi.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}));
27vi.mock("~/state/session", () => ({
28 usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }),
29}));
30vi.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});
42vi.mock("~/state/assets", () => ({
43 assetEnvironment: { createUrl: state.assetQuery },
44}));
45vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint }));
46
47import { useAssetUrlRefresh, useAssetUrlState } from "./assetUrls";
48
49const environmentId = EnvironmentId.make("asset-environment");
50const threadId = ThreadId.make("asset-thread");
51const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const;
52
53beforeEach(() => {
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
62it.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
77it("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
88it("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
94it("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";
11import { useCallback, useMemo } from "react";11import { useCallback, useMemo } from "react";
1212
13import { assetEnvironment } from "~/state/assets";13import { assetEnvironment } from "~/state/assets";
14import { useFilesystemReadAccess } from "~/state/filesystem";
14import { usePreparedConnection } from "~/state/session";15import { usePreparedConnection } from "~/state/session";
15import { useAtomQueryRunner } from "~/state/use-atom-query-runner";16import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
1617
...@@ -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 === null32 !canReadResource || environmentId === null || resource === null
26 ? EMPTY_ASSET_URL_ATOM33 ? 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 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { AsyncResult } from "effect/reactivity";
3import { act, type ComponentProps, type ReactNode } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { expect, it, vi } from "vite-plus/test";
6
7const mint = vi.hoisted(() => vi.fn());
8vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
9vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
10vi.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});
18vi.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});
32vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => mint }));
33vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
34vi.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}));
40vi.mock("../state/entities", () => ({
41 readEnvironmentSupportsServerBrowser: () => false,
42 readThreadShell: () => null,
43 useProjects: () => [],
44 useServerConfigs: () => new Map(),
45}));
46vi.mock("../remoteOpen", () => ({
47 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
48}));
49vi.mock("../editorPreferences", () => ({
50 useOpenInPreferredEditor: () => vi.fn(),
51 usePreferredEditor: () => [null, vi.fn()],
52}));
53vi.mock("~/lib/openPullRequestLink", () => ({
54 findProjectForChangeRequest: () => undefined,
55 matchesLinkedPullRequestUrl: () => false,
56 parseChangeRequestUrl: () => null,
57 useOpenChangeRequestLink: () => vi.fn(),
58}));
59
60import ChatMarkdown from "./ChatMarkdown";
61
62it("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 @@
1import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";1import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";2import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";
3import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
4import {4import {
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 {
32import {32import {
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";
101import { readLocalApi } from "../localApi";102import { readLocalApi } from "../localApi";
102import { desktopLocalBackendId } from "../connection/desktopLocal";103import { desktopLocalBackendId } from "../connection/desktopLocal";
103import { filesystemEnvironment } from "../state/filesystem";104import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem";
104import { projectEnvironment } from "../state/projects";105import { projectEnvironment } from "../state/projects";
105import { useEnvironmentQuery } from "../state/query";106import { useEnvironmentQuery } from "../state/query";
106import { serverEnvironment } from "../state/server";107import { 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 !relativePathNeedsActiveProject1169 !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 }
12081219
...@@ -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 <CommandPaletteVirtualizedResults3536 <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";
23import * as DateTime from "effect/DateTime";23import * as DateTime from "effect/DateTime";
24import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";24import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
25import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";25import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";
26import { useFilesystemReadAccess } from "~/state/filesystem";
26import { useOpenInPreferredEditor } from "../editorPreferences";27import { useOpenInPreferredEditor } from "../editorPreferences";
27import { useFileContextMenuHandler } from "../fileContextMenu";28import { useFileContextMenuHandler } from "../fileContextMenu";
28import { type DraftId } from "../composerDraftStore";29import { 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 && activeProjectId231 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 && activeCwd343 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 && serverConfig360 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 }
388391
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";
6import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";6import {
7 AuthFilesystemWriteScope,
8 type EnvironmentId,
9 type ScopedThreadRef,
10} from "@t3tools/contracts";
7import {11import {
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";
32import { projectEnvironment } from "~/state/projects";36import { projectEnvironment } from "~/state/projects";
33import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";37import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
34import { useAtomCommand } from "~/state/use-atom-command";38import { useAtomCommand } from "~/state/use-atom-command";
39import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
3540
36export const ProposedPlanCard = memo(function ProposedPlanCard({41export 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 };
8692
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({
101108
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 workspace179 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 <Button260 <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";
8import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";8import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
9import { AuthFilesystemWriteScope } from "@t3tools/contracts";
9import {10import {
10 isWorkspaceAudioPreviewPath,11 isWorkspaceAudioPreviewPath,
11 isWorkspaceImagePreviewPath,12 isWorkspaceImagePreviewPath,
...@@ -29,6 +30,7 @@ import {...@@ -29,6 +30,7 @@ import {
29import type { WorkerPoolManager } from "@pierre/diffs/worker";30import type { WorkerPoolManager } from "@pierre/diffs/worker";
30import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react";31import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react";
31import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";32import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
33import { useFilesystemReadAccess } from "~/state/filesystem";
32import {34import {
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";
63import { usePreviewAvailable } from "~/browser/previewRuntime";65import { usePreviewAvailable } from "~/browser/previewRuntime";
64import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";66import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";
65import { previewEnvironment } from "~/state/preview";67import { previewEnvironment } from "~/state/preview";
68import { useEnvironmentScope } from "~/state/session";
66import { useAtomCommand } from "~/state/use-atom-command";69import { useAtomCommand } from "~/state/use-atom-command";
67import { useAtomQueryRunner } from "~/state/use-atom-query-runner";70import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
6871
...@@ -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 never1047 // 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 a1048 // 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 folder1049 // 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 };
11651171
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 }
11901220
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 <SourceFilePreview1417 <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 });
9595
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";
22
3export interface FileSaveCoordinatorOptions<A, E> {3export 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}
911
10export class FileSaveCoordinator<A = unknown, E = unknown> {12export 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> {
4951
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 }
5257
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 }
6268
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 }
6874
apps/web/src/components/files/projectFilesQueryState.test.ts+142
...@@ -1,23 +1,165 @@...@@ -1,23 +1,165 @@
1import type { ProjectReadFileResult } from "@t3tools/contracts";1import type { ProjectReadFileResult } from "@t3tools/contracts";
2import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId } from "@t3tools/contracts";
3import { AsyncResult } from "effect/reactivity";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
45
6const registryTasks = vi.hoisted(() => new Set<() => void>());
7
8vi.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
22import { appAtomRegistry } from "~/rpc/atomRegistry";
23import { projectEnvironment } from "~/state/projects";
24import { FileSaveCoordinator } from "./fileSaveCoordinator";
5import {25import {
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";
1233
13const environmentId = EnvironmentId.make("environment-project-files-query-test");34const environmentId = EnvironmentId.make("environment-project-files-query-test");
35const optimisticFile = projectEnvironment.optimisticFile({
36 environmentId,
37 cwd: "/repo",
38 relativePath: "convex.json",
39});
40
41function drainRegistryTasks(): void {
42 while (registryTasks.size > 0) {
43 const tasks = [...registryTasks];
44 registryTasks.clear();
45 for (const task of tasks) task();
46 }
47}
1448
15describe("project files queries", () => {49describe("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 });
2056
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 @@
1import {1import {
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";
9import * as Cause from "effect/Cause";
7import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
9import { Atom, AtomRegistry } from "effect/reactivity";12import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
10import { beforeEach, describe, expect, it, vi } from "vite-plus/test";13import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1114
15const 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
22vi.mock("~/state/session", () => ({
23 environmentSession: { sessionStateAtom: () => authorizationMocks.sessionAtom },
24}));
25
26vi.mock("~/state/presentation", () => ({
27 useEnvironmentPresentation: () => ({
28 isReady: true,
29 presentation: { connection: { phase: authorizationMocks.phase, error: null } },
30 }),
31}));
32
12const projectMocks = vi.hoisted(() => ({33const 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}));
78100
79import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";101import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
102import { useT3ProjectFileState } from "~/hooks/useT3ProjectFileScripts";
80import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";103import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";
81104
82const environmentId = EnvironmentId.make("environment-1");105const environmentId = EnvironmentId.make("environment-1");
...@@ -114,12 +137,179 @@ async function flushEffects(): Promise<void> {...@@ -114,12 +137,179 @@ async function flushEffects(): Promise<void> {
114137
115describe("project query refresh", () => {138describe("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 });
122149
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";
12import { useCallback } from "react";12import { useCallback } from "react";
1313
14import { appAtomRegistry } from "~/rpc/atomRegistry";14import { appAtomRegistry } from "~/rpc/atomRegistry";
15import { useFilesystemReadAccess } from "~/state/filesystem";
15import { projectEnvironment } from "~/state/projects";16import { projectEnvironment } from "~/state/projects";
16import { useProjectPathSearch } from "~/state/queries";17import { useProjectPathSearch } from "~/state/queries";
17import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";18import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
1819
19const EMPTY_PROJECT_FILE_PATH = "";20const EMPTY_PROJECT_FILE_PATH = "";
21const EMPTY_PROJECT_ENTRIES_QUERY_ATOM = Atom.make(
22 AsyncResult.initial<ProjectListEntriesResult, never>(false),
23);
20const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(24const 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}
3135
36// Dirty contents must survive a closed preview, including failed or unauthorized saves.
37const unsavedFileMounts = new Map<ReturnType<typeof optimisticFileAtom>, () => void>();
38
39function releaseUnsavedFile(atom: ReturnType<typeof optimisticFileAtom>): void {
40 const unmount = unsavedFileMounts.get(atom);
41 unsavedFileMounts.delete(atom);
42 unmount?.();
43}
44
32interface ProjectQueryState<A> {45interface 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}
103120
121export 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
104export function confirmProjectFileQueryData(130export 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}
149178
150function failureCause<A>(result: AsyncResult.AsyncResult<A, unknown>): unknown {179function 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 folder256 // 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 = enabled258 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;
234275
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";
4import { create, type ReactTestRenderer } from "react-test-renderer";4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
66
7const { writeFile, confirmFile } = vi.hoisted(() => ({7const { 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}));
11vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } }));13vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } }));
14vi.mock("~/state/session", () => ({
15 readEnvironmentScope: readScope,
16 useEnvironmentScope: readScope,
17}));
12vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile }));18vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile }));
13vi.mock("./projectFilesQueryState", () => ({ confirmProjectFileQueryData: confirmFile }));19vi.mock("./projectFilesQueryState", () => ({
20 confirmProjectFileQueryData: confirmFile,
21 getUnsavedProjectFileQueryData: getUnsavedFile,
22}));
1423
15import { setMarkdownTaskChecked } from "./filePreviewMode";24import { setMarkdownTaskChecked } from "./filePreviewMode";
16import { useFileSaveCoordinator } from "./useFileSaveCoordinator";25import { 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});
5970
...@@ -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 });
126137
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 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import { AuthFilesystemWriteScope, type EnvironmentId } from "@t3tools/contracts";
2import { createRef, useEffect, useMemo } from "react";2import { createRef, useEffect, useMemo } from "react";
33
4import { projectEnvironment } from "~/state/projects";4import { projectEnvironment } from "~/state/projects";
5import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
5import { useAtomCommand } from "~/state/use-atom-command";6import { useAtomCommand } from "~/state/use-atom-command";
67
7import { FileSaveCoordinator } from "./fileSaveCoordinator";8import { FileSaveCoordinator } from "./fileSaveCoordinator";
8import { confirmProjectFileQueryData } from "./projectFilesQueryState";9import {
10 confirmProjectFileQueryData,
11 getUnsavedProjectFileQueryData,
12} from "./projectFilesQueryState";
913
10const FILE_SAVE_DEBOUNCE_MS = 500;14const FILE_SAVE_DEBOUNCE_MS = 500;
1115
...@@ -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 the57 // 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 @@
1import {
2 AuthFilesystemReadScope,
3 EnvironmentId,
4 ThreadId,
5 type AuthSessionState,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { createElement, isValidElement, type ReactNode } from "react";
9import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
10
11function 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
19const 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
30vi.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}));
36vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() }));
37vi.mock("../../localApi", () => ({
38 readLocalApi: () => ({ contextMenu: { show: state.showMenu } }),
39}));
40vi.mock("../../state/assets", () => ({ assetEnvironment: { createUrl: {} } }));
41vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint }));
42vi.mock("../../state/session", () => ({
43 environmentSession: { sessionStateAtom: (environmentId: string) => environmentId },
44 readPreparedConnection: () => ({ httpBaseUrl: "https://host.test" }),
45}));
46vi.mock("../../state/query", () => ({
47 useEnvironmentQuery: (environmentId: string) => ({
48 data: state.sessions.get(environmentId) ?? null,
49 error: null,
50 }),
51}));
52vi.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}));
60vi.mock("./mediaContent", () => ({ downloadMedia: state.download, readMediaPng: state.png }));
61vi.mock("../ui/tooltip", () => ({
62 Tooltip: "Tooltip",
63 TooltipTrigger: "TooltipTrigger",
64 TooltipPopup: "TooltipPopup",
65}));
66vi.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
77import { MediaActions, useMediaActions, type MediaActionSource } from "./MediaActions";
78
79const environmentId = EnvironmentId.make("media-environment");
80const otherEnvironmentId = EnvironmentId.make("other-environment");
81const threadId = ThreadId.make("media-thread");
82const granted: Pick<AuthSessionState, "authenticated" | "scopes"> = {
83 authenticated: true,
84 scopes: [AuthFilesystemReadScope],
85};
86const denied: Pick<AuthSessionState, "authenticated" | "scopes"> = {
87 authenticated: true,
88 scopes: [],
89};
90
91function 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
102function 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
121beforeEach(() => {
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
142afterEach(() => vi.unstubAllGlobals());
143
144it.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
171it("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
197it.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
218it.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
240it("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
254it.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
266it("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
278it("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
287it("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
299it.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";
6import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";6import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
7import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";7import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts";8import {
9 AuthFilesystemReadScope,
10 type AssetResource,
11 type AuthSessionState,
12 type ContextMenuItem,
13 type EnvironmentId,
14} from "@t3tools/contracts";
15import * as Option from "effect/Option";
16import { AsyncResult } from "effect/reactivity";
9import { useCallback, useRef, useState, type ReactElement } from "react";17import { useCallback, useRef, useState, type ReactElement } from "react";
1018
11import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";19import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
12import { readLocalApi } from "../../localApi";20import { readLocalApi } from "../../localApi";
21import { appAtomRegistry } from "../../rpc/atomRegistry";
13import { assetEnvironment } from "../../state/assets";22import { assetEnvironment } from "../../state/assets";
14import { readPreparedConnection } from "../../state/session";23import { useEnvironmentQuery } from "../../state/query";
24import { environmentSession, readPreparedConnection } from "../../state/session";
15import { useAtomQueryRunner } from "../../state/use-atom-query-runner";25import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
16import { stackedThreadToast, toastManager } from "../ui/toast";26import { stackedThreadToast, toastManager } from "../ui/toast";
17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";27import { 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}
3444
45/** An explicit action may ask the server while its grant is still unresolved. */
46function allowsHostMedia(session: Pick<AuthSessionState, "authenticated" | "scopes"> | null) {
47 return (
48 session === null ||
49 (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
50 );
51}
52
53function 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. */
36function useMediaActions(source: MediaActionSource) {60/** Exported for tests that drive the byte operations without the menu. */
61export 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}
71115
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}));
6565
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.
68vi.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 in73// Stubbed at the direct dependency rather than letting the real module pull in
67// `useSettings` -> `state/server`, which would drag the whole settings and74// `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 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { AuthFilesystemReadScope, EnvironmentId, ThreadId } from "@t3tools/contracts";
3import type { ReactNode } from "react";
4import { act, create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7const 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
15const 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
25vi.mock("~/hooks/useActiveProjectTarget", () => ({ useActiveProjectTarget: () => target }));
26vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
27vi.mock("~/rightPanelStore", () => ({
28 useRightPanelStore: { getState: () => ({ openFile: state.openFile }) },
29}));
30vi.mock("~/state/session", () => ({ readEnvironmentScope: state.readScope }));
31vi.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}));
54vi.mock("../CommandPaletteContent", () => ({ CommandPaletteContent: "section" }));
55vi.mock("../chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null }));
56vi.mock("./HighlightedSearchLine", () => ({ HighlightedSearchLine: () => null }));
57vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
58vi.mock("../ui/toggle", () => ({ Toggle: "button" }));
59vi.mock("../ui/tooltip", () => ({
60 Tooltip: "div",
61 TooltipPopup: "span",
62 TooltipTrigger: ({ render }: { render: ReactNode }) => render,
63}));
64
65import { ProjectContentSearchDialog } from "./ProjectContentSearchDialog";
66
67let renderer: ReactTestRenderer | undefined;
68const onOpenChange = vi.fn();
69
70beforeEach(() => {
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
81afterEach(async () => {
82 await act(() => renderer?.unmount());
83 vi.unstubAllGlobals();
84});
85
86async function openSearch() {
87 await act(() => {
88 renderer = create(<ProjectContentSearchDialog onOpenChange={onOpenChange} />);
89 });
90 return renderer!.root;
91}
92
93it("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
114it.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 @@
1import { Spinner } from "~/components/ui/spinner";1import { Spinner } from "~/components/ui/spinner";
2import type { ProjectContentMatch } from "@t3tools/contracts";2import { AuthFilesystemReadScope, type ProjectContentMatch } from "@t3tools/contracts";
33
4import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";4import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
55
...@@ -8,6 +8,7 @@ import { useTheme } from "~/hooks/useTheme";...@@ -8,6 +8,7 @@ import { useTheme } from "~/hooks/useTheme";
8import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
9import { useRightPanelStore } from "~/rightPanelStore";9import { useRightPanelStore } from "~/rightPanelStore";
10import { useProjectContentSearch } from "~/state/queries";10import { useProjectContentSearch } from "~/state/queries";
11import { readEnvironmentScope } from "~/state/session";
1112
12import { PierreEntryIcon } from "../chat/PierreEntryIcon";13import { PierreEntryIcon } from "../chat/PierreEntryIcon";
13import { CommandPaletteContent } from "../CommandPaletteContent";14import { CommandPaletteContent } from "../CommandPaletteContent";
...@@ -56,6 +57,7 @@ function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[]...@@ -56,6 +57,7 @@ function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[]
5657
57function SearchOptionButton(props: {58function 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 <Toggle69 <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 }, []);
154157
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);
163169
164 return (170 return (
165 <CommandPaletteContent171 <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 <SearchOptionButton177 <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 <SearchOptionButton185 <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 <SearchOptionButton193 <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: {
241251
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.error254 {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}
227229
228const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{230const 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]);
11471154
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
354355
355export async function createServerPairingCredential(input?: {356export 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 @@
1import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem";1import {
2 createFilesystemEnvironmentAtoms,
3 resolveFilesystemReadAccess,
4} from "@t3tools/client-runtime/state/filesystem";
5import type { EnvironmentId } from "@t3tools/contracts";
26
3import { connectionAtomRuntime } from "../connection/runtime";7import { connectionAtomRuntime } from "../connection/runtime";
8import { useEnvironmentPresentation } from "./presentation";
9import { useEnvironmentQuery } from "./query";
10import { environmentSession } from "./session";
411
5export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime);12export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime);
13
14export 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 @@
1import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState } from "@t3tools/contracts";
2import { beforeEach, expect, it, vi } from "vite-plus/test";
3
4const 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
20vi.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}));
27vi.mock("./session", () => ({
28 environmentSession: { sessionStateAtom: () => state.sessionAtom },
29}));
30vi.mock("./presentation", () => ({
31 useEnvironmentPresentation: () => ({
32 isReady: true,
33 presentation: { connection: { phase: state.phase, error: null } },
34 }),
35}));
36vi.mock("./projects", () => ({
37 projectEnvironment: { searchEntries: () => state.searchAtom },
38 projectContentSearch: (target: unknown) => {
39 state.contentRequests(target);
40 return state.contentAtom;
41 },
42}));
43vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
44vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} }));
45vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() }));
46vi.mock("./vcs", () => ({ vcsEnvironment: {} }));
47vi.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
70import { useProjectContentSearch, useProjectPathSearch } from "./queries";
71
72const useComposerPathSearch = (input: Parameters<typeof useProjectPathSearch>[0]) =>
73 useProjectPathSearch(input, 20);
74
75const target = {
76 environmentId: EnvironmentId.make("test-environment"),
77 cwd: "/repo",
78 query: "src",
79};
80
81beforeEach(() => {
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
90it("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
104it("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
120it("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
128const contentTarget = {
129 ...target,
130 query: " a match ",
131 caseSensitive: true,
132 wholeWord: true,
133 useRegex: false,
134};
135
136it("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
151it("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
167it("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
187it("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
209it("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
223it("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";
25import { useCallback, useEffect, useMemo, useState } from "react";25import { useCallback, useEffect, useMemo, useState } from "react";
2626
27import { appAtomRegistry } from "../rpc/atomRegistry";27import { appAtomRegistry } from "../rpc/atomRegistry";
28import { useFilesystemReadAccess } from "./filesystem";
28import { orchestrationEnvironment } from "./orchestration";29import { orchestrationEnvironment } from "./orchestration";
29import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";30import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";
30import { projectContentSearch, projectEnvironment } from "./projects";31import { 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;
257263
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}
281293
282export function useProjectContentSearch(target: ProjectContentSearchTarget) {294export 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 to299 // 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 > 0309 debouncedQuery.trim().length > 0
...@@ -305,9 +322,18 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) {...@@ -305,9 +322,18 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) {
305 );322 );
306323
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 boundary92## The environment is the filesystem boundary
9393
94Projects are organizational boundaries, not filesystem sandboxes.94Projects are organizational boundaries, not filesystem sandboxes.
95`orchestration:read` permits reading files the server account can read, including95`filesystem:read` permits reading files the server account can read, including
96absolute paths outside a project. This lets clients display artifacts that an96absolute paths outside a project. This lets clients display artifacts that an
97agent writes in a temporary directory. Relative paths and writes still follow97agent writes in a temporary directory. Relative paths and writes still follow
98the [workspace path rules](../../apps/server/src/workspace/WorkspaceFileSystem.ts).98the [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
104editing the same file in place does not. An HTML file authorized this way cannot104editing the same file in place does not. An HTML file authorized this way cannot
105load sibling assets; directory-scoped workspace previews are a separate grant.105load sibling assets; directory-scoped workspace previews are a separate grant.
106Clients should share the authored file reference so they do not disclose the106Clients should share the authored file reference so they do not disclose the
107temporary URL's credential.107temporary URL's credential. `filesystem:read` is checked when the URL is minted,
108not when it is served: a URL issued before the grant was revoked keeps working
109until it expires, and it is not bound to the session that minted it.
108110
109Host videos can change in place. Their [HTTP111Host videos can change in place. Their [HTTP
110responses](../../apps/server/src/http.ts) omit cache validators because file112responses](../../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
293receive newly separated permissions, pair the client again with the scopes it293receive newly separated permissions, pair the client again with the scopes it
294needs. Reconnecting or refreshing a session does not expand its grant.294needs. Reconnecting or refreshing a session does not expand its grant.
295295
296`filesystem:read` allows browsing host files, opening workspace files, and viewing
297local changes. Add `filesystem:write` to allow editing files or saving plans to
298the workspace. These scopes control direct file access from the client.
299
296To remove an environment from T3 Connect, open your account menu's **T3 Connect**300To remove an environment from T3 Connect, open your account menu's **T3 Connect**
297page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This301page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
298revokes its cloud access and frees its host space even when the environment is302revokes 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 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts";
23
3import {4import {
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";
911
12describe("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
10describe("filesystem browse model", () => {117describe("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 @@
1import { type FilesystemBrowseEntry, WS_METHODS } from "@t3tools/contracts";1import {
2 AuthFilesystemReadScope,
3 type AuthSessionState,
4 type FilesystemBrowseEntry,
5 WS_METHODS,
6} from "@t3tools/contracts";
2import { Atom } from "effect/reactivity";7import { Atom } from "effect/reactivity";
38
4import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";9import type {
10 EnvironmentConnectionPhase,
11 EnvironmentConnectionPresentation,
12} from "../connection/presentation.ts";
5import type { EnvironmentRegistry } from "../connection/registry.ts";13import type { EnvironmentRegistry } from "../connection/registry.ts";
6import {14import {
7 canNavigateUp,15 canNavigateUp,
...@@ -13,6 +21,38 @@ import {...@@ -13,6 +21,38 @@ import {
13} from "./projects.ts";21} from "./projects.ts";
14import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";22import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
1523
24export 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
16export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) {56export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3
4import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts";
5
6describe("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;
85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
86export const AuthTerminalOperateScope = "terminal:operate" as const;86export const AuthTerminalOperateScope = "terminal:operate" as const;
87export const AuthSourceControlWriteScope = "source-control:write" as const;87export const AuthSourceControlWriteScope = "source-control:write" as const;
88export const AuthReviewWriteScope = "review:write" as const;88export const AuthFilesystemReadScope = "filesystem:read" as const;
89export const AuthFilesystemWriteScope = "filesystem:write" as const;
90/** Retained for decoding existing credentials; grants no current RPC access. */
91const AuthReviewWriteScope = "review:write" as const;
89export const AuthAccessReadScope = "access:read" as const;92export const AuthAccessReadScope = "access:read" as const;
90export const AuthAccessWriteScope = "access:write" as const;93export const AuthAccessWriteScope = "access:write" as const;
91export const AuthRelayReadScope = "relay:read" as const;94export 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;
108export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope);113export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope);
109export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type;114export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type;
110115
116export const AuthGrantScope = Schema.Literals(
117 AuthEnvironmentScope.literals.filter((scope) => scope !== AuthReviewWriteScope),
118);
119export type AuthGrantScope = typeof AuthGrantScope.Type;
120export const AuthGrantScopes = Schema.Array(AuthGrantScope);
121export type AuthGrantScopes = typeof AuthGrantScopes.Type;
122
111export const AuthStandardClientScopes = [123export 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;
122export const AuthAdministrativeScopes = [135export const AuthAdministrativeScopes = [
...@@ -355,7 +368,7 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T...@@ -355,7 +368,7 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T
355368
356export const AuthCreatePairingCredentialInput = Schema.Struct({369export 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});
360export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;373export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;
361374