Load GraphQL schema from file for autocomplete (#462)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
This commit is contained in:
VDShelk
2026-08-15 10:14:13 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Gregory Schier
parent 5d1d24870a
commit b31c066717
9 changed files with 434 additions and 23 deletions
@@ -37,12 +37,15 @@ import { ErrorBoundary } from "../ErrorBoundary";
import { Button } from "./Button"; import { Button } from "./Button";
import { Hotkey } from "./Hotkey"; import { Hotkey } from "./Hotkey";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import type { SeparatorAction } from "./Separator";
import { Separator } from "./Separator"; import { Separator } from "./Separator";
export type DropdownItemSeparator = { export type DropdownItemSeparator = {
type: "separator"; type: "separator";
label?: ReactNode; label?: ReactNode;
hidden?: boolean; hidden?: boolean;
/** A control shown beside the label, eg. revealing the labelled file on disk. */
action?: SeparatorAction;
}; };
export type DropdownItemContent = { export type DropdownItemContent = {
@@ -791,6 +794,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
// oxlint-disable-next-line no-array-index-key -- Nothing else available // oxlint-disable-next-line no-array-index-key -- Nothing else available
key={i} key={i}
className={classNames("my-1.5", item.label ? "ml-2" : null)} className={classNames("my-1.5", item.label ? "ml-2" : null)}
action={item.action}
> >
{item.label} {item.label}
</Separator> </Separator>
@@ -1,13 +1,31 @@
import type { Color } from "@yaakapp-internal/plugins"; import type { Color } from "@yaakapp-internal/plugins";
import type { IconProps } from "@yaakapp-internal/ui";
import { IconButton } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
/**
* A single control attached to a labelled separator, rendered between the label
* and the rule.
*
* Declared rather than passed as a node so the separator keeps ownership of the
* things that are easy to get wrong by hand: matching the label's colour, and
* staying out of the rule's way when the label is long.
*/
export interface SeparatorAction {
icon: IconProps["icon"];
/** Tooltip and accessible name. Required — the control is icon-only. */
title: string;
onClick: () => void;
}
interface Props { interface Props {
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
dashed?: boolean; dashed?: boolean;
className?: string; className?: string;
children?: ReactNode; children?: ReactNode;
color?: Color; color?: Color;
action?: SeparatorAction;
} }
export function Separator({ export function Separator({
@@ -16,15 +34,31 @@ export function Separator({
dashed, dashed,
orientation = "horizontal", orientation = "horizontal",
children, children,
action,
}: Props) { }: Props) {
return ( return (
<div role="presentation" className={classNames(className, "flex items-center w-full")}> <div role="presentation" className={classNames(className, "flex items-center w-full")}>
{children && ( {children && (
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div> <div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
)} )}
{action && (
<IconButton
size="2xs"
iconSize="xs"
className="shrink-0 mr-2 -ml-1"
// Forced, because the button itself sets `text-text` at full strength.
iconClassName="text-text-subtlest!"
icon={action.icon}
title={action.title}
onClick={action.onClick}
/>
)}
<div <div
className={classNames( className={classNames(
"opacity-60", "opacity-60",
// Keep a stub of the line visible no matter how long the label is —
// `w-full` alone gets squeezed to nothing by a wide label.
orientation === "horizontal" && "min-w-8",
color == null && "border-border", color == null && "border-border",
color === "primary" && "border-primary", color === "primary" && "border-primary",
color === "secondary" && "border-secondary", color === "secondary" && "border-secondary",
@@ -1,4 +1,5 @@
import type { HttpRequest } from "@yaakapp-internal/models"; import type { HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
@@ -11,6 +12,7 @@ import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown"; import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor"; import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import type { RadioDropdownItem } from "../core/RadioDropdown"; import type { RadioDropdownItem } from "../core/RadioDropdown";
import { RadioDropdown } from "../core/RadioDropdown"; import { RadioDropdown } from "../core/RadioDropdown";
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui"; import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
@@ -18,6 +20,7 @@ import { Separator } from "../core/Separator";
import { tryFormatGraphql } from "../../lib/formatters"; import { tryFormatGraphql } from "../../lib/formatters";
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames"; import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion"; import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
import { revealInFinderText } from "../../lib/reveal";
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms"; import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & { type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
@@ -28,6 +31,10 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
const OPERATION_NAME_NOT_SPECIFIED = ""; const OPERATION_NAME_NOT_SPECIFIED = "";
// How much of the end of a schema filename is pinned when middle-truncating it.
// Enough to keep the extension and a little of the name before it.
const FILE_NAME_TAIL_CHARS = 12;
export function GraphQLEditor(props: Props) { export function GraphQLEditor(props: Props) {
// There's some weirdness with stale onChange being called when switching requests, so we'll // There's some weirdness with stale onChange being called when switching requests, so we'll
// key on the request ID as a workaround for now. // key on the request ID as a workaround for now.
@@ -38,9 +45,41 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage< const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
Record<string, boolean> Record<string, boolean>
>("graphQLAutoIntrospectDisabled", {}); >("graphQLAutoIntrospectDisabled", {});
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, { const {
schema,
isLoading,
error,
refetch,
clear,
loadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
} = useIntrospectGraphQL(baseRequest, {
disabled: autoIntrospectDisabled?.[baseRequest.id], disabled: autoIntrospectDisabled?.[baseRequest.id],
}); });
// Last path segment, for display only. The host owns real path semantics; this
// just needs something short enough to label the divider with.
const fileName = useMemo(() => filePath?.split(/[/\\]/).pop() || filePath, [filePath]);
// Selecting a file is all it takes — the request's source becomes that file,
// which is what keeps automatic introspection from overwriting it.
const handleLoadFromFile = useCallback(async () => {
const selected = await platform.dialog.open({
title: "Load GraphQL Schema",
multiple: false,
filters: [
{
name: "GraphQL Schema",
extensions: ["graphql", "graphqls", "gql", "json"],
},
],
});
if (selected == null) return;
await loadFromFile(selected);
}, [loadFromFile]);
const [currentBody, setCurrentBody] = useStateWithDeps<{ const [currentBody, setCurrentBody] = useStateWithDeps<{
query: string; query: string;
variables: string | undefined; variables: string | undefined;
@@ -160,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
...((schema != null ...((schema != null
? [ ? [
{ {
label: "Clear", label: "Clear Schema",
onSelect: clear, onSelect: clear,
color: "danger", color: "danger",
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
}, },
{ type: "separator" },
] ]
: []) satisfies DropdownItem[]), : []) satisfies DropdownItem[]),
{
// Labels the source actions below it, so the menu says where the
// schema came from without spending a row on it.
type: "separator",
hidden: schema == null && filePath == null,
label:
fileName == null || filePath == null ? undefined : (
// Middle truncation: the head shrinks and ellipsizes while the
// tail is pinned, so the extension always survives. Full path
// on hover.
<div className="flex min-w-0 max-w-[16rem] font-mono text-xs" title={filePath}>
<span className="truncate">{fileName.slice(0, -FILE_NAME_TAIL_CHARS)}</span>
<span className="shrink-0">{fileName.slice(-FILE_NAME_TAIL_CHARS)}</span>
</div>
),
action:
filePath == null
? undefined
: {
icon: "folder_symlink",
title: revealInFinderText,
onClick: () => platform.revealItemInDir(filePath),
},
},
{ {
hidden: !error, hidden: !error,
label: ( label: (
@@ -210,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
type: "content", type: "content",
}, },
{ {
hidden: schema == null, // One refresh action for either source: re-read the file, or
label: `${isDocOpen ? "Hide" : "Show"} Documentation`, // re-introspect the server.
leftSlot: <Icon icon="book_open_text" />, label: "Reload Schema",
onSelect: () => { leftSlot: <Icon icon="refresh" spin={isLoading} />,
setGraphqlDocStateAtomValue((v) => ({ keepOpenOnSelect: true,
...v, // Failures surface through the hook's error state either way.
[request.id]: isDocOpen ? undefined : null, onSelect: async () => {
})); if (filePath != null) await reloadFromFile();
else await refetch();
}, },
}, },
{ {
label: "Introspect Schema", label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
leftSlot: <Icon icon="refresh" spin={isLoading} />, leftSlot: <Icon icon="import" />,
keepOpenOnSelect: true, onSelect: handleLoadFromFile,
onSelect: refetch,
}, },
{ type: "separator", label: "Setting" },
{ {
label: "Automatic Introspection", hidden: filePath == null,
label: "Stop Using File",
leftSlot: <Icon icon="x" />,
onSelect: removeSchemaFile,
},
{ type: "separator", label: "Settings" },
{
// Governs both sources: re-introspecting the server, and
// re-reading the file when the request is opened.
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
keepOpenOnSelect: true, keepOpenOnSelect: true,
onSelect: () => { onSelect: () => {
setAutoIntrospectDisabled({ setAutoIntrospectDisabled({
@@ -261,6 +331,29 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
</Dropdown> </Dropdown>
)} )}
</div>, </div>,
// Sits after the schema control it depends on. Always rendered, disabled
// without a schema, so the row never changes shape.
<div key="documentation" className="opacity-100!">
<IconButton
size="sm"
variant="border"
icon="book_open_text"
disabled={schema == null}
title={
schema == null
? "Documentation unavailable without a schema"
: isDocOpen
? "Hide Documentation"
: "Show Documentation"
}
onClick={() => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
}}
/>
</div>,
], ],
[ [
schema, schema,
@@ -272,6 +365,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
isLoading, isLoading,
operationNames, operationNames,
refetch, refetch,
handleLoadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
fileName,
autoIntrospectDisabled, autoIntrospectDisabled,
baseRequest.id, baseRequest.id,
setGraphqlDocStateAtomValue, setGraphqlDocStateAtomValue,
@@ -0,0 +1,18 @@
import { useKeyValue } from "./useKeyValue";
// The file a request's GraphQL schema is loaded from, or null when the schema
// comes from an introspection request.
//
// This is the *source*, not the schema. The introspection row it produces is a
// cache that expires on its own; this outlives it and regenerates it, the same
// way gRPC keeps its proto file list separate from a reflection result.
export function graphqlSchemaFileArgs(requestId: string | null) {
return {
namespace: "global" as const,
key: ["graphql_schema_file", requestId ?? "n/a"],
};
}
export function useGraphQLSchemaFile(requestId: string | null) {
return useKeyValue<string | null>({ ...graphqlSchemaFileArgs(requestId), fallback: null });
}
+116 -5
View File
@@ -1,12 +1,14 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models"; import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import type { GraphQLSchema, IntrospectionQuery } from "graphql"; import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, getIntrospectionQuery } from "graphql"; import { buildClientSchema, getIntrospectionQuery } from "graphql";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
import { minPromiseMillis } from "../lib/minPromiseMillis"; import { minPromiseMillis } from "../lib/minPromiseMillis";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest"; import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
import { useActiveEnvironment } from "./useActiveEnvironment"; import { useActiveEnvironment } from "./useActiveEnvironment";
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
import { useDebouncedValue } from "@yaakapp-internal/ui"; import { useDebouncedValue } from "@yaakapp-internal/ui";
import { rpc } from "../lib/rpc"; import { rpc } from "../lib/rpc";
@@ -30,6 +32,11 @@ export function useIntrospectGraphQL(
const introspection = useIntrospectionResult(baseRequest); const introspection = useIntrospectionResult(baseRequest);
// The schema's source. Outlives the introspection row it produces, so a
// request configured with a file keeps working after the row is swept.
const schemaFile = useGraphQLSchemaFile(baseRequest.id);
const filePath = schemaFile.value ?? null;
const upsertIntrospection = useCallback( const upsertIntrospection = useCallback(
async (content: string | null) => { async (content: string | null) => {
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", { const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
@@ -92,15 +99,109 @@ export function useIntrospectGraphQL(
return; return;
} }
refetch().catch(console.error); // A request pointed at a file gets its schema from that file. Introspecting
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]); // here would overwrite it on the next URL edit.
if (filePath != null) {
return;
}
refetch().catch(console.error);
}, [
baseRequest.id,
debouncedRequest.url,
debouncedRequest.method,
activeEnvironment?.id,
filePath,
]);
// Clears the schema, not the source. Removing a file source is a separate
// action, because the source is what would rebuild this a moment later.
const clear = useCallback(async () => { const clear = useCallback(async () => {
setError(""); setError("");
setSchema(null); setSchema(null);
await upsertIntrospection(null); await upsertIntrospection(null);
}, [upsertIntrospection]); }, [upsertIntrospection]);
// Reads a schema file and produces an introspection row from it, the same way
// `refetch` produces one from a server. Does not touch the stored source.
const introspectFromFile = useCallback(
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
try {
setIsLoading(true);
setError(undefined);
const fileContent = await platform.files.readText(path);
const result = tryBuildIntrospectionFromFile(fileContent);
if ("error" in result) {
setError(result.error);
return { ok: false, error: result.error };
}
await upsertIntrospection(result.content);
return { ok: true };
} catch (err) {
// The host rejects with a bare string for a missing or unreadable path,
// so this can't assume an Error.
const message = err instanceof Error ? err.message : String(err);
setError(message);
return { ok: false, error: message };
} finally {
setIsLoading(false);
}
},
[upsertIntrospection],
);
// Points the request at a file and immediately builds its schema from it.
const loadFromFile = useCallback(
async (path: string) => {
const result = await introspectFromFile(path);
if (result.ok) await schemaFile.set(path);
return result;
},
[introspectFromFile, schemaFile],
);
const reloadFromFile = useCallback(async () => {
if (filePath == null) return { ok: false as const, error: "No schema file to reload" };
return introspectFromFile(filePath);
}, [filePath, introspectFromFile]);
// The file-source counterpart of automatic introspection: re-read the file
// when the request is opened, so an edited schema is picked up without asking.
//
// A missing row is repaired even with the setting off — that is recovering
// from the 7-day sweep, not keeping the schema fresh, and skipping it would
// make the schema disappear with no visible cause.
const reloadedFor = useRef<string | null>(null);
useEffect(() => {
if (filePath == null || introspection.isLoading) return;
// Only attempt once per path, so an unreadable file doesn't spin.
if (reloadedFor.current === filePath) return;
const hasContent = (introspection.data?.content ?? "") !== "";
if (hasContent && options.disabled) return;
reloadedFor.current = filePath;
introspectFromFile(filePath).catch(console.error);
}, [
filePath,
introspection.data?.content,
introspection.isLoading,
introspectFromFile,
options.disabled,
]);
// Stops using the file. The schema goes with it, since the file is what
// produced it; introspection repopulates if it's set to run automatically.
const removeSchemaFile = useCallback(async () => {
setError("");
setSchema(null);
await schemaFile.set(null);
await upsertIntrospection(null);
}, [schemaFile, upsertIntrospection]);
useEffect(() => { useEffect(() => {
if (introspection.data?.content == null || introspection.data.content === "") { if (introspection.data?.content == null || introspection.data.content === "") {
return; return;
@@ -114,7 +215,17 @@ export function useIntrospectGraphQL(
} }
}, [introspection.data?.content]); }, [introspection.data?.content]);
return { schema, isLoading, error, refetch, clear }; return {
schema,
isLoading,
error,
refetch,
clear,
loadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
};
} }
function useIntrospectionResult(request: HttpRequest) { function useIntrospectionResult(request: HttpRequest) {
@@ -0,0 +1,85 @@
import { buildSchema, introspectionFromSchema } from "graphql";
import { describe, expect, test } from "vite-plus/test";
import { tryBuildIntrospectionFromFile } from "./graphqlSchema";
const sdl = `
type Query {
hello: String!
user(id: ID!): User
}
type User {
id: ID!
name: String
}
`;
const introspection = introspectionFromSchema(buildSchema(sdl));
describe("tryBuildIntrospectionFromFile", () => {
test("accepts introspection JSON wrapped in { data: ... }", () => {
const input = JSON.stringify({ data: introspection });
const result = tryBuildIntrospectionFromFile(input);
expect("schema" in result).toBe(true);
if ("schema" in result) {
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("hello");
// Output content is the normalized, persistable shape.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});
test("accepts bare introspection JSON without a data wrapper", () => {
const input = JSON.stringify(introspection);
const result = tryBuildIntrospectionFromFile(input);
expect("schema" in result).toBe(true);
if ("schema" in result) {
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("user");
// Bare input is wrapped on the way out.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});
test("accepts a GraphQL SDL string", () => {
const result = tryBuildIntrospectionFromFile(sdl);
expect("schema" in result).toBe(true);
if ("schema" in result) {
const fields = result.schema.getQueryType()?.getFields() ?? {};
expect(fields).toHaveProperty("hello");
expect(fields).toHaveProperty("user");
// SDL is converted to introspection JSON for storage.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});
test("returns an error for JSON that is neither introspection nor SDL", () => {
const result = tryBuildIntrospectionFromFile('{"unrelated":"value"}');
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
}
});
test("returns an error for content that is neither valid JSON nor valid SDL", () => {
const result = tryBuildIntrospectionFromFile("not a schema!@#$");
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
}
});
test("returns an error when introspection JSON has a malformed __schema", () => {
// Has the data.__schema shape but the contents are invalid for buildClientSchema.
const input = JSON.stringify({ data: { __schema: { broken: true } } });
const result = tryBuildIntrospectionFromFile(input);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Failed to build schema from introspection JSON/);
}
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
// { __schema }) or an SDL string and normalizes both into the wrapped
// { data: <introspection> } JSON shape used by the introspection store.
export function tryBuildIntrospectionFromFile(
fileContent: string,
): { schema: GraphQLSchema; content: string } | { error: string } {
let parsedJson: unknown;
try {
parsedJson = JSON.parse(fileContent);
} catch {
parsedJson = undefined;
}
if (parsedJson != null && typeof parsedJson === "object") {
const candidates: unknown[] = [(parsedJson as { data?: unknown }).data, parsedJson];
for (const candidate of candidates) {
if (
candidate != null &&
typeof candidate === "object" &&
"__schema" in (candidate as Record<string, unknown>)
) {
try {
const schema = buildClientSchema(candidate as IntrospectionQuery, {});
return { schema, content: JSON.stringify({ data: candidate }) };
} catch (e) {
return {
error: `Failed to build schema from introspection JSON: ${errorMessage(e)}`,
};
}
}
}
}
try {
const schema = buildSchema(fileContent);
const introspection = introspectionFromSchema(schema);
return { schema, content: JSON.stringify({ data: introspection }) };
} catch (e) {
return {
error: `Could not parse file as introspection JSON or GraphQL SDL: ${errorMessage(e)}`,
};
}
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+2 -1
View File
@@ -5,7 +5,7 @@ import { basename, resolveResource } from "@tauri-apps/api/path";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager"; import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { open, save } from "@tauri-apps/plugin-dialog"; import { open, save } from "@tauri-apps/plugin-dialog";
import { readDir, readFile } from "@tauri-apps/plugin-fs"; import { readDir, readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import { type as osType } from "@tauri-apps/plugin-os"; import { type as osType } from "@tauri-apps/plugin-os";
import type { import type {
@@ -137,6 +137,7 @@ export function createTauriPlatform(): Platform {
files: { files: {
readDir: (path) => readDir(path), readDir: (path) => readDir(path),
readText: (path) => readTextFile(path),
url: (path) => convertFileSrc(path), url: (path) => convertFileSrc(path),
basename: (path) => basename(path), basename: (path) => basename(path),
resolveResource: (path) => resolveResource(path), resolveResource: (path) => resolveResource(path),
+9
View File
@@ -141,6 +141,15 @@ export interface PlatformDialog {
export interface PlatformFiles { export interface PlatformFiles {
readDir(path: string): Promise<DirEntry[]>; readDir(path: string): Promise<DirEntry[]>;
/**
* The text of a file the user picked, decoded as UTF-8.
*
* Only for paths the host itself handed us — a dialog result or a drag-drop
* payload — never one the page assembled. A host without a filesystem reads
* whatever its own handle points at.
*/
readText(path: string): Promise<string>;
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */ /** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
url(path: string): string; url(path: string): string;