@cytario/web 2.1.8 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/app/.server/auth/README.md +0 -1
  3. package/app/.server/auth/authMiddleware.ts +6 -28
  4. package/app/.server/auth/getSessionCredentials.ts +11 -1
  5. package/app/.server/auth/sessionPolicy.ts +69 -0
  6. package/app/.server/corsPreflight.ts +136 -0
  7. package/app/.server/csp.ts +34 -0
  8. package/app/components/.client/ImageViewer/README.md +2 -2
  9. package/app/components/DirectoryView/DirectoryViewGrid.tsx +2 -11
  10. package/app/components/DirectoryView/DirectoryViewTree.tsx +18 -26
  11. package/app/components/DirectoryView/buildDirectoryTree.ts +89 -42
  12. package/app/components/DirectoryView/filterNodes.ts +4 -19
  13. package/app/components/DirectoryView/useLazyTreeNodes.ts +120 -0
  14. package/app/components/GlobalSearch/GlobalSearch.tsx +8 -3
  15. package/app/components/GlobalSearch/Suggestions.tsx +21 -3
  16. package/app/config.ts +0 -7
  17. package/app/entry.server.tsx +9 -4
  18. package/app/hooks/useInitConnections.ts +3 -3
  19. package/app/root.tsx +11 -6
  20. package/app/routes/connections/connection.form.tsx +9 -3
  21. package/app/routes/connections/connection.schema.ts +60 -11
  22. package/app/routes/connections/connections.clientLoader.ts +91 -0
  23. package/app/routes/connections/connections.loader.ts +19 -39
  24. package/app/routes/connections/connections.route.tsx +5 -0
  25. package/app/routes/connections/createConnection.action.ts +39 -13
  26. package/app/routes/connections/deleteConnection.action.ts +5 -1
  27. package/app/routes/connections/updateConnection.action.ts +50 -13
  28. package/app/routes/home/home.route.tsx +5 -1
  29. package/app/routes/layouts/protected.layout.tsx +15 -1
  30. package/app/routes/objects/objects.clientLoader.ts +73 -0
  31. package/app/routes/objects/objects.loader.ts +58 -92
  32. package/app/routes/objects/objects.route.tsx +41 -40
  33. package/app/routes/search.route.tsx +133 -28
  34. package/app/routes.ts +0 -4
  35. package/app/utils/connectionsStore/useConnectionsStore.ts +25 -62
  36. package/app/utils/credentialsRefresh.ts +53 -0
  37. package/app/utils/db/convertCsvToParquet.ts +24 -16
  38. package/app/utils/db/createDatabase.ts +25 -33
  39. package/app/utils/db/duckdbBundles.ts +42 -0
  40. package/app/utils/db/ensureSpatialLoaded.ts +28 -0
  41. package/app/utils/db/escapeSqlString.ts +4 -0
  42. package/app/utils/db/getBlobFromObjectNode.ts +15 -36
  43. package/app/utils/db/getTileDataWasm.ts +4 -5
  44. package/app/utils/db/sqlQueries.ts +6 -1
  45. package/app/utils/filterObjects.ts +2 -18
  46. package/app/utils/limitConcurrency.ts +28 -0
  47. package/app/utils/listObjectsClient.ts +318 -0
  48. package/app/utils/listingLimits.ts +7 -0
  49. package/app/utils/loadConnectionLevel.ts +50 -0
  50. package/app/utils/localFilesStore/useFileStore.ts +1 -6
  51. package/app/utils/pathUtils.ts +65 -0
  52. package/app/utils/resourceId.ts +14 -29
  53. package/app/utils/s3HostAllowlist.ts +116 -0
  54. package/app/utils/signedFetch.ts +159 -24
  55. package/package.json +2 -2
  56. package/prisma/seed.ts +3 -3
  57. package/public/duckdb-extensions/checksums.json +12 -0
  58. package/scripts/download-duckdb-extensions.mjs +188 -0
  59. package/scripts/prebuild.mjs +18 -10
  60. package/app/.server/auth/getPresignedUrl.ts +0 -21
  61. package/app/.server/auth/getS3Client.ts +0 -93
  62. package/app/routes/presign.route.tsx +0 -42
  63. package/app/utils/getObjects.ts +0 -24
package/README.md CHANGED
@@ -23,7 +23,7 @@ For the hosted product, see [cytario.com](https://www.cytario.com).
23
23
  | Styling | Tailwind CSS, [@cytario/design](https://github.com/cytario/cytario-design) |
24
24
  | Auth | OAuth 2.0 via Keycloak, STS for S3 credentials |
25
25
  | Database | PostgreSQL (Prisma ORM), Redis/Valkey (sessions) |
26
- | Cloud | AWS SDK v3 (S3, STS), presigned URLs |
26
+ | Cloud | AWS SDK v3 (S3, STS) |
27
27
  | CI/CD | GitHub Actions, semantic-release, GHCR |
28
28
 
29
29
  ## Plugin model
@@ -8,5 +8,4 @@ OAuth 2.0 Authorization Code Flow against Keycloak. Session cookies are httpOnly
8
8
  | `authMiddleware.ts` | Refresh tokens if needed; populate `authContext` for downstream loaders. |
9
9
  | `getSessionCredentials.ts` | Mint STS credentials per connection. |
10
10
  | `verifyIdToken.ts` | JWKS-based idToken signature/expiry verification. |
11
- | `getS3Client.ts` | Build an SDK-v3 S3 client with the connection's signed credentials. |
12
11
  | `keycloakAdmin/` | Service-account-backed admin API for user/group management. |
@@ -16,10 +16,6 @@ export interface AuthContextData extends SessionData {
16
16
 
17
17
  export const authContext = createContext<AuthContextData>();
18
18
 
19
- /**
20
- * Lightweight expiry check for refresh tokens (opaque to clients).
21
- * Only checks the `exp` claim — no signature verification needed.
22
- */
23
19
  const isRefreshTokenValid = (token?: string): boolean => {
24
20
  if (!token) return false;
25
21
 
@@ -38,11 +34,6 @@ const isComplete = (data: Partial<SessionData>): boolean => {
38
34
 
39
35
  const label = createLabel("authorize", "green");
40
36
 
41
- /**
42
- * Fetches all connection configs and credentials for the user.
43
- * Only fetches credentials for connections with missing or expired credentials.
44
- * Returns updated session data and connection configs.
45
- */
46
37
  const fetchAllCredentials = async (
47
38
  sessionData: SessionData,
48
39
  ): Promise<{ sessionData: SessionData; connectionConfigs: ConnectionConfig[] }> => {
@@ -59,12 +50,6 @@ const fetchAllCredentials = async (
59
50
  };
60
51
  };
61
52
 
62
- /**
63
- * Middleware that validates and refreshes authentication tokens.
64
- * Fetches connection configs and credentials for all visible connections.
65
- * Sets validated session data in authContext for downstream use.
66
- * Export this from protected routes that require authentication.
67
- */
68
53
  export const authMiddleware: MiddlewareFunction = async ({ request, context }, next) => {
69
54
  console.info(`${label} ${request.method} ${request.url}`);
70
55
 
@@ -80,7 +65,6 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n
80
65
  let updatedSessionData = sessionData as SessionData;
81
66
  const { authTokens } = updatedSessionData;
82
67
 
83
- // Verify idToken signature via JWKS
84
68
  const idTokenPayload = await verifyIdToken(authTokens.idToken);
85
69
 
86
70
  if (idTokenPayload) {
@@ -88,7 +72,6 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n
88
72
  await fetchAllCredentials(updatedSessionData);
89
73
  updatedSessionData = withCredentials;
90
74
 
91
- // Only commit session if credentials changed
92
75
  if (updatedSessionData.credentials !== sessionData.credentials) {
93
76
  session.set("credentials", updatedSessionData.credentials);
94
77
  await sessionStorage.commitSession(session);
@@ -98,7 +81,6 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n
98
81
  return next();
99
82
  }
100
83
 
101
- // If idToken is invalid but refreshToken is valid, refresh tokens
102
84
  if (isRefreshTokenValid(authTokens.refreshToken)) {
103
85
  console.info(`${label} Fetch new tokens and credentials`);
104
86
 
@@ -127,21 +109,17 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n
127
109
  }
128
110
  }
129
111
 
130
- // Neither token is valid, logout
131
- await logout(request.url, session);
112
+ return logout(request.url, session);
132
113
  };
133
114
 
134
- /**
135
- * Logs out the user by destroying the session and redirecting to the login page.
136
- * @param url Current request URL for redirect after login
137
- * @param session Cytario session to destroy
138
- * @throws Redirect to login page
139
- */
140
- const logout = async (url: string, session: CytarioSession) => {
115
+ // Return the redirect rather than throwing it: under RR's middleware single-fetch
116
+ // path a thrown redirect Response is caught and re-encoded as a 500, which
117
+ // surfaces as `SingleFetchNoResultError` in the root ErrorBoundary.
118
+ const logout = async (url: string, session: CytarioSession): Promise<Response> => {
141
119
  console.info(`${label} Delete session and redirect to login`);
142
120
  const requestUrl = new URL(url);
143
121
  const relativeUrl = requestUrl.pathname + requestUrl.search;
144
- throw redirect(`/login?redirect=${encodeURIComponent(relativeUrl)}`, {
122
+ return redirect(`/login?redirect=${encodeURIComponent(relativeUrl)}`, {
145
123
  headers: {
146
124
  "Set-Cookie": await sessionStorage.destroySession(session),
147
125
  },
@@ -1,5 +1,6 @@
1
1
  import { AssumeRoleWithWebIdentityCommand, Credentials, STSClient } from "@aws-sdk/client-sts";
2
2
 
3
+ import { buildSessionPolicy } from "./sessionPolicy";
3
4
  import { type ConnectionsCredentials, type SessionData } from "./sessionStorage";
4
5
  import { ConnectionConfig } from "~/.generated/client";
5
6
  import { createLabel } from "~/.server/logging";
@@ -34,7 +35,7 @@ const fetchTemporaryCredentials = async (
34
35
  idToken: string,
35
36
  roleSessionName: string,
36
37
  ): Promise<Credentials> => {
37
- const { region, endpoint, roleArn } = connectionConfig;
38
+ const { region, endpoint, roleArn, provider, bucketName, prefix } = connectionConfig;
38
39
 
39
40
  const actualRegion = region ?? "eu-central-1";
40
41
  const providerConfig = getS3ProviderConfig(endpoint, actualRegion);
@@ -45,11 +46,20 @@ const fetchTemporaryCredentials = async (
45
46
  region: actualRegion,
46
47
  });
47
48
 
49
+ // Inline session policy is an AWS-specific STS feature: STS intersects it
50
+ // with the role's attached policy, so the minted credential cannot exceed
51
+ // the configured prefix scope even if the role itself is broader.
52
+ // Non-AWS providers (e.g. MinIO) may ignore or reject the `Policy` field,
53
+ // so we omit it there — the role's intrinsic scope is the only bound.
54
+ const sessionPolicy =
55
+ provider === "aws" ? buildSessionPolicy({ bucketName, prefix }) : undefined;
56
+
48
57
  const command = new AssumeRoleWithWebIdentityCommand({
49
58
  RoleArn: roleArn ?? undefined,
50
59
  RoleSessionName: roleSessionName,
51
60
  WebIdentityToken: idToken,
52
61
  DurationSeconds: 60 * 60 * 1, // 1 hour
62
+ ...(sessionPolicy ? { Policy: sessionPolicy } : {}),
53
63
  });
54
64
 
55
65
  const { Credentials } = await stsClient.send(command);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Builds an inline IAM session policy for `AssumeRoleWithWebIdentityCommand`.
3
+ *
4
+ * STS intersects this policy with the role's attached policy, so even if the
5
+ * underlying role permits more, the minted credential cannot escape the
6
+ * configured connection prefix.
7
+ *
8
+ * AWS-specific: non-AWS providers (MinIO) may ignore or reject `Policy`;
9
+ * guard the attachment behind `provider === "aws"`.
10
+ */
11
+
12
+ export interface SessionPolicyArgs {
13
+ bucketName: string;
14
+ prefix: string | null | undefined;
15
+ }
16
+
17
+ const stripSlashes = (prefix: string): string => prefix.replace(/^\/+|\/+$/g, "");
18
+
19
+ /** Build an inline IAM session policy for `AssumeRoleWithWebIdentityCommand`. */
20
+ export const buildSessionPolicy = ({ bucketName, prefix }: SessionPolicyArgs): string => {
21
+ const normalised = typeof prefix === "string" ? stripSlashes(prefix) : "";
22
+ // Defense-in-depth: refuse wildcards here so the schema is not the only gate
23
+ // protecting cross-tenant `StringLike` conditions.
24
+ if (/[*?]/.test(normalised)) {
25
+ throw new Error("Prefix may not contain IAM wildcard characters (`*`, `?`)");
26
+ }
27
+ const hasPrefix = normalised.length > 0;
28
+
29
+ const bucketArn = `arn:aws:s3:::${bucketName}`;
30
+ const objectArn = hasPrefix ? `${bucketArn}/${normalised}/*` : `${bucketArn}/*`;
31
+
32
+ // Empty-prefix listing must omit `Condition`: AWS evaluates an absent
33
+ // `prefix` query parameter as `""`, and `StringLike "*"` does not match it.
34
+ // Allowed values must anchor on `/`, otherwise IAM allows
35
+ // `ListBucket prefix=foo` which S3 expands to siblings like `foobar.txt`.
36
+ const listStatement = hasPrefix
37
+ ? {
38
+ Sid: "ListBucketScopedToPrefix",
39
+ Effect: "Allow",
40
+ Action: "s3:ListBucket",
41
+ Resource: bucketArn,
42
+ Condition: {
43
+ StringLike: {
44
+ "s3:prefix": [`${normalised}/`, `${normalised}/*`],
45
+ },
46
+ },
47
+ }
48
+ : {
49
+ Sid: "ListBucketWholeBucket",
50
+ Effect: "Allow",
51
+ Action: "s3:ListBucket",
52
+ Resource: bucketArn,
53
+ };
54
+
55
+ const policy = {
56
+ Version: "2012-10-17",
57
+ Statement: [
58
+ listStatement,
59
+ {
60
+ Sid: "GetObjectScopedToPrefix",
61
+ Effect: "Allow",
62
+ Action: "s3:GetObject",
63
+ Resource: objectArn,
64
+ },
65
+ ],
66
+ };
67
+
68
+ return JSON.stringify(policy);
69
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Server-side CORS preflight probe run at connection create / update.
3
+ *
4
+ * Cytario's data plane is browser-direct, so the bucket must advertise a CORS
5
+ * policy that matches the cytario origin. Probe outcomes:
6
+ * - `ok: false` — the browser will hard-block reads; the form rejects.
7
+ * - `ok: true, warnings: ["wildcard_origin"]` — `ACAO: *` works today (the
8
+ * read path is non-credentialed) but is flagged for operator hygiene.
9
+ */
10
+
11
+ import { isAllowedS3Host } from "~/utils/s3HostAllowlist";
12
+ import { SIGNED_REQUEST_HEADERS } from "~/utils/signedFetch";
13
+
14
+ export type CorsPreflightFailureReason =
15
+ | "network"
16
+ | "missing_origin_header"
17
+ | "preflight_status"
18
+ | "host_not_allowed";
19
+
20
+ export type CorsPreflightWarningReason = "wildcard_origin";
21
+
22
+ export interface CorsPreflightResult {
23
+ ok: boolean;
24
+ reason?: CorsPreflightFailureReason;
25
+ warnings: CorsPreflightWarningReason[];
26
+ detail?: string;
27
+ }
28
+
29
+ const PREFLIGHT_TIMEOUT_MS = 5_000;
30
+
31
+ /**
32
+ * Strips IPv4 / IPv6 literals from an error `detail` so the resolved IP of an
33
+ * upstream endpoint never reaches the operator UI — denies the probe as a
34
+ * port-scan oracle.
35
+ */
36
+ export function redactIpLiterals(message: string): string {
37
+ let result = message.replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g, "[redacted]");
38
+ result = result.replace(
39
+ /(?:[0-9a-fA-F]{1,4}:){1,7}(?::[0-9a-fA-F]{1,4}){1,7}|(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::1|::/g,
40
+ "[redacted]",
41
+ );
42
+ return result;
43
+ }
44
+
45
+ /** Issue a CORS preflight against the bucket; wildcard ACAO surfaces as a warning. */
46
+ export async function probeBucketCors(
47
+ bucketUrl: string,
48
+ cytarioOrigin: string,
49
+ ): Promise<CorsPreflightResult> {
50
+ // Refuse out-of-allowlist URLs so the probe can never be an SSRF oracle
51
+ // against IMDS / RFC1918 / loopback, independent of upstream form validation.
52
+ if (!isAllowedS3Host(bucketUrl)) {
53
+ return {
54
+ ok: false,
55
+ reason: "host_not_allowed",
56
+ warnings: [],
57
+ detail: "Host is not in the S3 allowlist",
58
+ };
59
+ }
60
+
61
+ let response: Response;
62
+ try {
63
+ response = await globalThis.fetch(bucketUrl, {
64
+ method: "OPTIONS",
65
+ headers: {
66
+ Origin: cytarioOrigin,
67
+ "Access-Control-Request-Method": "GET",
68
+ "Access-Control-Request-Headers": SIGNED_REQUEST_HEADERS.join(", "),
69
+ },
70
+ // Block redirects — a 30x would leak the Origin header onward and make
71
+ // the probe lie about the configured bucket.
72
+ redirect: "error",
73
+ signal: AbortSignal.timeout(PREFLIGHT_TIMEOUT_MS),
74
+ });
75
+ } catch (error) {
76
+ return {
77
+ ok: false,
78
+ reason: "network",
79
+ warnings: [],
80
+ detail: redactIpLiterals(error instanceof Error ? error.message : String(error)),
81
+ };
82
+ }
83
+
84
+ if (response.status < 200 || response.status >= 300) {
85
+ return {
86
+ ok: false,
87
+ reason: "preflight_status",
88
+ warnings: [],
89
+ detail: `Preflight returned HTTP ${response.status}`,
90
+ };
91
+ }
92
+
93
+ const allowOrigin = response.headers.get("access-control-allow-origin");
94
+ if (!allowOrigin) {
95
+ return {
96
+ ok: false,
97
+ reason: "missing_origin_header",
98
+ warnings: [],
99
+ detail: "Response did not include Access-Control-Allow-Origin",
100
+ };
101
+ }
102
+
103
+ const warnings: CorsPreflightWarningReason[] = [];
104
+ if (allowOrigin.trim() === "*") {
105
+ warnings.push("wildcard_origin");
106
+ }
107
+
108
+ return { ok: true, warnings };
109
+ }
110
+
111
+ /** Human-readable failure message shown on the connection form. */
112
+ export function describeCorsFailure(result: CorsPreflightResult, cytarioOrigin: string): string {
113
+ switch (result.reason) {
114
+ case "network":
115
+ return "Could not reach the bucket from the cytario server. Check the endpoint URL and DNS.";
116
+ case "missing_origin_header":
117
+ return `Bucket does not advertise CORS for cytario. Configure the bucket's CORS policy to allow Origin ${cytarioOrigin}.`;
118
+ case "preflight_status":
119
+ return `Bucket rejected the cytario CORS preflight (${result.detail ?? "non-2xx response"}). Configure the bucket's CORS policy to allow Origin ${cytarioOrigin}.`;
120
+ case "host_not_allowed":
121
+ return `Bucket host is not in the cytario S3 allowlist. Ask the operator to add it to CYTARIO_ALLOWED_S3_HOSTS.`;
122
+ default:
123
+ return `Bucket CORS preflight failed: ${result.detail ?? "unknown error"}.`;
124
+ }
125
+ }
126
+
127
+ /** Human-readable message for a non-blocking warning. */
128
+ export function describeCorsWarning(
129
+ warning: CorsPreflightWarningReason,
130
+ cytarioOrigin: string,
131
+ ): string {
132
+ switch (warning) {
133
+ case "wildcard_origin":
134
+ return `Bucket allows any origin (CORS: *). Consider restricting to ${cytarioOrigin}.`;
135
+ }
136
+ }
@@ -0,0 +1,34 @@
1
+ import { getAllowedS3Hosts } from "~/utils/s3HostAllowlist";
2
+
3
+ export { getAllowedS3Hosts };
4
+
5
+ /** Build the CSP header value. Callers feed the env so tests can override it. */
6
+ export function buildContentSecurityPolicy(
7
+ env: Record<string, string | undefined> = process.env,
8
+ ): string {
9
+ const connectSrc = ["'self'", ...getAllowedS3Hosts(env)].join(" ");
10
+
11
+ const directives: Record<string, string> = {
12
+ "default-src": "'self'",
13
+ "connect-src": connectSrc,
14
+ // `'unsafe-inline'` is required by React Router's streamed hydration
15
+ // bootstrap script. `'unsafe-eval'` is required by numcodecs' emscripten
16
+ // dyncall trampolines (Zarr v2 blosc / lz4 / zstd). `'wasm-unsafe-eval'`
17
+ // covers DuckDB WASM. TODO: drop `'unsafe-eval'` once codecs go CSP-clean.
18
+ "script-src": "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
19
+ "style-src": "'self' 'unsafe-inline'",
20
+ "img-src": "'self' data: blob:",
21
+ "font-src": "'self' data:",
22
+ // DuckDB WASM spawns its main worker from a same-origin asset and uses
23
+ // `blob:` URLs internally for in-worker file buffers.
24
+ "worker-src": "'self' blob:",
25
+ "base-uri": "'self'",
26
+ "form-action": "'self'",
27
+ "object-src": "'none'",
28
+ "frame-ancestors": "'none'",
29
+ };
30
+
31
+ return Object.entries(directives)
32
+ .map(([directive, value]) => `${directive} ${value}`)
33
+ .join("; ");
34
+ }
@@ -201,8 +201,8 @@ The viewer supports **offset sidecar files** (`.offsets.json`) for faster OME-TI
201
201
  ```
202
202
  Route Loader (server)
203
203
  ├── Detects OME-TIFF via getOffsetKeyForOmeTiff()
204
- ├── Generates presigned URL for image
205
- ├── Generates presigned URL for .offsets.json (in parallel)
204
+ ├── Provides signedFetch for direct SigV4-signed GetObject of image
205
+ ├── Provides signedFetch for direct SigV4-signed GetObject of .offsets.json (in parallel)
206
206
  └── Returns { url, offsetsUrl } to client
207
207
 
208
208
  ImageViewer (client)
@@ -35,7 +35,6 @@ const gridClasses: Partial<Record<ViewMode, string>> = {
35
35
  grid: "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6",
36
36
  };
37
37
 
38
- /** Create a signedFetch that lazily resolves credentials from the connections store. */
39
38
  function useSignedFetch(connectionName: string) {
40
39
  const connectionConfig = useConnectionsStore(select.connectionConfig(connectionName));
41
40
 
@@ -57,8 +56,6 @@ function BucketCardGridItem({ node, connectionName }: { node: TreeNode; connecti
57
56
  const to = buildConnectionPath(connectionName, node.pathName);
58
57
  const handlePress = useCallback(() => navigate(to), [navigate, to]);
59
58
 
60
- // Bucket nodes carry the first-image key from the connections loader on
61
- // `_Object.Key` (already absolute — includes any configured prefix).
62
59
  const previewKey = node._Object?.Key ?? null;
63
60
  const hasPreview = !!previewKey && isImageFile(previewKey) && !!signedFetch;
64
61
  const s3Url = hasPreview && connectionConfig ? constructS3Url(connectionConfig, previewKey) : "";
@@ -66,9 +63,8 @@ function BucketCardGridItem({ node, connectionName }: { node: TreeNode; connecti
66
63
  return (
67
64
  <StorageConnectionCard
68
65
  name={node.name}
69
- // TODO(C-151): hardcoded — not reflecting real connection state.
70
- // Needs real semantics (credential hydration, reachability, index status).
71
- status="connected"
66
+ status={node.connectionStatus ?? "loading"}
67
+ errorMessage={node.connectionErrorMessage}
72
68
  meta={
73
69
  connectionConfig && (
74
70
  <>
@@ -106,11 +102,6 @@ function FileCardGridItem({
106
102
  const handleInfo = useNodeInfoModal(node);
107
103
 
108
104
  const { connectionConfig: config, signedFetch } = useSignedFetch(connectionName);
109
- // `_Object.Key` is absolute (prefix already applied) and set for both file
110
- // nodes (from listing) and directory nodes (first image inside, via
111
- // buildDirectoryTree). File nodes without `_Object` — e.g. recently-viewed
112
- // entries reconstructed from DB — fall back to the resolver URL for the
113
- // node's own resourceId.
114
105
  const explicitKey = node._Object?.Key ?? null;
115
106
  const resolvedHttpsUrl = useConnectionsStore(selectHttpsUrl(node.id));
116
107
 
@@ -5,18 +5,14 @@ import { Link, useNavigate } from "react-router";
5
5
  import { type TreeNode } from "./buildDirectoryTree";
6
6
  import type { DirectoryKind } from "./DirectoryView";
7
7
  import { DirectoryViewEmptyState } from "./DirectoryViewEmptyState";
8
+ import { useLazyTreeNodes } from "./useLazyTreeNodes";
8
9
  import { TooltipSpan } from "../Tooltip/TooltipSpan";
9
10
  import { getFileTypeIcon } from "~/utils/fileType";
10
11
  import { buildConnectionPath } from "~/utils/resourceId";
11
12
 
12
- /* ------------------------------------------------------------------ */
13
- /* DirectoryViewTree */
14
- /* ------------------------------------------------------------------ */
15
-
16
13
  interface DirectoryViewTreeProps {
17
14
  /** The full (unfiltered) tree of nodes. Filtering is done via searchTerm. */
18
15
  nodes: TreeNode[];
19
- /** Pass-through search term for the Tree component's built-in filtering. */
20
16
  searchTerm?: string;
21
17
  kind: DirectoryKind;
22
18
  }
@@ -34,20 +30,20 @@ export function NodeLinkIcon({ node }: { node: TreeNode }) {
34
30
  }
35
31
 
36
32
  /**
37
- * Thin wrapper around `@cytario/design`'s `<Tree>` (react-arborist under the
38
- * hood) that navigates on row activation. Used by the tree view mode in
39
- * `DirectoryView` and `recent.route.tsx`.
33
+ * Thin wrapper around `@cytario/design`'s `<Tree>` that navigates on row
34
+ * activation.
40
35
  *
41
- * @deprecated Planned for removal as part of tree consolidation — see
42
- * [C-150](https://app.plane.so/cytario/browse/C-150/). Once the design-system
43
- * `<Tree>` supports auto-height + controllable open state (or is rewritten),
44
- * this wrapper and {@link DirectoryTree} should collapse into a single
45
- * component used across the app.
36
+ * @deprecated Planned for removal as part of tree consolidation — C-150.
46
37
  */
47
- export function DirectoryViewTree({ nodes, searchTerm, kind }: DirectoryViewTreeProps) {
38
+ export function DirectoryViewTree({
39
+ nodes: initialNodes,
40
+ searchTerm,
41
+ kind,
42
+ }: DirectoryViewTreeProps) {
48
43
  const navigate = useNavigate();
44
+ const { nodes, loadChildren } = useLazyTreeNodes(initialNodes);
49
45
 
50
- if (nodes.length === 0) return <DirectoryViewEmptyState kind={kind} />;
46
+ if (initialNodes.length === 0) return <DirectoryViewEmptyState kind={kind} />;
51
47
 
52
48
  return (
53
49
  <div className="overflow-hidden rounded-[var(--border-radius-md)] border border-[var(--color-border-default)]">
@@ -55,21 +51,20 @@ export function DirectoryViewTree({ nodes, searchTerm, kind }: DirectoryViewTree
55
51
  aria-label="Directory tree"
56
52
  data={nodes}
57
53
  selectionMode="none"
58
- openByDefault
54
+ openByDefault={false}
59
55
  size="comfortable"
60
56
  height={600}
61
57
  searchTerm={searchTerm}
62
58
  searchMatch={(node, term) => node.name.toLowerCase().includes(term.toLowerCase())}
59
+ onToggle={(node) => {
60
+ void loadChildren(node).catch(() => {});
61
+ }}
63
62
  onActivate={(node) => navigate(buildConnectionPath(node.connectionName, node.pathName))}
64
63
  />
65
64
  </div>
66
65
  );
67
66
  }
68
67
 
69
- /* ------------------------------------------------------------------ */
70
- /* Lightweight recursive tree (used by GlobalSearch / Suggestions) */
71
- /* ------------------------------------------------------------------ */
72
-
73
68
  interface DirectoryTreeProps {
74
69
  nodes: TreeNode[];
75
70
  action?: (node: TreeNode) => void;
@@ -119,13 +114,10 @@ function DirectoryTreeRecursive({ nodes, action, className }: DirectoryTreeProps
119
114
  }
120
115
 
121
116
  /**
122
- * Simple recursive tree for search suggestions and lightweight contexts
123
- * where the design-system `<Tree>` (with its fixed height + virtualization)
124
- * doesn't fit. Used by `search.route.tsx` and `GlobalSearch/Suggestions.tsx`.
117
+ * Recursive tree for lightweight contexts where the design-system `<Tree>`
118
+ * (fixed height + virtualization) doesn't fit.
125
119
  *
126
- * @deprecated Planned for removal as part of tree consolidation — see
127
- * [C-150](https://app.plane.so/cytario/browse/C-150/). One unified tree
128
- * component should replace both this and {@link DirectoryViewTree}.
120
+ * @deprecated Planned for removal as part of tree consolidation — C-150.
129
121
  */
130
122
  export function DirectoryTree(props: DirectoryTreeProps) {
131
123
  return <DirectoryTreeRecursive {...props} />;