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 {
1414} from "../lib/composerImages";
1515import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";
1616import { VideoAttachmentTile } from "./VideoAttachmentTile";
17import { type MediaActionsSource } from "../lib/mediaActions";
17import { type MediaActionsSource } from "../lib/mediaActionsSource";
1818import { PresentationSource } from "./NativePresentation";
1919import type { FilePreviewSource } from "./FilePreviewModal";
2020import { isPdfFile } from "../lib/filePreview";
apps/mobile/src/components/FilePreviewModal.types.ts+1-1
......@@ -1,7 +1,7 @@
11import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
22
33import type { FileBackedComposerAttachment } from "../lib/composerImages";
4import type { MediaActionsSource } from "../lib/mediaActions";
4import type { MediaActionsSource } from "../lib/mediaActionsSource";
55
66export interface ResolvedFilePreviewSource {
77 readonly kind: "image" | "pdf" | "document";
apps/mobile/src/components/MediaActionsMenu.tsx+1-1
......@@ -2,7 +2,7 @@ import { MenuView } from "@react-native-menu/menu";
22import type { ReactElement } from "react";
33import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
44
5import type { useMediaActions } from "../lib/mediaActions";
5import type { useMediaActions } from "../state/mediaActions";
66import { SymbolView } from "./AppSymbol";
77import { ControlPillMenu } from "./ControlPill";
88
apps/mobile/src/components/MediaImagePreview.tsx+1-1
......@@ -3,7 +3,7 @@ import { Pressable, View } from "react-native";
33import ImageViewing from "react-native-image-viewing";
44import { useSafeAreaInsets } from "react-native-safe-area-context";
55
6import { useMediaActions } from "../lib/mediaActions";
6import { useMediaActions } from "../state/mediaActions";
77import { AppText } from "./AppText";
88import { SymbolView } from "./AppSymbol";
99import 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";
77import { AppText } from "./AppText";
88import { SymbolView } from "./AppSymbol";
99import { VideoThumbnailImage } from "./VideoThumbnailImage";
10import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
10import { useMediaActions } from "../state/mediaActions";
11import { type MediaActionsSource } from "../lib/mediaActionsSource";
1112import { MediaActionsMenu } from "./MediaActionsMenu";
1213
1314/** 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
22
33import { cn } from "../lib/cn";
44import type { DraftComposerFileAttachment } from "../lib/composerImages";
5import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
5import { useMediaActions } from "../state/mediaActions";
6import { type MediaActionsSource } from "../lib/mediaActionsSource";
67import { SymbolView } from "./AppSymbol";
78import { AppText } from "./AppText";
89import { MediaActionsMenu } from "./MediaActionsMenu";
apps/mobile/src/components/VideoPreviewModal.tsx+2-1
......@@ -5,7 +5,8 @@ import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-nativ
55import { useSafeAreaInsets } from "react-native-safe-area-context";
66
77import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
8import { useMediaActions } from "../state/mediaActions";
9import { type MediaActionsSource } from "../lib/mediaActionsSource";
910import {
1011 mediaVideoPreviewUri,
1112 mediaVideoThumbnailKey,
apps/mobile/src/dependency-graph.test.ts+1-1
......@@ -274,7 +274,7 @@ describe("mobile dependency graph", () => {
274274 ["native", "features", 8, "native must not add imports from features"],
275275 // lib -> state: attachment/session plumbing that predates the cycle
276276 // 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"],
278278 ];
279279
280280 for (const [from, to, ceiling, message] of ceilings) {
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+55-8
......@@ -1,3 +1,5 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
13import { NativeStackScreenOptions } from "../../native/StackHeader";
24import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
35import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
......@@ -24,10 +26,12 @@ import { isPdfFile } from "../../lib/filePreview";
2426import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
2527import { useUniwindTheme } from "../../lib/useUniwindTheme";
2628import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
27import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
29import { useMediaActions } from "../../state/mediaActions";
30import { type MediaActionsSource } from "../../lib/mediaActionsSource";
2831import { useThreadSelection } from "../../state/use-thread-selection";
2932import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
3033import { useEnvironmentQuery } from "../../state/query";
34import { useEnvironmentPresentation } from "../../state/presentation";
3135import { projectEnvironment } from "../../state/projects";
3236import type { AssetUrlFailureReason } from "../../state/asset-url-state";
3337import {
......@@ -370,14 +374,15 @@ function useThreadFilesWorkspace(params: {
370374 };
371375}
372376
373function FilesUnavailable() {
377function FilesUnavailable({
378 detail = "This thread does not have an active workspace path.",
379}: {
380 detail?: string;
381}) {
374382 return (
375383 <View className="flex-1 items-center justify-center bg-sheet px-6">
376384 <NativeStackScreenOptions options={{ title: "Files" }} />
377 <EmptyState
378 title="Files unavailable"
379 detail="This thread does not have an active workspace path."
380 />
385 <EmptyState title="Files unavailable" detail={detail} />
381386 </View>
382387 );
383388}
......@@ -420,9 +425,20 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
420425 props.route.params,
421426 );
422427 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;
423439 const entriesQuery = useFileTreeEntries({
424440 environmentId,
425 cwd: fileInspector.supported ? null : cwd,
441 cwd: !canReadFiles || fileInspector.supported ? null : cwd,
426442 searchQuery,
427443 });
428444 const handleReturnToThread = useCallback(() => {
......@@ -509,6 +525,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
509525 return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;
510526 }
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 }
512536 if (cwd === null) {
513537 return <FilesUnavailable />;
514538 }
......@@ -656,8 +680,23 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
656680 !isVideoFile &&
657681 !isAudioFile &&
658682 (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;
659694 const fileQuery = useEnvironmentQuery(
660 environmentId !== null && cwd !== null && relativePath !== null && needsFileContents
695 canReadFiles &&
696 environmentId !== null &&
697 cwd !== null &&
698 relativePath !== null &&
699 needsFileContents
661700 ? projectEnvironment.readFile({
662701 environmentId,
663702 input: { cwd, relativePath },
......@@ -861,6 +900,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
861900 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
862901 }
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 }
864911 if (cwd === null) {
865912 return <FilesUnavailable />;
866913 }
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+2-1
......@@ -5,7 +5,8 @@ import { FilePreviewLoading } from "./FilePreviewFeedback";
55import { EmptyState } from "../../components/EmptyState";
66import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
77import { PresentationSource } from "../../components/NativePresentation";
8import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
8import { useMediaActions } from "../../state/mediaActions";
9import { type MediaActionsSource } from "../../lib/mediaActionsSource";
910import { MediaActionsMenu } from "../../components/MediaActionsMenu";
1011
1112function ResolvedWorkspaceFileImagePreview(props: {
apps/mobile/src/features/files/preload-workspace-file.ts+3
......@@ -1,3 +1,5 @@
1import { AuthFilesystemReadScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
13import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
24import type { EnvironmentId } from "@t3tools/contracts";
35import {
......@@ -30,6 +32,7 @@ export function preloadWorkspaceFileContents(input: {
3032 readonly theme: ReviewDiffTheme;
3133}): void {
3234 if (
35 !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope) ||
3336 isWorkspaceBrowserPreviewPath(input.relativePath) ||
3437 isWorkspaceImagePreviewPath(input.relativePath) ||
3538 isVideoPreviewFile(input.relativePath)
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+24-3
......@@ -1,3 +1,5 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
13import type { EnvironmentId } from "@t3tools/contracts";
24import { SymbolView } from "../../components/AppSymbol";
35import { MaterialScreenContent } from "../../components/MaterialScreenContent";
......@@ -15,6 +17,8 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App
1517import { MaterialFilesHeader } from "./MaterialFilesHeader";
1618import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
1719import { useUniwindTheme } from "../../lib/useUniwindTheme";
20import { useEnvironmentQuery } from "../../state/query";
21import { useEnvironmentPresentation } from "../../state/presentation";
1822import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1923import { FileTreeBrowser } from "./FileTreeBrowser";
2024import { useFileTreeEntries } from "./useFileTreeEntries";
......@@ -36,9 +40,20 @@ export function ThreadFileNavigatorPane(props: {
3640 const foregroundColor = theme["--color-foreground"];
3741 const sheetColor = theme["--color-sheet"];
3842 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;
3954 const entriesQuery = useFileTreeEntries({
4055 environmentId: props.environmentId,
41 cwd: props.cwd,
56 cwd: canReadFiles ? props.cwd : null,
4257 searchQuery,
4358 });
4459 const handlePreviewFile = useCallback(
......@@ -75,8 +90,14 @@ export function ThreadFileNavigatorPane(props: {
7590 entries={entriesQuery.entries}
7691 loadedDirectories={entriesQuery.loadedDirectories}
7792 onLoadDirectory={entriesQuery.loadDirectory}
78 error={entriesQuery.error}
79 isPending={entriesQuery.isPending}
93 error={
94 canReadFiles
95 ? entriesQuery.error
96 : fileAccess.isPending
97 ? null
98 : (fileAccess.error ?? "This connection cannot read host files.")
99 }
100 isPending={fileAccess.isPending || entriesQuery.isPending}
80101 searchQuery={searchQuery}
81102 searchTruncated={entriesQuery.searchTruncated}
82103 selectedPath={props.selectedPath}
apps/mobile/src/features/projects/AddProjectScreen.tsx+25-4
......@@ -32,6 +32,7 @@ import {
3232 createBrowseNavigationCoordinator,
3333 filterFilesystemBrowseEntries,
3434 getFilesystemBrowsePath,
35 resolveFilesystemReadAccess,
3536} from "@t3tools/client-runtime/state/filesystem";
3637import {
3738 appendBrowsePathSegment,
......@@ -41,6 +42,7 @@ import {
4142import {
4243 AuthOrchestrationOperateScope,
4344 AuthSourceControlWriteScope,
45 AuthFilesystemReadScope,
4446 CommandId,
4547 type EnvironmentId,
4648 type EnvironmentMachineKind,
......@@ -61,7 +63,8 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit
6163import { filesystemEnvironment } from "../../state/filesystem";
6264import { projectEnvironment } from "../../state/projects";
6365import { useEnvironmentQuery } from "../../state/query";
64import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
66import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
67import { useEnvironmentPresentation } from "../../state/presentation";
6568import { sourceControlEnvironment } from "../../state/sourceControl";
6669import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
6770import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
......@@ -345,7 +348,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto
345348 setIsBrowseNavigating(true);
346349 const committed = await browseNavigation.run(
347350 async () => {
348 if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) {
351 if (
352 environment &&
353 readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) &&
354 canPreloadBrowsePath(environmentRuntime?.connectionState)
355 ) {
349356 await loadBrowsePath({
350357 environmentId: environment.environmentId,
351358 input: { partialPath: selectedDirectoryPath },
......@@ -877,8 +884,19 @@ function FolderBrowser(props: {
877884 () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null),
878885 [browsePath.directoryPath],
879886 );
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;
880898 const browseState = useEnvironmentQuery(
881 browseInput === null
899 !canReadFiles || browseInput === null
882900 ? null
883901 : filesystemEnvironment.browse({
884902 environmentId: props.environment.environmentId,
......@@ -900,9 +918,12 @@ function FolderBrowser(props: {
900918 return (
901919 <>
902920 <SectionTitle>Browse folders</SectionTitle>
921 {!canReadFiles && !fileAccess.isPending ? (
922 <ErrorBanner message={fileAccess.error ?? "This connection cannot browse host folders."} />
923 ) : null}
903924 {browseState.error ? <ErrorBanner message={browseState.error} /> : null}
904925 <ListSection>
905 {browseState.isPending && browseState.data === null ? (
926 {fileAccess.isPending || (browseState.isPending && browseState.data === null) ? (
906927 <View className="items-center py-5">
907928 <ActivityIndicator colorClassName="accent-icon-muted" />
908929 </View>
apps/mobile/src/features/review/ReviewSheet.tsx+18-16
......@@ -825,23 +825,25 @@ export function ReviewSheet(props: ReviewSheetProps) {
825825 >
826826 {listHeader}
827827 {!selectedSection ? (
828 <View
829 className={
830 Platform.OS === "android"
831 ? "items-center px-6 py-5"
832 : "border-b border-border bg-card px-4 py-5"
833 }
834 >
835 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
836 <Text
837 className={cn(
838 "text-xs leading-normal text-foreground-muted",
839 Platform.OS === "android" && "mt-2 text-center",
840 )}
828 error ? null : (
829 <View
830 className={
831 Platform.OS === "android"
832 ? "items-center px-6 py-5"
833 : "border-b border-border bg-card px-4 py-5"
834 }
841835 >
842 This thread has no ready turn diffs and the worktree diff is empty.
843 </Text>
844 </View>
836 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
837 <Text
838 className={cn(
839 "text-xs leading-normal text-foreground-muted",
840 Platform.OS === "android" && "mt-2 text-center",
841 )}
842 >
843 This thread has no ready turn diffs and the worktree diff is empty.
844 </Text>
845 </View>
846 )
845847 ) : selectedSection.isLoading && selectedSection.diff === null ? (
846848 <View
847849 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 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
13import { useCallback, useEffect, useMemo } from "react";
24import * as DateTime from "effect/DateTime";
35
......@@ -9,6 +11,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
911
1012import { useCheckpointDiff } from "../../state/queries";
1113import { useEnvironmentQuery } from "../../state/query";
14import { useEnvironmentPresentation } from "../../state/presentation";
1215import { reviewEnvironment } from "../../state/review";
1316import { useSelectedThreadProjection } from "../../state/use-thread-detail";
1417import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
......@@ -35,10 +38,21 @@ export function useReviewSections(input: {
3538}) {
3639 const { environmentId, reviewCache, threadId } = input;
3740 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;
3852 const selectedThread = useSelectedThreadProjection();
3953 const { selectedThreadCwd } = useSelectedThreadWorktree();
4054 const diffPreview = useEnvironmentQuery(
41 enabled && environmentId !== undefined && selectedThreadCwd !== null
55 canReadFiles && enabled && environmentId !== undefined && selectedThreadCwd !== null
4256 ? reviewEnvironment.diffPreview({
4357 environmentId,
4458 input: { cwd: selectedThreadCwd },
......@@ -70,24 +84,33 @@ export function useReviewSections(input: {
7084 ) as Record<string, ThreadCheckpointSummary>,
7185 [readyCheckpoints],
7286 );
73 const reviewSections = useMemo(
74 () =>
75 buildReviewSectionItems({
76 checkpoints: readyCheckpoints,
77 gitSections: diffPreview.data?.sources ?? reviewCache.gitSections,
78 turnDiffById: reviewCache.turnDiffById,
79 loadingTurnIds,
80 loadingGitSections: diffPreview.isPending,
81 }),
82 [
83 diffPreview.isPending,
84 diffPreview.data?.sources,
87 const reviewSections = useMemo(() => {
88 const sections = buildReviewSectionItems({
89 checkpoints: readyCheckpoints,
90 gitSections:
91 canReadFiles || fileAccess.isPending
92 ? (diffPreview.data?.sources ?? reviewCache.gitSections)
93 : [],
94 turnDiffById: reviewCache.turnDiffById,
8595 loadingTurnIds,
86 readyCheckpoints,
87 reviewCache.gitSections,
88 reviewCache.turnDiffById,
89 ],
90 );
96 loadingGitSections: fileAccess.isPending || diffPreview.isPending,
97 });
98 // Keep the selected section while its grant loads, without displaying cached host files.
99 return fileAccess.isPending
100 ? sections.map((section) =>
101 section.kind === "turn" ? section : { ...section, diff: null, isLoading: true },
102 )
103 : sections;
104 }, [
105 canReadFiles,
106 diffPreview.data?.sources,
107 diffPreview.isPending,
108 fileAccess.isPending,
109 loadingTurnIds,
110 readyCheckpoints,
111 reviewCache.gitSections,
112 reviewCache.turnDiffById,
113 ]);
91114 const selectedSection = useMemo(
92115 () =>
93116 reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ??
......@@ -181,10 +204,16 @@ export function useReviewSections(input: {
181204 );
182205
183206 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),
185214 isSelectedSectionPending:
186215 selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending,
187 loadingGitDiffs: diffPreview.isPending,
216 loadingGitDiffs: fileAccess.isPending || diffPreview.isPending,
188217 diffPreviewRevision: diffPreview.data
189218 ? DateTime.formatIso(diffPreview.data.generatedAt)
190219 : undefined,
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+2-1
......@@ -13,7 +13,8 @@ import { AppText as Text } from "../../components/AppText";
1313import type { FilePreviewSource } from "../../components/FilePreviewModal";
1414import { MediaActionsMenu } from "../../components/MediaActionsMenu";
1515import { PresentationSource } from "../../components/NativePresentation";
16import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
16import { useMediaActions } from "../../state/mediaActions";
17import { type MediaActionsSource } from "../../lib/mediaActionsSource";
1718import { useAssetUrlState } from "../../state/assets";
1819import {
1920 MARKDOWN_IMAGE_MAX_WIDTH,
apps/mobile/src/features/threads/new-task-flow-provider.tsx+20-2
......@@ -1,3 +1,6 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { useEnvironmentPresentation } from "../../state/presentation";
3import { environmentSession } from "../../state/session";
14import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
25import { Alert } from "react-native";
36
......@@ -485,8 +488,23 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
485488 // Default mode until the user picks one explicitly — same resolution web
486489 // uses for new draft threads: per-project setting, then the repo's
487490 // checked-in t3.json, then the server's configured default.
488 const t3ProjectFileQuery = useEnvironmentQuery(
491 const fileAccessSession = useEnvironmentQuery(
489492 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 !== ""
490508 ? projectEnvironment.readFile({
491509 environmentId: selectedProject.environmentId,
492510 input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME },
......@@ -527,7 +545,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
527545 const defaultWorkspaceModeSettled =
528546 selectedProjectDraft.workspaceSelection?.mode !== undefined ||
529547 projectSettings.sources.defaultThreadEnvMode !== "environment" ||
530 !t3ProjectFileQuery.isPending;
548 (!t3ProjectFileQuery.isPending && !fileAccessPending);
531549 const workspaceMode = canChooseWorkspace
532550 ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode)
533551 : "local";
apps/mobile/src/lib/markdownMedia.ts+1-1
......@@ -4,7 +4,7 @@ import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"
44
55import type { FilePreviewSource } from "../components/FilePreviewModal";
66import type { MediaVideoPreviewSource } from "./videoPreviewSource";
7import type { MediaActionsSource } from "./mediaActions";
7import type { MediaActionsSource } from "./mediaActionsSource";
88
99/** Resolves only explicit media references. Ordinary links keep their existing navigation. */
1010export 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/
22import { videoMimeType } from "@t3tools/shared/video";
33
44import type { DraftComposerFileAttachment } from "./composerImages";
5import type { MediaActionsSource } from "./mediaActions";
5import type { MediaActionsSource } from "./mediaActionsSource";
66
77export type MediaVideoPreviewSource = {
88 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 @@
11import { useAtomValue } from "@effect/atom-react";
2import {
3 type EnvironmentConnectionPhase,
4 presentConnectionState,
5} from "@t3tools/client-runtime/connection";
2import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
63import {
74 assetUrlStateFromResult,
85 createAssetEnvironmentAtoms,
......@@ -10,16 +7,15 @@ import {
107 EMPTY_ASSET_URL_ATOM,
118} from "@t3tools/client-runtime/state/assets";
129import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
13import * as Option from "effect/Option";
14import { AsyncResult, Atom } from "effect/reactivity";
1510import { useCallback } from "react";
1611
17import { environmentCatalog } from "../connection/catalog";
1812import { connectionAtomRuntime } from "../connection/runtime";
1913import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
2014import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
2115import { environmentProjectCloneListAtom } from "./projectClones";
2216import { environmentSession, usePreparedConnection } from "./session";
17import { useEnvironmentPresentation } from "./presentation";
18import { useEnvironmentQuery } from "./query";
2319import { useAtomQueryRunner } from "./use-atom-query-runner";
2420
2521export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";
......@@ -33,35 +29,40 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
3329 projectClones: environmentProjectCloneListAtom,
3430});
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
5032export function useAssetUrlState(
5133 environmentId: EnvironmentId | null,
5234 resource: AssetResource | null,
5335): 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");
5451 const preparedConnection = usePreparedConnection(environmentId);
55 const connectionPhase = useConnectionPhase(environmentId);
52 const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available";
5653 const result = useAtomValue(
57 environmentId === null || resource === null
54 !canReadResource || environmentId === null || resource === null
5855 ? EMPTY_ASSET_URL_ATOM
5956 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
6057 );
61 const shared = assetUrlStateFromResult(
62 result,
63 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
64 );
58 const shared = !canReadResource
59 ? fileAccess.isPending
60 ? { _tag: "Loading" as const }
61 : { _tag: "Failure" as const }
62 : assetUrlStateFromResult(
63 result,
64 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
65 );
6566 return deriveAssetUrlState({
6667 connectionPhase,
6768 // 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 @@
11import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";
2import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
3import { environmentSession } from "./session";
4import { useEnvironmentPresentation } from "./presentation";
25import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
36import type {
47 EnvironmentId,
......@@ -309,25 +312,48 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) {
309312 [target.cwd, target.environmentId, target.query],
310313 );
311314 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 =
313329 debouncedTarget.environmentId !== null &&
314 debouncedTarget.cwd !== null &&
315 debouncedTarget.query.length > 0
316 ? projectEnvironment.searchEntries({
330 debouncedTarget.cwd !== null &&
331 debouncedTarget.query.length > 0
332 ? {
317333 environmentId: debouncedTarget.environmentId,
318334 input: {
319335 cwd: debouncedTarget.cwd,
320336 query: debouncedTarget.query,
321337 limit: COMPOSER_PATH_SEARCH_LIMIT,
322338 },
323 })
324 : null,
339 }
340 : null;
341 const result = useEnvironmentQuery(
342 canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null,
325343 );
344 const hasTarget = searchTarget !== null;
326345
327346 return {
328347 entries: result.data?.entries ?? [],
329 error: result.error,
330 isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending,
348 error:
349 !hasTarget || fileAccess.isPending
350 ? null
351 : canReadFiles
352 ? result.error
353 : (fileAccess.error ?? "This connection cannot search host files."),
354 isPending:
355 normalizedTarget.query !== debouncedTarget.query ||
356 (hasTarget && (fileAccess.isPending || result.isPending)),
331357 refresh: result.refresh,
332358 };
333359}
apps/server/src/auth/RpcAuthorization.test.ts+41
......@@ -1,5 +1,6 @@
11import {
22 AuthEnvironmentMaintainScope,
3 AuthFilesystemReadScope,
34 AuthProvidersManageScope,
45 AuthSettingsWriteScope,
56 DEFAULT_SERVER_SETTINGS,
......@@ -301,3 +302,43 @@ it.effect("requires task permission before attaching a prepared worktree to a th
301302 expect(handled).toBe(false);
302303 }).pipe(Effect.scoped),
303304);
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 {
22 CLIENT_GUARDED_RPC_SCOPES,
33 type DeviceListInput,
44 clientRpcRequiredScopes,
5 AssetCreateUrlInput,
56 AuthAccessReadScope,
67 ServerSettingsPatch,
78 ProviderInstanceMutation,
......@@ -9,11 +10,12 @@ import {
910 AuthSettingsWriteScope,
1011 AuthProvidersManageScope,
1112 AuthEnvironmentMaintainScope,
13 AuthFilesystemReadScope,
14 AuthFilesystemWriteScope,
1215 AuthOrchestrationOperateScope,
1316 AuthOrchestrationReadScope,
1417 AuthRelayReadScope,
1518 AuthRelayWriteScope,
16 AuthReviewWriteScope,
1719 AuthTerminalOperateScope,
1820 ORCHESTRATION_V2_WS_METHODS,
1921 type AuthEnvironmentScope,
......@@ -131,15 +133,15 @@ export const RPC_REQUIRED_SCOPES = {
131133 [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
132134 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
133135 [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
134 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
135 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
136 [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
137 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
138 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
136 [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope,
137 [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope,
138 [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope,
139 [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope,
140 [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope,
139141 [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope,
140142 [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope,
141143 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
142 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
144 [WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope,
143145 [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
144146 [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope,
145147 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
......@@ -154,8 +156,8 @@ export const RPC_REQUIRED_SCOPES = {
154156 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
155157 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
156158 [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
157 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,
158 [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope,
159 [WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope,
160 [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope,
159161 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
160162 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,
161163 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,
......@@ -227,6 +229,16 @@ const requiredScopesForRpcCall = (
227229 method: string,
228230 payload: unknown,
229231): 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 }
230242 if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);
231243 const guarded = clientRpcRequiredScopes(method, payload);
232244 if (guarded.length > 0) return guarded;
apps/server/src/auth/http.ts+2-24
......@@ -2,16 +2,7 @@ import {
22 AuthAccessReadScope,
33 AuthAccessWriteScope,
44 AuthStandardClientScopes,
5 AuthSettingsWriteScope,
6 AuthProvidersManageScope,
7 AuthEnvironmentMaintainScope,
8 AuthOrchestrationOperateScope,
9 AuthOrchestrationReadScope,
10 AuthRelayReadScope,
11 AuthRelayWriteScope,
12 AuthReviewWriteScope,
13 AuthSourceControlWriteScope,
14 AuthTerminalOperateScope,
5 AuthGrantScope,
156 EnvironmentAuthInvalidError,
167 type EnvironmentAuthInvalidReason,
178 EnvironmentHttpApi,
......@@ -367,20 +358,7 @@ export const layer = HttpApiBuilder.group(
367358 ? undefined
368359 : parseAllowedOAuthScope({
369360 value: args.payload.scope,
370 allowedScopes: new Set<AuthEnvironmentScope>([
371 AuthOrchestrationReadScope,
372 AuthOrchestrationOperateScope,
373 AuthSettingsWriteScope,
374 AuthProvidersManageScope,
375 AuthEnvironmentMaintainScope,
376 AuthTerminalOperateScope,
377 AuthReviewWriteScope,
378 AuthSourceControlWriteScope,
379 AuthAccessReadScope,
380 AuthAccessWriteScope,
381 AuthRelayReadScope,
382 AuthRelayWriteScope,
383 ]),
361 allowedScopes: new Set(AuthGrantScope.literals),
384362 });
385363 if (requestedScopes === null) {
386364 return yield* failEnvironmentInvalidRequest("invalid_scope");
apps/server/src/cli/authScopes.ts+3-3
......@@ -1,8 +1,8 @@
1import { AuthEnvironmentScope } from "@t3tools/contracts";
1import { AuthGrantScope } from "@t3tools/contracts";
22import { Flag } from "effect/cli";
33
4export const authScopesFlag = (defaults: ReadonlyArray<AuthEnvironmentScope>) =>
5 Flag.Literals("scope", AuthEnvironmentScope.literals).pipe(
4export const authScopesFlag = (defaults: ReadonlyArray<AuthGrantScope>) =>
5 Flag.Literals("scope", AuthGrantScope.literals).pipe(
66 Flag.withDescription(
77 `Authorization scope to grant. Repeat for multiple scopes; replaces the default set: ${defaults.join(", ")}.`,
88 ),
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";
1111import { useCallback, useMemo } from "react";
1212
1313import { assetEnvironment } from "~/state/assets";
14import { useFilesystemReadAccess } from "~/state/filesystem";
1415import { usePreparedConnection } from "~/state/session";
1516import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
1617
......@@ -20,12 +21,19 @@ export function useAssetUrlState(
2021 environmentId: EnvironmentId | null,
2122 resource: AssetResource | null,
2223): 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");
2330 const preparedConnection = usePreparedConnection(environmentId);
2431 const result = useAtomValue(
25 environmentId === null || resource === null
32 !canReadResource || environmentId === null || resource === null
2633 ? EMPTY_ASSET_URL_ATOM
2734 : assetEnvironment.createUrl({ environmentId, input: { resource } }),
2835 );
36 if (!canReadResource) return { _tag: fileAccess.isPending ? "Loading" : "Failure" };
2937 return assetUrlStateFromResult(
3038 result,
3139 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
......@@ -55,21 +63,40 @@ export function useAssetUrls(
5563 resources: ReadonlyArray<AssetResource>,
5664): ReadonlyArray<string | null> {
5765 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 );
5879 const results = useAtomValue(
5980 assetEnvironment.createUrls({
6081 environmentId,
61 resources,
82 resources: allowedResources,
6283 }),
6384 );
64 return useMemo(
65 () =>
66 preparedConnection._tag === "None"
67 ? resources.map(() => null)
68 : results.map((result) =>
69 AsyncResult.isSuccess(result)
70 ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl)
71 : null,
72 ),
73 [preparedConnection, resources, results],
74 );
85 return useMemo(() => {
86 if (preparedConnection._tag === "None") return resources.map(() => null);
87 let resultIndex = 0;
88 return resources.map((resource) => {
89 if (
90 !canReadFiles &&
91 (resource._tag === "workspace-file" ||
92 resource._tag === "media-file" ||
93 resource._tag === "draft-workspace-file")
94 )
95 return null;
96 const result = results[resultIndex++];
97 return result && AsyncResult.isSuccess(result)
98 ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl)
99 : null;
100 });
101 }, [canReadFiles, preparedConnection, resources, results]);
75102}
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 @@
11import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
2import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";
33import { useAtomValue } from "@effect/atom-react";
44import {
55 COMPOSER_CONTEXT_CLIPBOARD_MIME,
......@@ -2757,7 +2757,12 @@ function useChatMarkdownState({
27572757 );
27582758 const findWorkspaceBasenameMatch = useCallback(
27592759 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 ) {
27612766 return null;
27622767 }
27632768 const result = await searchProjectEntries({
apps/web/src/components/CommandPalette.tsx+28-7
......@@ -32,6 +32,7 @@ import {
3232import {
3333 AuthOrchestrationOperateScope,
3434 AuthSourceControlWriteScope,
35 AuthFilesystemReadScope,
3536 type DesktopWslState,
3637 type EnvironmentId,
3738 type EnvironmentMachineKind,
......@@ -100,7 +101,7 @@ import {
100101} from "./settings/ThemePreviewCircles";
101102import { readLocalApi } from "../localApi";
102103import { desktopLocalBackendId } from "../connection/desktopLocal";
103import { filesystemEnvironment } from "../state/filesystem";
104import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem";
104105import { projectEnvironment } from "../state/projects";
105106import { useEnvironmentQuery } from "../state/query";
106107import { serverEnvironment } from "../state/server";
......@@ -1160,11 +1161,14 @@ function OpenCommandPaletteDialog(props: {
11601161 );
11611162 const relativePathNeedsActiveProject =
11621163 isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null;
1163 const browseQuery = useEnvironmentQuery(
1164 const browseAccess = useFilesystemReadAccess(browseEnvironmentId);
1165 const hasBrowseTarget =
11641166 isBrowsing &&
1165 browsePath.directoryPath.length > 0 &&
1166 browseEnvironmentId !== null &&
1167 !relativePathNeedsActiveProject
1167 browsePath.directoryPath.length > 0 &&
1168 browseEnvironmentId !== null &&
1169 !relativePathNeedsActiveProject;
1170 const browseQuery = useEnvironmentQuery(
1171 browseAccess.canReadFiles && hasBrowseTarget
11681172 ? filesystemEnvironment.browse({
11691173 environmentId: browseEnvironmentId,
11701174 input: {
......@@ -1175,7 +1179,11 @@ function OpenCommandPaletteDialog(props: {
11751179 : null,
11761180 );
11771181 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;
11791187 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;
11801188 const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo(
11811189 () =>
......@@ -1202,7 +1210,10 @@ function OpenCommandPaletteDialog(props: {
12021210 const environment = environments.find(
12031211 (candidate) => candidate.environmentId === environmentId,
12041212 );
1205 if (!canPreloadBrowsePath(environment?.connection.phase)) {
1213 if (
1214 !readEnvironmentScope(environmentId, AuthFilesystemReadScope) ||
1215 !canPreloadBrowsePath(environment?.connection.phase)
1216 ) {
12061217 return;
12071218 }
12081219
......@@ -2992,6 +3003,7 @@ function OpenCommandPaletteDialog(props: {
29923003 const willCreateProjectPath =
29933004 canSubmitBrowsePath &&
29943005 !isBrowsePending &&
3006 browseAccessError === null &&
29953007 query.trim().length > 0 &&
29963008 !hasHighlightedBrowseItem &&
29973009 (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null);
......@@ -3512,6 +3524,15 @@ function OpenCommandPaletteDialog(props: {
35123524 </div>
35133525 </div>
35143526 ) : 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}
35153536 <CommandPaletteVirtualizedResults
35163537 rows={resultRows.rows}
35173538 listRef={resultListRef}
apps/web/src/components/DiffPanel.tsx+22-3
......@@ -23,6 +23,7 @@ import * as Schema from "effect/Schema";
2323import * as DateTime from "effect/DateTime";
2424import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2525import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";
26import { useFilesystemReadAccess } from "~/state/filesystem";
2627import { useOpenInPreferredEditor } from "../editorPreferences";
2728import { useFileContextMenuHandler } from "../fileContextMenu";
2829import { type DraftId } from "../composerDraftStore";
......@@ -223,6 +224,8 @@ export default function DiffPanel({
223224 const activeThreadId = routeThreadRef?.threadId ?? null;
224225 const activeThread = useThreadShell(routeThreadRef);
225226 const activeThreadProjection = useThreadProjection(routeThreadRef)?.projection ?? null;
227 const fileAccess = useFilesystemReadAccess(activeThread?.environmentId ?? null);
228 const { canReadFiles } = fileAccess;
226229 const activeProjectId = activeThread?.projectId ?? null;
227230 const activeProject = useProject(
228231 activeThread && activeProjectId
......@@ -337,7 +340,7 @@ export default function DiffPanel({
337340 { enabled: isGitRepo && selectedTurn !== undefined },
338341 );
339342 const primaryBranchDiffPreview = useEnvironmentQuery(
340 selectedRunId === null && activeThread && activeCwd
343 canReadFiles && selectedRunId === null && activeThread && activeCwd
341344 ? reviewEnvironment.diffPreview({
342345 environmentId: activeThread.environmentId,
343346 input: {
......@@ -354,7 +357,7 @@ export default function DiffPanel({
354357 serverConfig?.cwd !== undefined &&
355358 serverConfig.cwd !== activeCwd;
356359 const fallbackBranchDiffPreview = useEnvironmentQuery(
357 shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig
360 canReadFiles && shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig
358361 ? reviewEnvironment.diffPreview({
359362 environmentId: activeThread.environmentId,
360363 input: {
......@@ -386,6 +389,7 @@ export default function DiffPanel({
386389 return undefined;
387390 }
388391
392 if (!canReadFiles) return undefined;
389393 return createGitDiffFileContentsLoader(getDiffFileContents, {
390394 environmentId: activeThread.environmentId,
391395 cwd: preview.cwd,
......@@ -394,7 +398,14 @@ export default function DiffPanel({
394398 headRef: selectedGitSource.headRef,
395399 cacheKey: selectedGitSource.diffHash,
396400 });
397 }, [activeThread, branchDiffPreview.data, getDiffFileContents, selectedGitSource, selectedRunId]);
401 }, [
402 activeThread,
403 branchDiffPreview.data,
404 getDiffFileContents,
405 canReadFiles,
406 selectedGitSource,
407 selectedRunId,
408 ]);
398409 const loadDiffFilesRef = useRef(currentLoadDiffFiles);
399410 loadDiffFilesRef.current = currentLoadDiffFiles;
400411 const loadDiffFiles = useCallback<FileDiffContentsLoader>(async (fileDiff) => {
......@@ -1051,6 +1062,14 @@ export default function DiffPanel({
10511062 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
10521063 No completed turns yet.
10531064 </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 )
10541073 ) : (
10551074 <>
10561075 <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 {
33 isAtomCommandInterrupted,
44 squashAtomCommandFailure,
55} 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";
711import {
812 buildCollapsedProposedPlanPreviewMarkdown,
913 buildProposedPlanMarkdownFilename,
......@@ -32,6 +36,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
3236import { projectEnvironment } from "~/state/projects";
3337import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3438import { useAtomCommand } from "~/state/use-atom-command";
39import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
3540
3641export const ProposedPlanCard = memo(function ProposedPlanCard({
3742 planMarkdown,
......@@ -47,6 +52,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
4752 workspaceRoot: string | undefined;
4853}) {
4954 const [expanded, setExpanded] = useState(false);
55 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
5056 const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
5157 const [savePath, setSavePath] = useState("");
5258 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
......@@ -85,6 +91,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
8591 };
8692
8793 const openSaveDialog = () => {
94 if (!canWriteFiles) return;
8895 if (!workspaceRoot) {
8996 toastManager.add(
9097 stackedThreadToast({
......@@ -101,7 +108,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
101108
102109 const handleSaveToWorkspace = () => {
103110 const relativePath = savePath.trim();
104 if (!workspaceRoot) {
111 if (!workspaceRoot || !readEnvironmentScope(environmentId, AuthFilesystemWriteScope)) {
105112 return;
106113 }
107114 if (!relativePath) {
......@@ -165,7 +172,10 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
165172 {isCopied ? "Copied!" : "Copy to clipboard"}
166173 </MenuItem>
167174 <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 >
169179 Save to workspace
170180 </MenuItem>
171181 </MenuPopup>
......@@ -250,7 +260,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
250260 <Button
251261 size="sm"
252262 onClick={() => void handleSaveToWorkspace()}
253 disabled={isSavingToWorkspace}
263 disabled={!canWriteFiles || isSavingToWorkspace}
254264 >
255265 {isSavingToWorkspace ? "Saving..." : "Save"}
256266 </Button>
apps/web/src/components/files/FilePreviewPanel.tsx+39-4
......@@ -6,6 +6,7 @@ import type {
66 ScopedThreadRef,
77} from "@t3tools/contracts";
88import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
9import { AuthFilesystemWriteScope } from "@t3tools/contracts";
910import {
1011 isWorkspaceAudioPreviewPath,
1112 isWorkspaceImagePreviewPath,
......@@ -29,6 +30,7 @@ import {
2930import type { WorkerPoolManager } from "@pierre/diffs/worker";
3031import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react";
3132import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
33import { useFilesystemReadAccess } from "~/state/filesystem";
3234import {
3335 isAtomCommandInterrupted,
3436 squashAtomCommandFailure,
......@@ -63,6 +65,7 @@ import { assetEnvironment } from "~/state/assets";
6365import { usePreviewAvailable } from "~/browser/previewRuntime";
6466import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";
6567import { previewEnvironment } from "~/state/preview";
68import { useEnvironmentScope } from "~/state/session";
6669import { useAtomCommand } from "~/state/use-atom-command";
6770import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
6871
......@@ -1038,6 +1041,9 @@ export default function FilePreviewPanel({
10381041 // A file outside the workspace (an absolute path) is shown, never edited.
10391042 const isHostFile =
10401043 attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath));
1044 const fileAccess = useFilesystemReadAccess(environmentId);
1045 const { canReadFiles } = fileAccess;
1046 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
10411047 // Media and PDFs render from their absolute path, so their contents are never
10421048 // shown. The read still runs: a folder named `assets.png` is only knowable as a
10431049 // folder from the read failure, and the server stats before reading, so a folder
......@@ -1164,7 +1170,7 @@ export default function FilePreviewPanel({
11641170 };
11651171
11661172 const handleOpenInBrowser = useCallback(() => {
1167 if (!absolutePath || !environmentHttpBaseUrl) return;
1173 if (!canReadFiles || !absolutePath || !environmentHttpBaseUrl) return;
11681174 void (async () => {
11691175 const result = await openFileInPreview({
11701176 threadRef,
......@@ -1186,7 +1192,31 @@ export default function FilePreviewPanel({
11861192 }),
11871193 );
11881194 })();
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
11911221 return (
11921222 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
......@@ -1266,6 +1296,11 @@ export default function FilePreviewPanel({
12661296 ) : null}
12671297 </div>
12681298 ) : 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}
12691304 {previewPath &&
12701305 attachment === undefined &&
12711306 !isMedia &&
......@@ -1368,7 +1403,7 @@ export default function FilePreviewPanel({
13681403 relativePath={relativePath}
13691404 threadRef={threadRef}
13701405 contents={file.data.contents}
1371 readOnly={isHostFile}
1406 readOnly={isHostFile || !canWriteFiles}
13721407 onPendingChange={onPendingChange}
13731408 />
13741409 ) : tableDelimiter && renderTable ? (
......@@ -1378,7 +1413,7 @@ export default function FilePreviewPanel({
13781413 text={file.data.contents}
13791414 delimiter={tableDelimiter}
13801415 />
1381 ) : file.data.truncated || isHostFile ? (
1416 ) : file.data.truncated || isHostFile || !canWriteFiles ? (
13821417 <SourceFilePreview
13831418 name={relativePath}
13841419 text={file.data.contents}
apps/web/src/components/files/fileSaveCoordinator.test.ts+25
......@@ -93,6 +93,31 @@ describe("FileSaveCoordinator", () => {
9393 expect(persist).toHaveBeenCalledWith("unsaved");
9494 });
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
96121 it("flushes an edit made while a write was in flight when the editor closes", async () => {
97122 vi.useFakeTimers();
98123 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";
22
33export interface FileSaveCoordinatorOptions<A, E> {
44 readonly debounceMs: number;
5 readonly canPersist?: () => boolean;
56 readonly persist: (contents: string) => Promise<AtomCommandResult<A, E>>;
67 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;
810}
911
1012export class FileSaveCoordinator<A = unknown, E = unknown> {
......@@ -49,20 +51,24 @@ export class FileSaveCoordinator<A = unknown, E = unknown> {
4951
5052 private async persistLatest(): Promise<void> {
5153 if (this.saving || this.latestRevision === this.confirmedRevision) return;
54 if (this.options.canPersist?.() === false) {
55 return;
56 }
5257
5358 this.saving = true;
5459 const contents = this.latestContents;
5560 const revision = this.latestRevision;
5661 const result = await this.options.persist(contents);
5762 const succeeded = result._tag === "Success";
63 let confirmed = false;
5864 if (succeeded) {
5965 this.confirmedRevision = revision;
60 this.options.onConfirmed(contents);
66 confirmed = this.options.onConfirmed(contents) !== false;
6167 }
6268
6369 this.saving = false;
6470 if (revision === this.latestRevision) {
65 if (succeeded) this.options.onPendingChange(false);
71 if (confirmed) this.options.onPendingChange(false);
6672 return;
6773 }
6874
apps/web/src/components/files/projectFilesQueryState.test.ts+142
......@@ -1,23 +1,165 @@
11import type { ProjectReadFileResult } from "@t3tools/contracts";
22import { EnvironmentId } from "@t3tools/contracts";
3import { AsyncResult } from "effect/reactivity";
34import { 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";
525import {
626 clearProjectFileQueryData,
727 confirmProjectFileQueryData,
828 getOptimisticProjectFileQueryData,
29 getUnsavedProjectFileQueryData,
930 resolveProjectFileQueryData,
1031 setProjectFileQueryData,
1132} from "./projectFilesQueryState";
1233
1334const 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
1549describe("project files queries", () => {
1650 afterEach(() => {
1751 clearProjectFileQueryData(environmentId, "/repo", "convex.json");
52 drainRegistryTasks();
53 vi.useRealTimers();
1854 vi.unstubAllGlobals();
1955 });
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
21163 it("keeps the latest optimistic draft when an older write finishes", () => {
22164 vi.stubGlobal("window", {});
23165 const initial = {
apps/web/src/components/files/projectFilesQueryState.test.tsx+191-1
......@@ -1,14 +1,35 @@
11import {
2 AuthFilesystemReadScope,
23 EnvironmentId,
4 type AuthSessionState,
35 type ProjectListEntriesResult,
46 ProjectReadFileError,
57 type ProjectReadFileResult,
68} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
710import * as Effect from "effect/Effect";
811import * as Schema from "effect/Schema";
9import { Atom, AtomRegistry } from "effect/reactivity";
12import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
1013import { 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
1233const projectMocks = vi.hoisted(() => ({
1334 listEntries: vi.fn(),
1435 optimisticFile: vi.fn(),
......@@ -64,6 +85,7 @@ vi.mock("react", async (importOriginal) => {
6485 ...actual,
6586 useCallback: reactHooks.useCallback,
6687 useEffect: reactHooks.useEffect,
88 useMemo: <A,>(factory: () => A) => factory(),
6789 useRef: reactHooks.useRef,
6890 };
6991});
......@@ -77,6 +99,7 @@ vi.mock("~/state/queries", () => ({
7799}));
78100
79101import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
102import { useT3ProjectFileState } from "~/hooks/useT3ProjectFileScripts";
80103import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";
81104
82105const environmentId = EnvironmentId.make("environment-1");
......@@ -114,12 +137,179 @@ async function flushEffects(): Promise<void> {
114137
115138describe("project query refresh", () => {
116139 beforeEach(() => {
140 authorizationMocks.sessionAtom = Atom.make(
141 AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }),
142 );
143 authorizationMocks.phase = "connected";
117144 projectMocks.listEntries.mockReset();
118145 projectMocks.optimisticFile.mockReset();
119146 projectMocks.readFile.mockReset();
120147 reactHooks.reset();
121148 });
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
123313 it("replaces an in-flight initial read when a workspace mutation arrives", async () => {
124314 const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = [];
125315 const readAtom = Atom.make(
apps/web/src/components/files/projectFilesQueryState.ts+57-11
......@@ -12,11 +12,15 @@ import { AsyncResult, Atom } from "effect/reactivity";
1212import { useCallback } from "react";
1313
1414import { appAtomRegistry } from "~/rpc/atomRegistry";
15import { useFilesystemReadAccess } from "~/state/filesystem";
1516import { projectEnvironment } from "~/state/projects";
1617import { useProjectPathSearch } from "~/state/queries";
1718import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
1819
1920const EMPTY_PROJECT_FILE_PATH = "";
21const EMPTY_PROJECT_ENTRIES_QUERY_ATOM = Atom.make(
22 AsyncResult.initial<ProjectListEntriesResult, never>(false),
23);
2024const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(
2125 AsyncResult.initial<ProjectReadFileResult, never>(false),
2226).pipe(Atom.withLabel("project-file-query:empty"));
......@@ -29,6 +33,15 @@ export function optimisticFileAtom(
2933 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });
3034}
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
3245interface ProjectQueryState<A> {
3346 readonly data: A | null;
3447 readonly error: string | null;
......@@ -70,7 +83,11 @@ export function setProjectFileQueryData(
7083 relativePath: string,
7184 contents: string,
7285): 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, {
7491 confirmedAgainst: undefined,
7592 data: {
7693 relativePath,
......@@ -101,6 +118,15 @@ export function getProjectFileContents(
101118 return Option.getOrUndefined(AsyncResult.value(result))?.contents;
102119}
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
104130export function confirmProjectFileQueryData(
105131 environmentId: EnvironmentId,
106132 cwd: string,
......@@ -117,6 +143,7 @@ export function confirmProjectFileQueryData(
117143 confirmedAgainst: appAtomRegistry.get(queryAtom),
118144 };
119145 appAtomRegistry.set(atom, confirmed);
146 releaseUnsavedFile(atom);
120147 appAtomRegistry.refresh(queryAtom);
121148 void executeAtomQuery(appAtomRegistry, queryAtom, {
122149 reportDefect: false,
......@@ -144,7 +171,9 @@ export function clearProjectFileQueryData(
144171 cwd: string,
145172 relativePath: string,
146173): void {
147 appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null);
174 const atom = optimisticFileAtom(environmentId, cwd, relativePath);
175 appAtomRegistry.set(atom, null);
176 releaseUnsavedFile(atom);
148177}
149178
150179function failureCause<A>(result: AsyncResult.AsyncResult<A, unknown>): unknown {
......@@ -163,14 +192,22 @@ export function useProjectEntriesQuery(
163192 cwd: string,
164193 directoryPath?: string,
165194): 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;
167200 const result = useAtomValue(atom);
168201 const refreshAtom = useAtomRefresh(atom);
169202 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
170203 return {
171204 data: Option.getOrNull(AsyncResult.value(result)),
172 error: errorMessage(failureCause(result)),
173 isPending: result.waiting,
205 error: fileAccess.isPending
206 ? null
207 : canReadFiles
208 ? errorMessage(failureCause(result))
209 : (fileAccess.error ?? "This connection cannot read host files."),
210 isPending: fileAccess.isPending || result.waiting,
174211 refresh,
175212 };
176213}
......@@ -218,9 +255,13 @@ export function useProjectFileQuery(
218255): ProjectFileQueryState {
219256 // The caller decides what to read. A media path is not skipped here: a folder
220257 // named `assets.png` is only knowable as a folder from the read failure.
221 const atom = enabled
222 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
223 : EMPTY_PROJECT_FILE_QUERY_ATOM;
258 const fileAccess = useFilesystemReadAccess(environmentId);
259 const { canReadFiles } = fileAccess;
260 const isQueryEnabled = enabled;
261 const atom =
262 enabled && canReadFiles
263 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
264 : EMPTY_PROJECT_FILE_QUERY_ATOM;
224265 const result = useAtomValue(atom);
225266 const refreshAtom = useAtomRefresh(atom);
226267 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
......@@ -233,11 +274,16 @@ export function useProjectFileQuery(
233274 const readError = isProjectReadFileError(cause) ? cause : null;
234275
235276 return {
236 data: optimisticFile?.data ?? data,
237 error: errorMessage(cause),
277 data: canReadFiles ? (optimisticFile?.data ?? data) : null,
278 error:
279 !isQueryEnabled || fileAccess.isPending
280 ? null
281 : canReadFiles
282 ? errorMessage(cause)
283 : (fileAccess.error ?? "This connection cannot read host files."),
284 isPending: isQueryEnabled && (fileAccess.isPending || result.waiting),
238285 readError,
239286 isNotFile: readError?.failure === "path_not_file",
240 isPending: result.waiting,
241287 refresh,
242288 };
243289}
apps/web/src/components/files/useFileSaveCoordinator.test.tsx+64-2
......@@ -4,13 +4,22 @@ import { act, StrictMode } from "react";
44import { create, type ReactTestRenderer } from "react-test-renderer";
55import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
66
7const { writeFile, confirmFile } = vi.hoisted(() => ({
7const { writeFile, confirmFile, readScope, getUnsavedFile } = vi.hoisted(() => ({
88 writeFile: vi.fn(),
99 confirmFile: vi.fn(),
10 readScope: vi.fn(),
11 getUnsavedFile: vi.fn(),
1012}));
1113vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } }));
14vi.mock("~/state/session", () => ({
15 readEnvironmentScope: readScope,
16 useEnvironmentScope: readScope,
17}));
1218vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile }));
13vi.mock("./projectFilesQueryState", () => ({ confirmProjectFileQueryData: confirmFile }));
19vi.mock("./projectFilesQueryState", () => ({
20 confirmProjectFileQueryData: confirmFile,
21 getUnsavedProjectFileQueryData: getUnsavedFile,
22}));
1423
1524import { setMarkdownTaskChecked } from "./filePreviewMode";
1625import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
......@@ -54,6 +63,8 @@ beforeEach(() => {
5463 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
5564 writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined));
5665 confirmFile.mockReset();
66 readScope.mockReset().mockReturnValue(true);
67 getUnsavedFile.mockReset().mockReturnValue(null);
5768 onPendingChange.mockReset();
5869});
5970
......@@ -124,6 +135,57 @@ describe("file-save React lifecycle", () => {
124135 expect(writeFile.mock.calls[0]![0].input.contents).toBe("pending edit");
125136 });
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
127189 it.each([
128190 { relativePath: "other.txt" },
129191 { cwd: "/other-workspace" },
apps/web/src/components/files/useFileSaveCoordinator.ts+23-5
......@@ -1,11 +1,15 @@
1import type { EnvironmentId } from "@t3tools/contracts";
1import { AuthFilesystemWriteScope, type EnvironmentId } from "@t3tools/contracts";
22import { createRef, useEffect, useMemo } from "react";
33
44import { projectEnvironment } from "~/state/projects";
5import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
56import { useAtomCommand } from "~/state/use-atom-command";
67
78import { FileSaveCoordinator } from "./fileSaveCoordinator";
8import { confirmProjectFileQueryData } from "./projectFilesQueryState";
9import {
10 confirmProjectFileQueryData,
11 getUnsavedProjectFileQueryData,
12} from "./projectFilesQueryState";
913
1014const FILE_SAVE_DEBOUNCE_MS = 500;
1115
......@@ -22,6 +26,7 @@ export function useFileSaveCoordinator({
2226 relativePath,
2327 onPendingChange,
2428}: FileSaveOptions): Pick<FileSaveCoordinator, "change"> {
29 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
2530 const writeFile = useAtomCommand(projectEnvironment.writeFile);
2631 const session = useMemo(() => {
2732 const coordinatorRef = createRef<FileSaveCoordinator>();
......@@ -30,15 +35,15 @@ export function useFileSaveCoordinator({
3035 setup: () => {
3136 const coordinator = new FileSaveCoordinator({
3237 debounceMs: FILE_SAVE_DEBOUNCE_MS,
38 canPersist: () => readEnvironmentScope(environmentId, AuthFilesystemWriteScope),
3339 onPendingChange: (pending) => onPendingChange(relativePath, pending),
3440 persist: (nextContents) =>
3541 writeFile({
3642 environmentId,
3743 input: { cwd, relativePath, contents: nextContents },
3844 }),
39 onConfirmed: (confirmedContents) => {
40 confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents);
41 },
45 onConfirmed: (confirmedContents) =>
46 confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents),
4247 });
4348 coordinatorRef.current = coordinator;
4449 return () => {
......@@ -52,5 +57,18 @@ export function useFileSaveCoordinator({
5257 // StrictMode replays effect setup. Retired file sessions stay inert, while the
5358 // replay gets a fresh coordinator instead of reusing a disposed one.
5459 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]);
5573 return session;
5674}
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 {
55} from "@t3tools/client-runtime/media-reference";
66import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
77import { 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";
917import { useCallback, useRef, useState, type ReactElement } from "react";
1018
1119import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
1220import { readLocalApi } from "../../localApi";
21import { appAtomRegistry } from "../../rpc/atomRegistry";
1322import { assetEnvironment } from "../../state/assets";
14import { readPreparedConnection } from "../../state/session";
23import { useEnvironmentQuery } from "../../state/query";
24import { environmentSession, readPreparedConnection } from "../../state/session";
1525import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
1626import { stackedThreadToast, toastManager } from "../ui/toast";
1727import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -32,13 +42,45 @@ function mediaFileName(source: MediaActionSource): string {
3242 );
3343}
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
3559/** 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]);
3778 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
3879 reportFailure: false,
3980 refresh: true,
4081 });
4182 const actionUrl = useCallback(async () => {
83 assertCanReadMedia();
4284 if (!source.asset) {
4385 if (!source.src) throw new Error("This media is unavailable. Try reopening the preview.");
4486 return source.src;
......@@ -48,14 +90,16 @@ function useMediaActions(source: MediaActionSource) {
4890 if (!connection) throw new Error("Reconnect to this environment and try again.");
4991 const result = await createAssetUrl({ environmentId, input: { resource } });
5092 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
93 assertCanReadMedia();
5194 const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
5295 if (!url) throw new Error("The environment returned an invalid media URL.");
5396 return url;
54 }, [source, createAssetUrl]);
97 }, [source, createAssetUrl, assertCanReadMedia]);
5598 const save = useCallback(async () => {
5699 await downloadMedia(await actionUrl(), mediaFileName(source));
57100 }, [actionUrl, source]);
58101 const copyImage = useCallback(async () => {
102 assertCanReadMedia();
59103 if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
60104 throw new Error(
61105 "Image copying is unavailable. Use a secure browser connection or save the image.",
......@@ -65,8 +109,8 @@ function useMediaActions(source: MediaActionSource) {
65109 await navigator.clipboard.write([
66110 new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }),
67111 ]);
68 }, [actionUrl]);
69 return { save, copyImage };
112 }, [actionUrl, assertCanReadMedia]);
113 return { save, copyImage, canReadMedia, assertCanReadMedia };
70114}
71115
72116/** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */
......@@ -77,7 +121,7 @@ export function MediaActions({
77121 source: MediaActionSource;
78122 children: ReactElement;
79123}) {
80 const { save, copyImage } = useMediaActions(source);
124 const { save, copyImage, canReadMedia, assertCanReadMedia } = useMediaActions(source);
81125 const [tooltipOpen, setTooltipOpen] = useState(false);
82126 const menuOpen = useRef(false);
83127 const reference = source.reference;
......@@ -92,7 +136,7 @@ export function MediaActions({
92136 let progressToast: ReturnType<typeof toastManager.add> | undefined;
93137 try {
94138 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);
96140 const canCopyImage =
97141 typeof navigator !== "undefined" &&
98142 Boolean(navigator.clipboard?.write) &&
......@@ -105,7 +149,8 @@ export function MediaActions({
105149 } else if (reference?.kind === "url") {
106150 items.push({ id: "copy-url", label: "Copy URL" });
107151 }
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 });
109154 items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable });
110155 if (source.kind === "image") {
111156 items.push({
......@@ -133,6 +178,7 @@ export function MediaActions({
133178 title: action === "copy-url" ? "URL copied" : "Path copied",
134179 });
135180 } else if (action === "open-file") {
181 assertCanReadMedia();
136182 source.onOpenFile?.();
137183 } else if (action === "save" || action === "copy-image") {
138184 progressToast = toastManager.add({
apps/web/src/components/preview/PreviewView.test.tsx+7
......@@ -63,6 +63,13 @@ vi.mock("~/state/session", async (importOriginal) => ({
6363 readPreparedConnection: mocks.readPreparedConnection,
6464}));
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
6673// Stubbed at the direct dependency rather than letting the real module pull in
6774// `useSettings` -> `state/server`, which would drag the whole settings and
6875// 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 @@
11import { Spinner } from "~/components/ui/spinner";
2import type { ProjectContentMatch } from "@t3tools/contracts";
2import { AuthFilesystemReadScope, type ProjectContentMatch } from "@t3tools/contracts";
33
44import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
55
......@@ -8,6 +8,7 @@ import { useTheme } from "~/hooks/useTheme";
88import { cn } from "~/lib/utils";
99import { useRightPanelStore } from "~/rightPanelStore";
1010import { useProjectContentSearch } from "~/state/queries";
11import { readEnvironmentScope } from "~/state/session";
1112
1213import { PierreEntryIcon } from "../chat/PierreEntryIcon";
1314import { CommandPaletteContent } from "../CommandPaletteContent";
......@@ -56,6 +57,7 @@ function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[]
5657
5758function SearchOptionButton(props: {
5859 readonly active: boolean;
60 readonly disabled: boolean;
5961 readonly label: string;
6062 readonly onClick: () => void;
6163 readonly children: ReactNode;
......@@ -66,6 +68,7 @@ function SearchOptionButton(props: {
6668 render={
6769 <Toggle
6870 aria-label={props.label}
71 disabled={props.disabled}
6972 pressed={props.active}
7073 size="segmented"
7174 variant="segmented"
......@@ -121,7 +124,7 @@ function OpenContentSearchDialog(props: {
121124 wholeWord,
122125 useRegex,
123126 });
124 const canOpenMatches = !search.isPending;
127 const canOpenMatches = search.canReadFiles && !search.isPending && search.error === null;
125128 const matches = search.matches;
126129 const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]);
127130 const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]);
......@@ -153,13 +156,16 @@ function OpenContentSearchDialog(props: {
153156 }, []);
154157
155158 const openMatch = (match: ProjectContentMatch) => {
156 if (!canOpenMatches) return;
159 if (!canOpenMatches || !readEnvironmentScope(target.environmentId, AuthFilesystemReadScope)) {
160 return;
161 }
157162 props.onOpenChange(false);
158163 useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber);
159164 };
160165 const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]);
161166 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
164170 return (
165171 <CommandPaletteContent
......@@ -170,6 +176,7 @@ function OpenContentSearchDialog(props: {
170176 <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">
171177 <SearchOptionButton
172178 active={caseSensitive}
179 disabled={!search.canReadFiles}
173180 label="Match case"
174181 onClick={() => setCaseSensitive((current) => !current)}
175182 >
......@@ -177,6 +184,7 @@ function OpenContentSearchDialog(props: {
177184 </SearchOptionButton>
178185 <SearchOptionButton
179186 active={wholeWord}
187 disabled={!search.canReadFiles}
180188 label="Match whole word"
181189 onClick={() => setWholeWord((current) => !current)}
182190 >
......@@ -184,6 +192,7 @@ function OpenContentSearchDialog(props: {
184192 </SearchOptionButton>
185193 <SearchOptionButton
186194 active={useRegex}
195 disabled={!search.canReadFiles}
187196 label="Use regular expression"
188197 onClick={() => setUseRegex((current) => !current)}
189198 >
......@@ -193,6 +202,7 @@ function OpenContentSearchDialog(props: {
193202 }
194203 inputProps={{
195204 className: "pe-30",
205 disabled: !search.canReadFiles,
196206 placeholder: `Search in ${target.projectName}`,
197207 onKeyDown: (event) => {
198208 if (event.key === "ArrowDown" && matches.length > 0) {
......@@ -241,9 +251,13 @@ function OpenContentSearchDialog(props: {
241251
242252 {matches.length === 0 ? (
243253 <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground">
244 {search.hasQuery && !search.isPending && !search.error
245 ? "No results found."
246 : "Type to search across your project."}
254 {search.isCheckingAccess
255 ? "Checking file access…"
256 : !search.canReadFiles
257 ? search.error
258 : search.hasQuery && !search.isPending && !search.error
259 ? "No results found."
260 : "Type to search across your project."}
247261 </div>
248262 ) : (
249263 <ScrollArea className="min-h-0 flex-1" scrollFade>
apps/web/src/components/settings/ConnectionsSettings.tsx+19-12
......@@ -30,12 +30,14 @@ import {
3030 AuthOrchestrationReadScope,
3131 AuthRelayReadScope,
3232 AuthRelayWriteScope,
33 AuthReviewWriteScope,
3433 AuthSourceControlWriteScope,
34 AuthFilesystemReadScope,
35 AuthFilesystemWriteScope,
3536 AuthStandardClientScopes,
3637 AuthTerminalOperateScope,
3738 type AuthClientSession,
3839 type AuthEnvironmentScope,
40 type AuthGrantScope,
3941 type AuthPairingLink,
4042 type AuthPairingCredentialResult,
4143 type AdvertisedEndpoint,
......@@ -226,19 +228,19 @@ function formatAccessTimestamp(value: string): string {
226228}
227229
228230const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
229 readonly scope: AuthEnvironmentScope;
231 readonly scope: AuthGrantScope;
230232 readonly title: string;
231233 readonly description: string;
232234}> = [
233235 {
234236 scope: AuthOrchestrationReadScope,
235237 title: "View environment",
236 description: "Read threads, status, diffs, and configuration.",
238 description: "Read threads, status, checkpoints, and configuration.",
237239 },
238240 {
239241 scope: AuthOrchestrationOperateScope,
240242 title: "Operate tasks",
241 description: "Start tasks and perform changes in the environment.",
243 description: "Start, update, and stop tasks.",
242244 },
243245 {
244246 scope: AuthSettingsWriteScope,
......@@ -266,9 +268,14 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
266268 description: "Commit, push, manage branches and repositories, and change pull requests.",
267269 },
268270 {
269 scope: AuthReviewWriteScope,
270 title: "Write reviews",
271 description: "Create comments while reviewing changes.",
271 scope: AuthFilesystemReadScope,
272 title: "Read files",
273 description: "Browse host files, search workspaces, and inspect local changes.",
274 },
275 {
276 scope: AuthFilesystemWriteScope,
277 title: "Write files",
278 description: "Edit workspace files and save plans to disk.",
272279 },
273280 {
274281 scope: AuthAccessReadScope,
......@@ -1105,7 +1112,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
11051112 const primaryEnvironmentId = usePrimaryEnvironmentId();
11061113 const [dialogOpen, setDialogOpen] = useState(false);
11071114 const [pairingLabel, setPairingLabel] = useState("");
1108 const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([
1115 const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthGrantScope>>([
11091116 ...AuthStandardClientScopes,
11101117 ]);
11111118 const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope));
......@@ -1145,7 +1152,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
11451152 }
11461153 }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]);
11471154
1148 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {
1155 const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => {
11491156 setPairingScopes((current) =>
11501157 checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope),
11511158 );
......@@ -1217,9 +1224,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
12171224 disabled={isCreatingPairingLink}
12181225 onClick={() =>
12191226 setPairingScopes(
1220 delegatableScopes.includes(AuthOrchestrationReadScope)
1221 ? [AuthOrchestrationReadScope]
1222 : [],
1227 [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) =>
1228 delegatableScopes.includes(scope),
1229 ),
12231230 )
12241231 }
12251232 >
apps/web/src/environments/primary/auth.ts+2-1
......@@ -2,6 +2,7 @@ import type {
22 AuthBrowserSessionResult,
33 AuthClientMetadata,
44 AuthEnvironmentScope,
5 AuthGrantScope,
56 AuthPairingCredentialResult,
67 ServerAuthSessionMethod,
78 AuthSessionId,
......@@ -354,7 +355,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo
354355
355356export async function createServerPairingCredential(input?: {
356357 readonly label?: string;
357 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
358 readonly scopes?: ReadonlyArray<AuthGrantScope>;
358359}): Promise<AuthPairingCredentialResult> {
359360 const trimmedLabel = input?.label?.trim();
360361 try {
apps/web/src/state/filesystem.ts+21-1
......@@ -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
37import { connectionAtomRuntime } from "../connection/runtime";
8import { useEnvironmentPresentation } from "./presentation";
9import { useEnvironmentQuery } from "./query";
10import { environmentSession } from "./session";
411
512export 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";
2525import { useCallback, useEffect, useMemo, useState } from "react";
2626
2727import { appAtomRegistry } from "../rpc/atomRegistry";
28import { useFilesystemReadAccess } from "./filesystem";
2829import { orchestrationEnvironment } from "./orchestration";
2930import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";
3031import { projectContentSearch, projectEnvironment } from "./projects";
......@@ -237,12 +238,14 @@ export function useProjectPathSearch(
237238 [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query],
238239 );
239240 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 =
241244 debouncedTarget.environmentId !== null &&
242 debouncedTarget.cwd !== null &&
243 debouncedTarget.query !== null &&
244 (allowEmptyQuery || debouncedTarget.query.length > 0)
245 ? projectEnvironment.searchEntries({
245 debouncedTarget.cwd !== null &&
246 debouncedTarget.query !== null &&
247 (allowEmptyQuery || debouncedTarget.query.length > 0)
248 ? {
246249 environmentId: debouncedTarget.environmentId,
247250 input: {
248251 cwd: debouncedTarget.cwd,
......@@ -251,15 +254,24 @@ export function useProjectPathSearch(
251254 ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),
252255 ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}),
253256 },
254 })
255 : null,
257 }
258 : null;
259 const result = useEnvironmentQuery(
260 canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null,
256261 );
262 const hasTarget = searchTarget !== null;
257263
258264 return {
259265 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."),
261272 isPending:
262 !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending,
273 !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) ||
274 (hasTarget && (fileAccess.isPending || result.isPending)),
263275 searchedQuery: debouncedTarget.query ?? "",
264276 truncated: result.data?.truncated ?? false,
265277 refresh: result.refresh,
......@@ -280,13 +292,18 @@ interface ProjectContentSearchTarget {
280292}
281293
282294export 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;
283299 // Whitespace is significant in content queries; trimming is only used to
284300 // decide whether the input is blank.
285301 const query = target.query;
286302 const hasQuery = query.trim().length > 0;
287303 const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS);
288304 const result = useEnvironmentQuery(
289 target.environmentId !== null &&
305 canReadFiles &&
306 target.environmentId !== null &&
290307 target.cwd !== null &&
291308 hasQuery &&
292309 debouncedQuery.trim().length > 0
......@@ -305,9 +322,18 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) {
305322 );
306323
307324 return {
325 canReadFiles,
326 isCheckingAccess,
308327 matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES,
309 error: result.error,
310 isPending: hasQuery && (query !== debouncedQuery || result.isPending),
328 error:
329 !hasTarget || isCheckingAccess
330 ? null
331 : canReadFiles
332 ? result.error
333 : (fileAccess.error ?? "This connection cannot search host files."),
334 isPending:
335 isCheckingAccess ||
336 (canReadFiles && hasQuery && (query !== debouncedQuery || result.isPending)),
311337 hasQuery,
312338 truncated: result.data?.truncated ?? false,
313339 invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined,
docs/internals/environment-auth.md+4-2
......@@ -92,7 +92,7 @@ after 30 days.
9292## The environment is the filesystem boundary
9393
9494Projects are organizational boundaries, not filesystem sandboxes.
95`orchestration:read` permits reading files the server account can read, including
95`filesystem:read` permits reading files the server account can read, including
9696absolute paths outside a project. This lets clients display artifacts that an
9797agent writes in a temporary directory. Relative paths and writes still follow
9898the [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
104104editing the same file in place does not. An HTML file authorized this way cannot
105105load sibling assets; directory-scoped workspace previews are a separate grant.
106106Clients 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
109111Host videos can change in place. Their [HTTP
110112responses](../../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
293293receive newly separated permissions, pair the client again with the scopes it
294294needs. 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
296300To remove an environment from T3 Connect, open your account menu's **T3 Connect**
297301page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
298302revokes 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 @@
11import { describe, expect, it } from "vite-plus/test";
2import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts";
23
34import {
45 canPreloadBrowsePath,
56 createBrowseNavigationCoordinator,
67 filterFilesystemBrowseEntries,
78 getFilesystemBrowsePath,
9 resolveFilesystemReadAccess,
810} 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
10117describe("filesystem browse model", () => {
11118 it("derives the browse target and navigation state", () => {
12119 expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({
packages/client-runtime/src/state/filesystem.ts+42-2
......@@ -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";
27import { Atom } from "effect/reactivity";
38
4import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
9import type {
10 EnvironmentConnectionPhase,
11 EnvironmentConnectionPresentation,
12} from "../connection/presentation.ts";
513import type { EnvironmentRegistry } from "../connection/registry.ts";
614import {
715 canNavigateUp,
......@@ -13,6 +21,38 @@ import {
1321} from "./projects.ts";
1422import { 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
1656export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) {
1757 const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform);
1858 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;
8585export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
8686export const AuthTerminalOperateScope = "terminal:operate" as const;
8787export 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;
8992export const AuthAccessReadScope = "access:read" as const;
9093export const AuthAccessWriteScope = "access:write" as const;
9194export const AuthRelayReadScope = "relay:read" as const;
......@@ -97,6 +100,8 @@ export const AuthEnvironmentScope = Schema.Literals([
97100 AuthProvidersManageScope,
98101 AuthEnvironmentMaintainScope,
99102 AuthTerminalOperateScope,
103 AuthFilesystemReadScope,
104 AuthFilesystemWriteScope,
100105 AuthReviewWriteScope,
101106 AuthSourceControlWriteScope,
102107 AuthAccessReadScope,
......@@ -108,6 +113,13 @@ export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type;
108113export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope);
109114export 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
111123export const AuthStandardClientScopes = [
112124 AuthOrchestrationReadScope,
113125 AuthOrchestrationOperateScope,
......@@ -115,8 +127,9 @@ export const AuthStandardClientScopes = [
115127 AuthProvidersManageScope,
116128 AuthEnvironmentMaintainScope,
117129 AuthTerminalOperateScope,
118 AuthReviewWriteScope,
119130 AuthSourceControlWriteScope,
131 AuthFilesystemReadScope,
132 AuthFilesystemWriteScope,
120133 AuthRelayReadScope,
121134] as const;
122135export const AuthAdministrativeScopes = [
......@@ -355,7 +368,7 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T
355368
356369export const AuthCreatePairingCredentialInput = Schema.Struct({
357370 label: Schema.optionalKey(TrimmedNonEmptyString),
358 scopes: Schema.optionalKey(AuthEnvironmentScopes),
371 scopes: Schema.optionalKey(AuthGrantScopes),
359372});
360373export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;
361374