diff --git a/pkgs/clan-app/clan_app/views/webview.py b/pkgs/clan-app/clan_app/views/webview.py index ade90b0b..83ebbbe2 100644 --- a/pkgs/clan-app/clan_app/views/webview.py +++ b/pkgs/clan-app/clan_app/views/webview.py @@ -38,8 +38,10 @@ def dataclass_to_dict(obj: Any) -> Any: return [dataclass_to_dict(item) for item in obj] elif isinstance(obj, dict): return {k: dataclass_to_dict(v) for k, v in obj.items()} - else: + elif isinstance(obj, Path): return str(obj) + else: + return obj # Implement the abstract open_file function @@ -265,6 +267,7 @@ class WebView: result = handler_fn() else: reconciled_arguments = {} + op_key = data.pop("op_key", None) for k, v in data.items(): # Some functions expect to be called with dataclass instances # But the js api returns dictionaries. @@ -276,8 +279,13 @@ class WebView: else: reconciled_arguments[k] = v - result = handler_fn(**reconciled_arguments) - serialized = json.dumps(dataclass_to_dict(result)) + r = handler_fn(**reconciled_arguments) + # Parse the result to a serializable dictionary + # Echo back the "op_key" to the js api + result = dataclass_to_dict(r) + result["op_key"] = op_key + + serialized = json.dumps(result) # Use idle_add to queue the response call to js on the main GTK thread GLib.idle_add(self.return_data_to_js, method_name, serialized) diff --git a/pkgs/clan-cli/clan_cli/api/__init__.py b/pkgs/clan-cli/clan_cli/api/__init__.py index 937035fb..cd8e46b7 100644 --- a/pkgs/clan-cli/clan_cli/api/__init__.py +++ b/pkgs/clan-cli/clan_cli/api/__init__.py @@ -1,6 +1,7 @@ from collections.abc import Callable from dataclasses import dataclass from functools import wraps +from inspect import Parameter, signature from typing import Annotated, Any, Generic, Literal, TypeVar, get_type_hints from clan_cli.errors import ClanError @@ -21,17 +22,34 @@ class ApiError: class SuccessDataClass(Generic[ResponseDataType]): status: Annotated[Literal["success"], "The status of the response."] data: ResponseDataType + op_key: str | None @dataclass class ErrorDataClass: status: Literal["error"] errors: list[ApiError] + op_key: str | None ApiResponse = SuccessDataClass[ResponseDataType] | ErrorDataClass +def update_wrapper_signature(wrapper: Callable, wrapped: Callable) -> None: + sig = signature(wrapped) + params = list(sig.parameters.values()) + + # Add 'op_key' parameter + op_key_param = Parameter( + "op_key", Parameter.KEYWORD_ONLY, default=None, annotation=str | None + ) + params.append(op_key_param) + + # Create a new signature + new_sig = sig.replace(parameters=params) + wrapper.__signature__ = new_sig # type: ignore + + class _MethodRegistry: def __init__(self) -> None: self._orig: dict[str, Callable[[Any], Any]] = {} @@ -41,13 +59,16 @@ class _MethodRegistry: self._orig[fn.__name__] = fn @wraps(fn) - def wrapper(*args: Any, **kwargs: Any) -> ApiResponse[T]: + def wrapper( + *args: Any, op_key: str | None = None, **kwargs: Any + ) -> ApiResponse[T]: try: data: T = fn(*args, **kwargs) - return SuccessDataClass(status="success", data=data) + return SuccessDataClass(status="success", data=data, op_key=op_key) except ClanError as e: return ErrorDataClass( status="error", + op_key=op_key, errors=[ ApiError( message=e.msg, @@ -63,6 +84,11 @@ class _MethodRegistry: orig_return_type = get_type_hints(fn).get("return") wrapper.__annotations__["return"] = ApiResponse[orig_return_type] # type: ignore + # Add additional argument for the operation key + wrapper.__annotations__["op_key"] = str | None # type: ignore + + update_wrapper_signature(wrapper, fn) + self._registry[fn.__name__] = wrapper return fn @@ -91,6 +117,12 @@ class _MethodRegistry: return_type = serialized_hints.pop("return") + sig = signature(func) + required_args = [] + for n, param in sig.parameters.items(): + if param.default == Parameter.empty: + required_args.append(n) + api_schema["properties"][name] = { "type": "object", "required": ["arguments", "return"], @@ -99,7 +131,7 @@ class _MethodRegistry: "return": return_type, "arguments": { "type": "object", - "required": [k for k in serialized_hints.keys()], + "required": required_args, "additionalProperties": False, "properties": serialized_hints, }, diff --git a/pkgs/webview-ui/app/mock.ts b/pkgs/webview-ui/app/mock.ts index bcd1b6f8..be8094cb 100644 --- a/pkgs/webview-ui/app/mock.ts +++ b/pkgs/webview-ui/app/mock.ts @@ -11,15 +11,17 @@ faker.option({ const getFakeResponse = (method: OperationNames, data: any) => { const fakeData = faker.generate(schema.properties[method].properties.return); - + const { op_key } = data; if (method === "open_file") { return { status: "success", data: "/path/to/clan", + op_key, }; } - return fakeData; + // @ts-expect-error: fakeData is guaranteed to always be some object + return { ...fakeData, op_key }; }; export { getFakeResponse }; diff --git a/pkgs/webview-ui/app/src/App.tsx b/pkgs/webview-ui/app/src/App.tsx index cc1878ea..c70f93a7 100644 --- a/pkgs/webview-ui/app/src/App.tsx +++ b/pkgs/webview-ui/app/src/App.tsx @@ -7,6 +7,12 @@ import { Toaster } from "solid-toast"; // Global state const [route, setRoute] = createSignal("machines"); +const [currClanURI, setCurrClanURI] = createSignal( + "/home/johannes/1_clans/myclan" +); + +export { currClanURI, setCurrClanURI }; + export { route, setRoute }; const App: Component = () => { diff --git a/pkgs/webview-ui/app/src/Config.tsx b/pkgs/webview-ui/app/src/Config.tsx index fff8ad57..0966f3ad 100644 --- a/pkgs/webview-ui/app/src/Config.tsx +++ b/pkgs/webview-ui/app/src/Config.tsx @@ -6,6 +6,7 @@ import { createEffect, } from "solid-js"; import { OperationResponse, pyApi } from "./api"; +import { currClanURI } from "./App"; export const makeMachineContext = () => { const [machines, setMachines] = @@ -27,7 +28,10 @@ export const makeMachineContext = () => { getMachines: () => { // When the gtk function sends its data the loading state will be set to false setLoading(true); - pyApi.list_machines.dispatch({ debug: true, flake_url: "." }); + pyApi.list_machines.dispatch({ + debug: true, + flake_url: currClanURI(), + }); }, }, ] as const; diff --git a/pkgs/webview-ui/app/src/api.ts b/pkgs/webview-ui/app/src/api.ts index 805fb3a3..d0e8ab10 100644 --- a/pkgs/webview-ui/app/src/api.ts +++ b/pkgs/webview-ui/app/src/api.ts @@ -11,6 +11,10 @@ export type SuccessData = Extract< OperationResponse, { status: "success" } >; +export type ErrorData = Extract< + OperationResponse, + { status: "error" } +>; export type ClanOperations = { [K in OperationNames]: (str: string) => void; @@ -33,6 +37,27 @@ declare global { // Make sure window.webkit is defined although the type is not correctly filled yet. window.clan = {} as ClanOperations; +const operations = schema.properties; +const operationNames = Object.keys(operations) as OperationNames[]; + +type ObserverRegistry = { + [K in OperationNames]: ((response: OperationResponse) => void)[]; +}; +const obs: ObserverRegistry = operationNames.reduce( + (acc, opName) => ({ + ...acc, + [opName]: [], + }), + {} as ObserverRegistry +); + +interface ReceiveOptions { + /** + * Calls only the registered function that has the same key as used with dispatch + * + */ + fnKey: string; +} function createFunctions( operationName: K ): { @@ -41,24 +66,27 @@ function createFunctions( } { return { dispatch: (args: OperationArgs) => { - console.log( - `Operation: ${String(operationName)}, Arguments: ${JSON.stringify(args)}` - ); + // console.log( + // `Operation: ${String(operationName)}, Arguments: ${JSON.stringify(args)}` + // ); // Send the data to the gtk app window.webkit.messageHandlers.gtk.postMessage({ method: operationName, data: args, }); }, - receive: (fn: (response: OperationResponse) => void) => { - window.clan[operationName] = deserialize(fn); + receive: ( + fn: (response: OperationResponse) => void + // options?: ReceiveOptions + ) => { + obs[operationName].push(fn); + window.clan[operationName] = (s: string) => { + obs[operationName].forEach((f) => deserialize(f)(s)); + }; }, }; } -const operations = schema.properties; -const operationNames = Object.keys(operations) as OperationNames[]; - type PyApi = { [K in OperationNames]: { dispatch: (args: OperationArgs) => void; @@ -70,7 +98,6 @@ const deserialize = (fn: (response: T) => void) => (str: string) => { try { - console.debug("Received data: ", str); fn(JSON.parse(str) as T); } catch (e) { alert(`Error parsing JSON: ${e}`); diff --git a/pkgs/webview-ui/app/src/components/MachineListItem.tsx b/pkgs/webview-ui/app/src/components/MachineListItem.tsx new file mode 100644 index 00000000..9bddf0dc --- /dev/null +++ b/pkgs/webview-ui/app/src/components/MachineListItem.tsx @@ -0,0 +1,83 @@ +import { For, Show, createSignal } from "solid-js"; +import { ErrorData, SuccessData, pyApi } from "../api"; +import { currClanURI } from "../App"; + +interface MachineListItemProps { + name: string; +} + +type MachineDetails = Record["data"]>; + +type MachineErrors = Record["errors"]>; + +const [details, setDetails] = createSignal({}); +const [errors, setErrors] = createSignal({}); + +pyApi.show_machine.receive((r) => { + if (r.status === "error") { + const { op_key } = r; + if (op_key) { + setErrors((e) => ({ ...e, [op_key]: r.errors })); + } + console.error(r.errors); + } + if (r.status === "success") { + setDetails((d) => ({ ...d, [r.data.machine_name]: r.data })); + } +}); + +export const MachineListItem = (props: MachineListItemProps) => { + const { name } = props; + + pyApi.show_machine.dispatch({ + op_key: name, + machine_name: name, + debug: false, + flake_url: currClanURI(), + }); + + return ( +
  • +
    +
    + + devices_other + +
    +
    +
    +

    {name}

    + + {(errors) => ( + + {(error) => ( +

    + {error.message}: {error.description} +

    + )} +
    + )} +
    + + {(details) => ( +

    + {details().machine_description || "No description"} +

    + )} +
    +
    +
    + +
    +
    +
    +
  • + ); +}; diff --git a/pkgs/webview-ui/app/src/index.tsx b/pkgs/webview-ui/app/src/index.tsx index 0d011c3f..6a25caeb 100644 --- a/pkgs/webview-ui/app/src/index.tsx +++ b/pkgs/webview-ui/app/src/index.tsx @@ -14,7 +14,6 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { ); } -console.log(import.meta.env); if (import.meta.env.DEV) { console.log("Development mode"); // Load the debugger in development mode @@ -27,7 +26,7 @@ if (import.meta.env.DEV) { console.debug("Python API call", { method, data }); setTimeout(() => { const mock = getFakeResponse(method, data); - console.log("mock", { mock }); + console.log("Returning mock-data: ", { mock }); window.clan[method](JSON.stringify(mock)); }, 200); diff --git a/pkgs/webview-ui/app/src/routes/machines/view.tsx b/pkgs/webview-ui/app/src/routes/machines/view.tsx index 0606093e..342dbdfc 100644 --- a/pkgs/webview-ui/app/src/routes/machines/view.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/view.tsx @@ -10,6 +10,7 @@ import { useMachineContext } from "../../Config"; import { route } from "@/src/App"; import { OperationResponse, pyApi } from "@/src/api"; import toast from "solid-toast"; +import { MachineListItem } from "@/src/components/MachineListItem"; type FilesModel = Extract< OperationResponse<"get_directory">, @@ -99,37 +100,7 @@ export const MachineListView: Component = () => {
      - {(entry) => ( -
    • -
      -
      - - devices_other - -
      -
      -
      -

      {entry}

      - {/* -

      - {entry.machine_description || "No description"} -

      - */} -
      -
      - -
      -
      -
      -
    • - )} + {(entry) => }