fix lint: make bun tsc --noEmit actually pass (was a TS crash + latent errors)
tsconfig used jsx:"preserve"+jsxImportSource, which trips a TS 5.9
internal crash ("Expected sourceFile.imports[0] to be the synthesized JSX
runtime import") so tsc could never run clean. Switch to jsx:"react-jsx"
with the package's real jsx-runtime types, and fix the real errors that
surfaced:
- delete dead src/components/Navigation.tsx (imported ./Tab that doesn't
exist; the component has no importers)
- PlaybackControls: fix relative path to @/utils/audio-player
- SourceBadge: drop dead module-level typeColor (bare 'theme')
- command palette: bind to the real 'command' keybind (:) instead of the
never-defined 'command_list' action (palette was unreachable)
- yazi-pane-row test: destroy() must return Promise<void> as typed
Also fold in the package.json lint fix (bun tsc --noEmit) and doc polish.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
For,
|
||||
Show,
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
For,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
||||
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
|
||||
@@ -21,313 +21,319 @@ import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
* Command option for the command palette.
|
||||
*/
|
||||
export type CommandOption = {
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Unique identifier */
|
||||
value: string;
|
||||
/** Description shown below title */
|
||||
description?: string;
|
||||
/** Category for grouping */
|
||||
category?: string;
|
||||
/** Keybind reference */
|
||||
keybind?: keyof KeybindsResolved;
|
||||
/** Whether this command is suggested */
|
||||
suggested?: boolean;
|
||||
/** Slash command configuration */
|
||||
slash?: {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
/** Whether to hide from command list */
|
||||
hidden?: boolean;
|
||||
/** Whether command is enabled */
|
||||
enabled?: boolean;
|
||||
/** Footer text (usually keybind display) */
|
||||
footer?: string;
|
||||
/** Handler when command is selected */
|
||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Unique identifier */
|
||||
value: string;
|
||||
/** Description shown below title */
|
||||
description?: string;
|
||||
/** Category for grouping */
|
||||
category?: string;
|
||||
/** Keybind reference */
|
||||
keybind?: keyof KeybindsResolved;
|
||||
/** Whether this command is suggested */
|
||||
suggested?: boolean;
|
||||
/** Slash command configuration */
|
||||
slash?: {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
/** Whether to hide from command list */
|
||||
hidden?: boolean;
|
||||
/** Whether command is enabled */
|
||||
enabled?: boolean;
|
||||
/** Footer text (usually keybind display) */
|
||||
footer?: string;
|
||||
/** Handler when command is selected */
|
||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
|
||||
};
|
||||
|
||||
type CommandContext = ReturnType<typeof init>;
|
||||
const ctx = createContext<CommandContext>();
|
||||
|
||||
function init() {
|
||||
const [registrations, setRegistrations] = createSignal<
|
||||
Accessor<CommandOption[]>[]
|
||||
>([]);
|
||||
const [suspendCount, setSuspendCount] = createSignal(0);
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
const [registrations, setRegistrations] = createSignal<
|
||||
Accessor<CommandOption[]>[]
|
||||
>([]);
|
||||
const [suspendCount, setSuspendCount] = createSignal(0);
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x());
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}));
|
||||
});
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x());
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false;
|
||||
const isVisible = (option: CommandOption) =>
|
||||
isEnabled(option) && !option.hidden;
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false;
|
||||
const isVisible = (option: CommandOption) =>
|
||||
isEnabled(option) && !option.hidden;
|
||||
|
||||
const visibleOptions = createMemo(() =>
|
||||
entries().filter((option) => isVisible(option)),
|
||||
);
|
||||
const suggestedOptions = createMemo(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
);
|
||||
const suspended = () => suspendCount() > 0;
|
||||
const visibleOptions = createMemo(() =>
|
||||
entries().filter((option) => isVisible(option)),
|
||||
);
|
||||
const suggestedOptions = createMemo(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
);
|
||||
const suspended = () => suspendCount() > 0;
|
||||
|
||||
// Handle keybind shortcuts
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue;
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault();
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: option.value });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Handle keybind shortcuts
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue;
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault();
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: option.value });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = {
|
||||
/**
|
||||
* Trigger a command by its value.
|
||||
*/
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
if (!isEnabled(option)) return;
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: name });
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get all slash commands.
|
||||
*/
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash;
|
||||
if (!slash) return [];
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
};
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Enable/disable keybinds temporarily.
|
||||
*/
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1));
|
||||
},
|
||||
suspended,
|
||||
/**
|
||||
* Show the command palette dialog.
|
||||
*/
|
||||
show() {
|
||||
dialog.replace(() => (
|
||||
<CommandDialog
|
||||
options={visibleOptions()}
|
||||
suggestedOptions={suggestedOptions()}
|
||||
/>
|
||||
));
|
||||
},
|
||||
/**
|
||||
* Register commands. Returns cleanup function.
|
||||
*/
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb);
|
||||
setRegistrations((arr) => [results, ...arr]);
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get all visible options.
|
||||
*/
|
||||
get options() {
|
||||
return visibleOptions();
|
||||
},
|
||||
};
|
||||
return result;
|
||||
const result = {
|
||||
/**
|
||||
* Trigger a command by its value.
|
||||
*/
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
if (!isEnabled(option)) return;
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: name });
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get all slash commands.
|
||||
*/
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash;
|
||||
if (!slash) return [];
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
};
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Enable/disable keybinds temporarily.
|
||||
*/
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1));
|
||||
},
|
||||
suspended,
|
||||
/**
|
||||
* Show the command palette dialog.
|
||||
*/
|
||||
show() {
|
||||
dialog.replace(() => (
|
||||
<CommandDialog
|
||||
options={visibleOptions()}
|
||||
suggestedOptions={suggestedOptions()}
|
||||
/>
|
||||
));
|
||||
},
|
||||
/**
|
||||
* Register commands. Returns cleanup function.
|
||||
*/
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb);
|
||||
setRegistrations((arr) => [results, ...arr]);
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get all visible options.
|
||||
*/
|
||||
get options() {
|
||||
return visibleOptions();
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useCommandDialog() {
|
||||
const value = useContext(ctx);
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider");
|
||||
}
|
||||
return value;
|
||||
const value = useContext(ctx);
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function CommandProvider(props: ParentProps) {
|
||||
const value = init();
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
const value = init();
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
|
||||
// Open command palette on ctrl+p or command_list keybind
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
if (evt.defaultPrevented) return;
|
||||
if (keybind.match("command_list", evt)) {
|
||||
evt.preventDefault();
|
||||
value.show();
|
||||
return;
|
||||
}
|
||||
});
|
||||
// Open the command palette via the `command` keybind (bound to `:` in
|
||||
// keybinds.jsonc). The old hardcoded "command_list" name was never a
|
||||
// canonical action, so the palette was unreachable dead code.
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
if (evt.defaultPrevented) return;
|
||||
if (keybind.match("command", evt)) {
|
||||
evt.preventDefault();
|
||||
value.show();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command palette dialog component.
|
||||
*/
|
||||
function CommandDialog(props: {
|
||||
options: CommandOption[];
|
||||
suggestedOptions: CommandOption[];
|
||||
options: CommandOption[];
|
||||
suggestedOptions: CommandOption[];
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const dialog = useDialog();
|
||||
const dimensions = useTerminalDimensions();
|
||||
const [filter, setFilter] = createSignal("");
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const { theme } = useTheme();
|
||||
const dialog = useDialog();
|
||||
const dimensions = useTerminalDimensions();
|
||||
const [filter, setFilter] = createSignal("");
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const filteredOptions = createMemo(() => {
|
||||
const query = filter().toLowerCase();
|
||||
if (!query) {
|
||||
return [...props.suggestedOptions, ...props.options];
|
||||
}
|
||||
return props.options.filter(
|
||||
(option) =>
|
||||
option.title.toLowerCase().includes(query) ||
|
||||
option.description?.toLowerCase().includes(query) ||
|
||||
option.category?.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
const filteredOptions = createMemo(() => {
|
||||
const query = filter().toLowerCase();
|
||||
if (!query) {
|
||||
return [...props.suggestedOptions, ...props.options];
|
||||
}
|
||||
return props.options.filter(
|
||||
(option) =>
|
||||
option.title.toLowerCase().includes(query) ||
|
||||
option.description?.toLowerCase().includes(query) ||
|
||||
option.category?.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
// Reset selection when filter changes
|
||||
createMemo(() => {
|
||||
filter();
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
// Reset selection when filter changes
|
||||
createMemo(() => {
|
||||
filter();
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape") {
|
||||
dialog.clear();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape") {
|
||||
dialog.clear();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return" || evt.name === "enter") {
|
||||
const option = filteredOptions()[selectedIndex()];
|
||||
if (option) {
|
||||
option.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "return" || evt.name === "enter") {
|
||||
const option = filteredOptions()[selectedIndex()];
|
||||
if (option) {
|
||||
option.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text input
|
||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter((f) => f + evt.name);
|
||||
return;
|
||||
}
|
||||
// Handle text input
|
||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter((f) => f + evt.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace") {
|
||||
setFilter((f) => f.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (evt.name === "backspace") {
|
||||
setFilter((f) => f.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const maxHeight = Math.floor(dimensions().height * 0.6);
|
||||
const maxHeight = Math.floor(dimensions().height * 0.6);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" padding={1} borderColor={theme.border}>
|
||||
{/* Search input */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.textMuted}>{"> "}</text>
|
||||
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
|
||||
</box>
|
||||
return (
|
||||
<box flexDirection="column" padding={1} borderColor={theme.border}>
|
||||
{/* Search input */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.textMuted}>{"> "}</text>
|
||||
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
|
||||
</box>
|
||||
|
||||
{/* Command list */}
|
||||
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}>
|
||||
<For each={filteredOptions().slice(0, 10)}>
|
||||
{(option, index) => (
|
||||
<SelectableBox
|
||||
selected={() => index() === selectedIndex()}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index());
|
||||
const selectedOption = filteredOptions()[index()];
|
||||
if (selectedOption) {
|
||||
selectedOption.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{option.title}
|
||||
</SelectableText>
|
||||
<Show when={option.footer}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
tertiary
|
||||
>
|
||||
{option.footer}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
<Show when={option.description}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
tertiary
|
||||
>
|
||||
{option.description}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
</box>
|
||||
</SelectableBox>
|
||||
)}
|
||||
</For>
|
||||
<Show when={filteredOptions().length === 0}>
|
||||
<text fg={theme.textMuted} style={{ padding: 1 }}>
|
||||
No commands found
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
{/* Command list */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
maxHeight={maxHeight}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<For each={filteredOptions().slice(0, 10)}>
|
||||
{(option, index) => (
|
||||
<SelectableBox
|
||||
selected={() => index() === selectedIndex()}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index());
|
||||
const selectedOption = filteredOptions()[index()];
|
||||
if (selectedOption) {
|
||||
selectedOption.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{option.title}
|
||||
</SelectableText>
|
||||
<Show when={option.footer}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
tertiary
|
||||
>
|
||||
{option.footer}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
<Show when={option.description}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
tertiary
|
||||
>
|
||||
{option.description}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
</box>
|
||||
</SelectableBox>
|
||||
)}
|
||||
</For>
|
||||
<Show when={filteredOptions().length === 0}>
|
||||
<text fg={theme.textMuted} style={{ padding: 1 }}>
|
||||
No commands found
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user