diff --git a/package-lock.json b/package-lock.json index c61b736..a539b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arena-cli", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arena-cli", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "ink": "^6.8.0", "openapi-fetch": "^0.17.0", diff --git a/package.json b/package.json index e2c22ec..1f742e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aredotna/cli", - "version": "0.4.0", + "version": "0.5.0", "description": "Are.na from the terminal", "type": "module", "bin": { diff --git a/src/api/types.ts b/src/api/types.ts index d1cc37b..b760af9 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -14,7 +14,8 @@ export type Connectable = Schemas["Block"] | Schemas["Channel"]; export type SearchResult = | Schemas["Block"] | Schemas["Channel"] - | Schemas["User"]; + | Schemas["User"] + | Schemas["Group"]; export type Followable = | Schemas["User"] | Schemas["Channel"] diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 63c3f88..80b0c8e 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -35,6 +35,10 @@ export function SessionMode() { const { exit } = useApp(); const auth = useSessionAuth(); const [paletteActive, setPaletteActive] = useState(false); + const [paletteOpenRequest, setPaletteOpenRequest] = useState<{ + id: number; + seed: string; + } | null>(null); const [headerOverride, setHeaderOverride] = useState(null); const [runtimeFooterActions, setRuntimeFooterActions] = useState< SessionFooterAction[] @@ -86,6 +90,16 @@ export function SessionMode() { }, [auth.status]); const normalizedCurrent = normalizeView(current); + const openPalette = useCallback((seed = "") => { + setPaletteOpenRequest((current) => ({ + id: (current?.id ?? 0) + 1, + seed, + })); + }, []); + const logout = useCallback(() => { + config.clearToken(); + exit(); + }, [exit]); useEffect(() => { if (!isSameView(current, normalizedCurrent)) { @@ -119,6 +133,9 @@ export function SessionMode() { pop, replace, reset, + logout, + exit, + openPalette, }; const defaultFooterActions = getSessionFooterActions(view, me); @@ -167,12 +184,12 @@ export function SessionMode() { }} onBack={pop} onLogout={() => { - config.clearToken(); - exit(); + logout(); }} onExit={exit} onOpenBrowser={() => openBrowserForView(view)} onActiveChange={setPaletteActive} + openRequest={paletteOpenRequest} /> diff --git a/src/commands/session/command-specs.ts b/src/commands/session/command-specs.ts index 4fa97cf..a40f2d6 100644 --- a/src/commands/session/command-specs.ts +++ b/src/commands/session/command-specs.ts @@ -168,6 +168,12 @@ export const SESSION_COMMAND_SPECS: CommandSpec[] = [ }, ]; +export function getAvailableSessionCommands(view: SessionView): CommandSpec[] { + return SESSION_COMMAND_SPECS.filter( + (command) => command.when?.(view) ?? true, + ); +} + export const SESSION_ARG_HINTS: Record = { "": "enter slug", "": "enter search query", diff --git a/src/commands/session/session-view-config.tsx b/src/commands/session/session-view-config.tsx index 67ef724..0df327d 100644 --- a/src/commands/session/session-view-config.tsx +++ b/src/commands/session/session-view-config.tsx @@ -22,6 +22,9 @@ export interface SessionViewContext { pop: () => void; replace: (view: SessionView) => void; reset: (view: SessionView) => void; + logout: () => void; + exit: () => void; + openPalette: (seed?: string) => void; } export interface SessionViewConfig< @@ -52,13 +55,21 @@ const LIST_FOOTER: SessionFooterAction[] = [ export const SESSION_VIEW_REGISTRY: SessionViewRegistry = { home: { - render: ({ context }) => , + render: ({ context }) => ( + + ), buildBreadcrumbTitle: ({ me }) => me.name, footerActions: [ - { key: "↑↓", label: "select" }, - { key: "↵", label: "run" }, - { key: "tab", label: "complete" }, - { key: "esc", label: "clear" }, + { key: "j/k", label: "move" }, + { key: "↵", label: "run/edit" }, + { key: "/", label: "open commands" }, + { key: "ctrl+c", label: "exit" }, ], }, channel: { diff --git a/src/components/ChannelsList.tsx b/src/components/ChannelsList.tsx index 6e3dcbc..bf6902b 100644 --- a/src/components/ChannelsList.tsx +++ b/src/components/ChannelsList.tsx @@ -85,7 +85,7 @@ export function ChannelsList({ return ( - + {channels.map((ch, i) => ( diff --git a/src/components/HomeScreen.tsx b/src/components/HomeScreen.tsx index 1139ad6..b478215 100644 --- a/src/components/HomeScreen.tsx +++ b/src/components/HomeScreen.tsx @@ -1,6 +1,97 @@ -import { Box } from "ink"; +import { Box, Text } from "ink"; +import { useMemo, useState } from "react"; import type { User } from "../api/types"; +import { + getAvailableSessionCommands, + type CommandSpecContext, +} from "../commands/session/command-specs"; +import { usePagedCursorList } from "../hooks/usePagedCursorList"; +import { useSessionListNavigation } from "../hooks/useSessionListNavigation"; +import { accentColor, mutedColor } from "../lib/theme"; +import type { SessionView } from "../commands/session/session-view"; +import { useSessionPaletteActive } from "./SessionPaletteContext"; +import { Panel, ScreenFrame } from "./ScreenChrome"; -export function HomeScreen({ me: _me }: { me: User }) { - return ; +export function HomeScreen({ + me, + onNavigate, + onLogout, + onExit, + onOpenPalette, +}: { + me: User; + onNavigate: (view: SessionView) => void; + onLogout: () => void; + onExit: () => void; + onOpenPalette: (seed?: string) => void; +}) { + const paletteActive = useSessionPaletteActive(); + const [error, setError] = useState(null); + const listState = usePagedCursorList({}); + const actions = useMemo( + () => getAvailableSessionCommands({ kind: "home" }), + [], + ); + const commandContext = useMemo( + () => ({ + me, + view: { kind: "home" }, + navigate: onNavigate, + back: () => {}, + logout: onLogout, + exit: onExit, + openBrowser: () => {}, + }), + [me, onExit, onLogout, onNavigate], + ); + + const list = useSessionListNavigation({ + state: { + page: listState.page, + cursor: listState.cursor, + }, + handlers: listState, + itemCount: actions.length, + paletteActive, + canNextPage: () => false, + onBack: () => {}, + onOpen: (index) => { + const action = actions[index]; + if (!action) return; + if (action.args) { + onOpenPalette(`${action.name} `); + return; + } + try { + action.run(commandContext, ""); + setError(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + }); + + return ( + + + + {actions.map((action, index) => ( + + + {list.state.cursor === index ? "▸ " : " "} + + + /{action.name} + + {action.args ? ( + {action.args} + ) : null} + · {action.desc} + + ))} + {error ? ✕ {error} : null} + + + + ); } diff --git a/src/components/ScreenChrome.tsx b/src/components/ScreenChrome.tsx index 3760fef..ba6fcd2 100644 --- a/src/components/ScreenChrome.tsx +++ b/src/components/ScreenChrome.tsx @@ -49,7 +49,7 @@ export function useSessionShellHeaderOverride(title?: ReactNode): boolean { export function SessionHeader({ title }: { title?: ReactNode }) { return ( - + ** diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index 02eb4b8..50a6036 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -1,12 +1,18 @@ import { Box, Text } from "ink"; import useSWR from "swr"; import { ArenaError, client, getData } from "../api/client"; -import type { Block, Channel } from "../api/types"; -import { truncate } from "../lib/format"; +import type { + Block, + Channel, + Connectable, + Group, + SearchResult, + User, +} from "../api/types"; import { openUrl } from "../lib/open"; import { usePagedCursorList } from "../hooks/usePagedCursorList"; import { useSessionListNavigation } from "../hooks/useSessionListNavigation"; -import { channelColor, INDICATORS } from "../lib/theme"; +import { accentColor, mutedColor } from "../lib/theme"; import { BlockItem } from "./BlockItem"; import { useSessionPaletteActive } from "./SessionPaletteContext"; import { Panel, ScreenFrame } from "./ScreenChrome"; @@ -14,7 +20,29 @@ import { ScreenEmpty, ScreenError, ScreenLoading } from "./ScreenStates"; type SearchNavigateView = | { kind: "channel"; slug: string } - | { kind: "block"; blockIds: number[]; index: number }; + | { kind: "block"; blockIds: number[]; index: number } + | { kind: "userProfile"; slug: string } + | { kind: "groupProfile"; slug: string }; + +function isChannel(item: SearchResult): item is Channel { + return item.type === "Channel"; +} + +function isUser(item: SearchResult): item is User { + return item.type === "User"; +} + +function isGroup(item: SearchResult): item is Group { + return item.type === "Group"; +} + +function isBlock(item: SearchResult): item is Block { + return !isChannel(item) && !isUser(item) && !isGroup(item); +} + +function isConnectable(item: SearchResult): item is Connectable { + return isChannel(item) || isBlock(item); +} export function SearchResults({ query, @@ -38,23 +66,10 @@ export function SearchResults({ client.GET("/v3/search", { params: { query: { query, page: pagedCursor.page, per: PER } }, }), - ).then((r) => { - const channels = r.data.filter((i) => i.type === "Channel") as Channel[]; - const blocks = r.data.filter( - (i) => i.type !== "Channel" && i.type !== "User", - ) as Block[]; - return { - channels, - blocks, - items: [...channels, ...blocks], - meta: r.meta, - }; - }), + ), ); - const items = data?.items ?? []; - const channels = data?.channels ?? []; - const blocks = data?.blocks ?? []; + const items = (data?.data ?? []) as SearchResult[]; const list = useSessionListNavigation({ state: { @@ -69,14 +84,20 @@ export function SearchResults({ onOpen: (selectedIndex) => { const item = items[selectedIndex]; if (!item) return; - if (item.type === "Channel") { - const channel = item as Channel; - if (channel.slug) { - onNavigate({ kind: "channel", slug: channel.slug }); - } + if (isChannel(item)) { + if (item.slug) onNavigate({ kind: "channel", slug: item.slug }); + return; + } + if (isUser(item)) { + if (item.slug) onNavigate({ kind: "userProfile", slug: item.slug }); + return; + } + if (isGroup(item)) { + if (item.slug) onNavigate({ kind: "groupProfile", slug: item.slug }); return; } - const blockIds = blocks.map((block) => block.id); + + const blockIds = items.filter(isBlock).map((block) => block.id); const index = blockIds.indexOf(item.id); if (index >= 0) { onNavigate({ @@ -90,13 +111,20 @@ export function SearchResults({ onOpenBrowser: (selectedIndex) => { const item = items[selectedIndex]; if (!item) return; - if (item.type === "Channel") { - const channel = item as Channel; + if (isChannel(item)) { openUrl( - `https://www.are.na/${channel.owner?.slug || ""}/${channel.slug || ""}`, + `https://www.are.na/${item.owner?.slug || ""}/${item.slug || ""}`, ); return; } + if (isUser(item)) { + openUrl(`https://www.are.na/${item.slug || ""}`); + return; + } + if (isGroup(item)) { + openUrl(`https://www.are.na/group/${item.slug || ""}`); + return; + } openUrl(`https://www.are.na/block/${item.id}`); }, }); @@ -119,45 +147,43 @@ export function SearchResults({ - {channels.length > 0 && ( - <> - Channels - {channels.map((ch, i) => ( - + {items.map((item, i) => { + if (isConnectable(item)) { + return ( + + ); + } + + if (isUser(item)) { + return ( + {i === list.state.cursor ? "▸ " : " "} - - {INDICATORS.Channel} {truncate(ch.title ?? "Untitled", 50)} - - - {" "} - · {ch.visibility} · {ch.counts.contents} + + @{item.slug} + · {item.name} - ))} - - )} - - {blocks.length > 0 && ( - <> - {channels.length > 0 && } - Blocks - {blocks.map((block, i) => { - const idx = channels.length + i; - return ( - - ); - })} - - )} + ); + } + + return ( + + + {i === list.state.cursor ? "▸ " : " "} + + + {item.name} + + · group/{item.slug} + + ); + })} diff --git a/src/components/SessionFooter.tsx b/src/components/SessionFooter.tsx index f768ab3..3f732f4 100644 --- a/src/components/SessionFooter.tsx +++ b/src/components/SessionFooter.tsx @@ -6,7 +6,7 @@ export function SessionFooter({ actions }: { actions: SessionFooterAction[] }) { if (actions.length === 0) return null; return ( - + {actions.map((action, index) => ( diff --git a/src/components/SessionPalette.tsx b/src/components/SessionPalette.tsx index 0f29427..795bb2e 100644 --- a/src/components/SessionPalette.tsx +++ b/src/components/SessionPalette.tsx @@ -3,7 +3,7 @@ import { Box, Text, useInput } from "ink"; import type { User } from "../api/types"; import { SESSION_ARG_HINTS, - SESSION_COMMAND_SPECS, + getAvailableSessionCommands, type CommandSpec, type CommandSpecContext, } from "../commands/session/command-specs"; @@ -11,7 +11,6 @@ import type { SessionView } from "../commands/session/session-view"; import { accentColor, brandColor, - dockPromptBackgroundColor, dockTextColor, mutedColor, } from "../lib/theme"; @@ -100,6 +99,7 @@ export function SessionPalette({ onExit, onOpenBrowser, onActiveChange, + openRequest, }: { me: User; view: SessionView; @@ -109,8 +109,9 @@ export function SessionPalette({ onExit: () => void; onOpenBrowser: () => void; onActiveChange: (active: boolean) => void; + openRequest: { id: number; seed: string } | null; }) { - const [active, setActive] = useState(view.kind === "home"); + const [active, setActive] = useState(false); const [input, setInput] = useState(""); const [cursor, setCursor] = useState(0); const [error, setError] = useState(null); @@ -129,8 +130,7 @@ export function SessionPalette({ ); const availableCommands = useMemo( - () => - SESSION_COMMAND_SPECS.filter((command) => command.when?.(view) ?? true), + () => getAvailableSessionCommands(view), [view], ); @@ -152,15 +152,9 @@ export function SessionPalette({ const selected = filtered[cursor] ?? filtered[0] ?? null; const startHex = normalizeHex(dockTextColor() ?? "white") ?? "#ffffff"; - const endHex = normalizeHex(dockPromptBackgroundColor()) ?? startHex; + const endHex = normalizeHex(mutedColor()) ?? startHex; + const divider = "─".repeat(Math.max(1, process.stdout.columns ?? 80)); - const inactivePreviewCommands = useMemo(() => { - const names = availableCommands.map((command) => command.name); - const columns = process.stdout.columns ?? 80; - // Account for prompt caret, left/right padding, and breathing room. - const maxWidth = Math.max(0, columns - 8); - return fitCommandNamesToWidth(names, maxWidth); - }, [availableCommands]); const activePreviewCommands = useMemo(() => { const names = filtered.map((command) => command.name); const columns = process.stdout.columns ?? 80; @@ -174,13 +168,14 @@ export function SessionPalette({ }, [active, onActiveChange]); useEffect(() => { - if (view.kind === "home") { - setActive(true); - return; - } closePalette(); }, [view.kind]); + useEffect(() => { + if (!openRequest) return; + openPalette(openRequest.seed); + }, [openRequest]); + useEffect(() => { setCursor((value) => { if (filtered.length === 0) return 0; @@ -274,32 +269,16 @@ export function SessionPalette({ return ( - - - {active ? "›" : "/"} - - {active ? ( - {input || " "} - ) : ( - - {inactivePreviewCommands.map((name, index) => { - const ratio = - inactivePreviewCommands.length <= 1 - ? 0 - : (index / (inactivePreviewCommands.length - 1)) * 0.95; - - return ( - - {index > 0 ? " · " : ""} - {name} - - ); - })} - - )} - {active ? : null} - + {divider} + + + + + {active ? input || " " : "/"} + + {active ? : null} + {divider} {active && selected ? (