Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
caf84b6
feat(auth): separate filesystem access scopes
juliusmarminge Sep 4, 2026
5bb86ee
docs(auth): name filesystem scope in host boundary
juliusmarminge Sep 4, 2026
46b3b32
fix(auth): consolidate filesystem scope imports
juliusmarminge Sep 4, 2026
1302d7d
style(auth): format filesystem scope changes
juliusmarminge Sep 4, 2026
b66ddae
test(web): retain file preview subscription during scope changes
juliusmarminge Sep 4, 2026
ff6e884
fix(web): preserve unsaved files after permission loss
juliusmarminge Sep 4, 2026
67f3105
fix(web): keep newer file edits marked unsaved
juliusmarminge Sep 4, 2026
18a476e
fix(mobile): hide cached local diffs without file access
juliusmarminge Sep 4, 2026
038f872
fix(mobile): await file access before choosing workspace defaults
juliusmarminge Sep 4, 2026
64c3c53
style(web): format filesystem markdown guards
juliusmarminge Sep 4, 2026
df091b0
fix(web): explain missing local diff access
juliusmarminge Sep 4, 2026
a0c7491
fix(files): wait for permissions before showing denial
juliusmarminge Sep 4, 2026
c81b985
fix(files): stop waiting for offline permission checks
juliusmarminge Sep 4, 2026
ea1a9a0
fix(files): wait for the initial environment catalog
juliusmarminge Sep 4, 2026
2d27bba
fix(files): distinguish pending access from unavailable connections
juliusmarminge Sep 4, 2026
9e67ba3
fix(clients): wait for file grants before failing assets
juliusmarminge Sep 5, 2026
b3f01c2
test(web): isolate pending asset grants from preview controls
juliusmarminge Sep 5, 2026
b70dded
fix(mobile): preserve review selection while grants load
juliusmarminge Sep 5, 2026
8c7df86
fix(mobile): report access failures for unavailable reviews
juliusmarminge Sep 5, 2026
dfc9067
fix(web): keep filesystem access checks pending
juliusmarminge Sep 5, 2026
bd91521
fix(web): retain new-folder prompts after listing errors
juliusmarminge Sep 5, 2026
48e31a5
fix(mobile): report denied local review access
juliusmarminge Sep 5, 2026
3394154
fix(web): gate content search on filesystem access
juliusmarminge Sep 5, 2026
ddd71bb
fix(clients): gate host media actions by file permission
juliusmarminge Sep 5, 2026
3e43305
fix(clients): wait for media grants before enabling actions
juliusmarminge Sep 5, 2026
fb52f71
test(web): use compatible media action receipts
juliusmarminge Sep 5, 2026
615ab70
test(web): isolate asset hooks in preview view tests
juliusmarminge Sep 5, 2026
1a2d140
refactor(web): resolve file access through one hook
juliusmarminge Sep 6, 2026
a0e76cd
fix(web): align filesystem gating with the current file UI
juliusmarminge Sep 7, 2026
32d77d2
fix(auth): keep legacy review scope internal
juliusmarminge Sep 7, 2026
bcc0788
test(web): include content search result indices
juliusmarminge Sep 10, 2026
1738141
test(web): provide server configs in markdown asset fixtures
juliusmarminge Sep 10, 2026
ec90742
style(auth): format rebased asset access check
juliusmarminge Sep 15, 2026
5bec8a3
style(auth): format rebased file access guards
juliusmarminge Sep 19, 2026
bb553b3
fix(auth): retain filesystem access boundaries on v2
juliusmarminge Oct 4, 2026
112a857
chore(auth): align filesystem changes with current lint rules
juliusmarminge Oct 4, 2026
9f16d95
refactor(mobile): keep media permission state out of lib
juliusmarminge Oct 5, 2026
c19c997
fix(auth): follow current Effect module and layer names
juliusmarminge Oct 6, 2026
b850f19
test(auth): update file asset fixtures for current contracts
juliusmarminge Oct 7, 2026
ca4ea2b
test(mobile): include asset expiry in permission assertions
juliusmarminge Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/components/ComposerAttachmentStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "../lib/composerImages";
import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";
import { VideoAttachmentTile } from "./VideoAttachmentTile";
import { type MediaActionsSource } from "../lib/mediaActions";
import { type MediaActionsSource } from "../lib/mediaActionsSource";
import { PresentationSource } from "./NativePresentation";
import type { FilePreviewSource } from "./FilePreviewModal";
import { isPdfFile } from "../lib/filePreview";
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/FilePreviewModal.types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AssetResource, EnvironmentId } from "@t3tools/contracts";

import type { FileBackedComposerAttachment } from "../lib/composerImages";
import type { MediaActionsSource } from "../lib/mediaActions";
import type { MediaActionsSource } from "../lib/mediaActionsSource";

export interface ResolvedFilePreviewSource {
readonly kind: "image" | "pdf" | "document";
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/MediaActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { MenuView } from "@react-native-menu/menu";
import type { ReactElement } from "react";
import { Platform, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";

import type { useMediaActions } from "../lib/mediaActions";
import type { useMediaActions } from "../state/mediaActions";
import { SymbolView } from "./AppSymbol";
import { ControlPillMenu } from "./ControlPill";

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/MediaImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Pressable, View } from "react-native";
import ImageViewing from "react-native-image-viewing";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { useMediaActions } from "../lib/mediaActions";
import { useMediaActions } from "../state/mediaActions";
import { AppText } from "./AppText";
import { SymbolView } from "./AppSymbol";
import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types";
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/components/MediaVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { ActivityIndicator, AppState, Pressable, View } from "react-native";
import { AppText } from "./AppText";
import { SymbolView } from "./AppSymbol";
import { VideoThumbnailImage } from "./VideoThumbnailImage";
import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
import { useMediaActions } from "../state/mediaActions";
import { type MediaActionsSource } from "../lib/mediaActionsSource";
import { MediaActionsMenu } from "./MediaActionsMenu";

/** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/components/VideoAttachmentTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react

import { cn } from "../lib/cn";
import type { DraftComposerFileAttachment } from "../lib/composerImages";
import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
import { useMediaActions } from "../state/mediaActions";
import { type MediaActionsSource } from "../lib/mediaActionsSource";
import { SymbolView } from "./AppSymbol";
import { AppText } from "./AppText";
import { MediaActionsMenu } from "./MediaActionsMenu";
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/components/VideoPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { ActivityIndicator, Keyboard, Modal, Pressable, View } from "react-nativ
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions";
import { useMediaActions } from "../state/mediaActions";
import { type MediaActionsSource } from "../lib/mediaActionsSource";
import {
mediaVideoPreviewUri,
mediaVideoThumbnailKey,
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/dependency-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ describe("mobile dependency graph", () => {
["native", "features", 8, "native must not add imports from features"],
// lib -> state: attachment/session plumbing that predates the cycle
// cleanup; each remaining edge needs a real owner-side seam.
["lib", "state", 11, "lib must not add imports from state"],
["lib", "state", 10, "lib must not add imports from state"],
];

for (const [from, to, ceiling, message] of ceilings) {
Expand Down
63 changes: 55 additions & 8 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
import { environmentSession } from "../../state/session";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
Expand All @@ -24,10 +26,12 @@ import { isPdfFile } from "../../lib/filePreview";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
import { useMediaActions } from "../../state/mediaActions";
import { type MediaActionsSource } from "../../lib/mediaActionsSource";
import { useThreadSelection } from "../../state/use-thread-selection";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useEnvironmentQuery } from "../../state/query";
import { useEnvironmentPresentation } from "../../state/presentation";
import { projectEnvironment } from "../../state/projects";
import type { AssetUrlFailureReason } from "../../state/asset-url-state";
import {
Expand Down Expand Up @@ -370,14 +374,15 @@ function useThreadFilesWorkspace(params: {
};
}

function FilesUnavailable() {
function FilesUnavailable({
detail = "This thread does not have an active workspace path.",
}: {
detail?: string;
}) {
return (
<View className="flex-1 items-center justify-center bg-sheet px-6">
<NativeStackScreenOptions options={{ title: "Files" }} />
<EmptyState
title="Files unavailable"
detail="This thread does not have an active workspace path."
/>
<EmptyState title="Files unavailable" detail={detail} />
</View>
);
}
Expand Down Expand Up @@ -420,9 +425,20 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
props.route.params,
);
const revealedInspectorRef = useRef(false);
const fileAccessSession = useEnvironmentQuery(
environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null,
);
const fileEnvironment = useEnvironmentPresentation(environmentId);
const fileAccess = resolveFilesystemReadAccess({
isCatalogReady: fileEnvironment.isReady,
connection: fileEnvironment.presentation?.connection ?? null,
session: fileAccessSession.data,
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const entriesQuery = useFileTreeEntries({
environmentId,
cwd: fileInspector.supported ? null : cwd,
cwd: !canReadFiles || fileInspector.supported ? null : cwd,
searchQuery,
});
const handleReturnToThread = useCallback(() => {
Expand Down Expand Up @@ -509,6 +525,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;
}

if (!canReadFiles) {
if (fileAccess.isPending) {
return <LoadingScreen message="Checking file access..." messagePlacement="above-spinner" />;
}
return (
<FilesUnavailable detail={fileAccess.error ?? "This connection cannot read host files."} />
);
}
if (cwd === null) {
return <FilesUnavailable />;
}
Expand Down Expand Up @@ -656,8 +680,23 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
!isVideoFile &&
!isAudioFile &&
(resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
const fileAccessSession = useEnvironmentQuery(
environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null,
);
const fileEnvironment = useEnvironmentPresentation(environmentId);
const fileAccess = resolveFilesystemReadAccess({
isCatalogReady: fileEnvironment.isReady,
connection: fileEnvironment.presentation?.connection ?? null,
session: fileAccessSession.data,
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const fileQuery = useEnvironmentQuery(
environmentId !== null && cwd !== null && relativePath !== null && needsFileContents
canReadFiles &&
environmentId !== null &&
cwd !== null &&
relativePath !== null &&
needsFileContents
? projectEnvironment.readFile({
environmentId,
input: { cwd, relativePath },
Expand Down Expand Up @@ -861,6 +900,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
}

if (!canReadFiles) {
if (fileAccess.isPending) {
return <LoadingScreen message="Checking file access..." messagePlacement="above-spinner" />;
}
return (
<FilesUnavailable detail={fileAccess.error ?? "This connection cannot read host files."} />
);
}
if (cwd === null) {
return <FilesUnavailable />;
}
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { FilePreviewLoading } from "./FilePreviewFeedback";
import { EmptyState } from "../../components/EmptyState";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
import { PresentationSource } from "../../components/NativePresentation";
import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";
import { useMediaActions } from "../../state/mediaActions";
import { type MediaActionsSource } from "../../lib/mediaActionsSource";
import { MediaActionsMenu } from "../../components/MediaActionsMenu";

function ResolvedWorkspaceFileImagePreview(props: {
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/files/preload-workspace-file.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AuthFilesystemReadScope } from "@t3tools/contracts";
import { readEnvironmentScope } from "../../state/session";
import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId } from "@t3tools/contracts";
import {
Expand Down Expand Up @@ -30,6 +32,7 @@ export function preloadWorkspaceFileContents(input: {
readonly theme: ReviewDiffTheme;
}): void {
if (
!readEnvironmentScope(input.environmentId, AuthFilesystemReadScope) ||
isWorkspaceBrowserPreviewPath(input.relativePath) ||
isWorkspaceImagePreviewPath(input.relativePath) ||
isVideoPreviewFile(input.relativePath)
Expand Down
27 changes: 24 additions & 3 deletions apps/mobile/src/features/files/thread-file-navigator-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
import { environmentSession } from "../../state/session";
import type { EnvironmentId } from "@t3tools/contracts";
import { SymbolView } from "../../components/AppSymbol";
import { MaterialScreenContent } from "../../components/MaterialScreenContent";
Expand All @@ -15,6 +17,8 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App
import { MaterialFilesHeader } from "./MaterialFilesHeader";
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useEnvironmentQuery } from "../../state/query";
import { useEnvironmentPresentation } from "../../state/presentation";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { FileTreeBrowser } from "./FileTreeBrowser";
import { useFileTreeEntries } from "./useFileTreeEntries";
Expand All @@ -36,9 +40,20 @@ export function ThreadFileNavigatorPane(props: {
const foregroundColor = theme["--color-foreground"];
const sheetColor = theme["--color-sheet"];
const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
const fileAccessSession = useEnvironmentQuery(
environmentSession.sessionStateAtom(props.environmentId),
);
const fileEnvironment = useEnvironmentPresentation(props.environmentId);
const fileAccess = resolveFilesystemReadAccess({
isCatalogReady: fileEnvironment.isReady,
connection: fileEnvironment.presentation?.connection ?? null,
session: fileAccessSession.data,
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const entriesQuery = useFileTreeEntries({
environmentId: props.environmentId,
cwd: props.cwd,
cwd: canReadFiles ? props.cwd : null,
searchQuery,
});
const handlePreviewFile = useCallback(
Expand Down Expand Up @@ -75,8 +90,14 @@ export function ThreadFileNavigatorPane(props: {
entries={entriesQuery.entries}
loadedDirectories={entriesQuery.loadedDirectories}
onLoadDirectory={entriesQuery.loadDirectory}
error={entriesQuery.error}
isPending={entriesQuery.isPending}
error={
canReadFiles
? entriesQuery.error
: fileAccess.isPending
? null
: (fileAccess.error ?? "This connection cannot read host files.")
}
isPending={fileAccess.isPending || entriesQuery.isPending}
searchQuery={searchQuery}
searchTruncated={entriesQuery.searchTruncated}
selectedPath={props.selectedPath}
Expand Down
29 changes: 25 additions & 4 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
createBrowseNavigationCoordinator,
filterFilesystemBrowseEntries,
getFilesystemBrowsePath,
resolveFilesystemReadAccess,
} from "@t3tools/client-runtime/state/filesystem";
import {
appendBrowsePathSegment,
Expand All @@ -41,6 +42,7 @@ import {
import {
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
AuthFilesystemReadScope,
CommandId,
type EnvironmentId,
type EnvironmentMachineKind,
Expand All @@ -61,7 +63,8 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit
import { filesystemEnvironment } from "../../state/filesystem";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
import { useEnvironmentPresentation } from "../../state/presentation";
import { sourceControlEnvironment } from "../../state/sourceControl";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
Expand Down Expand Up @@ -345,7 +348,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto
setIsBrowseNavigating(true);
const committed = await browseNavigation.run(
async () => {
if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) {
if (
environment &&
readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) &&
canPreloadBrowsePath(environmentRuntime?.connectionState)
) {
await loadBrowsePath({
environmentId: environment.environmentId,
input: { partialPath: selectedDirectoryPath },
Expand Down Expand Up @@ -877,8 +884,19 @@ function FolderBrowser(props: {
() => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null),
[browsePath.directoryPath],
);
const fileAccessSession = useEnvironmentQuery(
environmentSession.sessionStateAtom(props.environment.environmentId),
);
const fileEnvironment = useEnvironmentPresentation(props.environment.environmentId);
const fileAccess = resolveFilesystemReadAccess({
isCatalogReady: fileEnvironment.isReady,
connection: fileEnvironment.presentation?.connection ?? null,
session: fileAccessSession.data,
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const browseState = useEnvironmentQuery(
browseInput === null
!canReadFiles || browseInput === null
? null
: filesystemEnvironment.browse({
environmentId: props.environment.environmentId,
Expand All @@ -900,9 +918,12 @@ function FolderBrowser(props: {
return (
<>
<SectionTitle>Browse folders</SectionTitle>
{!canReadFiles && !fileAccess.isPending ? (
<ErrorBanner message={fileAccess.error ?? "This connection cannot browse host folders."} />
) : null}
{browseState.error ? <ErrorBanner message={browseState.error} /> : null}
<ListSection>
{browseState.isPending && browseState.data === null ? (
{fileAccess.isPending || (browseState.isPending && browseState.data === null) ? (
<View className="items-center py-5">
<ActivityIndicator colorClassName="accent-icon-muted" />
</View>
Expand Down
34 changes: 18 additions & 16 deletions apps/mobile/src/features/review/ReviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -825,23 +825,25 @@ export function ReviewSheet(props: ReviewSheetProps) {
>
{listHeader}
{!selectedSection ? (
<View
className={
Platform.OS === "android"
? "items-center px-6 py-5"
: "border-b border-border bg-card px-4 py-5"
}
>
<Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
<Text
className={cn(
"text-xs leading-normal text-foreground-muted",
Platform.OS === "android" && "mt-2 text-center",
)}
error ? null : (
<View
className={
Platform.OS === "android"
? "items-center px-6 py-5"
: "border-b border-border bg-card px-4 py-5"
}
>
This thread has no ready turn diffs and the worktree diff is empty.
</Text>
</View>
<Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
<Text
className={cn(
"text-xs leading-normal text-foreground-muted",
Platform.OS === "android" && "mt-2 text-center",
)}
>
This thread has no ready turn diffs and the worktree diff is empty.
</Text>
</View>
)
) : selectedSection.isLoading && selectedSection.diff === null ? (
<View
className={cn(
Expand Down
Loading
Loading