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:
@@ -23,8 +23,8 @@ make native # build libcavacore.dylib from the vendored C source
|
|||||||
bun run dev # launch with hot reload (alias: make dev)
|
bun run dev # launch with hot reload (alias: make dev)
|
||||||
```
|
```
|
||||||
|
|
||||||
The app is a TUI — it expects a real terminal (kitty, iTerm2, WezTerm, tmux,
|
The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
|
||||||
…). It will not render in a plain captured `bash` session.
|
WezTerm, tmux, …). It will not render in a plain captured `bash` session.
|
||||||
|
|
||||||
## What each command does
|
## What each command does
|
||||||
|
|
||||||
@@ -35,14 +35,11 @@ The app is a TUI — it expects a real terminal (kitty, iTerm2, WezTerm, tmux,
|
|||||||
| `bun run dev` | Run with hot reload |
|
| `bun run dev` | Run with hot reload |
|
||||||
| `bun run start` | Run once (no watch) |
|
| `bun run start` | Run once (no watch) |
|
||||||
| `bun test` | Run the test suite (see [Testing](#testing)) |
|
| `bun test` | Run the test suite (see [Testing](#testing)) |
|
||||||
| `make lint` | Type-check with `bun tsc --noEmit` |
|
| `bun run lint` | Type-check |
|
||||||
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
|
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
|
||||||
| `make dist` | Compile the standalone binary + make the current platform's tarball |
|
| `make dist` | Compile the standalone binary + make the current platform's tarball |
|
||||||
| `make clean` | Remove `dist/` |
|
| `make clean` | Remove `dist/` |
|
||||||
|
|
||||||
> Note: `package.json` also has a `lint` script that points at a
|
|
||||||
> `lint.ts` file that doesn't exist. Use `make lint` (real type-checking).
|
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -112,10 +109,6 @@ Cavacore smoke test: `bun tests/cavacore-smoke.ts`
|
|||||||
`-headerpad`” for a prebuilt dylib. The app dlopens the libs by path, so
|
`-headerpad`” for a prebuilt dylib. The app dlopens the libs by path, so
|
||||||
the warning is cosmetic; installs complete and the app boots.
|
the warning is cosmetic; installs complete and the app boots.
|
||||||
|
|
||||||
4. **`make lint` is the truth, not the `package.json` scripts.**
|
|
||||||
The repo's ESLint wiring is stale; `make lint` runs the real
|
|
||||||
type-check and is what CI treats as the clean bar.
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -135,7 +128,7 @@ Audio is a no-op during those snapshots. The last frame lands in
|
|||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
Releases are built and published from **tags**; CI does the heavy lifting.
|
Releases are built and published from **tags**
|
||||||
|
|
||||||
### Steps
|
### Steps
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"build": "bun run build.ts",
|
"build": "bun run build.ts",
|
||||||
"dist": "bun dist/index.js",
|
"dist": "bun dist/index.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"lint": "bun run lint.ts"
|
"lint": "bun tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { TabId } from "./Tab"
|
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
|
||||||
|
|
||||||
type NavigationProps = {
|
|
||||||
activeTab: TabId
|
|
||||||
onTabSelect: (tab: TabId) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
|
||||||
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "player" ? "[" : " "}Player{props.activeTab === "player" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "settings" ? "[" : " "}Settings{props.activeTab === "settings" ? "]" : " "}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +1,86 @@
|
|||||||
import type { BackendName } from "../utils/audio-player"
|
import type { BackendName } from "@/utils/audio-player";
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
type PlaybackControlsProps = {
|
type PlaybackControlsProps = {
|
||||||
isPlaying: boolean
|
isPlaying: boolean;
|
||||||
volume: number
|
volume: number;
|
||||||
speed: number
|
speed: number;
|
||||||
backendName?: BackendName
|
backendName?: BackendName;
|
||||||
hasAudioUrl?: boolean
|
hasAudioUrl?: boolean;
|
||||||
onToggle: () => void
|
onToggle: () => void;
|
||||||
onPrev: () => void
|
onPrev: () => void;
|
||||||
onNext: () => void
|
onNext: () => void;
|
||||||
onVolumeChange: (value: number) => void
|
onVolumeChange: (value: number) => void;
|
||||||
onSpeedChange: (value: number) => void
|
onSpeedChange: (value: number) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
const BACKEND_LABELS: Record<BackendName, string> = {
|
||||||
mpv: "mpv",
|
mpv: "mpv",
|
||||||
ffplay: "ffplay",
|
ffplay: "ffplay",
|
||||||
afplay: "afplay",
|
afplay: "afplay",
|
||||||
system: "system",
|
system: "system",
|
||||||
none: "none",
|
none: "none",
|
||||||
}
|
};
|
||||||
|
|
||||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={1} alignItems="center" border padding={1} borderColor={theme.border}>
|
<box
|
||||||
<box border padding={0} onMouseDown={props.onPrev} borderColor={theme.border}>
|
flexDirection="row"
|
||||||
<text fg={theme.primary}>[Prev]</text>
|
gap={1}
|
||||||
</box>
|
alignItems="center"
|
||||||
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}>
|
border
|
||||||
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
padding={1}
|
||||||
</box>
|
borderColor={theme.border}
|
||||||
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}>
|
>
|
||||||
<text fg={theme.primary}>[Next]</text>
|
<box
|
||||||
</box>
|
border
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
padding={0}
|
||||||
<text fg={theme.textMuted}>Vol</text>
|
onMouseDown={props.onPrev}
|
||||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
borderColor={theme.border}
|
||||||
</box>
|
>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<text fg={theme.primary}>[Prev]</text>
|
||||||
<text fg={theme.textMuted}>Speed</text>
|
</box>
|
||||||
<text fg={theme.text}>{props.speed}x</text>
|
<box
|
||||||
</box>
|
border
|
||||||
{props.backendName && props.backendName !== "none" && (
|
padding={0}
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
onMouseDown={props.onToggle}
|
||||||
<text fg={theme.textMuted}>via</text>
|
borderColor={theme.border}
|
||||||
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
>
|
||||||
</box>
|
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||||
)}
|
</box>
|
||||||
{props.backendName === "none" && (
|
<box
|
||||||
<box marginLeft={2}>
|
border
|
||||||
<text fg={theme.warning}>No audio player found</text>
|
padding={0}
|
||||||
</box>
|
onMouseDown={props.onNext}
|
||||||
)}
|
borderColor={theme.border}
|
||||||
{props.hasAudioUrl === false && (
|
>
|
||||||
<box marginLeft={2}>
|
<text fg={theme.primary}>[Next]</text>
|
||||||
<text fg={theme.warning}>No audio URL</text>
|
</box>
|
||||||
</box>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
)}
|
<text fg={theme.textMuted}>Vol</text>
|
||||||
</box>
|
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||||
)
|
</box>
|
||||||
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
|
</box>
|
||||||
|
{props.backendName && props.backendName !== "none" && (
|
||||||
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
|
<text fg={theme.textMuted}>via</text>
|
||||||
|
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
{props.backendName === "none" && (
|
||||||
|
<box marginLeft={2}>
|
||||||
|
<text fg={theme.warning}>No audio player found</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
{props.hasAudioUrl === false && (
|
||||||
|
<box marginLeft={2}>
|
||||||
|
<text fg={theme.warning}>No audio URL</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,42 +2,37 @@ import { SourceType } from "@/types/source";
|
|||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
type SourceBadgeProps = {
|
type SourceBadgeProps = {
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
sourceType?: SourceType;
|
sourceType?: SourceType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeLabel = (sourceType?: SourceType) => {
|
const typeLabel = (sourceType?: SourceType) => {
|
||||||
if (sourceType === SourceType.API) return "API";
|
if (sourceType === SourceType.API) return "API";
|
||||||
if (sourceType === SourceType.RSS) return "RSS";
|
if (sourceType === SourceType.RSS) return "RSS";
|
||||||
if (sourceType === SourceType.CUSTOM) return "Custom";
|
if (sourceType === SourceType.CUSTOM) return "Custom";
|
||||||
return "Source";
|
return "Source";
|
||||||
};
|
|
||||||
|
|
||||||
const typeColor = (sourceType?: SourceType) => {
|
|
||||||
if (sourceType === SourceType.API) return theme.primary;
|
|
||||||
if (sourceType === SourceType.RSS) return theme.success;
|
|
||||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
|
||||||
return theme.textMuted;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// No module-level typeColor here — it needs the theme from the component.
|
||||||
|
// The correct definition lives inside SourceBadge below.
|
||||||
export function SourceBadge(props: SourceBadgeProps) {
|
export function SourceBadge(props: SourceBadgeProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const label = () => props.sourceName || props.sourceId;
|
const label = () => props.sourceName || props.sourceId;
|
||||||
|
|
||||||
const typeColor = (sourceType?: SourceType) => {
|
const typeColor = (sourceType?: SourceType) => {
|
||||||
if (sourceType === SourceType.API) return theme.primary;
|
if (sourceType === SourceType.API) return theme.primary;
|
||||||
if (sourceType === SourceType.RSS) return theme.success;
|
if (sourceType === SourceType.RSS) return theme.success;
|
||||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
||||||
return theme.textMuted;
|
return theme.textMuted;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={1} padding={0}>
|
<box flexDirection="row" gap={1} padding={0}>
|
||||||
<text fg={typeColor(props.sourceType)}>
|
<text fg={typeColor(props.sourceType)}>
|
||||||
[{typeLabel(props.sourceType)}]
|
[{typeLabel(props.sourceType)}]
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.textMuted}>{label()}</text>
|
<text fg={theme.textMuted}>{label()}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
useContext,
|
useContext,
|
||||||
type Accessor,
|
type Accessor,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
For,
|
For,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
||||||
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
|
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
|
||||||
@@ -21,313 +21,319 @@ import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|||||||
* Command option for the command palette.
|
* Command option for the command palette.
|
||||||
*/
|
*/
|
||||||
export type CommandOption = {
|
export type CommandOption = {
|
||||||
/** Display title */
|
/** Display title */
|
||||||
title: string;
|
title: string;
|
||||||
/** Unique identifier */
|
/** Unique identifier */
|
||||||
value: string;
|
value: string;
|
||||||
/** Description shown below title */
|
/** Description shown below title */
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Category for grouping */
|
/** Category for grouping */
|
||||||
category?: string;
|
category?: string;
|
||||||
/** Keybind reference */
|
/** Keybind reference */
|
||||||
keybind?: keyof KeybindsResolved;
|
keybind?: keyof KeybindsResolved;
|
||||||
/** Whether this command is suggested */
|
/** Whether this command is suggested */
|
||||||
suggested?: boolean;
|
suggested?: boolean;
|
||||||
/** Slash command configuration */
|
/** Slash command configuration */
|
||||||
slash?: {
|
slash?: {
|
||||||
name: string;
|
name: string;
|
||||||
aliases?: string[];
|
aliases?: string[];
|
||||||
};
|
};
|
||||||
/** Whether to hide from command list */
|
/** Whether to hide from command list */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
/** Whether command is enabled */
|
/** Whether command is enabled */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Footer text (usually keybind display) */
|
/** Footer text (usually keybind display) */
|
||||||
footer?: string;
|
footer?: string;
|
||||||
/** Handler when command is selected */
|
/** Handler when command is selected */
|
||||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
|
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CommandContext = ReturnType<typeof init>;
|
type CommandContext = ReturnType<typeof init>;
|
||||||
const ctx = createContext<CommandContext>();
|
const ctx = createContext<CommandContext>();
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const [registrations, setRegistrations] = createSignal<
|
const [registrations, setRegistrations] = createSignal<
|
||||||
Accessor<CommandOption[]>[]
|
Accessor<CommandOption[]>[]
|
||||||
>([]);
|
>([]);
|
||||||
const [suspendCount, setSuspendCount] = createSignal(0);
|
const [suspendCount, setSuspendCount] = createSignal(0);
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const keybind = useKeybinds();
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
const entries = createMemo(() => {
|
const entries = createMemo(() => {
|
||||||
const all = registrations().flatMap((x) => x());
|
const all = registrations().flatMap((x) => x());
|
||||||
return all.map((x) => ({
|
return all.map((x) => ({
|
||||||
...x,
|
...x,
|
||||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEnabled = (option: CommandOption) => option.enabled !== false;
|
const isEnabled = (option: CommandOption) => option.enabled !== false;
|
||||||
const isVisible = (option: CommandOption) =>
|
const isVisible = (option: CommandOption) =>
|
||||||
isEnabled(option) && !option.hidden;
|
isEnabled(option) && !option.hidden;
|
||||||
|
|
||||||
const visibleOptions = createMemo(() =>
|
const visibleOptions = createMemo(() =>
|
||||||
entries().filter((option) => isVisible(option)),
|
entries().filter((option) => isVisible(option)),
|
||||||
);
|
);
|
||||||
const suggestedOptions = createMemo(() =>
|
const suggestedOptions = createMemo(() =>
|
||||||
visibleOptions()
|
visibleOptions()
|
||||||
.filter((option) => option.suggested)
|
.filter((option) => option.suggested)
|
||||||
.map((option) => ({
|
.map((option) => ({
|
||||||
...option,
|
...option,
|
||||||
value: `suggested:${option.value}`,
|
value: `suggested:${option.value}`,
|
||||||
category: "Suggested",
|
category: "Suggested",
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const suspended = () => suspendCount() > 0;
|
const suspended = () => suspendCount() > 0;
|
||||||
|
|
||||||
// Handle keybind shortcuts
|
// Handle keybind shortcuts
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (suspended()) return;
|
if (suspended()) return;
|
||||||
if (dialog.isOpen) return;
|
if (dialog.isOpen) return;
|
||||||
for (const option of entries()) {
|
for (const option of entries()) {
|
||||||
if (!isEnabled(option)) continue;
|
if (!isEnabled(option)) continue;
|
||||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
option.onSelect?.(dialog);
|
option.onSelect?.(dialog);
|
||||||
emit("command.execute", { command: option.value });
|
emit("command.execute", { command: option.value });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
/**
|
/**
|
||||||
* Trigger a command by its value.
|
* Trigger a command by its value.
|
||||||
*/
|
*/
|
||||||
trigger(name: string) {
|
trigger(name: string) {
|
||||||
for (const option of entries()) {
|
for (const option of entries()) {
|
||||||
if (option.value === name) {
|
if (option.value === name) {
|
||||||
if (!isEnabled(option)) return;
|
if (!isEnabled(option)) return;
|
||||||
option.onSelect?.(dialog);
|
option.onSelect?.(dialog);
|
||||||
emit("command.execute", { command: name });
|
emit("command.execute", { command: name });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Get all slash commands.
|
* Get all slash commands.
|
||||||
*/
|
*/
|
||||||
slashes() {
|
slashes() {
|
||||||
return visibleOptions().flatMap((option) => {
|
return visibleOptions().flatMap((option) => {
|
||||||
const slash = option.slash;
|
const slash = option.slash;
|
||||||
if (!slash) return [];
|
if (!slash) return [];
|
||||||
return {
|
return {
|
||||||
display: "/" + slash.name,
|
display: "/" + slash.name,
|
||||||
description: option.description ?? option.title,
|
description: option.description ?? option.title,
|
||||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||||
onSelect: () => result.trigger(option.value),
|
onSelect: () => result.trigger(option.value),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Enable/disable keybinds temporarily.
|
* Enable/disable keybinds temporarily.
|
||||||
*/
|
*/
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
setSuspendCount((count) => count + (enabled ? -1 : 1));
|
setSuspendCount((count) => count + (enabled ? -1 : 1));
|
||||||
},
|
},
|
||||||
suspended,
|
suspended,
|
||||||
/**
|
/**
|
||||||
* Show the command palette dialog.
|
* Show the command palette dialog.
|
||||||
*/
|
*/
|
||||||
show() {
|
show() {
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<CommandDialog
|
<CommandDialog
|
||||||
options={visibleOptions()}
|
options={visibleOptions()}
|
||||||
suggestedOptions={suggestedOptions()}
|
suggestedOptions={suggestedOptions()}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Register commands. Returns cleanup function.
|
* Register commands. Returns cleanup function.
|
||||||
*/
|
*/
|
||||||
register(cb: () => CommandOption[]) {
|
register(cb: () => CommandOption[]) {
|
||||||
const results = createMemo(cb);
|
const results = createMemo(cb);
|
||||||
setRegistrations((arr) => [results, ...arr]);
|
setRegistrations((arr) => [results, ...arr]);
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Get all visible options.
|
* Get all visible options.
|
||||||
*/
|
*/
|
||||||
get options() {
|
get options() {
|
||||||
return visibleOptions();
|
return visibleOptions();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommandDialog() {
|
export function useCommandDialog() {
|
||||||
const value = useContext(ctx);
|
const value = useContext(ctx);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error("useCommandDialog must be used within a CommandProvider");
|
throw new Error("useCommandDialog must be used within a CommandProvider");
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandProvider(props: ParentProps) {
|
export function CommandProvider(props: ParentProps) {
|
||||||
const value = init();
|
const value = init();
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const keybind = useKeybinds();
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
// Open command palette on ctrl+p or command_list keybind
|
// Open the command palette via the `command` keybind (bound to `:` in
|
||||||
useKeyboard((evt) => {
|
// keybinds.jsonc). The old hardcoded "command_list" name was never a
|
||||||
if (value.suspended()) return;
|
// canonical action, so the palette was unreachable dead code.
|
||||||
if (dialog.isOpen) return;
|
useKeyboard((evt) => {
|
||||||
if (evt.defaultPrevented) return;
|
if (value.suspended()) return;
|
||||||
if (keybind.match("command_list", evt)) {
|
if (dialog.isOpen) return;
|
||||||
evt.preventDefault();
|
if (evt.defaultPrevented) return;
|
||||||
value.show();
|
if (keybind.match("command", evt)) {
|
||||||
return;
|
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.
|
* Command palette dialog component.
|
||||||
*/
|
*/
|
||||||
function CommandDialog(props: {
|
function CommandDialog(props: {
|
||||||
options: CommandOption[];
|
options: CommandOption[];
|
||||||
suggestedOptions: CommandOption[];
|
suggestedOptions: CommandOption[];
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const dimensions = useTerminalDimensions();
|
const dimensions = useTerminalDimensions();
|
||||||
const [filter, setFilter] = createSignal("");
|
const [filter, setFilter] = createSignal("");
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||||
|
|
||||||
const filteredOptions = createMemo(() => {
|
const filteredOptions = createMemo(() => {
|
||||||
const query = filter().toLowerCase();
|
const query = filter().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return [...props.suggestedOptions, ...props.options];
|
return [...props.suggestedOptions, ...props.options];
|
||||||
}
|
}
|
||||||
return props.options.filter(
|
return props.options.filter(
|
||||||
(option) =>
|
(option) =>
|
||||||
option.title.toLowerCase().includes(query) ||
|
option.title.toLowerCase().includes(query) ||
|
||||||
option.description?.toLowerCase().includes(query) ||
|
option.description?.toLowerCase().includes(query) ||
|
||||||
option.category?.toLowerCase().includes(query),
|
option.category?.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset selection when filter changes
|
// Reset selection when filter changes
|
||||||
createMemo(() => {
|
createMemo(() => {
|
||||||
filter();
|
filter();
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.name === "escape") {
|
if (evt.name === "escape") {
|
||||||
dialog.clear();
|
dialog.clear();
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "return" || evt.name === "enter") {
|
if (evt.name === "return" || evt.name === "enter") {
|
||||||
const option = filteredOptions()[selectedIndex()];
|
const option = filteredOptions()[selectedIndex()];
|
||||||
if (option) {
|
if (option) {
|
||||||
option.onSelect?.(dialog);
|
option.onSelect?.(dialog);
|
||||||
dialog.clear();
|
dialog.clear();
|
||||||
}
|
}
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
|
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle text input
|
// Handle text input
|
||||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||||
setFilter((f) => f + evt.name);
|
setFilter((f) => f + evt.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "backspace") {
|
if (evt.name === "backspace") {
|
||||||
setFilter((f) => f.slice(0, -1));
|
setFilter((f) => f.slice(0, -1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxHeight = Math.floor(dimensions().height * 0.6);
|
const maxHeight = Math.floor(dimensions().height * 0.6);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" padding={1} borderColor={theme.border}>
|
<box flexDirection="column" padding={1} borderColor={theme.border}>
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<box marginBottom={1}>
|
<box marginBottom={1}>
|
||||||
<text fg={theme.textMuted}>{"> "}</text>
|
<text fg={theme.textMuted}>{"> "}</text>
|
||||||
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
|
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Command list */}
|
{/* Command list */}
|
||||||
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}>
|
<box
|
||||||
<For each={filteredOptions().slice(0, 10)}>
|
flexDirection="column"
|
||||||
{(option, index) => (
|
maxHeight={maxHeight}
|
||||||
<SelectableBox
|
borderColor={theme.border}
|
||||||
selected={() => index() === selectedIndex()}
|
>
|
||||||
flexDirection="column"
|
<For each={filteredOptions().slice(0, 10)}>
|
||||||
padding={1}
|
{(option, index) => (
|
||||||
onMouseDown={() => {
|
<SelectableBox
|
||||||
setSelectedIndex(index());
|
selected={() => index() === selectedIndex()}
|
||||||
const selectedOption = filteredOptions()[index()];
|
flexDirection="column"
|
||||||
if (selectedOption) {
|
padding={1}
|
||||||
selectedOption.onSelect?.(dialog);
|
onMouseDown={() => {
|
||||||
dialog.clear();
|
setSelectedIndex(index());
|
||||||
}
|
const selectedOption = filteredOptions()[index()];
|
||||||
}}
|
if (selectedOption) {
|
||||||
>
|
selectedOption.onSelect?.(dialog);
|
||||||
<box flexDirection="column" flexGrow={1}>
|
dialog.clear();
|
||||||
<SelectableText
|
}
|
||||||
selected={() => index() === selectedIndex()}
|
}}
|
||||||
primary
|
>
|
||||||
>
|
<box flexDirection="column" flexGrow={1}>
|
||||||
{option.title}
|
<SelectableText
|
||||||
</SelectableText>
|
selected={() => index() === selectedIndex()}
|
||||||
<Show when={option.footer}>
|
primary
|
||||||
<SelectableText
|
>
|
||||||
selected={() => index() === selectedIndex()}
|
{option.title}
|
||||||
tertiary
|
</SelectableText>
|
||||||
>
|
<Show when={option.footer}>
|
||||||
{option.footer}
|
<SelectableText
|
||||||
</SelectableText>
|
selected={() => index() === selectedIndex()}
|
||||||
</Show>
|
tertiary
|
||||||
<Show when={option.description}>
|
>
|
||||||
<SelectableText
|
{option.footer}
|
||||||
selected={() => index() === selectedIndex()}
|
</SelectableText>
|
||||||
tertiary
|
</Show>
|
||||||
>
|
<Show when={option.description}>
|
||||||
{option.description}
|
<SelectableText
|
||||||
</SelectableText>
|
selected={() => index() === selectedIndex()}
|
||||||
</Show>
|
tertiary
|
||||||
</box>
|
>
|
||||||
</SelectableBox>
|
{option.description}
|
||||||
)}
|
</SelectableText>
|
||||||
</For>
|
</Show>
|
||||||
<Show when={filteredOptions().length === 0}>
|
</box>
|
||||||
<text fg={theme.textMuted} style={{ padding: 1 }}>
|
</SelectableBox>
|
||||||
No commands found
|
)}
|
||||||
</text>
|
</For>
|
||||||
</Show>
|
<Show when={filteredOptions().length === 0}>
|
||||||
</box>
|
<text fg={theme.textMuted} style={{ padding: 1 }}>
|
||||||
</box>
|
No commands found
|
||||||
);
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,12 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
|||||||
await new Promise((r) => setTimeout(r, 40));
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
}
|
}
|
||||||
const spans = setup.captureSpans() as unknown as Frame;
|
const spans = setup.captureSpans() as unknown as Frame;
|
||||||
return { spans, destroy: () => setup.renderer.destroy() };
|
return {
|
||||||
|
spans,
|
||||||
|
destroy: async () => {
|
||||||
|
setup.renderer.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanups: (() => void | Promise<void>)[] = [];
|
const cleanups: (() => void | Promise<void>)[] = [];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "@opentui/solid",
|
"jsxImportSource": "@opentui/solid",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user