From 4dffbf67a36931eccd938ed85c4fe574a14d6f2c Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Thu, 9 Jul 2026 01:02:29 +0530 Subject: [PATCH 1/4] feat: render a data preview for .parquet files Clicking a .parquet file in the file browser previously threw "No renderer available for file type: .parquet". Add a renderer that queries the file with DuckDB and shows its contents in a preview table. - Add `isPreviewableDataFile` to FileArtifact and route .parquet files to a new `ParquetWorkspace` that reads them via `read_parquet(...)` - Resolve the file's absolute path from the repo connector root, since DuckDB resolves relative paths against its own working directory - Skip fetching (binary) text content for these files - Add a dedicated icon for .parquet files in the file browser --- .../(workspace)/files/[...file]/+page.svelte | 9 +- .../components/icons/ParquetFileIcon.svelte | 8 ++ .../entity-management/file-artifact.ts | 11 +- .../resource-icon-mapping.ts | 17 ++- .../workspaces/ParquetWorkspace.svelte | 124 ++++++++++++++++++ .../workspaces/WorkspaceDispatcher.svelte | 3 + .../(workspace)/files/[...file]/+page.ts | 8 ++ 7 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 web-common/src/components/icons/ParquetFileIcon.svelte create mode 100644 web-common/src/features/workspaces/ParquetWorkspace.svelte diff --git a/web-admin/src/routes/[organization]/[project]/-/edit/(workspace)/files/[...file]/+page.svelte b/web-admin/src/routes/[organization]/[project]/-/edit/(workspace)/files/[...file]/+page.svelte index e7ed9a167321..1e61b5e33c68 100644 --- a/web-admin/src/routes/[organization]/[project]/-/edit/(workspace)/files/[...file]/+page.svelte +++ b/web-admin/src/routes/[organization]/[project]/-/edit/(workspace)/files/[...file]/+page.svelte @@ -11,8 +11,15 @@ // Fetch file content reactively once the runtime is available. // Unlike web-local, the runtime credentials aren't ready during +page.ts load. + // Data files like .parquet have no editable text content and are rendered as + // a data preview instead, so skip fetching their (binary) content. $effect(() => { - if (client.host && client.instanceId && fileArtifact) { + if ( + client.host && + client.instanceId && + fileArtifact && + !fileArtifact.isPreviewableDataFile + ) { void fileArtifact.fetchContent(); } }); diff --git a/web-common/src/components/icons/ParquetFileIcon.svelte b/web-common/src/components/icons/ParquetFileIcon.svelte new file mode 100644 index 000000000000..49852837a30f --- /dev/null +++ b/web-common/src/components/icons/ParquetFileIcon.svelte @@ -0,0 +1,8 @@ + + + diff --git a/web-common/src/features/entity-management/file-artifact.ts b/web-common/src/features/entity-management/file-artifact.ts index fba5e3c001d7..81dcba90ed57 100644 --- a/web-common/src/features/entity-management/file-artifact.ts +++ b/web-common/src/features/entity-management/file-artifact.ts @@ -41,11 +41,14 @@ import type { FileIO } from "./file-io"; import type { EditorSelection } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; +// Data files that can't be edited as text, but whose contents can be +// previewed by querying them with DuckDB (see ParquetWorkspace). +const PREVIEWABLE_DATA_EXTENSIONS = [".parquet"]; + const UNSUPPORTED_EXTENSIONS = [ // Data formats ".db", ".db.wal", - ".parquet", ".xls", ".xlsx", @@ -85,6 +88,9 @@ export class FileArtifact { ); readonly fileExtension: string; readonly fileTypeUnsupported: boolean; + // True for data files (e.g. .parquet) that have no editable text content and + // are instead rendered as a queryable data preview. + readonly isPreviewableDataFile: boolean; readonly folderName: string; readonly fileName: string; readonly disableAutoSave: boolean; @@ -130,6 +136,9 @@ export class FileArtifact { this.fileTypeUnsupported = UNSUPPORTED_EXTENSIONS.includes( this.fileExtension, ); + this.isPreviewableDataFile = PREVIEWABLE_DATA_EXTENSIONS.includes( + this.fileExtension, + ); this.pinned = isPinned(filePath); this.managed = isManaged(filePath); diff --git a/web-common/src/features/entity-management/resource-icon-mapping.ts b/web-common/src/features/entity-management/resource-icon-mapping.ts index 18de4a345dae..28c0ad1e4200 100644 --- a/web-common/src/features/entity-management/resource-icon-mapping.ts +++ b/web-common/src/features/entity-management/resource-icon-mapping.ts @@ -11,8 +11,10 @@ import ConnectorIcon from "../../components/icons/ConnectorIcon.svelte"; import MetricsViewIcon from "../../components/icons/MetricsViewIcon.svelte"; import ModelIcon from "@rilldata/web-common/components/icons/ModelIcon.svelte"; import File from "@rilldata/web-common/components/icons/File.svelte"; +import ParquetFileIcon from "@rilldata/web-common/components/icons/ParquetFileIcon.svelte"; import SettingsIcon from "@rilldata/web-common/components/icons/SettingsIcon.svelte"; import { isEnvFile } from "@rilldata/web-common/features/entity-management/actions/protected-files.ts"; +import { extractFileExtension } from "@rilldata/web-common/features/entity-management/file-path-utils"; export const resourceIconMapping = { [ResourceKind.Source]: TableIcon, @@ -60,9 +62,14 @@ export function getIconComponent( kind: ResourceKind | undefined, filePath: string, ) { - return kind - ? resourceIconMapping[kind] - : isEnvFile(filePath) || filePath === "/rill.yaml" - ? SettingsIcon - : File; + if (kind) { + return resourceIconMapping[kind]; + } + if (isEnvFile(filePath) || filePath === "/rill.yaml") { + return SettingsIcon; + } + if (extractFileExtension(filePath) === ".parquet") { + return ParquetFileIcon; + } + return File; } diff --git a/web-common/src/features/workspaces/ParquetWorkspace.svelte b/web-common/src/features/workspaces/ParquetWorkspace.svelte new file mode 100644 index 000000000000..16ff945d9232 --- /dev/null +++ b/web-common/src/features/workspaces/ParquetWorkspace.svelte @@ -0,0 +1,124 @@ + + + + Rill Developer | {fileName} + + + + + + + {#if isLoading} + + {:else if error} +
+

Unable to read Parquet file

+

+ {error.message ?? "Unknown error"} +

+
+ {:else} +
+ +
+ {/if} +
+
diff --git a/web-common/src/features/workspaces/WorkspaceDispatcher.svelte b/web-common/src/features/workspaces/WorkspaceDispatcher.svelte index 62209c1c5452..b625f965687c 100644 --- a/web-common/src/features/workspaces/WorkspaceDispatcher.svelte +++ b/web-common/src/features/workspaces/WorkspaceDispatcher.svelte @@ -14,6 +14,7 @@ import ExploreWorkspace from "@rilldata/web-common/features/workspaces/ExploreWorkspace.svelte"; import MetricsWorkspace from "@rilldata/web-common/features/workspaces/MetricsWorkspace.svelte"; import ModelWorkspace from "@rilldata/web-common/features/workspaces/ModelWorkspace.svelte"; + import ParquetWorkspace from "@rilldata/web-common/features/workspaces/ParquetWorkspace.svelte"; import WorkspaceContainer from "@rilldata/web-common/layout/workspace/WorkspaceContainer.svelte"; import WorkspaceEditorContainer from "@rilldata/web-common/layout/workspace/WorkspaceEditorContainer.svelte"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.js"; @@ -91,6 +92,8 @@
{#if isGeneratingThisFile} + {:else if fileArtifact.isPreviewableDataFile} + {:else if WorkspaceComponent} {:else} diff --git a/web-local/src/routes/(application)/(workspace)/files/[...file]/+page.ts b/web-local/src/routes/(application)/(workspace)/files/[...file]/+page.ts index 6c2e5fd06508..e66e3a78e9a0 100644 --- a/web-local/src/routes/(application)/(workspace)/files/[...file]/+page.ts +++ b/web-local/src/routes/(application)/(workspace)/files/[...file]/+page.ts @@ -27,6 +27,14 @@ export const load = async ({ params: { file }, parent }) => { ); } + // Data files like .parquet have no editable text content to fetch; they are + // rendered as a queryable data preview instead. + if (fileArtifact.isPreviewableDataFile) { + return { + fileArtifact, + }; + } + try { await fileArtifact.fetchContent(); From b6edf01b1d824256d1b1ace4c8766259334798aa Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Thu, 9 Jul 2026 02:44:46 +0530 Subject: [PATCH 2/4] fix: run Parquet preview on DuckDB and invalidate it on file writes - Explicitly target a DuckDB connector for the read_parquet query so projects whose default OLAP connector is not DuckDB (e.g. ClickHouse) still get a preview instead of an error - Key the preview query on the project-relative path and invalidate it on FILE_EVENT_WRITE, so overwriting the file refreshes the table; also skip the pointless binary content fetch for these files in the watcher --- .../workspaces/ParquetWorkspace.svelte | 40 ++++++++++++++----- .../features/workspaces/parquet-preview.ts | 9 +++++ .../invalidation/file-invalidators.ts | 15 ++++++- 3 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 web-common/src/features/workspaces/parquet-preview.ts diff --git a/web-common/src/features/workspaces/ParquetWorkspace.svelte b/web-common/src/features/workspaces/ParquetWorkspace.svelte index 16ff945d9232..87e9518fd63d 100644 --- a/web-common/src/features/workspaces/ParquetWorkspace.svelte +++ b/web-common/src/features/workspaces/ParquetWorkspace.svelte @@ -11,6 +11,7 @@ createRuntimeServiceQueryResolver, } from "@rilldata/web-common/runtime-client"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; + import { getParquetPreviewQueryKey } from "./parquet-preview"; // Number of rows to preview. Parquet files can be large, so we cap the // preview the same way the model results table does. @@ -23,13 +24,14 @@ let { path } = $derived(fileArtifact); let fileName = $derived(fileArtifact.fileName); + let instanceQuery = createRuntimeServiceGetInstance(runtimeClient, { + sensitive: true, + }); + // DuckDB resolves relative paths against its own working directory, not the // project root, so we must read the file by its absolute path. The project's // local root is the `dsn` of the repo connector (only present in Rill // Developer, where the project lives on the local filesystem). - let instanceQuery = createRuntimeServiceGetInstance(runtimeClient, { - sensitive: true, - }); let repoRoot = $derived.by(() => { const instance = $instanceQuery.data?.instance; const repo = instance?.connectors?.find( @@ -39,6 +41,18 @@ return typeof dsn === "string" ? dsn : ""; }); + // read_parquet is DuckDB-specific, so the query must run against a DuckDB + // connector even when the project's default OLAP connector is something else + // (e.g. ClickHouse). Prefer the default OLAP connector if it is DuckDB, + // otherwise fall back to any DuckDB connector in the project. + let duckDbConnector = $derived.by(() => { + const instance = $instanceQuery.data?.instance; + const connectors = instance?.connectors ?? []; + const olap = connectors.find((c) => c.name === instance?.olapConnector); + if (olap?.type === "duckdb") return olap.name ?? ""; + return connectors.find((c) => c.type === "duckdb")?.name ?? ""; + }); + // Join the repo root and the file's project-relative path into an absolute // path. Single quotes are escaped to keep the generated SQL valid. let absolutePath = $derived( @@ -56,14 +70,15 @@ { resolver: "sql", resolverProperties: { + connector: duckDbConnector, sql, limit: PREVIEW_LIMIT, } as unknown as PartialMessage, }, { query: { - enabled: !!absolutePath, - queryKey: ["parquet-preview", runtimeClient.instanceId, absolutePath], + enabled: !!absolutePath && !!duckDbConnector, + queryKey: getParquetPreviewQueryKey(runtimeClient.instanceId, path), }, }, ), @@ -72,11 +87,16 @@ // While the instance is loading we don't yet know the repo root, so treat it // as part of the preview's loading state. let isLoading = $derived($instanceQuery.isLoading || $previewQuery.isLoading); - let error = $derived( - !$instanceQuery.isLoading && !repoRoot - ? new Error("Could not resolve the project directory for this file.") - : $previewQuery.error, - ); + let error = $derived.by(() => { + if ($instanceQuery.isLoading) return null; + if (!repoRoot) + return new Error( + "Could not resolve the project directory for this file.", + ); + if (!duckDbConnector) + return new Error("Previewing Parquet files requires a DuckDB connector."); + return $previewQuery.error; + }); let data = $derived($previewQuery.data); let rows = $derived(data?.data ?? []); diff --git a/web-common/src/features/workspaces/parquet-preview.ts b/web-common/src/features/workspaces/parquet-preview.ts new file mode 100644 index 000000000000..3a597ec19438 --- /dev/null +++ b/web-common/src/features/workspaces/parquet-preview.ts @@ -0,0 +1,9 @@ +/** + * Query key for the DuckDB-powered preview of a data file (e.g. .parquet) + * rendered by ParquetWorkspace. It is keyed on the file's project-relative + * path (the same form as file watch events) so the preview can be invalidated + * when the underlying file is rewritten. + */ +export function getParquetPreviewQueryKey(instanceId: string, path: string) { + return ["parquet-preview", instanceId, path]; +} diff --git a/web-common/src/runtime-client/invalidation/file-invalidators.ts b/web-common/src/runtime-client/invalidation/file-invalidators.ts index 0a821a92f610..385a275187b0 100644 --- a/web-common/src/runtime-client/invalidation/file-invalidators.ts +++ b/web-common/src/runtime-client/invalidation/file-invalidators.ts @@ -1,5 +1,6 @@ import { invalidate } from "$app/navigation"; import { fileArtifacts } from "@rilldata/web-common/features/entity-management/file-artifacts"; +import { getParquetPreviewQueryKey } from "@rilldata/web-common/features/workspaces/parquet-preview"; import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus"; import { Throttler } from "@rilldata/web-common/lib/throttler"; import type { QueryClient } from "@tanstack/svelte-query"; @@ -52,8 +53,17 @@ export async function handleFileEvent( if (!event.isDir) { switch (event.event) { - case V1FileEvent.FILE_EVENT_WRITE: - await fileArtifacts.getFileArtifact(event.path).fetchContent(true); + case V1FileEvent.FILE_EVENT_WRITE: { + const artifact = fileArtifacts.getFileArtifact(event.path); + if (artifact.isPreviewableDataFile) { + // Data files (e.g. .parquet) have no editable text content; refresh + // their DuckDB-powered preview instead of fetching binary content. + void queryClient.invalidateQueries({ + queryKey: getParquetPreviewQueryKey(instanceId, event.path), + }); + } else { + await artifact.fetchContent(true); + } if (event.path === "/rill.yaml") { void queryClient.invalidateQueries({ queryKey: getRuntimeServiceIssueDevJWTQueryKey(instanceId), @@ -65,6 +75,7 @@ export async function handleFileEvent( } state.seenFiles.add(event.path); break; + } case V1FileEvent.FILE_EVENT_DELETE: void queryClient.resetQueries({ From d59c0eb556df4c726cc48914d8712bf71faf7315 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Thu, 9 Jul 2026 23:10:54 +0530 Subject: [PATCH 3/4] refactor: address review feedback on Parquet preview - Use the lucide `Sheet` icon directly for .parquet files instead of a one-off wrapper component - Move the preview query construction into `parquet-preview` - Invalidate the preview via an extension switch in the file invalidator rather than referencing the Parquet query key directly --- .../components/icons/ParquetFileIcon.svelte | 8 --- .../entity-management/file-artifact.ts | 3 ++ .../resource-icon-mapping.ts | 4 +- .../workspaces/ParquetWorkspace.svelte | 39 +++----------- .../features/workspaces/parquet-preview.ts | 52 +++++++++++++++++-- .../invalidation/file-invalidators.ts | 24 +++++++-- 6 files changed, 81 insertions(+), 49 deletions(-) delete mode 100644 web-common/src/components/icons/ParquetFileIcon.svelte diff --git a/web-common/src/components/icons/ParquetFileIcon.svelte b/web-common/src/components/icons/ParquetFileIcon.svelte deleted file mode 100644 index 49852837a30f..000000000000 --- a/web-common/src/components/icons/ParquetFileIcon.svelte +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/web-common/src/features/entity-management/file-artifact.ts b/web-common/src/features/entity-management/file-artifact.ts index 81dcba90ed57..4dcb98df3704 100644 --- a/web-common/src/features/entity-management/file-artifact.ts +++ b/web-common/src/features/entity-management/file-artifact.ts @@ -43,6 +43,9 @@ import type { EditorView } from "@codemirror/view"; // Data files that can't be edited as text, but whose contents can be // previewed by querying them with DuckDB (see ParquetWorkspace). +// NOTE: When adding an extension here, also map it to its preview query in +// `invalidateDataFilePreview` (file-invalidators.ts) so edits on disk refresh +// the preview. const PREVIEWABLE_DATA_EXTENSIONS = [".parquet"]; const UNSUPPORTED_EXTENSIONS = [ diff --git a/web-common/src/features/entity-management/resource-icon-mapping.ts b/web-common/src/features/entity-management/resource-icon-mapping.ts index 28c0ad1e4200..3027b4e3ea48 100644 --- a/web-common/src/features/entity-management/resource-icon-mapping.ts +++ b/web-common/src/features/entity-management/resource-icon-mapping.ts @@ -11,10 +11,10 @@ import ConnectorIcon from "../../components/icons/ConnectorIcon.svelte"; import MetricsViewIcon from "../../components/icons/MetricsViewIcon.svelte"; import ModelIcon from "@rilldata/web-common/components/icons/ModelIcon.svelte"; import File from "@rilldata/web-common/components/icons/File.svelte"; -import ParquetFileIcon from "@rilldata/web-common/components/icons/ParquetFileIcon.svelte"; import SettingsIcon from "@rilldata/web-common/components/icons/SettingsIcon.svelte"; import { isEnvFile } from "@rilldata/web-common/features/entity-management/actions/protected-files.ts"; import { extractFileExtension } from "@rilldata/web-common/features/entity-management/file-path-utils"; +import { Sheet } from "lucide-svelte"; export const resourceIconMapping = { [ResourceKind.Source]: TableIcon, @@ -69,7 +69,7 @@ export function getIconComponent( return SettingsIcon; } if (extractFileExtension(filePath) === ".parquet") { - return ParquetFileIcon; + return Sheet; } return File; } diff --git a/web-common/src/features/workspaces/ParquetWorkspace.svelte b/web-common/src/features/workspaces/ParquetWorkspace.svelte index 87e9518fd63d..724727e9efdb 100644 --- a/web-common/src/features/workspaces/ParquetWorkspace.svelte +++ b/web-common/src/features/workspaces/ParquetWorkspace.svelte @@ -1,21 +1,13 @@