mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
89a5507602
* fix(ui): defer GitHub project clone until submit Keep remote project selection as draft state and materialize it before sessions.create on Start Session. * fix(ui): consolidate deferred project selection * test(ui): drop duplicate remote project assertion
574 lines
18 KiB
TypeScript
574 lines
18 KiB
TypeScript
import { initialState, Task, TaskStatus } from "@lit/task";
|
|
import type { ReactiveControllerHost } from "lit";
|
|
import type {
|
|
FsListDirResult,
|
|
ProjectRecord,
|
|
ProjectRecent,
|
|
ProjectsListResult,
|
|
ProjectsRegisterResult,
|
|
ProjectsSearchRemoteResult,
|
|
WorktreesBranchesResult,
|
|
} from "../../../../packages/gateway-protocol/src/index.js";
|
|
import type { ApplicationContext } from "../../app/context.ts";
|
|
import { t } from "../../i18n/index.ts";
|
|
import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
|
import type { BrowserTarget, DraftNode } from "./discovery.ts";
|
|
import type { DraftGatewayState } from "./draft-gateway-state.ts";
|
|
import { folderDisplayName, isAbsolutePath, isKnownWorkspacePath } from "./path.ts";
|
|
import { projectCloneInput, type DraftRemoteProject } from "./project-chip.ts";
|
|
import { recentPlaces, type RecentPlaceSource } from "./recent-places.ts";
|
|
|
|
const PROJECT_SEARCH_DEBOUNCE_MS = 300;
|
|
type DraftPickerKind = "where" | "project" | "detail";
|
|
|
|
type DraftPlaceBrowserSnapshot = Readonly<{
|
|
context: ApplicationContext | undefined;
|
|
nodes: readonly DraftNode[];
|
|
folder: string;
|
|
execNode: string;
|
|
isAdmin: boolean;
|
|
}>;
|
|
|
|
type DraftProjectSelection =
|
|
| { kind: "local"; id: string }
|
|
| { kind: "remote"; project: DraftRemoteProject }
|
|
| null;
|
|
|
|
type DraftPlaceBrowserCallbacks = {
|
|
requestUpdate: () => void;
|
|
onProjectMissing: () => void;
|
|
onSelectProject: (projectId: string) => void;
|
|
onApprovedListing: (listing: FsListDirResult) => void;
|
|
querySelector: (selector: string) => Element | null;
|
|
activeElement: () => Element | null;
|
|
body: () => HTMLElement | null;
|
|
};
|
|
|
|
export class DraftPlaceBrowser {
|
|
private projectsValue: ProjectRecord[] = [];
|
|
private projectRecentsValue: ProjectRecent[] | undefined;
|
|
private projectSelection: DraftProjectSelection = null;
|
|
private projectQueryValue = "";
|
|
private debouncedProjectQuery = "";
|
|
private browserLoadingValue = false;
|
|
private browserErrorValue: string | null = null;
|
|
private browserListingValue: FsListDirResult | null = null;
|
|
private browserTargetValue: BrowserTarget | null = null;
|
|
private browserProjectPathValue: string | null = null;
|
|
private browserRegisteringValue = false;
|
|
private openPopoverValue: DraftPickerKind | null = null;
|
|
private hidingPopoverValue: DraftPickerKind | null = null;
|
|
// Live head input; absolute paths stay applicable even without fs.listDir.
|
|
private browserPathDraftValue = "";
|
|
private browserRequestToken = 0;
|
|
private projectSearchTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
|
|
|
private readonly projectsTask: Task<readonly unknown[], ProjectsListResult>;
|
|
private readonly projectSearchTask: Task<readonly unknown[], ProjectsSearchRemoteResult>;
|
|
|
|
constructor(
|
|
host: ReactiveControllerHost,
|
|
private readonly gateway: DraftGatewayState,
|
|
private readonly read: () => DraftPlaceBrowserSnapshot,
|
|
private readonly callbacks: DraftPlaceBrowserCallbacks,
|
|
) {
|
|
this.projectsTask = new Task(host, {
|
|
args: () =>
|
|
[
|
|
this.read().context && this.gateway.connected ? this.gateway.client : null,
|
|
isGatewayMethodAdvertised(
|
|
this.read().context?.gateway.snapshot ?? {},
|
|
"projects.list",
|
|
) === true,
|
|
this.gateway.connectionEpoch,
|
|
] as const,
|
|
task: async ([client, advertised]) => {
|
|
if (!client || !advertised) {
|
|
return { projects: [] } as ProjectsListResult;
|
|
}
|
|
return await (
|
|
client as NonNullable<ApplicationContext["gateway"]["snapshot"]["client"]>
|
|
).request<ProjectsListResult>("projects.list", {});
|
|
},
|
|
onComplete: (result) => {
|
|
const projects = result.projects ?? [];
|
|
this.projectsValue = projects;
|
|
this.projectRecentsValue = result.recents;
|
|
if (this.projectId && !projects.some((project) => project.id === this.projectId)) {
|
|
this.callbacks.onProjectMissing();
|
|
}
|
|
this.callbacks.requestUpdate();
|
|
},
|
|
onError: () => {
|
|
this.projectsValue = [];
|
|
this.projectRecentsValue = undefined;
|
|
this.callbacks.onProjectMissing();
|
|
this.callbacks.requestUpdate();
|
|
},
|
|
});
|
|
this.projectSearchTask = new Task(host, {
|
|
args: () =>
|
|
[
|
|
this.read().context && this.gateway.connected ? this.gateway.client : null,
|
|
this.read().context
|
|
? canCallGatewayMethod(
|
|
this.read().context?.gateway.snapshot,
|
|
"projects.searchRemote",
|
|
"operator.read",
|
|
)
|
|
: false,
|
|
this.debouncedProjectQuery,
|
|
this.gateway.connectionEpoch,
|
|
] as const,
|
|
task: ([client, advertised, query], { signal }) => {
|
|
if (!client || !advertised || query.length < 2 || projectCloneInput(query)) {
|
|
return initialState;
|
|
}
|
|
return client.request<ProjectsSearchRemoteResult>(
|
|
"projects.searchRemote",
|
|
{ query },
|
|
{ signal },
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
get projects(): readonly ProjectRecord[] {
|
|
return this.projectsValue;
|
|
}
|
|
|
|
get projectsReady(): boolean {
|
|
return (
|
|
this.projectsTask.status === TaskStatus.COMPLETE ||
|
|
this.projectsTask.status === TaskStatus.ERROR
|
|
);
|
|
}
|
|
|
|
get projectRecents(): readonly ProjectRecent[] | undefined {
|
|
return this.projectRecentsValue;
|
|
}
|
|
|
|
get projectId(): string {
|
|
return this.projectSelection?.kind === "local" ? this.projectSelection.id : "";
|
|
}
|
|
|
|
get remoteProject(): DraftRemoteProject | null {
|
|
return this.projectSelection?.kind === "remote" ? this.projectSelection.project : null;
|
|
}
|
|
|
|
get projectQuery(): string {
|
|
return this.projectQueryValue;
|
|
}
|
|
|
|
get projectSearchResult(): ProjectsSearchRemoteResult | null {
|
|
return this.projectSearchTask.status === TaskStatus.COMPLETE &&
|
|
this.debouncedProjectQuery === this.projectQueryValue.trim()
|
|
? (this.projectSearchTask.value ?? null)
|
|
: null;
|
|
}
|
|
|
|
get projectSearchLoading(): boolean {
|
|
return (
|
|
this.debouncedProjectQuery.length >= 2 &&
|
|
this.debouncedProjectQuery === this.projectQueryValue.trim() &&
|
|
this.projectSearchTask.status === TaskStatus.PENDING
|
|
);
|
|
}
|
|
|
|
get projectSearchError(): string | null {
|
|
if (
|
|
this.projectSearchTask.status !== TaskStatus.ERROR ||
|
|
this.debouncedProjectQuery !== this.projectQueryValue.trim()
|
|
) {
|
|
return null;
|
|
}
|
|
const error = this.projectSearchTask.error;
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
get browserLoading(): boolean {
|
|
return this.browserLoadingValue;
|
|
}
|
|
|
|
get browserError(): string | null {
|
|
return this.browserErrorValue;
|
|
}
|
|
|
|
get browserListing(): FsListDirResult | null {
|
|
return this.browserListingValue;
|
|
}
|
|
|
|
get browserTarget(): BrowserTarget | null {
|
|
return this.browserTargetValue;
|
|
}
|
|
|
|
get browserProjectPath(): string | null {
|
|
return this.browserProjectPathValue;
|
|
}
|
|
|
|
get browserRegistering(): boolean {
|
|
return this.browserRegisteringValue;
|
|
}
|
|
|
|
popoverOpen(kind: DraftPickerKind): boolean {
|
|
return this.openPopoverValue === kind;
|
|
}
|
|
|
|
popoverHiding(kind: DraftPickerKind): boolean {
|
|
return this.hidingPopoverValue === kind;
|
|
}
|
|
|
|
get browserPathDraft(): string {
|
|
return this.browserPathDraftValue;
|
|
}
|
|
|
|
set browserPathDraft(value: string) {
|
|
this.browserPathDraftValue = value;
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
|
|
async refreshProjects(): Promise<unknown> {
|
|
const context = this.read().context;
|
|
return await this.projectsTask.run([
|
|
this.gateway.connected ? this.gateway.client : null,
|
|
context
|
|
? isGatewayMethodAdvertised(context.gateway.snapshot, "projects.list") === true
|
|
: false,
|
|
this.gateway.connectionEpoch,
|
|
]);
|
|
}
|
|
|
|
selectedProject(): ProjectRecord | undefined {
|
|
return this.projectsValue.find((project) => project.id === this.projectId);
|
|
}
|
|
|
|
selectProject(selection: Exclude<DraftProjectSelection, null>) {
|
|
this.projectSelection = selection;
|
|
}
|
|
|
|
recordRemoteProjectId(cloneUrl: string, projectId: string) {
|
|
const project = this.remoteProject;
|
|
if (project?.cloneUrl === cloneUrl) {
|
|
this.projectSelection = { kind: "remote", project: { ...project, projectId } };
|
|
}
|
|
}
|
|
|
|
clearProjectSelection() {
|
|
this.projectSelection = null;
|
|
}
|
|
|
|
resolveProjectRecents(params: {
|
|
sessions: readonly RecentPlaceSource[];
|
|
workspace: string;
|
|
workspaceRoots: readonly string[];
|
|
execNodes: readonly DraftNode[];
|
|
isAdmin: boolean;
|
|
}): ProjectRecent[] {
|
|
const allowGatewayFolder = (folder: string) =>
|
|
params.isAdmin || isKnownWorkspacePath(params.workspaceRoots, folder);
|
|
const serverRecents = this.projectRecentsValue?.filter((recent) =>
|
|
recent.kind === "project"
|
|
? this.projectsValue.some((project) => project.id === recent.projectId)
|
|
: recent.execNode
|
|
? params.execNodes.some((node) => node.nodeId === recent.execNode)
|
|
: allowGatewayFolder(recent.folder),
|
|
);
|
|
return (
|
|
serverRecents ??
|
|
recentPlaces(params.sessions, {
|
|
workspace: params.workspace,
|
|
execNodes: params.execNodes,
|
|
allowGatewayFolder,
|
|
}).map((recent) => {
|
|
const item: ProjectRecent = {
|
|
kind: "folder",
|
|
folder: recent.folder,
|
|
displayName: folderDisplayName(recent.folder),
|
|
};
|
|
if (recent.execNode) {
|
|
item.execNode = recent.execNode;
|
|
}
|
|
return item;
|
|
})
|
|
);
|
|
}
|
|
|
|
changeProjectQuery(query: string) {
|
|
this.projectQueryValue = query;
|
|
this.clearProjectSearchTimer();
|
|
this.debouncedProjectQuery = "";
|
|
void this.projectSearchTask.run([null, false, "", this.gateway.connectionEpoch]);
|
|
const normalized = query.trim();
|
|
const context = this.read().context;
|
|
if (
|
|
normalized.length < 2 ||
|
|
projectCloneInput(normalized) ||
|
|
!this.gateway.connected ||
|
|
!this.gateway.client ||
|
|
!context ||
|
|
!canCallGatewayMethod(context.gateway.snapshot, "projects.searchRemote", "operator.read")
|
|
) {
|
|
this.callbacks.requestUpdate();
|
|
return;
|
|
}
|
|
const client = this.gateway.client;
|
|
const connectionEpoch = this.gateway.connectionEpoch;
|
|
this.projectSearchTimer = globalThis.setTimeout(() => {
|
|
this.projectSearchTimer = undefined;
|
|
if (client !== this.gateway.client || connectionEpoch !== this.gateway.connectionEpoch) {
|
|
return;
|
|
}
|
|
this.debouncedProjectQuery = normalized;
|
|
void this.projectSearchTask.run([client, true, normalized, connectionEpoch]);
|
|
this.callbacks.requestUpdate();
|
|
}, PROJECT_SEARCH_DEBOUNCE_MS);
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
|
|
resetProjectSearch() {
|
|
this.clearProjectSearchTimer();
|
|
this.projectQueryValue = "";
|
|
this.debouncedProjectQuery = "";
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
|
|
resetProjects() {
|
|
this.projectsValue = [];
|
|
this.projectRecentsValue = undefined;
|
|
this.clearProjectSelection();
|
|
this.resetProjectSearch();
|
|
}
|
|
|
|
close() {
|
|
this.resetBrowser(true);
|
|
for (const kind of ["where", "project", "detail"] as const) {
|
|
const popover = this.callbacks.querySelector(`.new-session-page__${kind}-popover`) as
|
|
| (HTMLElement & { open: boolean })
|
|
| null;
|
|
if (popover) {
|
|
popover.open = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
showRoot() {
|
|
this.resetBrowser(false);
|
|
}
|
|
|
|
usableBrowserPath(): string | null {
|
|
const draft = this.browserPathDraftValue.trim();
|
|
if (draft.length === 0) {
|
|
return "";
|
|
}
|
|
return isAbsolutePath(draft) ? draft : null;
|
|
}
|
|
|
|
selectBrowserTarget(target: BrowserTarget) {
|
|
const snapshot = this.read();
|
|
const folder = snapshot.folder.trim();
|
|
const matchesCurrentTarget = target.nodeId === snapshot.execNode;
|
|
const path = matchesCurrentTarget && isAbsolutePath(folder) ? folder : undefined;
|
|
this.browserTargetValue = target;
|
|
this.loadBrowser(path);
|
|
}
|
|
|
|
loadBrowser(path: string | undefined) {
|
|
const snapshot = this.read();
|
|
const gatewaySnapshot = snapshot.context?.gateway.snapshot;
|
|
const client = gatewaySnapshot?.client;
|
|
const target = this.browserTargetValue;
|
|
if (gatewaySnapshot?.phase !== "connected" || !client || !target) {
|
|
return;
|
|
}
|
|
const targetNode = snapshot.nodes.find((node) => node.nodeId === target.nodeId);
|
|
if (targetNode?.canExec && !targetNode.canBrowse) {
|
|
this.showRoot();
|
|
this.browserTargetValue = target;
|
|
this.browserPathDraftValue = path ?? "";
|
|
this.callbacks.requestUpdate();
|
|
return;
|
|
}
|
|
const requestId = ++this.browserRequestToken;
|
|
this.browserLoadingValue = true;
|
|
this.browserErrorValue = null;
|
|
this.browserProjectPathValue = null;
|
|
this.browserListingValue = null;
|
|
this.browserPathDraftValue = path ?? "";
|
|
const draftAtRequest = this.browserPathDraftValue;
|
|
this.callbacks.requestUpdate();
|
|
void client
|
|
.request<FsListDirResult>("fs.listDir", {
|
|
...(path ? { path } : {}),
|
|
...(target.nodeId ? { nodeId: target.nodeId } : {}),
|
|
})
|
|
.then((result) => {
|
|
if (requestId !== this.browserRequestToken) {
|
|
return;
|
|
}
|
|
this.browserListingValue = result ?? null;
|
|
if (result) {
|
|
this.callbacks.onApprovedListing(result);
|
|
}
|
|
if (result?.path && this.browserPathDraftValue === draftAtRequest) {
|
|
this.browserPathDraftValue = result.path;
|
|
}
|
|
if (result?.path && !target.nodeId && snapshot.isAdmin) {
|
|
void client
|
|
.request<WorktreesBranchesResult>("worktrees.branches", {
|
|
repoRoot: result.path,
|
|
includeRepositoryStatus: true,
|
|
})
|
|
.then((branches) => {
|
|
if (
|
|
requestId === this.browserRequestToken &&
|
|
this.browserListingValue?.path === result.path &&
|
|
branches.repositoryStatus === "git"
|
|
) {
|
|
this.browserProjectPathValue = result.path;
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
this.callbacks.requestUpdate();
|
|
})
|
|
.catch(() => {
|
|
if (requestId !== this.browserRequestToken) {
|
|
return;
|
|
}
|
|
if (path) {
|
|
this.loadBrowser(undefined);
|
|
return;
|
|
}
|
|
this.browserErrorValue = t("newSession.browserLoadFailed");
|
|
this.callbacks.requestUpdate();
|
|
})
|
|
.finally(() => {
|
|
if (requestId === this.browserRequestToken) {
|
|
this.browserLoadingValue = false;
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
});
|
|
}
|
|
|
|
async registerBrowserProject(path: string) {
|
|
const snapshot = this.read();
|
|
const gatewaySnapshot = snapshot.context?.gateway.snapshot;
|
|
const client = gatewaySnapshot?.client;
|
|
if (
|
|
gatewaySnapshot?.phase !== "connected" ||
|
|
!client ||
|
|
!snapshot.isAdmin ||
|
|
this.browserTargetValue?.nodeId ||
|
|
this.browserProjectPathValue !== path ||
|
|
this.browserRegisteringValue
|
|
) {
|
|
return;
|
|
}
|
|
const requestId = this.browserRequestToken;
|
|
const connectionEpoch = this.gateway.connectionEpoch;
|
|
this.browserRegisteringValue = true;
|
|
this.browserErrorValue = null;
|
|
this.callbacks.requestUpdate();
|
|
try {
|
|
const project = await client.request<ProjectsRegisterResult>("projects.register", { path });
|
|
if (requestId !== this.browserRequestToken || client !== this.gateway.client) {
|
|
return;
|
|
}
|
|
await this.projectsTask.run([client, true, connectionEpoch]);
|
|
if (requestId !== this.browserRequestToken || client !== this.gateway.client) {
|
|
return;
|
|
}
|
|
this.callbacks.onSelectProject(project.id);
|
|
this.close();
|
|
} catch (error) {
|
|
if (requestId === this.browserRequestToken && client === this.gateway.client) {
|
|
this.browserErrorValue = error instanceof Error ? error.message : String(error);
|
|
}
|
|
} finally {
|
|
if (requestId === this.browserRequestToken) {
|
|
this.browserRegisteringValue = false;
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
}
|
|
}
|
|
|
|
onPopoverShow(kind: DraftPickerKind) {
|
|
this.openPopoverValue = kind;
|
|
if (kind === "project") {
|
|
this.showRoot();
|
|
} else {
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
}
|
|
|
|
onPopoverHide(kind: DraftPickerKind) {
|
|
if (this.openPopoverValue === kind) {
|
|
this.openPopoverValue = null;
|
|
}
|
|
this.hidingPopoverValue = kind;
|
|
if (kind === "project") {
|
|
this.showRoot();
|
|
} else {
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
}
|
|
|
|
onPopoverAfterHide(kind: DraftPickerKind) {
|
|
if (this.hidingPopoverValue === kind) {
|
|
this.hidingPopoverValue = null;
|
|
}
|
|
this.restorePopoverTrigger(`new-session-${kind}-trigger`, `.new-session-page__${kind}-popover`);
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
|
|
guardPopoverTransition(event: Event, kind: DraftPickerKind) {
|
|
if (this.hidingPopoverValue !== kind) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
}
|
|
|
|
clearPopoverHiding() {
|
|
this.hidingPopoverValue = null;
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
|
|
disconnect() {
|
|
this.clearProjectSearchTimer();
|
|
void this.projectsTask.run([null, false, -1]);
|
|
void this.projectSearchTask.run([null, false, "", -1]);
|
|
}
|
|
|
|
private resetBrowser(closePopover: boolean) {
|
|
this.browserRequestToken += 1;
|
|
this.browserLoadingValue = false;
|
|
this.browserErrorValue = null;
|
|
this.browserListingValue = null;
|
|
this.browserTargetValue = null;
|
|
this.browserProjectPathValue = null;
|
|
this.browserRegisteringValue = false;
|
|
this.browserPathDraftValue = "";
|
|
if (closePopover) {
|
|
this.openPopoverValue = null;
|
|
}
|
|
this.callbacks.requestUpdate();
|
|
}
|
|
|
|
private clearProjectSearchTimer() {
|
|
globalThis.clearTimeout(this.projectSearchTimer);
|
|
this.projectSearchTimer = undefined;
|
|
}
|
|
|
|
private restorePopoverTrigger(id: string, popoverSelector: string) {
|
|
const active = this.callbacks.activeElement();
|
|
const popover = this.callbacks.querySelector(popoverSelector);
|
|
const body = this.callbacks.body();
|
|
if (active && active !== body && !popover?.contains(active)) {
|
|
return;
|
|
}
|
|
(this.callbacks.querySelector(`#${id}`) as HTMLButtonElement | null)?.focus();
|
|
}
|
|
}
|