app/tui/Explore.tsx
zzmq.dev@gmail.com/zz / app / tui / Explore.tsx
@ 0caf808f
Download
Ladder Summary
- file The file implements the **Explore** pane used by the “find more repos” tab of the `zz top` TUI: it renders either a **RemoteExplore** (search‑box that queries the backend, shows debounced results, loads a preview with ladder and lane info, and lets the user “track” a repo) or a **LocalExplore** (filesystem browser) depending on the `local` prop, while managing edit‑mode state so the surrounding `TabView` can suspend its own key handling. **Exported members** - `Explore` – the top‑level React component that switches between the remote and local explore modes. (All other functions and components such as `IdleLogo`, `callToolAnon`, `loadPreview`, `RemoteExplore`, etc. are internal to the file.)
// `zz top`'s "find more repos" tab -- reachable at any time via the
// normal tab navigation (see TopApp.tsx), same "track" action either
// way (top.ts's own onTrack), but a completely different interaction
// per mode: remote is a search box against the real backend; local is
// a filesystem browser, since there's no server to search against.
//
// Free-text search needs every character (including letters/digits
// TabView's own hotkeys use for tab-switch/quit/copy), so remote mode
// has an explicit edit-mode toggle ("/" to start typing, Enter to
// search and stop, Escape to cancel and stop) -- while editing,
// onEditingChange(true) tells TopApp to pass `inputPaused` down to
// TabView, suppressing its own key handling for exactly as long as
// typing is happening. Local mode never needs this: directory
// navigation (arrows/enter) and "t" to track don't collide with
// anything TabView listens for.
import React, { useEffect, useState } from "react";
import { Box, Text, useInput, useStdin } from "ink";
import { existsSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { apiFetch } from "../cli/lib/api.js";
import { isLocallyOnboarded, inferGitHubSlugFromDir, resolveRepo } from "../cli/lib/repo.js";
import { collectLadder } from "../engine/ladderRender.js";
import { listLanes } from "../engine/store.js";
import { MUTED, PINK, GREEN, YELLOW, CYAN } from "./theme.js";
interface RepoHit {
slug: string;
description: string | null;
}
interface RepoPreview {
slug: string;
description: string | null;
ladderSummary: string | null;
childCount: number;
laneCount: number | null;
error: string | null;
}
export function Explore({ local, onTrack, onEditingChange }: { local: boolean; onTrack: (slug: string) => Promise<void>; onEditingChange: (editing: boolean) => void }) {
return local ? <LocalExplore onTrack={onTrack} /> : <RemoteExplore onTrack={onTrack} onEditingChange={onEditingChange} />;
}
/** Anonymous POST to one repo's tool-call endpoint -- allowAnonymous works here specifically because the server now allows lane_list/ladder_tree with no auth for a PUBLIC repo (see webApp.ts's ANON_READ_TOOLS); any other tool, or a private repo, would 401 same as before. */
async function callToolAnon<T>(slug: string, tool: string, args: Record<string, unknown> = {}): Promise<T> {
const data = await apiFetch<{ result: T }>("POST", `/api/repos/${slug}/tools/${tool}`, args, undefined, true);
return data.result;
}
async function loadPreview(hit: RepoHit): Promise<RepoPreview> {
try {
const [tree, lanes] = await Promise.all([
callToolAnon<{ summary: string; children: unknown[] } | null>(hit.slug, "ladder_tree"),
callToolAnon<unknown[]>(hit.slug, "lane_list"),
]);
return { slug: hit.slug, description: hit.description, ladderSummary: tree?.summary ?? null, childCount: tree?.children.length ?? 0, laneCount: lanes.length, error: null };
} catch (err) {
return { slug: hit.slug, description: hit.description, ladderSummary: null, childCount: 0, laneCount: null, error: err instanceof Error ? err.message : String(err) };
}
}
function RemoteExplore({ onTrack, onEditingChange }: { onTrack: (slug: string) => Promise<void>; onEditingChange: (editing: boolean) => void }) {
const [editing, setEditing] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<RepoHit[]>([]);
const [selected, setSelected] = useState(0);
const [status, setStatus] = useState("press / to search");
const [tracked, setTracked] = useState<Set<string>>(new Set());
const [preview, setPreview] = useState<RepoPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const { isRawModeSupported } = useStdin();
useEffect(() => onEditingChange(editing), [editing, onEditingChange]);
// Debounced search-as-you-type -- fires ~250ms after the query stops
// changing, not on every keystroke, so a fast typist doesn't queue
// up a dozen overlapping requests against the real backend.
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
let cancelled = false;
const id = setTimeout(async () => {
setStatus("searching...");
try {
// allowAnonymous: this has to work with no `zz login` session
// at all -- that's the whole point of remote Explore existing
// (see top.ts's own header comment).
const data = await apiFetch<{ repos: RepoHit[] }>("GET", `/api/explore/search?q=${encodeURIComponent(query)}`, undefined, undefined, true);
if (cancelled) return;
setResults(data.repos);
setSelected(0);
setStatus(data.repos.length === 0 ? "no matches" : `${data.repos.length} match${data.repos.length === 1 ? "" : "es"}`);
} catch (err) {
if (!cancelled) setStatus(`search failed: ${err instanceof Error ? err.message : String(err)}`);
}
}, 250);
return () => {
cancelled = true;
clearTimeout(id);
};
}, [query]);
useInput(
(input, key) => {
if (editing) {
if (key.return) {
setEditing(false);
} else if (key.escape) {
setEditing(false);
} else if (key.backspace || key.delete) {
setQuery((q) => q.slice(0, -1));
} else if (input && !key.ctrl && !key.meta) {
setQuery((q) => q + input);
}
return;
}
if (preview) {
if (input === "t" && !tracked.has(preview.slug)) {
setStatus(`tracking ${preview.slug}...`);
onTrack(preview.slug)
.then(() => {
setTracked((t) => new Set(t).add(preview.slug));
setStatus(`tracking ${preview.slug} -- switch to the lanes/ladder tabs to watch it`);
})
.catch((err) => setStatus(`track failed: ${err instanceof Error ? err.message : String(err)}`));
} else if (key.escape || key.backspace || key.delete) {
setPreview(null);
setStatus("");
}
return;
}
if (input === "/") {
setEditing(true);
} else if (key.upArrow) {
setSelected((s) => Math.max(0, s - 1));
} else if (key.downArrow) {
setSelected((s) => Math.min(results.length - 1, s + 1));
} else if (key.return && results[selected]) {
const hit = results[selected];
setPreviewLoading(true);
setStatus("");
loadPreview(hit).then((p) => {
setPreview(p);
setPreviewLoading(false);
});
}
},
{ isActive: isRawModeSupported },
);
if (previewLoading || preview) {
return (
<Box flexDirection="column">
<Text bold color={PINK}>
{preview?.slug ?? "loading..."}
</Text>
{previewLoading && <Text color={MUTED}>loading preview...</Text>}
{preview && (
<>
{preview.description && <Text color={MUTED}>{preview.description}</Text>}
{preview.error ? (
<Text color={YELLOW}>couldn't load a preview: {preview.error}</Text>
) : (
<Box flexDirection="column" marginTop={1}>
<Text color={CYAN}>{preview.laneCount ?? 0} lane(s)</Text>
<Text color={CYAN}>{preview.ladderSummary ?? "(no Ladder content yet)"}</Text>
<Text color={MUTED}>{preview.childCount} top-level entr{preview.childCount === 1 ? "y" : "ies"} in the Ladder</Text>
</Box>
)}
{tracked.has(preview.slug) && <Text color={GREEN}>✓ tracked</Text>}
</>
)}
<Box marginTop={1}>
<Text color={MUTED}>{preview && !tracked.has(preview.slug) ? "t: track " : ""}esc/backspace: back to results</Text>
</Box>
{status && <Text color={MUTED}>{status}</Text>}
</Box>
);
}
return (
<Box flexDirection="column">
<Text color={MUTED}>
{editing ? <Text color={YELLOW}>search: {query}█</Text> : <>search: {query || <Text color={MUTED}>(press / to search)</Text>}</>}
</Text>
<Text color={MUTED}>{status}</Text>
<Box flexDirection="column" marginTop={1}>
{results.map((r, i) => (
<Box key={r.slug}>
<Text bold={i === selected} color={i === selected ? PINK : undefined}>
{i === selected ? "> " : " "}
{r.slug}
</Text>
{tracked.has(r.slug) && <Text color={GREEN}> ✓ tracked</Text>}
{r.description && (
<Text color={MUTED} wrap="truncate-end">
{" "}
-- {r.description}
</Text>
)}
</Box>
))}
</Box>
<Box marginTop={1}>
<Text color={MUTED}>/: search ↑/↓: select enter: view (while typing: enter: search, esc: cancel)</Text>
</Box>
</Box>
);
}
interface DirEntry {
name: string;
isGit: boolean;
}
function listDirs(dir: string): DirEntry[] {
try {
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => ({ name: e.name, isGit: existsSync(join(dir, e.name, ".git")) }))
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
return [];
}
}
interface LocalPreview {
ladderSummary: string | null;
childCount: number;
laneCount: number;
}
function LocalExplore({ onTrack }: { onTrack: (slug: string) => Promise<void> }) {
const [cwd, setCwd] = useState(process.cwd());
const [selected, setSelected] = useState(0);
const [status, setStatus] = useState("");
const [onboarded, setOnboarded] = useState<boolean | null>(null);
const [preview, setPreview] = useState<LocalPreview | null>(null);
const { isRawModeSupported } = useStdin();
const atRoot = dirname(cwd) === cwd;
const isGitRepo = existsSync(join(cwd, ".git"));
const slugHere = isGitRepo ? inferGitHubSlugFromDir(cwd) : null;
const entries = isGitRepo ? [] : listDirs(cwd);
const items = atRoot || isGitRepo ? entries : [{ name: "..", isGit: false }, ...entries];
useEffect(() => {
if (!isGitRepo || !slugHere) {
setOnboarded(null);
setPreview(null);
return;
}
let cancelled = false;
const repo = resolveRepo(slugHere);
isLocallyOnboarded(repo).then(async (result) => {
if (cancelled) return;
setOnboarded(result);
if (!result) return;
const [{ tree }, lanes] = await Promise.all([collectLadder(repo.repoDir, repo.stateDir), Promise.resolve(listLanes(repo.stateDir))]);
if (!cancelled) setPreview({ ladderSummary: tree.summary, childCount: tree.children.length, laneCount: lanes.length });
});
return () => {
cancelled = true;
};
}, [isGitRepo, slugHere]);
useInput((input, key) => {
if (isGitRepo) {
if (input === "t" && slugHere && onboarded) {
setStatus(`tracking ${slugHere}...`);
onTrack(slugHere)
.then(() => setStatus(`tracking ${slugHere} -- switch to the lanes/ladder tabs to watch it`))
.catch((err) => setStatus(`track failed: ${err instanceof Error ? err.message : String(err)}`));
} else if (key.backspace || key.delete || input === "u") {
setCwd(dirname(cwd));
setSelected(0);
setStatus("");
}
return;
}
if (key.upArrow) setSelected((s) => Math.max(0, s - 1));
else if (key.downArrow) setSelected((s) => Math.min(items.length - 1, s + 1));
else if (key.return && items[selected]) {
const item = items[selected];
setCwd(item.name === ".." ? dirname(cwd) : join(cwd, item.name));
setSelected(0);
setStatus("");
}
}, { isActive: isRawModeSupported });
return (
<Box flexDirection="column">
<Text color={CYAN}>{cwd}</Text>
{isGitRepo ? (
<Box flexDirection="column" marginTop={1}>
<Text color={MUTED}>this is a git repo{slugHere ? ` (${slugHere} on GitHub)` : " (no GitHub origin remote)"}</Text>
{slugHere ? (
onboarded === null ? (
<Text color={MUTED}>checking onboarding status...</Text>
) : onboarded ? (
<Box flexDirection="column">
<Text color={GREEN}>✓ onboarded -- press t to track it</Text>
{preview && (
<Box flexDirection="column" marginTop={1}>
<Text color={CYAN}>{preview.laneCount} lane(s)</Text>
<Text color={CYAN}>{preview.ladderSummary ?? "(no Ladder content yet)"}</Text>
<Text color={MUTED}>{preview.childCount} top-level entr{preview.childCount === 1 ? "y" : "ies"} in the Ladder</Text>
</Box>
)}
</Box>
) : (
<Text color={YELLOW}>not onboarded yet -- run: zz ladder build {slugHere}</Text>
)
) : (
<Text color={YELLOW}>zz ladder build needs a GitHub origin remote -- this repo doesn't have one, so there's no onboarding command to offer here.</Text>
)}
<Box marginTop={1}>
<Text color={MUTED}>t: track (if onboarded) backspace/u: up a directory</Text>
</Box>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
{items.length === 0 && <Text color={MUTED}>(empty)</Text>}
{items.map((item, i) => (
<Box key={item.name}>
<Text bold={i === selected} color={i === selected ? PINK : undefined}>
{i === selected ? "> " : " "}
{item.name}/{item.isGit ? " [git]" : ""}
</Text>
</Box>
))}
<Box marginTop={1}>
<Text color={MUTED}>↑/↓: select enter: open</Text>
</Box>
</Box>
)}
{status && <Text color={MUTED}>{status}</Text>}
</Box>
);
}