From 0cc15c8d90b9234c5de436c8e4c4a79a75f91d44 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 7 Aug 2026 18:18:25 -0400 Subject: [PATCH] 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 as typed Also fold in the package.json lint fix (bun tsc --noEmit) and doc polish. --- CONTRIBUTING.md | 15 +- package.json | 2 +- src/components/Navigation.tsx | 28 -- src/pages/Player/PlaybackControls.tsx | 136 +++--- src/pages/Search/SourceBadge.tsx | 55 ++- src/ui/command.tsx | 568 +++++++++++++------------- tests/yazi-pane-row.test.tsx | 7 +- tsconfig.json | 2 +- 8 files changed, 403 insertions(+), 410 deletions(-) delete mode 100644 src/components/Navigation.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d9bd83..da54f01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,8 @@ make native # build libcavacore.dylib from the vendored C source bun run dev # launch with hot reload (alias: make dev) ``` -The app is a TUI — it expects a real terminal (kitty, iTerm2, WezTerm, tmux, -…). It will not render in a plain captured `bash` session. +The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2, + WezTerm, tmux, …). It will not render in a plain captured `bash` session. ## 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 start` | Run once (no watch) | | `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) | | `make dist` | Compile the standalone binary + make the current platform's tarball | | `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 ``` @@ -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 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 ```bash @@ -135,7 +128,7 @@ Audio is a no-op during those snapshots. The last frame lands in ## Releasing -Releases are built and published from **tags**; CI does the heavy lifting. +Releases are built and published from **tags** ### Steps diff --git a/package.json b/package.json index e606e66..1a21a75 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build": "bun run build.ts", "dist": "bun dist/index.js", "test": "bun test", - "lint": "bun run lint.ts" + "lint": "bun tsc --noEmit" }, "devDependencies": { "@types/bun": "latest", diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx deleted file mode 100644 index e5b15fc..0000000 --- a/src/components/Navigation.tsx +++ /dev/null @@ -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 ( - - - {props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "} - - {props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "} - - {props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "} - - {props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "} - - {props.activeTab === "player" ? "[" : " "}Player{props.activeTab === "player" ? "]" : " "} - - {props.activeTab === "settings" ? "[" : " "}Settings{props.activeTab === "settings" ? "]" : " "} - - - ) -} diff --git a/src/pages/Player/PlaybackControls.tsx b/src/pages/Player/PlaybackControls.tsx index 0e7bf33..46a7776 100644 --- a/src/pages/Player/PlaybackControls.tsx +++ b/src/pages/Player/PlaybackControls.tsx @@ -1,64 +1,86 @@ -import type { BackendName } from "../utils/audio-player" -import { useTheme } from "@/context/ThemeContext" +import type { BackendName } from "@/utils/audio-player"; +import { useTheme } from "@/context/ThemeContext"; type PlaybackControlsProps = { - isPlaying: boolean - volume: number - speed: number - backendName?: BackendName - hasAudioUrl?: boolean - onToggle: () => void - onPrev: () => void - onNext: () => void - onVolumeChange: (value: number) => void - onSpeedChange: (value: number) => void -} + isPlaying: boolean; + volume: number; + speed: number; + backendName?: BackendName; + hasAudioUrl?: boolean; + onToggle: () => void; + onPrev: () => void; + onNext: () => void; + onVolumeChange: (value: number) => void; + onSpeedChange: (value: number) => void; +}; const BACKEND_LABELS: Record = { - mpv: "mpv", - ffplay: "ffplay", - afplay: "afplay", - system: "system", - none: "none", -} + mpv: "mpv", + ffplay: "ffplay", + afplay: "afplay", + system: "system", + none: "none", +}; export function PlaybackControls(props: PlaybackControlsProps) { - const { theme } = useTheme(); - return ( - - - [Prev] - - - {props.isPlaying ? "[Pause]" : "[Play]"} - - - [Next] - - - Vol - {Math.round(props.volume * 100)}% - - - Speed - {props.speed}x - - {props.backendName && props.backendName !== "none" && ( - - via - {BACKEND_LABELS[props.backendName]} - - )} - {props.backendName === "none" && ( - - No audio player found - - )} - {props.hasAudioUrl === false && ( - - No audio URL - - )} - - ) + const { theme } = useTheme(); + return ( + + + [Prev] + + + {props.isPlaying ? "[Pause]" : "[Play]"} + + + [Next] + + + Vol + {Math.round(props.volume * 100)}% + + + Speed + {props.speed}x + + {props.backendName && props.backendName !== "none" && ( + + via + {BACKEND_LABELS[props.backendName]} + + )} + {props.backendName === "none" && ( + + No audio player found + + )} + {props.hasAudioUrl === false && ( + + No audio URL + + )} + + ); } diff --git a/src/pages/Search/SourceBadge.tsx b/src/pages/Search/SourceBadge.tsx index fc90246..3946b17 100644 --- a/src/pages/Search/SourceBadge.tsx +++ b/src/pages/Search/SourceBadge.tsx @@ -2,42 +2,37 @@ import { SourceType } from "@/types/source"; import { useTheme } from "@/context/ThemeContext"; type SourceBadgeProps = { - sourceId: string; - sourceName?: string; - sourceType?: SourceType; + sourceId: string; + sourceName?: string; + sourceType?: SourceType; }; const typeLabel = (sourceType?: SourceType) => { - if (sourceType === SourceType.API) return "API"; - if (sourceType === SourceType.RSS) return "RSS"; - if (sourceType === SourceType.CUSTOM) return "Custom"; - 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; + if (sourceType === SourceType.API) return "API"; + if (sourceType === SourceType.RSS) return "RSS"; + if (sourceType === SourceType.CUSTOM) return "Custom"; + return "Source"; }; +// No module-level typeColor here — it needs the theme from the component. +// The correct definition lives inside SourceBadge below. export function SourceBadge(props: SourceBadgeProps) { - const { theme } = useTheme(); - const label = () => props.sourceName || props.sourceId; + const { theme } = useTheme(); + const label = () => props.sourceName || props.sourceId; - 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; - }; + 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; + }; - return ( - - - [{typeLabel(props.sourceType)}] - - {label()} - - ); + return ( + + + [{typeLabel(props.sourceType)}] + + {label()} + + ); } diff --git a/src/ui/command.tsx b/src/ui/command.tsx index b1d0e85..fe963a9 100644 --- a/src/ui/command.tsx +++ b/src/ui/command.tsx @@ -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) => 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) => void; }; type CommandContext = ReturnType; const ctx = createContext(); function init() { - const [registrations, setRegistrations] = createSignal< - Accessor[] - >([]); - const [suspendCount, setSuspendCount] = createSignal(0); - const dialog = useDialog(); - const keybind = useKeybinds(); + const [registrations, setRegistrations] = createSignal< + Accessor[] + >([]); + 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(() => ( - - )); - }, - /** - * 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(() => ( + + )); + }, + /** + * 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 {props.children}; + return {props.children}; } /** * 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 ( - - {/* Search input */} - - {"> "} - {filter() || "Type to search commands..."} - + return ( + + {/* Search input */} + + {"> "} + {filter() || "Type to search commands..."} + - {/* Command list */} - - - {(option, index) => ( - index() === selectedIndex()} - flexDirection="column" - padding={1} - onMouseDown={() => { - setSelectedIndex(index()); - const selectedOption = filteredOptions()[index()]; - if (selectedOption) { - selectedOption.onSelect?.(dialog); - dialog.clear(); - } - }} - > - - index() === selectedIndex()} - primary - > - {option.title} - - - index() === selectedIndex()} - tertiary - > - {option.footer} - - - - index() === selectedIndex()} - tertiary - > - {option.description} - - - - - )} - - - - No commands found - - - - - ); + {/* Command list */} + + + {(option, index) => ( + index() === selectedIndex()} + flexDirection="column" + padding={1} + onMouseDown={() => { + setSelectedIndex(index()); + const selectedOption = filteredOptions()[index()]; + if (selectedOption) { + selectedOption.onSelect?.(dialog); + dialog.clear(); + } + }} + > + + index() === selectedIndex()} + primary + > + {option.title} + + + index() === selectedIndex()} + tertiary + > + {option.footer} + + + + index() === selectedIndex()} + tertiary + > + {option.description} + + + + + )} + + + + No commands found + + + + + ); } diff --git a/tests/yazi-pane-row.test.tsx b/tests/yazi-pane-row.test.tsx index d7d0b88..2c8013d 100644 --- a/tests/yazi-pane-row.test.tsx +++ b/tests/yazi-pane-row.test.tsx @@ -106,7 +106,12 @@ async function renderPaneRow(props: TestPaneProps): Promise<{ await new Promise((r) => setTimeout(r, 40)); } 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)[] = []; diff --git a/tsconfig.json b/tsconfig.json index b2bd7cc..88815dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", - "jsx": "preserve", + "jsx": "react-jsx", "jsxImportSource": "@opentui/solid", "strict": true, "skipLibCheck": true,