Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental typescript support #19842

Merged
merged 2 commits into from
Apr 10, 2024
Merged
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
13 changes: 13 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@
"require": false,
"module": false
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"plugins": [
"@typescript-eslint"
],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": ["./tsconfig.json"]
}
}
],
"settings": {
"react": {
"version": "detect"
Expand Down
2 changes: 1 addition & 1 deletion node_modules
Submodule node_modules updated 3244 files
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
"xterm-addon-canvas": "0.5.0"
},
"devDependencies": {
"@types/deep-equal": "1.0.4",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"@typescript-eslint/eslint-plugin": "7.4.0",
"argparse": "2.0.1",
"chrome-remote-interface": "0.33.0",
"esbuild": "0.20.2",
Expand Down Expand Up @@ -53,7 +57,8 @@
"stylelint-config-standard": "36.0.0",
"stylelint-config-standard-scss": "13.1.0",
"stylelint-formatter-pretty": "4.0.0",
"stylelint-use-logical-spec": "5.0.1"
"stylelint-use-logical-spec": "5.0.1",
"typescript": "^5.3.3"
},
"scripts": {
"eslint": "eslint --ext .js --ext .jsx pkg/ test/common/",
Expand Down
200 changes: 200 additions & 0 deletions pkg/lib/cockpit.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/* This file is part of Cockpit.
*
* Copyright (C) 2024 Red Hat, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

declare module 'cockpit' {
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
type JsonObject = Record<string, JsonValue>;

class BasicError {
problem: string;
message: string;
toString(): string;
}

/* === Events mix-in ========================= */

interface EventMap {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[_: string]: (...args: any[]) => void;
}

type EventListener<E extends (...args: unknown[]) => void> =
(event: CustomEvent<Parameters<E>>, ...args: Parameters<E>) => void;

interface EventSource<EM extends EventMap> {
addEventListener<E extends keyof EM>(event: E, listener: EventListener<EM[E]>): void;
removeEventListener<E extends keyof EM>(event: E, listener: EventListener<EM[E]>): void;
dispatchEvent<E extends keyof EM>(event: E, ...args: Parameters<EM[E]>): void;
}

interface CockpitEvents extends EventMap {
locationchanged(): void;
visibilitychange(): void;
}

function addEventListener<E extends keyof CockpitEvents>(
event: E, listener: EventListener<CockpitEvents[E]>
): void;
function removeEventListener<E extends keyof CockpitEvents>(
event: E, listener: EventListener<CockpitEvents[E]>
): void;

interface ChangedEvents {
changed(): void;
}

/* === Channel =============================== */

interface ControlMessage extends JsonObject {
command: string;
}

interface ChannelEvents<T> extends EventMap {
control(options: JsonObject): void;
ready(options: JsonObject): void;
close(options: JsonObject): void;
message(data: T): void;
}

interface Channel<T> extends EventSource<ChannelEvents<T>> {
id: string | null;
binary: boolean;
options: JsonObject;
valid: boolean;
send(data: T): void;
control(options: ControlMessage): void;
wait(): Promise<void>;
close(options?: JsonObject): void;
}

interface ChannelOptions {
payload: string;
superuser?: string;
[_: string]: JsonValue | undefined;
}

function channel(options: ChannelOptions & { binary?: false; }): Channel<string>;
function channel(options: ChannelOptions & { binary: true; }): Channel<Uint8Array>;

/* === cockpit.location ========================== */

interface Location {
url_root: string;
options: { [name: string]: string | Array<string> };
path: Array<string>;
href: string;
go(path: Location | string, options?: { [key: string]: string }): void;
replace(path: Location | string, options?: { [key: string]: string }): void;
}

export const location: Location;

/* === cockpit.dbus ========================== */

interface DBusProxyEvents extends EventMap {
changed(changes: { [property: string]: unknown }): void;
}

interface DBusProxy extends EventSource<DBusProxyEvents> {
valid: boolean;
[property: string]: unknown;
}

interface DBusOptions {
bus?: string;
address?: string;
superuser?: "require" | "try";
track?: boolean;
}

interface DBusClient {
readonly unique_name: string;
readonly options: DBusOptions;
proxy(interface: string, path: string, options?: { watch?: boolean }): DBusProxy;
close(): void;
}

function dbus(name: string | null, options?: DBusOptions): DBusClient;

/* === cockpit.file ========================== */

interface FileSyntaxObject<T, B> {
parse(content: B): T;
stringify(content: T): B;
}

type FileTag = string;

type FileWatchCallback<T> = (data: T | null, tag: FileTag | null, error: BasicError | null) => void;
interface FileWatchHandle {
remove(): void;
}

interface FileHandle<T> {
read(): Promise<T>;
replace(content: T): Promise<FileTag>;
watch(callback: FileWatchCallback<T>, options?: { read?: boolean }): FileWatchHandle;
modify(callback: (data: T) => T): Promise<[T, FileTag]>;
close(): void;
path: string;
}

type FileOpenOptions = {
max_read_size?: number;
superuser?: string;
};

function file(
path: string,
options?: FileOpenOptions & { binary?: false; syntax?: undefined; }
): FileHandle<string>;
function file(
path: string,
options: FileOpenOptions & { binary: true; syntax?: undefined; }
): FileHandle<Uint8Array>;
function file<T>(
path: string,
options: FileOpenOptions & { binary?: false; syntax: FileSyntaxObject<T, string>; }
): FileHandle<T>;
function file<T>(
path: string,
options: FileOpenOptions & { binary: true; syntax: FileSyntaxObject<T, Uint8Array>; }
): FileHandle<T>;

/* === cockpit.user ========================== */

type UserInfo = {
id: number;
name: string;
full_name: string;
groups: Array<string>;
home: string;
shell: string;
};
export function user(): Promise<UserInfo>;

/* === String helpers ======================== */

function gettext(message: string): string;
function gettext(context: string, message?: string): string;
function ngettext(message1: string, messageN: string, n: number): string;
function ngettext(context: string, message1: string, messageN: string, n: number): string;

function format_bytes(n: number): string;
function format(format_string: string, ...args: unknown[]): string;
}
51 changes: 31 additions & 20 deletions pkg/lib/hooks.js → pkg/lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ export function usePageLocation() {
*/

const cockpit_user_promise = cockpit.user();
let cockpit_user = null;
let cockpit_user: cockpit.UserInfo | null = null;
cockpit_user_promise.then(user => { cockpit_user = user }).catch(err => console.log(err));

export function useLoggedInUser() {
const [user, setUser] = useState(cockpit_user);
const [user, setUser] = useState<cockpit.UserInfo | null>(cockpit_user);
useEffect(() => { if (!cockpit_user) cockpit_user_promise.then(setUser); }, []);
return user;
}
Expand Down Expand Up @@ -138,7 +138,7 @@ export function useLoggedInUser() {
* again, which will cause a new event, ...).
*/

export function useDeepEqualMemo(value) {
export function useDeepEqualMemo<T>(value: T): T {
const ref = useRef(value);
if (!deep_equal(ref.current, value))
ref.current = value;
Expand Down Expand Up @@ -181,14 +181,18 @@ export function useDeepEqualMemo(value) {
* return false.
*/

export function useFileWithError(path, options, hook_options) {
const [content_and_error, setContentAndError] = useState([null, null]);
type UseFileWithErrorOptions = {
log_errors?: boolean;
};

export function useFileWithError(path: string, options: cockpit.JsonObject, hook_options: UseFileWithErrorOptions) {
const [content_and_error, setContentAndError] = useState<[string | false | null, cockpit.BasicError | false | null]>([null, null]);
const memo_options = useDeepEqualMemo(options);
const memo_hook_options = useDeepEqualMemo(hook_options);

useEffect(() => {
const handle = cockpit.file(path, memo_options);
handle.watch((data, tag, error) => {
handle.watch((data, _tag, error) => {
setContentAndError([data || false, error || false]);
if (!data && memo_hook_options?.log_errors)
console.warn("Can't read " + path + ": " + (error ? error.toString() : "not found"));
Expand All @@ -199,7 +203,7 @@ export function useFileWithError(path, options, hook_options) {
return content_and_error;
}

export function useFile(path, options) {
export function useFile(path: string, options: cockpit.JsonObject) {
const [content] = useFileWithError(path, options, { log_errors: true });
return content;
}
Expand All @@ -209,7 +213,7 @@ export function useFile(path, options) {
* function Component(param) {
* const obj = useObject(() => create_object(param),
* obj => obj.close(),
* [param], [deep_equal])
* [param] as const, [deep_equal])
*
* ...
* }
Expand Down Expand Up @@ -243,17 +247,24 @@ export function useFile(path, options) {
* are compared with the function at the same index in "comparators".
*/

function deps_changed(old_deps, new_deps, comps) {
type Tuple = readonly [...unknown[]];
type Comparator<T> = (a: T, b: T) => boolean;
type Comparators<T extends Tuple> = {[ t in keyof T ]?: Comparator<T[t]>};

function deps_changed<T extends Tuple>(old_deps: T | null, new_deps: T, comps: Comparators<T>): boolean {
return (!old_deps || old_deps.length != new_deps.length ||
old_deps.findIndex((o, i) => !(comps[i] || Object.is)(o, new_deps[i])) >= 0);
}

export function useObject(create, destroy, deps, comps) {
const ref = useRef(null);
const deps_ref = useRef(null);
const destroy_ref = useRef(null);
export function useObject<T, D extends Tuple>(create: () => T, destroy: ((value: T) => void) | null, deps: D, comps?: Comparators<D>): T {
const ref = useRef<T | null>(null);
const deps_ref = useRef<D | null>(null);
const destroy_ref = useRef<((value: T) => void) | null>(destroy);

if (deps_changed(deps_ref.current, deps, comps || [])) {
/* Since each item in Comparators<> is optional, `[]` should be valid here
* but for some reason it doesn't work — but `{}` does.
*/
if (deps_changed(deps_ref.current, deps, comps || {})) {
if (ref.current && destroy)
destroy(ref.current);
ref.current = create();
Expand All @@ -262,10 +273,10 @@ export function useObject(create, destroy, deps, comps) {

destroy_ref.current = destroy;
useEffect(() => {
return () => destroy_ref.current?.(ref.current);
return () => { destroy_ref.current?.(ref.current!) };
Copy link
Member

Choose a reason for hiding this comment

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

What's the bang needed for?

Copy link
Member Author

Choose a reason for hiding this comment

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

The destroy function takes a non-null pointer, but the current value is null-typed. The ! is an assertion which means "I promise this isn't null".

Copy link
Member

Choose a reason for hiding this comment

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

Why does it need to be wrapped into {} now? Does the current() call return something non-void?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think the ! somehow caused syntactic problems if it wasn't in a block. I honestly don't remember: it was a long time ago.

}, []);

return ref.current;
return ref.current!;
}

/* - useEvent(obj, event, handler)
Expand All @@ -283,16 +294,16 @@ export function useObject(create, destroy, deps, comps) {
* arguments of the event.
*/

export function useEvent(obj, event, handler) {
export function useEvent<EM extends cockpit.EventMap, E extends keyof EM>(obj: cockpit.EventSource<EM>, event: E, handler?: cockpit.EventListener<EM[E]>) {
// We increase a (otherwise unused) state variable whenever the event
// happens. That reliably triggers a re-render.

const [, forceUpdate] = useReducer(x => x + 1, 0);

useEffect(() => {
function update() {
function update(...args: Parameters<cockpit.EventListener<EM[E]>>) {
if (handler)
handler.apply(null, arguments);
handler(...args);
forceUpdate();
}

Expand Down Expand Up @@ -321,6 +332,6 @@ export function useEvent(obj, event, handler) {
* "useInit" and default to "[]".
*/

export function useInit(func, deps, comps, destroy = null) {
export function useInit<T, D extends Tuple>(func: () => T, deps: D, comps?: Comparators<D>, destroy: ((value: T) => void) | null = null): T {
return useObject(func, destroy, deps || [], comps);
}
Loading