184 lines
5.3 KiB
TypeScript
184 lines
5.3 KiB
TypeScript
import {
|
|
Container,
|
|
type Focusable,
|
|
fuzzyFilter,
|
|
getKeybindings,
|
|
Input,
|
|
Spacer,
|
|
TruncatedText,
|
|
} from "@mariozechner/pi-tui";
|
|
import type { AuthStorage } from "../../../core/auth-storage.js";
|
|
import { theme } from "../theme/theme.js";
|
|
import { DynamicBorder } from "./dynamic-border.js";
|
|
|
|
export type AuthSelectorProvider = {
|
|
id: string;
|
|
name: string;
|
|
authType: "oauth" | "api_key";
|
|
};
|
|
|
|
/**
|
|
* Component that renders an auth provider selector
|
|
*/
|
|
export class OAuthSelectorComponent extends Container implements Focusable {
|
|
private searchInput: Input;
|
|
|
|
// Focusable implementation - propagate to search input for IME cursor positioning
|
|
private _focused = false;
|
|
get focused(): boolean {
|
|
return this._focused;
|
|
}
|
|
set focused(value: boolean) {
|
|
this._focused = value;
|
|
this.searchInput.focused = value;
|
|
}
|
|
|
|
private listContainer: Container;
|
|
private allProviders: AuthSelectorProvider[];
|
|
private filteredProviders: AuthSelectorProvider[];
|
|
private selectedIndex: number = 0;
|
|
private mode: "login" | "logout";
|
|
private authStorage: AuthStorage;
|
|
private onSelectCallback: (providerId: string) => void;
|
|
private onCancelCallback: () => void;
|
|
|
|
constructor(
|
|
mode: "login" | "logout",
|
|
authStorage: AuthStorage,
|
|
providers: AuthSelectorProvider[],
|
|
onSelect: (providerId: string) => void,
|
|
onCancel: () => void,
|
|
) {
|
|
super();
|
|
|
|
this.mode = mode;
|
|
this.authStorage = authStorage;
|
|
this.allProviders = providers;
|
|
this.filteredProviders = providers;
|
|
this.onSelectCallback = onSelect;
|
|
this.onCancelCallback = onCancel;
|
|
|
|
// Add top border
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Add title
|
|
const title = mode === "login" ? "Select provider to configure:" : "Select provider to logout:";
|
|
this.addChild(new TruncatedText(theme.bold(title)));
|
|
this.addChild(new Spacer(1));
|
|
|
|
this.searchInput = new Input();
|
|
this.searchInput.onSubmit = () => {
|
|
const selectedProvider = this.filteredProviders[this.selectedIndex];
|
|
if (selectedProvider) {
|
|
this.onSelectCallback(selectedProvider.id);
|
|
}
|
|
};
|
|
this.addChild(this.searchInput);
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Create list container
|
|
this.listContainer = new Container();
|
|
this.addChild(this.listContainer);
|
|
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Add bottom border
|
|
this.addChild(new DynamicBorder());
|
|
|
|
// Initial render
|
|
this.filterProviders("");
|
|
}
|
|
|
|
private filterProviders(query: string): void {
|
|
this.filteredProviders = query
|
|
? fuzzyFilter(this.allProviders, query, (provider) => `${provider.name} ${provider.id} ${provider.authType}`)
|
|
: this.allProviders;
|
|
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.filteredProviders.length - 1)));
|
|
this.updateList();
|
|
}
|
|
|
|
private updateList(): void {
|
|
this.listContainer.clear();
|
|
|
|
const maxVisible = 8;
|
|
const startIndex = Math.max(
|
|
0,
|
|
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredProviders.length - maxVisible),
|
|
);
|
|
const endIndex = Math.min(startIndex + maxVisible, this.filteredProviders.length);
|
|
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const provider = this.filteredProviders[i];
|
|
if (!provider) continue;
|
|
|
|
const isSelected = i === this.selectedIndex;
|
|
|
|
// Check if user is configured for this provider
|
|
const credentials = this.authStorage.get(provider.id);
|
|
const statusIndicator = credentials
|
|
? theme.fg("success", " ✓ configured")
|
|
: theme.fg("muted", " • unconfigured");
|
|
let line = "";
|
|
if (isSelected) {
|
|
const prefix = theme.fg("accent", "→ ");
|
|
const text = theme.fg("accent", provider.name);
|
|
line = prefix + text + statusIndicator;
|
|
} else {
|
|
const text = ` ${provider.name}`;
|
|
line = text + statusIndicator;
|
|
}
|
|
|
|
this.listContainer.addChild(new TruncatedText(line, 0, 0));
|
|
}
|
|
|
|
if (startIndex > 0 || endIndex < this.filteredProviders.length) {
|
|
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredProviders.length})`);
|
|
this.listContainer.addChild(new TruncatedText(scrollInfo, 0, 0));
|
|
}
|
|
|
|
// Show "no providers" if empty
|
|
if (this.filteredProviders.length === 0) {
|
|
const message =
|
|
this.allProviders.length === 0
|
|
? this.mode === "login"
|
|
? "No providers available"
|
|
: "No providers logged in. Use /login first."
|
|
: "No matching providers";
|
|
this.listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
|
|
}
|
|
}
|
|
|
|
handleInput(keyData: string): void {
|
|
const kb = getKeybindings();
|
|
// Up arrow
|
|
if (kb.matches(keyData, "tui.select.up")) {
|
|
if (this.filteredProviders.length === 0) return;
|
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
this.updateList();
|
|
}
|
|
// Down arrow
|
|
else if (kb.matches(keyData, "tui.select.down")) {
|
|
if (this.filteredProviders.length === 0) return;
|
|
this.selectedIndex = Math.min(this.filteredProviders.length - 1, this.selectedIndex + 1);
|
|
this.updateList();
|
|
}
|
|
// Enter
|
|
else if (kb.matches(keyData, "tui.select.confirm")) {
|
|
const selectedProvider = this.filteredProviders[this.selectedIndex];
|
|
if (selectedProvider) {
|
|
this.onSelectCallback(selectedProvider.id);
|
|
}
|
|
}
|
|
// Escape or Ctrl+C
|
|
else if (kb.matches(keyData, "tui.select.cancel")) {
|
|
this.onCancelCallback();
|
|
}
|
|
// Pass everything else to search input
|
|
else {
|
|
this.searchInput.handleInput(keyData);
|
|
this.filterProviders(this.searchInput.getValue());
|
|
}
|
|
}
|
|
}
|