Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
Expand Down
14 changes: 13 additions & 1 deletion web-common/src/features/entity-management/file-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,17 @@ 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).
// 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 = [
// Data formats
".db",
".db.wal",
".parquet",
".xls",
".xlsx",

Expand Down Expand Up @@ -85,6 +91,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;
Expand Down Expand Up @@ -130,6 +139,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);
Expand Down
17 changes: 12 additions & 5 deletions web-common/src/features/entity-management/resource-icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import ModelIcon from "@rilldata/web-common/components/icons/ModelIcon.svelte";
import File from "@rilldata/web-common/components/icons/File.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,
Expand Down Expand Up @@ -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 Sheet;
}
return File;
}
119 changes: 119 additions & 0 deletions web-common/src/features/workspaces/ParquetWorkspace.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<script lang="ts">
import PreviewTable from "@rilldata/web-common/components/preview-table/PreviewTable.svelte";
import type { VirtualizedTableColumns } from "@rilldata/web-common/components/virtualized-table/types";
import ReconcilingSpinner from "@rilldata/web-common/features/entity-management/ReconcilingSpinner.svelte";
import type { FileArtifact } from "@rilldata/web-common/features/entity-management/file-artifact";
import WorkspaceContainer from "@rilldata/web-common/layout/workspace/WorkspaceContainer.svelte";
import WorkspaceHeader from "@rilldata/web-common/layout/workspace/WorkspaceHeader.svelte";
import { m } from "@rilldata/web-common/lib/i18n/gen/messages";
import { createRuntimeServiceGetInstance } from "@rilldata/web-common/runtime-client";
import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2";
import { createParquetPreviewQuery } from "./parquet-preview";

let { fileArtifact }: { fileArtifact: FileArtifact } = $props();

const runtimeClient = useRuntimeClient();

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 repoRoot = $derived.by(() => {
const instance = $instanceQuery.data?.instance;
const repo = instance?.connectors?.find(
(c) => c.name === instance?.repoConnector,
);
const dsn = repo?.config?.dsn;
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 for read_parquet.
let absolutePath = $derived(
repoRoot
? `${repoRoot.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`
: "",
);

let previewQuery = $derived(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets move this query to parquet-preview?

createParquetPreviewQuery(runtimeClient, {
path,
absolutePath,
connector: duckDbConnector,
}),
);

// 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.by(() => {
if ($instanceQuery.isLoading) return null;
if (!repoRoot) return new Error(m.parquet_preview_no_project_dir());
if (!duckDbConnector)
return new Error(m.parquet_preview_requires_duckdb());
return $previewQuery.error;
});
let data = $derived($previewQuery.data);

let rows = $derived(data?.data ?? []);
let columns = $derived(
(data?.schema?.fields?.map((field) => ({
name: field.name,
type: field.type?.code,
})) ?? []) as VirtualizedTableColumns[],
);
</script>

<svelte:head>
<title>Rill Developer | {fileName}</title>
</svelte:head>

<WorkspaceContainer inspector={false}>
<WorkspaceHeader
slot="header"
filePath={path}
resourceKind={undefined}
titleInput={fileName}
editable={false}
showInspectorToggle={false}
hasUnsavedChanges={false}
/>

<svelte:fragment slot="body">
{#if isLoading}
<ReconcilingSpinner />
{:else if error}
<div
class="flex flex-col size-full items-center justify-center text-fg-secondary gap-y-1"
>
<p class="font-semibold">{m.parquet_preview_read_error_title()}</p>
<p class="text-sm">
{error.message ?? m.parquet_preview_unknown_error()}
</p>
</div>
{:else}
<div class="size-full overflow-hidden border rounded-[2px]">
<PreviewTable {rows} columnNames={columns} name={fileName} />
</div>
{/if}
</svelte:fragment>
</WorkspaceContainer>
3 changes: 3 additions & 0 deletions web-common/src/features/workspaces/WorkspaceDispatcher.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +92,8 @@
<div class="flex-1 overflow-hidden">
{#if isGeneratingThisFile}
<GeneratingMessage title="Generating your Canvas dashboard..." />
{:else if fileArtifact.isPreviewableDataFile}
<ParquetWorkspace {fileArtifact} />
{:else if WorkspaceComponent}
<WorkspaceComponent {fileArtifact} />
{:else}
Expand Down
53 changes: 53 additions & 0 deletions web-common/src/features/workspaces/parquet-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { PartialMessage, Struct } from "@bufbuild/protobuf";
import { createRuntimeServiceQueryResolver } from "@rilldata/web-common/runtime-client";
import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";

// Number of rows to preview. Parquet files can be large, so we cap the preview
// the same way the model results table does.
export const PARQUET_PREVIEW_LIMIT = 150;

/**
* Query key for the DuckDB-powered preview of a Parquet file 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];
}

/**
* Creates the query that previews a Parquet file by reading it with DuckDB.
*
* `read_parquet` is DuckDB-specific and resolves relative paths against its own
* working directory, so the caller must pass the file's absolute path and a
* DuckDB connector to run the query against.
*/
export function createParquetPreviewQuery(
client: RuntimeClient,
{
path,
absolutePath,
connector,
}: { path: string; absolutePath: string; connector: string },
) {
// Single quotes are escaped to keep the generated SQL valid.
const sql = `SELECT * FROM read_parquet('${absolutePath.replaceAll("'", "''")}')`;
return createRuntimeServiceQueryResolver(
client,
{
resolver: "sql",
resolverProperties: {
connector,
sql,
limit: PARQUET_PREVIEW_LIMIT,
} as unknown as PartialMessage<Struct>,
},
{
query: {
enabled: !!absolutePath && !!connector,
queryKey: getParquetPreviewQueryKey(client.instanceId, path),
},
},
);
}
6 changes: 5 additions & 1 deletion web-common/src/lib/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2169,5 +2169,9 @@
],
"users_yes_remove": "Yes, remove",
"users_yes_upgrade": "Yes, upgrade",
"workspace_generating_sample_data": "Generating your sample data..."
"workspace_generating_sample_data": "Generating your sample data...",
"parquet_preview_read_error_title": "Unable to read Parquet file",
"parquet_preview_unknown_error": "Unknown error",
"parquet_preview_no_project_dir": "Could not resolve the project directory for this file.",
"parquet_preview_requires_duckdb": "Previewing Parquet files requires a DuckDB connector."
}
6 changes: 5 additions & 1 deletion web-common/src/lib/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2178,5 +2178,9 @@
],
"users_yes_remove": "Sí, eliminar",
"users_yes_upgrade": "Sí, promover",
"workspace_generating_sample_data": "Generando tus datos de ejemplo..."
"workspace_generating_sample_data": "Generando tus datos de ejemplo...",
"parquet_preview_read_error_title": "No se pudo leer el archivo Parquet",
"parquet_preview_unknown_error": "Error desconocido",
"parquet_preview_no_project_dir": "No se pudo resolver el directorio del proyecto para este archivo.",
"parquet_preview_requires_duckdb": "La vista previa de archivos Parquet requiere un conector DuckDB."
}
31 changes: 29 additions & 2 deletions web-common/src/runtime-client/invalidation/file-invalidators.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { invalidate } from "$app/navigation";
import { fileArtifacts } from "@rilldata/web-common/features/entity-management/file-artifacts";
import { extractFileExtension } from "@rilldata/web-common/features/entity-management/file-path-utils";
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";
Expand Down Expand Up @@ -52,8 +54,15 @@ 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 data preview instead of fetching binary content.
invalidateDataFilePreview(queryClient, instanceId, event.path);
} else {
await artifact.fetchContent(true);
}
if (event.path === "/rill.yaml") {
void queryClient.invalidateQueries({
queryKey: getRuntimeServiceIssueDevJWTQueryKey(instanceId),
Expand All @@ -65,6 +74,7 @@ export async function handleFileEvent(
}
state.seenFiles.add(event.path);
break;
}

case V1FileEvent.FILE_EVENT_DELETE:
void queryClient.resetQueries({
Expand Down Expand Up @@ -101,6 +111,23 @@ export async function handleFileEvent(
}
}

// Refreshes the data-preview query for a rewritten data file. Keyed per
// extension so each previewable file type (see
// FileArtifact.isPreviewableDataFile) maps to its own preview query.
function invalidateDataFilePreview(
queryClient: QueryClient,
instanceId: string,
path: string,
) {
switch (extractFileExtension(path)) {
case ".parquet":
void queryClient.invalidateQueries({
queryKey: getParquetPreviewQueryKey(instanceId, path),
});
break;
}
}

function resetGitStatusQuery(queryClient: QueryClient, instanceId: string) {
const queryKey = getRuntimeServiceGitStatusQueryKey(instanceId, {});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading