Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
36398c8
feat(auth): separate filesystem access scopes
juliusmarminge Sep 4, 2026
c89c320
docs(auth): name filesystem scope in host boundary
juliusmarminge Sep 4, 2026
06b0d7b
fix(auth): consolidate filesystem scope imports
juliusmarminge Sep 4, 2026
e5da7e1
style(auth): format filesystem scope changes
juliusmarminge Sep 4, 2026
21688a9
test(web): retain file preview subscription during scope changes
juliusmarminge Sep 4, 2026
c91c3d5
fix(web): preserve unsaved files after permission loss
juliusmarminge Sep 4, 2026
bcb3003
fix(web): keep newer file edits marked unsaved
juliusmarminge Sep 4, 2026
36f5e44
fix(mobile): hide cached local diffs without file access
juliusmarminge Sep 4, 2026
898b838
fix(mobile): await file access before choosing workspace defaults
juliusmarminge Sep 4, 2026
c7451f7
style(web): format filesystem markdown guards
juliusmarminge Sep 4, 2026
ad27107
fix(web): explain missing local diff access
juliusmarminge Sep 4, 2026
cdedb2a
fix(files): wait for permissions before showing denial
juliusmarminge Sep 4, 2026
11166ab
fix(files): stop waiting for offline permission checks
juliusmarminge Sep 4, 2026
0ebd427
fix(files): wait for the initial environment catalog
juliusmarminge Sep 4, 2026
efc1b15
fix(files): distinguish pending access from unavailable connections
juliusmarminge Sep 4, 2026
9b4f2a6
fix(clients): wait for file grants before failing assets
juliusmarminge Sep 5, 2026
e46fec2
test(web): isolate pending asset grants from preview controls
juliusmarminge Sep 5, 2026
175e73b
fix(mobile): preserve review selection while grants load
juliusmarminge Sep 5, 2026
1be4816
fix(mobile): report access failures for unavailable reviews
juliusmarminge Sep 5, 2026
6a00d44
fix(web): keep filesystem access checks pending
juliusmarminge Sep 5, 2026
b48b1c8
fix(web): retain new-folder prompts after listing errors
juliusmarminge Sep 5, 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
64 changes: 55 additions & 9 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 { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import type { MenuAction } from "@react-native-menu/menu";
Expand Down Expand Up @@ -36,6 +38,7 @@ import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions
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 @@ -259,14 +262,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 @@ -313,8 +317,19 @@ 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 = useEnvironmentQuery(
environmentId !== null && cwd !== null && !fileInspector.supported
canReadFiles && environmentId !== null && cwd !== null && !fileInspector.supported
? projectEnvironment.listEntries({
environmentId,
input: { cwd },
Expand Down Expand Up @@ -406,6 +421,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 @@ -517,7 +540,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
)}
<FileTreeBrowser
entries={entriesData?.entries ?? []}
error={entriesQuery.error}
error={canReadFiles ? entriesQuery.error : "This connection cannot read host files."}
isPending={entriesQuery.isPending}
searchQuery={searchQuery}
selectedPath={null}
Expand Down Expand Up @@ -621,8 +644,23 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
relativePath !== null &&
!isVideoFile &&
(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 @@ -799,6 +837,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 Expand Up @@ -926,7 +972,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
mediaSource={mediaSource}
resolveVideoUri={assetPreview.refresh}
fileContents={fileData?.contents ?? null}
fileError={fileQuery.error}
fileError={canReadFiles ? fileQuery.error : "This connection cannot read host files."}
initialLine={targetLine}
relativePath={relativePath}
threadId={threadId}
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
34 changes: 28 additions & 6 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, ProjectListEntriesResult } from "@t3tools/contracts";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useMemo, useState, type ComponentProps } from "react";
Expand All @@ -15,6 +17,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
import { useEnvironmentPresentation } from "../../state/presentation";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { FileTreeBrowser } from "./FileTreeBrowser";
import { preloadWorkspaceFileContents } from "./preload-workspace-file";
Expand All @@ -33,11 +36,24 @@ 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 = useEnvironmentQuery(
projectEnvironment.listEntries({
environmentId: props.environmentId,
input: { cwd: props.cwd },
}),
canReadFiles
? projectEnvironment.listEntries({
environmentId: props.environmentId,
input: { cwd: props.cwd },
})
: null,
);
const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
const handlePreviewFile = useCallback(
Expand Down Expand Up @@ -71,8 +87,14 @@ export function ThreadFileNavigatorPane(props: {
const fileTree = (
<FileTreeBrowser
entries={entriesData?.entries ?? []}
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}
selectedPath={props.selectedPath}
onPreviewFile={handlePreviewFile}
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 @@ -25,6 +25,7 @@ import {
createBrowseNavigationCoordinator,
filterFilesystemBrowseEntries,
getFilesystemBrowsePath,
resolveFilesystemReadAccess,
} from "@t3tools/client-runtime/state/filesystem";
import {
appendBrowsePathSegment,
Expand All @@ -34,6 +35,7 @@ import {
import {
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
AuthFilesystemReadScope,
CommandId,
type EnvironmentId,
type EnvironmentMachineKind,
Expand All @@ -55,7 +57,8 @@ import { useProjects, useServerConfigs } from "../../state/entities";
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 @@ -301,7 +304,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 @@ -774,8 +781,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 @@ -797,9 +815,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
14 changes: 8 additions & 6 deletions apps/mobile/src/features/review/ReviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -840,12 +840,14 @@ export function ReviewSheet(props: ReviewSheetProps) {
>
{listHeader}
{!selectedSection ? (
<View className="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="text-xs leading-normal text-foreground-muted">
This thread has no ready turn diffs and the worktree diff is empty.
</Text>
</View>
error ? null : (
<View className="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="text-xs leading-normal text-foreground-muted">
This thread has no ready turn diffs and the worktree diff is empty.
</Text>
</View>
)
) : selectedSection.isLoading && selectedSection.diff === null ? (
<View className="items-center gap-3 border-b border-border bg-card px-4 py-6">
<ActivityIndicator size="small" />
Expand Down
Loading
Loading