Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plug/src/tui/components/hotkey-bar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const HINTS = {
['Enter', 'detail'],
['Space', 'toggle'],
['/', 'search'],
['t', 'filter type'],
['i', 'install'],
['Esc', 'exit'],
],
Expand Down
9 changes: 6 additions & 3 deletions plug/src/tui/components/status-line.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box paddingX={1}>
<Text dimColor>
Discover plugins {position}{filterNote}{typeLabel}{searchHint}
</Text>
Discover plugins {position}{filterNote}</Text>
<Text color={typeFilter !== 'all' ? 'cyan' : 'gray'}>{typeLabel}</Text>
<Text dimColor>{searchHint}</Text>
</Box>
);
}
11 changes: 7 additions & 4 deletions plug/src/tui/screens/discover.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down
159 changes: 150 additions & 9 deletions plug/src/tui/screens/vaults.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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];
Expand All @@ -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 });
Expand All @@ -178,7 +214,7 @@ export default function VaultsScreen({ onInputCapture }) {
<AddVaultForm
state={addState}
onChange={setAddState}
onSubmit={(name, url, branch, isPrivate) => doAdd(name, url, branch, isPrivate)}
onSubmit={(name, url, branch, isPrivate, token) => doAdd(name, url, branch, isPrivate, token)}
onCancel={() => { setAddState(null); setView('list'); }}
terminalWidth={terminalWidth}
/>
Expand All @@ -195,6 +231,17 @@ export default function VaultsScreen({ onInputCapture }) {
);
}

if (view === 'token-prompt' && tokenPromptVault) {
return (
<TokenPrompt
vaultName={tokenPromptVault}
syncResults={opResult?.results}
onSubmit={(token) => doSetToken(tokenPromptVault, token)}
onSkip={() => { setTokenPromptVault(null); setView('result'); }}
/>
);
}

if (view === 'syncing') {
return (
<Box flexDirection="column" paddingX={2} paddingY={1}>
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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] || '';
Expand All @@ -450,17 +505,23 @@ function AddVaultForm({ state, onChange, onSubmit, onCancel, terminalWidth }) {
<Text>{state.url}</Text>
</Box>
)}
{state.branch && state.step === 'private' && (
{state.branch && (state.step === 'private' || state.step === 'token') && (
<Box>
<Text dimColor>Branch : </Text>
<Text>{state.branch}</Text>
</Box>
)}
{state.step === 'token' && (
<Box>
<Text dimColor>Private: </Text>
<Text>Yes</Text>
</Box>
)}

{/* Current prompt */}
<Box marginTop={1}>
<Text>{prompt} </Text>
<Text color="cyan">{inputBuffer}</Text>
<Text color="cyan">{state.step === 'token' ? '*'.repeat(inputBuffer.length) : inputBuffer}</Text>
<Text color="cyan">_</Text>
</Box>
<Box>
Expand Down Expand Up @@ -517,6 +578,17 @@ function OperationResult({ result, onDone }) {
</>
)}

{result.type === 'set-token' && (
<>
<Box marginBottom={1}><Text bold>Set Token</Text></Box>
{result.ok ? (
<Text color="green">✓ Token set for vault '{result.name}' — try syncing again</Text>
) : (
<Text color="red">✗ {result.error}</Text>
)}
</>
)}

{result.type === 'add' && (
<>
<Box marginBottom={1}><Text bold>Add Vault</Text></Box>
Expand All @@ -534,3 +606,72 @@ function OperationResult({ result, onDone }) {
</Box>
);
}

// ── 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 (
<Box flexDirection="column" paddingX={2} paddingY={1}>
{syncResults && (
<Box flexDirection="column" marginBottom={1}>
{syncResults.map((r) => (
<Box key={r.name}>
{r.ok ? (
<Text color="green">✓ {r.name}: {r.packages} packages</Text>
) : (
<Text color="yellow">✗ {r.name}: {r.error}</Text>
)}
</Box>
))}
</Box>
)}

<Box marginBottom={1}>
<Text color="yellow">Vault '</Text>
<Text bold color="yellow">{vaultName}</Text>
<Text color="yellow">' requires authentication.</Text>
</Box>

<Box>
<Text>GitHub PAT: </Text>
<Text color="cyan">{'*'.repeat(inputBuffer.length)}</Text>
<Text color="cyan">_</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Enter to save · Esc to skip</Text>
</Box>
</Box>
);
}
Loading