Archived
Add global web auth flows
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
|
||||
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseAborted,
|
||||
parseAccepted,
|
||||
parseArchived,
|
||||
parseAuthProvidersResponse,
|
||||
parseClosed,
|
||||
parseCommandResult,
|
||||
parseDetached,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
parseGitStatusResponse,
|
||||
parseMessagePage,
|
||||
parseModelSelectionResponse,
|
||||
parseOAuthFlowState,
|
||||
parseProject,
|
||||
parseRestored,
|
||||
parseSessionInfo,
|
||||
@@ -61,6 +63,19 @@ export const sessionsApi = {
|
||||
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
||||
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
|
||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.mode !== undefined) params.set("mode", options.mode);
|
||||
if (options?.authType !== undefined) params.set("authType", options.authType);
|
||||
const query = params.toString();
|
||||
return request(`/api/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
|
||||
},
|
||||
saveApiKey: (providerId: string, key: string) => request("/api/auth/api-key", parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
|
||||
logoutProvider: (providerId: string) => request("/api/auth/logout", parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
|
||||
startOAuthLogin: (providerId: string) => request("/api/auth/oauth", parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
|
||||
oauthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
|
||||
respondOAuthFlow: (flowId: string, requestId: string, value: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||
cancelOAuthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }),
|
||||
};
|
||||
|
||||
export const terminalsApi = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
|
||||
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -152,6 +152,76 @@ export function parseThinkingLevelsResponse(value: unknown): ThinkingLevelsRespo
|
||||
return { levels: arrayOf(parseThinkingLevel)(record["levels"]) };
|
||||
}
|
||||
|
||||
function parseAuthType(value: unknown): AuthType {
|
||||
if (value !== "oauth" && value !== "api_key") throw new Error("Invalid auth type");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseAuthStatusSource(value: unknown): AuthStatusSource {
|
||||
if (value !== "stored" && value !== "runtime" && value !== "environment" && value !== "fallback" && value !== "models_json_key" && value !== "models_json_command") throw new Error("Invalid auth status source");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseAuthProviderStatus(value: unknown): AuthProviderStatus {
|
||||
const record = requireRecord(value);
|
||||
const source = record["source"] === undefined ? undefined : parseAuthStatusSource(record["source"]);
|
||||
return { configured: requireBoolean(record, "configured"), ...optionalField("source", source), ...optionalField("label", optionalString(record, "label")) };
|
||||
}
|
||||
|
||||
function parseAuthProviderOption(value: unknown): AuthProviderOption {
|
||||
const record = requireRecord(value);
|
||||
return { id: requireString(record, "id"), name: requireString(record, "name"), authType: parseAuthType(record["authType"]), status: parseAuthProviderStatus(record["status"]) };
|
||||
}
|
||||
|
||||
export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse {
|
||||
const record = requireRecord(value);
|
||||
return { providers: arrayOf(parseAuthProviderOption)(record["providers"]) };
|
||||
}
|
||||
|
||||
export function parseOAuthFlowState(value: unknown): OAuthFlowState {
|
||||
const record = requireRecord(value);
|
||||
const flow = {
|
||||
flowId: requireString(record, "flowId"),
|
||||
providerId: requireString(record, "providerId"),
|
||||
providerName: requireString(record, "providerName"),
|
||||
status: parseOAuthFlowStatus(record["status"]),
|
||||
progress: arrayOf((item) => {
|
||||
if (typeof item !== "string") throw new Error("Expected progress item string");
|
||||
return item;
|
||||
})(record["progress"]),
|
||||
...optionalField("error", optionalString(record, "error")),
|
||||
...optionalField("auth", optionalOAuthAuth(record["auth"])),
|
||||
...optionalField("prompt", optionalOAuthPrompt(record["prompt"])),
|
||||
...optionalField("select", optionalOAuthSelect(record["select"])),
|
||||
};
|
||||
return flow;
|
||||
}
|
||||
|
||||
function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] {
|
||||
if (value !== "running" && value !== "complete" && value !== "error" && value !== "cancelled") throw new Error("Invalid OAuth flow status");
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const record = requireRecord(value);
|
||||
return { url: requireString(record, "url"), ...optionalField("instructions", optionalString(record, "instructions")) };
|
||||
}
|
||||
|
||||
function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const record = requireRecord(value);
|
||||
const kind = requireString(record, "kind");
|
||||
if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind");
|
||||
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")), ...(record["allowEmpty"] === true ? { allowEmpty: true } : {}) };
|
||||
}
|
||||
|
||||
function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const record = requireRecord(value);
|
||||
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), options: arrayOf(parseCommandOption)(record["options"]) };
|
||||
}
|
||||
|
||||
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
|
||||
if (value === undefined) return {};
|
||||
const record = requireRecord(value);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/types";
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface AppState {
|
||||
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
||||
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
authDialog: AuthDialogState | undefined;
|
||||
actionPaletteOpen: boolean;
|
||||
projectDialogOpen: boolean;
|
||||
workspaceTool: QualifiedContributionId;
|
||||
@@ -39,6 +40,13 @@ export interface AppState {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type AuthDialogState =
|
||||
| { step: "method" }
|
||||
| { step: "providers"; mode: "login"; authType?: "oauth" | "api_key"; providers: AuthProviderOption[] }
|
||||
| { step: "apiKey"; provider: AuthProviderOption; value: string; saving?: boolean; error?: string }
|
||||
| { step: "oauth"; flow: OAuthFlowState; responding?: boolean; inputValue?: string; error?: string }
|
||||
| { step: "logout"; providers: AuthProviderOption[] };
|
||||
|
||||
export function initialAppState(): AppState {
|
||||
return {
|
||||
projects: [],
|
||||
@@ -59,6 +67,7 @@ export function initialAppState(): AppState {
|
||||
commandDialog: undefined,
|
||||
modelDialog: undefined,
|
||||
thinkingDialog: undefined,
|
||||
authDialog: undefined,
|
||||
actionPaletteOpen: false,
|
||||
projectDialogOpen: false,
|
||||
workspaceTool: "core:workspace.files",
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import type { AuthDialogState } from "../appState";
|
||||
import type { AuthProviderOption } from "../api";
|
||||
import { commandPickerStyles } from "./shared";
|
||||
|
||||
@customElement("auth-dialog")
|
||||
export class AuthDialog extends LitElement {
|
||||
@property({ attribute: false }) state?: AuthDialogState;
|
||||
@property({ attribute: false }) onChooseMethod?: (authType: "oauth" | "api_key") => void;
|
||||
@property({ attribute: false }) onSelectProvider?: (providerId: string, authType: "oauth" | "api_key") => void;
|
||||
@property({ attribute: false }) onApiKeyInput?: (value: string) => void;
|
||||
@property({ attribute: false }) onSaveApiKey?: () => void;
|
||||
@property({ attribute: false }) onLogoutProvider?: (providerId: string) => void;
|
||||
@property({ attribute: false }) onOAuthInput?: (value: string) => void;
|
||||
@property({ attribute: false }) onOAuthRespond?: (value?: string) => void;
|
||||
@property({ attribute: false }) onOAuthCancel?: () => void;
|
||||
@property({ attribute: false }) onCancel?: () => void;
|
||||
@query("input") private input?: HTMLInputElement;
|
||||
private lastFocusedInputKey: string | undefined;
|
||||
|
||||
override render() {
|
||||
const state = this.state;
|
||||
if (state === undefined) return null;
|
||||
return html`
|
||||
<div class="backdrop" @mousedown=${() => { this.cancel(); }}>
|
||||
<section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
|
||||
<header>
|
||||
<strong>${this.dialogTitle(state)}</strong>
|
||||
<button title="Close" @click=${() => { this.cancel(); }}>×</button>
|
||||
</header>
|
||||
${this.renderBody(state)}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected override updated(): void {
|
||||
this.focusInputIfNeeded();
|
||||
}
|
||||
|
||||
private dialogTitle(state: AuthDialogState): string {
|
||||
switch (state.step) {
|
||||
case "method": return "Configure provider authentication";
|
||||
case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select API key provider";
|
||||
case "apiKey": return `API key for ${state.provider.name}`;
|
||||
case "oauth": return `Login to ${state.flow.providerName}`;
|
||||
case "logout": return "Remove stored provider authentication";
|
||||
}
|
||||
}
|
||||
|
||||
private renderBody(state: AuthDialogState) {
|
||||
switch (state.step) {
|
||||
case "method": return html`
|
||||
<div class="options">
|
||||
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
|
||||
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in pi auth.json</small></button>
|
||||
</div>
|
||||
`;
|
||||
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
|
||||
case "apiKey": return html`
|
||||
<div class="form">
|
||||
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored by pi in <code>auth.json</code>.</p>
|
||||
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
|
||||
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
|
||||
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
|
||||
</div>
|
||||
`;
|
||||
case "oauth": return this.renderOAuth(state);
|
||||
case "logout": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No stored credentials. Environment variables and models.json settings are unchanged.</div>` : state.providers.map((provider) => html`
|
||||
<button @click=${() => { this.onLogoutProvider?.(provider.id); }}><span>${provider.name}</span><small>${provider.id} · ${authTypeLabel(provider.authType)}</small></button>
|
||||
`)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
private renderProviderButton(provider: AuthProviderOption) {
|
||||
return html`
|
||||
<button @click=${() => { this.onSelectProvider?.(provider.id, provider.authType); }}>
|
||||
<span>${provider.name}${provider.status.source !== undefined ? html` <em>${statusLabel(provider)}</em>` : null}</span>
|
||||
<small>${provider.id} · ${authTypeLabel(provider.authType)}</small>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOAuth(state: Extract<AuthDialogState, { step: "oauth" }>) {
|
||||
const flow = state.flow;
|
||||
const prompt = flow.prompt;
|
||||
const select = flow.select;
|
||||
return html`
|
||||
<div class="form">
|
||||
${flow.auth !== undefined ? html`
|
||||
<p>Open this authorization link:</p>
|
||||
<p><a href=${flow.auth.url} target="_blank" rel="noreferrer">${flow.auth.url}</a></p>
|
||||
${flow.auth.instructions !== undefined ? html`<p class="warning">${flow.auth.instructions}</p>` : null}
|
||||
` : html`<p>Starting login flow…</p>`}
|
||||
${flow.progress.length > 0 ? html`<ul class="progress">${flow.progress.map((line) => html`<li>${line}</li>`)}</ul>` : null}
|
||||
${prompt !== undefined ? html`
|
||||
<label>${prompt.message}</label>
|
||||
<input .value=${state.inputValue ?? ""} placeholder=${prompt.placeholder ?? ""} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
|
||||
<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button><button class="primary" ?disabled=${state.responding === true} @click=${() => { this.onOAuthRespond?.(); }}>Submit</button></div>
|
||||
` : null}
|
||||
${select !== undefined ? html`
|
||||
<p>${select.message}</p>
|
||||
<div class="inline-options">${select.options.map((option) => html`<button @click=${() => { this.onOAuthRespond?.(option.value); }}>${option.label}</button>`)}</div>
|
||||
` : null}
|
||||
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
|
||||
${flow.status === "error" || flow.status === "cancelled" ? html`<div class="error-text">${flow.error ?? flow.status}</div><div class="actions"><button @click=${() => { this.cancel(); }}>Close</button></div>` : null}
|
||||
${prompt === undefined && select === undefined && flow.status === "running" ? html`<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button></div>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private focusInputIfNeeded(): void {
|
||||
const key = focusKey(this.state);
|
||||
if (key === undefined) {
|
||||
this.lastFocusedInputKey = undefined;
|
||||
return;
|
||||
}
|
||||
if (key === this.lastFocusedInputKey) return;
|
||||
this.lastFocusedInputKey = key;
|
||||
this.input?.focus();
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Enter") return;
|
||||
const state = this.state;
|
||||
if (state?.step === "apiKey") {
|
||||
event.preventDefault();
|
||||
this.onSaveApiKey?.();
|
||||
} else if (state?.step === "oauth" && state.flow.prompt !== undefined) {
|
||||
event.preventDefault();
|
||||
this.onOAuthRespond?.();
|
||||
}
|
||||
}
|
||||
|
||||
private cancel(): void {
|
||||
const state = this.state;
|
||||
if (state?.step === "oauth") this.onOAuthCancel?.();
|
||||
else this.onCancel?.();
|
||||
}
|
||||
|
||||
static override styles = [commandPickerStyles, css`
|
||||
.form { display: grid; gap: 12px; padding: 14px; overflow: auto; }
|
||||
.form p { margin: 0; color: #c9d1d9; overflow-wrap: anywhere; }
|
||||
.form a { color: #58a6ff; overflow-wrap: anywhere; }
|
||||
.form code { border: 1px solid #30363d; border-radius: 4px; background: #161b22; padding: 1px 4px; }
|
||||
label { color: #8b949e; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.actions button, .inline-options button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; }
|
||||
.actions button.primary { border-color: #238636; background: #0f2a16; color: #3fb950; }
|
||||
.actions button:disabled { opacity: .6; cursor: wait; }
|
||||
.warning { color: #d29922; }
|
||||
.error-text { color: #ff7b72; }
|
||||
.progress { margin: 0; padding-left: 18px; color: #8b949e; }
|
||||
.inline-options { display: grid; gap: 8px; }
|
||||
em { color: #3fb950; font-style: normal; font-size: 12px; }
|
||||
`];
|
||||
}
|
||||
|
||||
function authTypeLabel(authType: "oauth" | "api_key"): string {
|
||||
return authType === "oauth" ? "subscription" : "API key";
|
||||
}
|
||||
|
||||
function focusKey(state: AuthDialogState | undefined): string | undefined {
|
||||
if (state?.step === "apiKey") return `api-key:${state.provider.authType}:${state.provider.id}`;
|
||||
if (state?.step === "oauth" && state.flow.prompt !== undefined) return `oauth:${state.flow.flowId}:${state.flow.prompt.requestId}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function statusLabel(provider: AuthProviderOption): string {
|
||||
if (provider.status.source === undefined) return "";
|
||||
switch (provider.status.source) {
|
||||
case "stored": return "✓ configured";
|
||||
case "environment": return `✓ env${provider.status.label === undefined ? "" : `: ${provider.status.label}`}`;
|
||||
case "runtime": return "✓ runtime";
|
||||
case "fallback": return "✓ custom key";
|
||||
case "models_json_key": return "✓ models.json key";
|
||||
case "models_json_command": return "✓ models.json command";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { customElement, query, state } from "lit/decorators.js";
|
||||
import { terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { AuthController } from "../controllers/authController";
|
||||
import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
import { GitController } from "../controllers/gitController";
|
||||
import { ProjectController } from "../controllers/projectController";
|
||||
@@ -26,6 +27,7 @@ import type { PromptEditor } from "./PromptEditor";
|
||||
import "./StatusBar";
|
||||
import "./CommandPicker";
|
||||
import "./ActionPalette";
|
||||
import "./AuthDialog";
|
||||
import "./ProjectDialog";
|
||||
import "./WorkspacePanel";
|
||||
import { appStyles } from "./shared";
|
||||
@@ -41,6 +43,11 @@ export class PiWebApp extends LitElement {
|
||||
(patch) => { this.setState(patch); },
|
||||
() => { this.updateUrl(); },
|
||||
);
|
||||
private readonly auth = new AuthController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
(status) => { this.sessions.applySessionStatus(status); },
|
||||
);
|
||||
private readonly workspaces = new WorkspaceController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
@@ -103,6 +110,7 @@ export class PiWebApp extends LitElement {
|
||||
window.removeEventListener("keydown", this.onKeyDown);
|
||||
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
|
||||
this.keyboard.reset();
|
||||
this.auth.dispose();
|
||||
this.sessions.dispose();
|
||||
this.realtime.close();
|
||||
this.git.dispose();
|
||||
@@ -327,6 +335,8 @@ export class PiWebApp extends LitElement {
|
||||
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
||||
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
||||
addProject: () => { this.setState({ projectDialogOpen: true }); },
|
||||
configureAuth: () => this.auth.openLogin(),
|
||||
logoutAuth: () => this.auth.openLogout(),
|
||||
selectMainView: (view) => { this.selectMainView(view); },
|
||||
selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); },
|
||||
refreshFiles: () => this.files.refreshFiles(),
|
||||
@@ -384,6 +394,11 @@ export class PiWebApp extends LitElement {
|
||||
if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value);
|
||||
}
|
||||
|
||||
private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp"): void {
|
||||
if (streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return;
|
||||
void this.sessions.send(text, streamingBehavior);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const state = this.state;
|
||||
return html`
|
||||
@@ -401,11 +416,12 @@ export class PiWebApp extends LitElement {
|
||||
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
|
||||
${state.authDialog !== undefined ? html`<auth-dialog .state=${state.authDialog} .onChooseMethod=${(authType: "oauth" | "api_key") => { void this.auth.chooseLoginMethod(authType); }} .onSelectProvider=${(providerId: string, authType: "oauth" | "api_key") => { void this.auth.selectLoginProvider(providerId, authType); }} .onApiKeyInput=${(value: string) => { this.auth.updateApiKey(value); }} .onSaveApiKey=${() => { void this.auth.saveApiKey(); }} .onLogoutProvider=${(providerId: string) => { void this.auth.logoutProvider(providerId); }} .onOAuthInput=${(value: string) => { this.auth.updateOAuthInput(value); }} .onOAuthRespond=${(value?: string) => { void this.auth.respondOAuth(value); }} .onOAuthCancel=${() => { void this.auth.cancelOAuth(); }} .onCancel=${() => { this.auth.closeDialog(); }}></auth-dialog>` : null}
|
||||
` : html`<div class="empty">Select or start a session.</div>`}
|
||||
</main>
|
||||
${this.renderWorkspacePanel()}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { api as defaultApi, type AuthProviderOption, type OAuthFlowState } from "../api";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { AuthController, parseAuthSlashCommand } from "./authController";
|
||||
|
||||
describe("parseAuthSlashCommand", () => {
|
||||
it("parses login and logout commands", () => {
|
||||
expect(parseAuthSlashCommand("/login")).toEqual({ command: "login" });
|
||||
expect(parseAuthSlashCommand("/logout")).toEqual({ command: "logout" });
|
||||
});
|
||||
|
||||
it("parses provider arguments", () => {
|
||||
expect(parseAuthSlashCommand("/login openai")).toEqual({ command: "login", providerId: "openai" });
|
||||
expect(parseAuthSlashCommand("/logout openai-codex ")).toEqual({ command: "logout", providerId: "openai-codex" });
|
||||
});
|
||||
|
||||
it("ignores non-auth commands and extra arguments", () => {
|
||||
expect(parseAuthSlashCommand("/model")).toBeUndefined();
|
||||
expect(parseAuthSlashCommand("hello /login")).toBeUndefined();
|
||||
expect(parseAuthSlashCommand("/login openai extra")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthController", () => {
|
||||
it("uses auth type to disambiguate provider options with the same id", async () => {
|
||||
const providers = [authProvider("anthropic", "oauth"), authProvider("anthropic", "api_key")];
|
||||
const { controller, getState } = createController({ authDialog: { step: "providers", mode: "login", providers } });
|
||||
|
||||
await controller.selectLoginProvider("anthropic", "api_key");
|
||||
|
||||
expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } });
|
||||
});
|
||||
|
||||
it("keeps OAuth prompt input and submit state across poll refreshes for the same request", async () => {
|
||||
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||
const { controller, getState } = createController(
|
||||
{ authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } },
|
||||
{ respondOAuthFlow: () => Promise.resolve(oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" }, progress: ["Still waiting"] })) },
|
||||
);
|
||||
|
||||
await controller.respondOAuth();
|
||||
|
||||
expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true });
|
||||
});
|
||||
});
|
||||
|
||||
function createController(statePatch: Partial<AppState>, apiPatch: Partial<typeof defaultApi> = {}) {
|
||||
let state: AppState = { ...initialAppState(), ...statePatch };
|
||||
const api = { ...defaultApi, ...apiPatch };
|
||||
const controller = new AuthController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
{ api },
|
||||
);
|
||||
return { controller, getState: () => state };
|
||||
}
|
||||
|
||||
function authProvider(id: string, authType: "oauth" | "api_key"): AuthProviderOption {
|
||||
return { id, authType, name: `${id} ${authType}`, status: { configured: false } };
|
||||
}
|
||||
|
||||
function oauthFlow(patch: Partial<OAuthFlowState> = {}): OAuthFlowState {
|
||||
return {
|
||||
flowId: "flow-1",
|
||||
providerId: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
status: "running",
|
||||
progress: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
|
||||
import type { GetState, SetState } from "./types";
|
||||
|
||||
export interface AuthControllerDependencies {
|
||||
api?: typeof defaultApi;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export class AuthController {
|
||||
private readonly api: typeof defaultApi;
|
||||
private readonly pollIntervalMs: number;
|
||||
private pollTimer: number | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly getState: GetState,
|
||||
private readonly setState: SetState,
|
||||
private readonly applyStatus: (status: SessionStatus) => void,
|
||||
deps: AuthControllerDependencies = {},
|
||||
) {
|
||||
this.api = deps.api ?? defaultApi;
|
||||
this.pollIntervalMs = deps.pollIntervalMs ?? 1000;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
handleSlashCommand(text: string): boolean {
|
||||
const parsed = parseAuthSlashCommand(text);
|
||||
if (parsed === undefined) return false;
|
||||
if (parsed.command === "login") void this.openLogin(parsed.providerId);
|
||||
else void this.openLogout(parsed.providerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async openLogin(providerId?: string): Promise<void> {
|
||||
if (providerId !== undefined && providerId !== "") {
|
||||
await this.openLoginProvider(providerId);
|
||||
return;
|
||||
}
|
||||
this.setState({ authDialog: { step: "method" } });
|
||||
}
|
||||
|
||||
async chooseLoginMethod(authType: AuthType): Promise<void> {
|
||||
try {
|
||||
const { providers } = await this.api.authProviders({ mode: "login", authType });
|
||||
this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async selectLoginProvider(providerId: string, authType?: AuthType): Promise<void> {
|
||||
const dialog = this.getState().authDialog;
|
||||
if (dialog?.step !== "providers") return;
|
||||
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
|
||||
if (provider === undefined) return;
|
||||
if (provider.authType === "oauth") await this.startOAuth(provider);
|
||||
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
|
||||
}
|
||||
|
||||
updateApiKey(value: string): void {
|
||||
const dialog = this.getState().authDialog;
|
||||
if (dialog?.step !== "apiKey") return;
|
||||
const clean = { ...dialog };
|
||||
delete clean.error;
|
||||
this.setState({ authDialog: { ...clean, value } });
|
||||
}
|
||||
|
||||
async saveApiKey(): Promise<void> {
|
||||
const dialog = this.getState().authDialog;
|
||||
if (dialog?.step !== "apiKey") return;
|
||||
const key = dialog.value.trim();
|
||||
if (key === "") {
|
||||
this.setState({ authDialog: { ...dialog, error: "API key is required" } });
|
||||
return;
|
||||
}
|
||||
const clean = { ...dialog };
|
||||
delete clean.error;
|
||||
this.setState({ authDialog: { ...clean, saving: true } });
|
||||
try {
|
||||
await this.api.saveApiKey(dialog.provider.id, key);
|
||||
this.closeDialog();
|
||||
void this.refreshStatus();
|
||||
} catch (error) {
|
||||
this.setState({ authDialog: { ...dialog, saving: false, error: String(error) } });
|
||||
}
|
||||
}
|
||||
|
||||
async openLogout(providerId?: string): Promise<void> {
|
||||
try {
|
||||
const { providers } = await this.api.authProviders({ mode: "logout" });
|
||||
if (providerId !== undefined && providerId !== "") {
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (provider !== undefined) await this.logoutProvider(provider.id);
|
||||
else this.setState({ error: `No stored credentials for ${providerId}` });
|
||||
return;
|
||||
}
|
||||
this.setState({ authDialog: { step: "logout", providers } });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async logoutProvider(providerId: string): Promise<void> {
|
||||
try {
|
||||
await this.api.logoutProvider(providerId);
|
||||
this.closeDialog();
|
||||
void this.refreshStatus();
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
updateOAuthInput(value: string): void {
|
||||
const dialog = this.getState().authDialog;
|
||||
if (dialog?.step !== "oauth") return;
|
||||
const clean = { ...dialog };
|
||||
delete clean.error;
|
||||
this.setState({ authDialog: { ...clean, inputValue: value } });
|
||||
}
|
||||
|
||||
async respondOAuth(value?: string): Promise<void> {
|
||||
const dialog = this.getState().authDialog;
|
||||
if (dialog?.step !== "oauth") return;
|
||||
const request = dialog.flow.prompt ?? dialog.flow.select;
|
||||
if (request === undefined) return;
|
||||
const responseValue = value ?? dialog.inputValue ?? "";
|
||||
const clean = { ...dialog };
|
||||
delete clean.error;
|
||||
this.setState({ authDialog: { ...clean, responding: true } });
|
||||
try {
|
||||
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue);
|
||||
this.updateOAuthFlow(flow);
|
||||
} catch (error) {
|
||||
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
|
||||
}
|
||||
}
|
||||
|
||||
async cancelOAuth(): Promise<void> {
|
||||
const dialog = this.getState().authDialog;
|
||||
if (dialog?.step !== "oauth") {
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
this.stopPolling();
|
||||
try {
|
||||
await this.api.cancelOAuthFlow(dialog.flow.flowId);
|
||||
} catch {
|
||||
// Best-effort cancel. The dialog closes either way.
|
||||
}
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
closeDialog(): void {
|
||||
this.stopPolling();
|
||||
this.setState({ authDialog: undefined });
|
||||
}
|
||||
|
||||
private async openLoginProvider(providerId: string): Promise<void> {
|
||||
try {
|
||||
const { providers } = await this.api.authProviders({ mode: "login" });
|
||||
const exact = providers.filter((provider) => provider.id === providerId);
|
||||
if (exact.length === 0) {
|
||||
this.setState({ error: `Auth provider not found: ${providerId}` });
|
||||
return;
|
||||
}
|
||||
if (exact.length > 1) {
|
||||
this.setState({ authDialog: { step: "providers", mode: "login", providers: exact } });
|
||||
return;
|
||||
}
|
||||
const provider = exact[0];
|
||||
if (provider === undefined) return;
|
||||
if (provider.authType === "oauth") await this.startOAuth(provider);
|
||||
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private async startOAuth(provider: AuthProviderOption): Promise<void> {
|
||||
try {
|
||||
const flow = await this.api.startOAuthLogin(provider.id);
|
||||
this.updateOAuthFlow(flow);
|
||||
this.startPolling(flow.flowId);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private updateOAuthFlow(flow: OAuthFlowState): void {
|
||||
if (flow.status === "complete") {
|
||||
this.stopPolling();
|
||||
this.closeDialog();
|
||||
void this.refreshStatus();
|
||||
return;
|
||||
}
|
||||
if (flow.status === "error" || flow.status === "cancelled") this.stopPolling();
|
||||
const existing = this.getState().authDialog;
|
||||
const previousInput = existing?.step === "oauth" && existing.flow.flowId === flow.flowId ? existing.inputValue ?? "" : "";
|
||||
const previousRequestId = existing?.step === "oauth" ? existing.flow.prompt?.requestId ?? existing.flow.select?.requestId : undefined;
|
||||
const newRequestId = flow.prompt?.requestId ?? flow.select?.requestId;
|
||||
const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId;
|
||||
const inputValue = sameRequest ? previousInput : "";
|
||||
const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false;
|
||||
this.setState({ authDialog: { step: "oauth", flow, inputValue, responding } });
|
||||
}
|
||||
|
||||
private startPolling(flowId: string): void {
|
||||
this.stopPolling();
|
||||
this.pollTimer = window.setInterval(() => { void this.poll(flowId); }, this.pollIntervalMs);
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer === undefined) return;
|
||||
window.clearInterval(this.pollTimer);
|
||||
this.pollTimer = undefined;
|
||||
}
|
||||
|
||||
private async poll(flowId: string): Promise<void> {
|
||||
const dialog = this.getState().authDialog;
|
||||
if (dialog?.step !== "oauth" || dialog.flow.flowId !== flowId) {
|
||||
this.stopPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.updateOAuthFlow(await this.api.oauthFlow(flowId));
|
||||
} catch (error) {
|
||||
this.stopPolling();
|
||||
this.setState({ authDialog: { ...dialog, error: String(error) } });
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshStatus(): Promise<void> {
|
||||
const sessionId = this.sessionId();
|
||||
if (sessionId === undefined) return;
|
||||
try {
|
||||
this.applyStatus(await this.api.status(sessionId));
|
||||
} catch {
|
||||
// Status refresh is opportunistic after login completes.
|
||||
}
|
||||
}
|
||||
|
||||
private sessionId(): string | undefined {
|
||||
const session = this.getState().selectedSession;
|
||||
if (session === undefined || session.archived === true) return undefined;
|
||||
return session.id;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAuthSlashCommand(text: string): { command: "login" | "logout"; providerId?: string } | undefined {
|
||||
const trimmed = text.trim();
|
||||
const match = /^\/(login|logout)(?:\s+(\S+))?\s*$/u.exec(trimmed);
|
||||
if (match === null) return undefined;
|
||||
const command = match[1];
|
||||
if (command !== "login" && command !== "logout") return undefined;
|
||||
const providerId = match[2];
|
||||
return providerId === undefined || providerId === "" ? { command } : { command, providerId };
|
||||
}
|
||||
|
||||
export type { AuthDialogState } from "../appState";
|
||||
@@ -199,6 +199,10 @@ export class SessionController {
|
||||
this.setState({ commandDialog: undefined });
|
||||
}
|
||||
|
||||
applySessionStatus(status: SessionStatus): void {
|
||||
this.applyStatus(status);
|
||||
}
|
||||
|
||||
async archiveSession(session = this.getState().selectedSession) {
|
||||
if (!session) return;
|
||||
if (isCachedNewSessionInfo(session)) {
|
||||
|
||||
@@ -25,6 +25,20 @@ export function createCoreActions(): PluginAction[] {
|
||||
group: "Project",
|
||||
run: (context) => context.addProject(),
|
||||
},
|
||||
{
|
||||
id: "auth.login",
|
||||
title: "Configure Provider Authentication",
|
||||
description: "Run /login without tying authentication to a session",
|
||||
group: "General",
|
||||
run: (context) => context.configureAuth(),
|
||||
},
|
||||
{
|
||||
id: "auth.logout",
|
||||
title: "Remove Provider Authentication",
|
||||
description: "Run /logout for stored pi credentials",
|
||||
group: "General",
|
||||
run: (context) => context.logoutAuth(),
|
||||
},
|
||||
{
|
||||
id: "view.chat",
|
||||
title: "Go to Chat",
|
||||
|
||||
@@ -12,6 +12,8 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
|
||||
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
|
||||
addProject: vi.fn(() => { calls.push("addProject"); }),
|
||||
configureAuth: vi.fn(() => { calls.push("configureAuth"); }),
|
||||
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
|
||||
selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }),
|
||||
selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }),
|
||||
refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }),
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PluginRuntimeContext {
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
addProject: () => void | Promise<void>;
|
||||
configureAuth: () => void | Promise<void>;
|
||||
logoutAuth: () => void | Promise<void>;
|
||||
selectMainView: (view: AppState["mainView"]) => void;
|
||||
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
|
||||
refreshFiles: () => void | Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user