-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
} | ||
|
@@ -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; | ||
|
@@ -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")); | ||
|
@@ -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; | ||
} | ||
|
@@ -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]) | ||
* | ||
* ... | ||
* } | ||
|
@@ -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(); | ||
|
@@ -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!) }; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does it need to be wrapped into There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the |
||
}, []); | ||
|
||
return ref.current; | ||
return ref.current!; | ||
} | ||
|
||
/* - useEvent(obj, event, handler) | ||
|
@@ -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(); | ||
} | ||
|
||
|
@@ -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); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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".