app/tui/Explore.tsx
zzmq.dev@gmail.com/zz / app / tui / Explore.tsx
@ acd49583
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 slug = results[selected].slug;
- setStatus(`tracking ${slug}...`);
- onTrack(slug)
- .then(() => {
- setTracked((t) => new Set(t).add(slug));
- setStatus(`tracking ${slug} -- switch to the lanes/ladder tabs to watch it`);
- })
- .catch((err) => setStatus(`track failed: ${err instanceof Error ? err.message : String(err)}`));
+ 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: track (while typing: enter: search, esc: cancel)</Text>
+ <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;
- isLocallyOnboarded(resolveRepo(slugHere)).then((result) => {
- if (!cancelled) setOnboarded(result);
+ 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 ? (
- <Text color={GREEN}>✓ onboarded -- press t to track it</Text>
+ <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>
);
}