diff --git a/plug/src/tui/components/hotkey-bar.jsx b/plug/src/tui/components/hotkey-bar.jsx index 59ccb9f..75df3ea 100644 --- a/plug/src/tui/components/hotkey-bar.jsx +++ b/plug/src/tui/components/hotkey-bar.jsx @@ -9,6 +9,7 @@ const HINTS = { ['Enter', 'detail'], ['Space', 'toggle'], ['/', 'search'], + ['t', 'filter type'], ['i', 'install'], ['Esc', 'exit'], ], diff --git a/plug/src/tui/components/status-line.jsx b/plug/src/tui/components/status-line.jsx index 8392c32..2f520e1 100644 --- a/plug/src/tui/components/status-line.jsx +++ b/plug/src/tui/components/status-line.jsx @@ -17,13 +17,16 @@ export default function StatusLine({ cursor, total, filtered, isFiltered, search const position = total > 0 ? `${cursor + 1}/${filtered}` : '0/0'; const filterNote = isFiltered ? ` (filtered from ${total})` : ''; const searchHint = searchFocused ? ' · type to filter · Esc to clear' : ''; - const typeLabel = typeFilter !== 'all' ? ` · [${typeFilter}s]` : ''; + const typeLabel = typeFilter !== 'all' + ? ` · type: ${typeFilter}s` + : ' · type: all'; return ( - Discover plugins {position}{filterNote}{typeLabel}{searchHint} - + Discover plugins {position}{filterNote} + {typeLabel} + {searchHint} ); } diff --git a/plug/src/tui/screens/discover.jsx b/plug/src/tui/screens/discover.jsx index 862db95..2b7621c 100644 --- a/plug/src/tui/screens/discover.jsx +++ b/plug/src/tui/screens/discover.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; import PackageList from '../components/package-list.jsx'; import SearchBox from '../components/search-box.jsx'; @@ -45,9 +45,12 @@ export default function DiscoverScreen({ onInputCapture }) { // Filtered/searched package list const searchedPackages = useSearch(packages, searchQuery); - const filteredPackages = typeFilter === 'all' - ? searchedPackages - : searchedPackages.filter(p => p.type === typeFilter); + const filteredPackages = useMemo( + () => typeFilter === 'all' + ? searchedPackages + : searchedPackages.filter(p => p.type === typeFilter), + [searchedPackages, typeFilter] + ); // Load installed package names useEffect(() => { diff --git a/plug/src/tui/screens/vaults.jsx b/plug/src/tui/screens/vaults.jsx index cff91f1..17ba8cf 100644 --- a/plug/src/tui/screens/vaults.jsx +++ b/plug/src/tui/screens/vaults.jsx @@ -9,13 +9,14 @@ import { runVaultAdd, runVaultRemove, runVaultSetDefault, + runVaultSetToken, runVaultSync, } from '../../commands/vault.js'; import { fetchRegistry } from '../../utils/registry.js'; import { getConfig } from '../../utils/config.js'; import { ctx } from '../../utils/context.js'; -/** @typedef {'list'|'confirm-remove'|'adding'|'syncing'|'result'} VaultView */ +/** @typedef {'list'|'confirm-remove'|'adding'|'syncing'|'result'|'token-prompt'} VaultView */ /** * Vaults screen — shows registered vaults and supports add/remove/sync/set-default. @@ -30,6 +31,7 @@ export default function VaultsScreen({ onInputCapture }) { const [pendingRemove, setPendingRemove] = useState(null); const [addState, setAddState] = useState(null); // { step, name, url, branch, private } const [syncResults, setSyncResults] = useState([]); + const [tokenPromptVault, setTokenPromptVault] = useState(null); const { columns: terminalWidth } = useTerminalSize(); // Notify parent when input is locked @@ -122,6 +124,7 @@ export default function VaultsScreen({ onInputCapture }) { const config = await getConfig(); const order = config.resolve_order || []; const perVault = []; + let authFailedVault = null; for (const name of order) { const v = config.vaults?.[name]; @@ -133,31 +136,64 @@ export default function VaultsScreen({ onInputCapture }) { perVault.push({ name, ok: true, packages: count }); } catch (err) { perVault.push({ name, ok: false, error: err.message }); + if (v.private && (err.code === 'AUTH_FAILED' || err.code === 'NOT_FOUND')) { + authFailedVault = name; + } } setSyncResults([...perVault]); } reload(); - setOpResult({ type: 'sync', results: perVault }); - setView('result'); + + if (authFailedVault) { + setOpResult({ type: 'sync', results: perVault }); + setTokenPromptVault(authFailedVault); + setView('token-prompt'); + } else { + setOpResult({ type: 'sync', results: perVault }); + setView('result'); + } } catch (err) { setOpResult({ type: 'sync', results: [], error: err.message }); setView('result'); } } + async function doSetToken(vaultName, token) { + setView('syncing'); + setSyncResults([]); + try { + ctx.set({ yes: true, json: true }); + await yieldToInk(); + await captureOutput(() => runVaultSetToken(vaultName, token)); + ctx.set({ yes: false, json: false }); + reload(); + setTokenPromptVault(null); + setOpResult({ type: 'set-token', name: vaultName, ok: true }); + setView('result'); + } catch (err) { + ctx.set({ yes: false, json: false }); + setTokenPromptVault(null); + setOpResult({ type: 'set-token', name: vaultName, ok: false, error: err.message }); + setView('result'); + } + } + // ── Add ──────────────────────────────────────────────────────────────────── - async function doAdd(name, url, branch, isPrivate) { + async function doAdd(name, url, branch, isPrivate, token) { setOpResult(null); - setView('syncing'); // Reuse syncing spinner for connectivity check + setView('syncing'); setSyncResults([]); try { ctx.set({ yes: true, json: true }); await yieldToInk(); await captureOutput(() => runVaultAdd(name, url, { private: isPrivate })); + if (token) { + await captureOutput(() => runVaultSetToken(name, token)); + } ctx.set({ yes: false, json: false }); reload(); setOpResult({ type: 'add', name, ok: true }); @@ -178,7 +214,7 @@ export default function VaultsScreen({ onInputCapture }) { doAdd(name, url, branch, isPrivate)} + onSubmit={(name, url, branch, isPrivate, token) => doAdd(name, url, branch, isPrivate, token)} onCancel={() => { setAddState(null); setView('list'); }} terminalWidth={terminalWidth} /> @@ -195,6 +231,17 @@ export default function VaultsScreen({ onInputCapture }) { ); } + if (view === 'token-prompt' && tokenPromptVault) { + return ( + doSetToken(tokenPromptVault, token)} + onSkip={() => { setTokenPromptVault(null); setView('result'); }} + /> + ); + } + if (view === 'syncing') { return ( @@ -417,7 +464,14 @@ function AddVaultForm({ state, onChange, onSubmit, onCancel, terminalWidth }) { setInputBuffer(''); } else if (state.step === 'private') { const isPrivate = value.toLowerCase() === 'y'; - onSubmit(state.name, state.url, state.branch, isPrivate); + if (isPrivate) { + onChange({ ...state, step: 'token', isPrivate: true }); + setInputBuffer(''); + } else { + onSubmit(state.name, state.url, state.branch, false, null); + } + } else if (state.step === 'token') { + onSubmit(state.name, state.url, state.branch, true, value || null); } } @@ -426,6 +480,7 @@ function AddVaultForm({ state, onChange, onSubmit, onCancel, terminalWidth }) { url: 'GitHub URL (e.g. https://github.com/owner/repo):', branch: `Branch [default: main]:`, private: 'Private vault? [y/N]:', + token: 'GitHub PAT (personal access token):', }; const prompt = prompts[state.step] || ''; @@ -450,17 +505,23 @@ function AddVaultForm({ state, onChange, onSubmit, onCancel, terminalWidth }) { {state.url} )} - {state.branch && state.step === 'private' && ( + {state.branch && (state.step === 'private' || state.step === 'token') && ( Branch : {state.branch} )} + {state.step === 'token' && ( + + Private: + Yes + + )} {/* Current prompt */} {prompt} - {inputBuffer} + {state.step === 'token' ? '*'.repeat(inputBuffer.length) : inputBuffer} _ @@ -517,6 +578,17 @@ function OperationResult({ result, onDone }) { )} + {result.type === 'set-token' && ( + <> + Set Token + {result.ok ? ( + ✓ Token set for vault '{result.name}' — try syncing again + ) : ( + ✗ {result.error} + )} + + )} + {result.type === 'add' && ( <> Add Vault @@ -534,3 +606,72 @@ function OperationResult({ result, onDone }) { ); } + +// ── TokenPrompt ────────────────────────────────────────────────────────────── + +function TokenPrompt({ vaultName, syncResults, onSubmit, onSkip }) { + const [inputBuffer, setInputBuffer] = useState(''); + + useInput((ch, key) => { + if (key.escape) { + onSkip(); + return; + } + + if (key.return) { + const value = inputBuffer.trim(); + if (value) { + onSubmit(value); + } else { + onSkip(); + } + return; + } + + if (key.backspace || key.delete) { + setInputBuffer((prev) => prev.slice(0, -1)); + return; + } + + if (ch && ch.length === 1 && !key.ctrl && !key.meta) { + setInputBuffer((prev) => prev + ch); + } + }); + + usePaste(useCallback((text) => { + setInputBuffer((prev) => prev + text); + }, [])); + + return ( + + {syncResults && ( + + {syncResults.map((r) => ( + + {r.ok ? ( + ✓ {r.name}: {r.packages} packages + ) : ( + ✗ {r.name}: {r.error} + )} + + ))} + + )} + + + Vault ' + {vaultName} + ' requires authentication. + + + + GitHub PAT: + {'*'.repeat(inputBuffer.length)} + _ + + + Enter to save · Esc to skip + + + ); +}