import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { listen } from "@tauri-apps/api/event";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
type DesktopStatus = "loading" | "ready" | "error";
type CheckStatus = "pass" | "warn" | "fail";
type DesktopCheck = {
id: string;
label: string;
status: CheckStatus;
detail: string;
};
type DesktopDependency = {
name: string;
description: string;
website?: string;
status: CheckStatus;
path?: string;
version?: string;
verify_command: string;
install_commands: string[];
detail: string;
};
type GitCommitBinding = {
github_login: string;
commit_name: string;
commit_email: string;
};
type DesktopProfile = {
identity: string;
handle: string;
display_name: string;
rho_home: string;
source: string;
has_local_private_keys: boolean;
signing_key_path?: string;
encryption_key_path?: string;
proof_verified: boolean;
proof_url?: string;
git_commit_binding?: GitCommitBinding;
};
type ProfileSelection = {
identity?: string;
rho_home?: string;
};
type CommandResult = {
ok: boolean;
code?: number;
stdout: string;
stderr: string;
};
type GitHubAccount = {
login: string;
host: string;
active: boolean;
state: string;
git_protocol: string;
token_source: string;
matching_identity: string;
has_rho_profile: boolean;
has_local_private_keys: boolean;
signing_key_path?: string;
encryption_key_path?: string;
proof_verified: boolean;
proof_url?: string;
git_commit_binding?: GitCommitBinding;
};
type OnboardingState = {
rho_home: string;
gh_accounts: GitHubAccount[];
local_profiles: DesktopProfile[];
};
type Project = {
id: string;
name: string;
path: string;
exists: boolean;
is_git: boolean;
is_rho: boolean;
rho_project_id?: string;
owner?: string;
role: string;
current_branch?: string;
remote_url?: string;
dirty: boolean;
upstream?: string;
ahead: number;
behind: number;
status_summary: string;
warning?: string;
imported?: boolean;
};
type FileEntry = {
path: string;
kind: string;
status?: string;
};
type MemberInfo = {
identity: string;
handle: string;
role: string;
};
type ParticipantInfo = {
handle: string;
identity?: string;
display_name?: string;
signing_fingerprint?: string;
encryption_fingerprint?: string;
proof_url?: string;
};
type RepoFileSummary = {
path: string;
title: string;
summary: string;
};
type ToolInfo = {
id: string;
action_type?: string;
owner?: string;
approval_required: boolean;
path: string;
};
type MessageInfo = {
path: string;
inbox: string;
thread: string;
kind: string;
encrypted: boolean;
signed: boolean;
summary: string;
};
type PendingAction = {
id: string;
kind: string;
actor?: string;
summary: string;
path: string;
};
type GitStatusEntry = {
path: string;
status: string;
};
type RepoDetail = {
project: Project;
files: FileEntry[];
members: MemberInfo[];
participants: ParticipantInfo[];
policy_files: RepoFileSummary[];
tools: ToolInfo[];
messages: MessageInfo[];
pending_actions: PendingAction[];
status_entries: GitStatusEntry[];
};
type GitRemote = {
name: string;
url: string;
webUrl: string;
};
type RepoEvent = {
id: string;
projectName: string;
projectPath: string;
prNumber: number;
title: string;
author: string;
url: string;
createdAt: string;
state?: string;
};
type PrReviewDetail = {
prNumber: number;
title: string;
body: string;
url: string;
state: string;
authorLogin: string;
authorAvatarUrl: string;
authorProfileUrl: string;
repoSlug: string;
projectName: string;
projectPath: string;
};
type NavIconName =
| "setup"
| "projects"
| "messages"
| "activity"
| "settings"
| "refresh"
| "bug"
| "sun"
| "moon"
| "bell";
function NavIcon({ name }: { name: NavIconName }) {
const common = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true
};
if (name === "setup") {
return (
<svg {...common}>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="m9 12 2 2 4-5" />
</svg>
);
}
if (name === "projects") {
return (
<svg {...common}>
<path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>
);
}
if (name === "messages") {
return (
<svg {...common}>
<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z" />
</svg>
);
}
if (name === "activity") {
return (
<svg {...common}>
<path d="M3 12h4l3 8 4-16 3 8h4" />
</svg>
);
}
if (name === "bell") {
return (
<svg {...common}>
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
);
}
if (name === "sun") {
return (
<svg {...common}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
if (name === "moon") {
return (
<svg {...common}>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
);
}
if (name === "bug") {
return (
<svg {...common}>
<path d="M8 6a4 4 0 0 1 8 0" />
<rect x="8" y="6" width="8" height="12" rx="4" />
<path d="M3 11h3M18 11h3M3 17h4M17 17h4M12 6v14" />
</svg>
);
}
if (name === "settings") {
return (
<svg {...common}>
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z" />
<path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.04.04a2 2 0 1 1-2.83 2.83l-.04-.04A1.8 1.8 0 0 0 15 19.4a1.8 1.8 0 0 0-1 .6 1.8 1.8 0 0 0-.4 1.1V21a2 2 0 1 1-4 0v-.08A1.8 1.8 0 0 0 8.6 19.4a1.8 1.8 0 0 0-1.98.36l-.04.04a2 2 0 1 1-2.83-2.83l.04-.04A1.8 1.8 0 0 0 4.6 15a1.8 1.8 0 0 0-.6-1 1.8 1.8 0 0 0-1.1-.4H3a2 2 0 1 1 0-4h.08A1.8 1.8 0 0 0 4.6 8.6a1.8 1.8 0 0 0-.36-1.98l-.04-.04a2 2 0 1 1 2.83-2.83l.04.04A1.8 1.8 0 0 0 9 4.6a1.8 1.8 0 0 0 1-.6 1.8 1.8 0 0 0 .4-1.1V3a2 2 0 1 1 4 0v.08A1.8 1.8 0 0 0 15.4 4.6a1.8 1.8 0 0 0 1.98-.36l.04-.04a2 2 0 1 1 2.83 2.83l-.04.04A1.8 1.8 0 0 0 19.4 9a1.8 1.8 0 0 0 .6 1 1.8 1.8 0 0 0 1.1.4H21a2 2 0 1 1 0 4h-.08A1.8 1.8 0 0 0 19.4 15Z" />
</svg>
);
}
return (
<svg {...common}>
<path d="M21 12a9 9 0 0 1-9 9 8.7 8.7 0 0 1-6.4-2.8" />
<path d="M3 12a9 9 0 0 1 15.1-6.6" />
<path d="M18 2v4h-4" />
<path d="M6 22v-4h4" />
</svg>
);
}
function ClipboardIcon({ copied }: { copied: boolean }) {
if (copied) {
return (
<svg
width={15}
height={15}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<path d="m5 13 4 4L19 7" />
</svg>
);
}
return (
<svg
width={15}
height={15}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
);
}
function RhoLogo({ size = 26 }: { size?: number }) {
return (
<svg
viewBox="0 0 1024 1024"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={80}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<path d="M 510 930 V 245 A 195 195 0 1 1 510 575" />
<path d="M 388 575 H 510" />
<path d="M 437 852 H 583" />
</svg>
);
}
function ForkIcon({ size = 15 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<circle cx="6" cy="6" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="12" cy="18" r="3" />
<path d="M6 9v3a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V9M12 12v3" />
</svg>
);
}
function CloseIcon({ size = 15 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function FolderIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>
);
}
// Logins whose avatar failed to load — kept so we don't retry a broken image or
// flicker back to it on re-mount. Successful images are served from the webview's
// HTTP cache by URL, so an <img> (CORS-free, unlike fetch) won't reload.
const avatarFailed = new Set<string>();
function Avatar({ login, size = 40 }: { login: string; size?: number }) {
const [failed, setFailed] = useState(() => avatarFailed.has(login));
if (failed || !login) {
return (
<span
className="avatar avatar-fallback"
style={{ width: size, height: size, fontSize: Math.round(size * 0.4) }}
>
{(login.slice(0, 1) || "?").toUpperCase()}
</span>
);
}
return (
<img
className="avatar"
src={`https://github.com/${encodeURIComponent(login)}.png?size=${Math.round(size * 2)}`}
width={size}
height={size}
alt=""
onError={() => {
avatarFailed.add(login);
setFailed(true);
}}
/>
);
}
function AppRail({
active,
onReportBug,
onOpenSettings,
onOpenProfiles,
onOpenProjects,
onOpenNotifications,
unreadCount,
onToggleTheme,
theme,
currentLogin
}: {
active: "setup" | "projects" | "messages" | "settings" | "notifications" | "review";
onReportBug: () => void;
onOpenSettings: () => void;
onOpenProfiles: () => void;
onOpenProjects: () => void;
onOpenNotifications: () => void;
unreadCount: number;
onToggleTheme: () => void;
theme: "light" | "dark";
currentLogin?: string;
}) {
return (
<aside className="desktop-sidebar" aria-label="Rho navigation">
<div className="sidebar-brand" title="Rho">
<RhoLogo size={24} />
</div>
{currentLogin ? (
<button
type="button"
className="sidebar-icon sidebar-profile"
onClick={onOpenProfiles}
title={`Profile: ${currentLogin} — switch`}
>
<Avatar login={currentLogin} size={30} />
</button>
) : null}
<button
type="button"
className="sidebar-icon sidebar-bell"
data-active={active === "notifications" || active === "review"}
onClick={onOpenNotifications}
title="Notifications"
>
<NavIcon name="bell" />
{unreadCount > 0 ? (
<span className="sidebar-badge">{unreadCount > 9 ? "9+" : unreadCount}</span>
) : null}
</button>
<button
type="button"
className="sidebar-icon"
data-active={active === "projects"}
onClick={onOpenProjects}
title="Projects"
>
<NavIcon name="projects" />
</button>
<button
type="button"
className="sidebar-icon"
data-active={active === "messages"}
disabled
title="Messages"
>
<NavIcon name="messages" />
</button>
<button
type="button"
className="sidebar-icon sidebar-bottom"
onClick={onToggleTheme}
title={theme === "dark" ? "Dark mode (switch to light)" : "Light mode (switch to dark)"}
>
<NavIcon name={theme === "dark" ? "moon" : "sun"} />
</button>
<button type="button" className="sidebar-icon" onClick={onReportBug} title="Report a bug">
<NavIcon name="bug" />
</button>
<button
type="button"
className="sidebar-icon"
data-active={active === "settings"}
onClick={onOpenSettings}
title="Settings"
>
<NavIcon name="settings" />
</button>
</aside>
);
}
export default function App() {
const [status, setStatus] = useState<DesktopStatus>("loading");
const [version, setVersion] = useState<string>("");
const [error, setError] = useState<string>("");
const [profiles, setProfiles] = useState<DesktopProfile[]>([]);
const [dependencies, setDependencies] = useState<DesktopDependency[]>([]);
const [selectedDependencyName, setSelectedDependencyName] = useState<string>("");
const [dependenciesError, setDependenciesError] = useState<string>("");
const [isCheckingDependencies, setIsCheckingDependencies] = useState<boolean>(false);
const [selectedIdentity, setSelectedIdentity] = useState<string>("");
const [checks, setChecks] = useState<DesktopCheck[]>([]);
const [isChecking, setIsChecking] = useState<boolean>(false);
const [actionResult, setActionResult] = useState<string>("");
const [isSwitchingGh, setIsSwitchingGh] = useState<boolean>(false);
const [isCheckingEnv, setIsCheckingEnv] = useState<boolean>(false);
const [projects, setProjects] = useState<Project[]>([]);
const [projectsError, setProjectsError] = useState<string>("");
const [isLoadingProjects, setIsLoadingProjects] = useState<boolean>(false);
const [isAddingProject, setIsAddingProject] = useState<boolean>(false);
const [isCloningProject, setIsCloningProject] = useState<boolean>(false);
const [isSettingUpRhoRepo, setIsSettingUpRhoRepo] = useState<boolean>(false);
const [localProjectPath, setLocalProjectPath] = useState<string>("");
const [cloneUrl, setCloneUrl] = useState<string>("");
const [cloneDestination, setCloneDestination] = useState<string>("");
const [createSlug, setCreateSlug] = useState<string>("");
const [createPublic, setCreatePublic] = useState<boolean>(true);
const [isCreatingProject, setIsCreatingProject] = useState<boolean>(false);
const [joinSlug, setJoinSlug] = useState<string>("");
const [isJoiningProject, setIsJoiningProject] = useState<boolean>(false);
const [projectsSubview, setProjectsSubview] = useState<
"list" | "create" | "join" | "detail"
>("list");
const [viewedProject, setViewedProject] = useState<Project | null>(null);
const [remotes, setRemotes] = useState<GitRemote[]>([]);
const [events, setEvents] = useState<RepoEvent[]>([]);
const [seenEventIds, setSeenEventIds] = useState<string[]>(() => {
try {
const raw = window.localStorage.getItem("rho.desktop.seenEvents");
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
});
const [nameCheck, setNameCheck] = useState<{
valid: boolean;
exists: boolean;
available: boolean;
message: string;
} | null>(null);
const [isCheckingName, setIsCheckingName] = useState<boolean>(false);
const [joinCheck, setJoinCheck] = useState<{
readable: boolean;
isRho: boolean;
message: string;
} | null>(null);
const [isCheckingJoin, setIsCheckingJoin] = useState<boolean>(false);
const [progress, setProgress] = useState<{
kind: string;
index: number;
total: number;
label: string;
} | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
confirmLabel: string;
onConfirm: () => void;
} | null>(null);
const [setupRepoPath, setSetupRepoPath] = useState<string>("");
const [setupRepoId, setSetupRepoId] = useState<string>("");
const [setupRemoteUrl, setSetupRemoteUrl] = useState<string>("");
const [setupProtectOwnerInbox, setSetupProtectOwnerInbox] = useState<boolean>(true);
const [selectedProjectPath, setSelectedProjectPath] = useState<string>("");
const [repoDetail, setRepoDetail] = useState<RepoDetail | null>(null);
const [repoDetailError, setRepoDetailError] = useState<string>("");
const [isLoadingRepoDetail, setIsLoadingRepoDetail] = useState<boolean>(false);
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
const [onboardingError, setOnboardingError] = useState<string>("");
const [isLoadingOnboarding, setIsLoadingOnboarding] = useState<boolean>(false);
const [isCreatingProfile, setIsCreatingProfile] = useState<string>("");
const [isVerifyingProfile, setIsVerifyingProfile] = useState<string>("");
const [isBindingCommitAuthor, setIsBindingCommitAuthor] = useState<string>("");
const [manualHandle, setManualHandle] = useState<string>("");
const [repoActionTargetHandle, setRepoActionTargetHandle] = useState<string>("");
const [repoActionRequestId, setRepoActionRequestId] = useState<string>("");
const [isRunningRepoAction, setIsRunningRepoAction] = useState<string>("");
const [commitMessage, setCommitMessage] = useState<string>("");
const [isCommittingRepo, setIsCommittingRepo] = useState<boolean>(false);
const [messageRecipient, setMessageRecipient] = useState<string>("");
const [messageThread, setMessageThread] = useState<string>("");
const [messageText, setMessageText] = useState<string>("");
const [isSendingMessage, setIsSendingMessage] = useState<boolean>(false);
const [branchName, setBranchName] = useState<string>("");
const [isRunningGitAction, setIsRunningGitAction] = useState<string>("");
const [onboardingStep, setOnboardingStep] = useState<"tools" | "accounts" | "profile">("tools");
const [toolsSkipped, setToolsSkipped] = useState<boolean>(false);
const [isOpeningTerminal, setIsOpeningTerminal] = useState<boolean>(false);
const [copiedField, setCopiedField] = useState<string>("");
const [showProfileSwitcher, setShowProfileSwitcher] = useState<boolean>(false);
const [view, setView] = useState<
"onboarding" | "projects" | "settings" | "notifications" | "review"
>("onboarding");
const [reviewEvent, setReviewEvent] = useState<RepoEvent | null>(null);
const [reviewDetail, setReviewDetail] = useState<PrReviewDetail | null>(null);
const [reviewBusy, setReviewBusy] = useState<boolean>(false);
const [reviewError, setReviewError] = useState<string>("");
const [reviewAction, setReviewAction] = useState<"" | "accept" | "reject">("");
const [hiddenProfiles, setHiddenProfiles] = useState<string[]>(() => {
try {
const raw = window.localStorage.getItem("rho.desktop.hiddenProfiles");
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
});
const [theme, setTheme] = useState<"light" | "dark">(() => {
const saved = window.localStorage.getItem("rho.desktop.theme");
if (saved === "light" || saved === "dark") {
return saved;
}
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
});
const [proofIssue, setProofIssue] = useState<{
handle: string;
kind: "missing" | "invalid";
} | null>(null);
useEffect(() => {
const queryIdentity = new URLSearchParams(window.location.search).get("identity") ?? "";
void Promise.all([
invoke<string>("app_version"),
invoke<DesktopProfile[]>("desktop_profiles"),
invoke<OnboardingState>("desktop_onboarding"),
invoke<DesktopDependency[]>("desktop_dependencies"),
invoke<string | null>("launch_identity").catch(() => null)
])
.then(([value, discoveredProfiles, onboardingState, dependencyState, launchedIdentity]) => {
setVersion(value);
setProfiles(discoveredProfiles);
setOnboarding(onboardingState);
setDependencies(dependencyState);
setSelectedDependencyName(dependencyState[0]?.name || "");
setSelectedIdentity(
queryIdentity || launchedIdentity || discoveredProfiles[0]?.identity || ""
);
// Land on Projects if a profile already exists; otherwise start onboarding.
setView(discoveredProfiles.length > 0 ? "projects" : "onboarding");
setStatus("ready");
})
.catch((cause: unknown) => {
setError(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
});
}, []);
useEffect(() => {
if (status !== "ready") {
return;
}
void runChecks(selectedIdentity);
void loadProjects(selectedIdentity);
void loadEvents();
setSelectedProjectPath("");
setRepoDetail(null);
}, [selectedIdentity, status]);
const selectedProfile = profiles.find((profile) => profile.identity === selectedIdentity);
const selectedHandle = selectedProfile?.handle || githubHandle(selectedIdentity);
const selectedAccount = onboarding?.gh_accounts.find((account) => account.login === selectedHandle);
const prerequisiteIds = ["rho_cli", "git", "gh", "gh_auth"];
const prerequisiteChecks = checks.filter((check) => prerequisiteIds.includes(check.id));
const profileChecks = checks.filter((check) => !prerequisiteIds.includes(check.id));
const prerequisitesReady =
prerequisiteChecks.length >= prerequisiteIds.length &&
prerequisiteChecks.every((check) => check.status === "pass");
const selectedProfileReady = selectedAccount
? isAccountReady(selectedAccount)
: Boolean(
selectedProfile?.has_local_private_keys &&
selectedProfile.proof_verified &&
selectedProfile.git_commit_binding
);
const finalReadinessReady =
prerequisitesReady && selectedProfileReady && checks.every((check) => check.status === "pass");
const visibleDependencies = dependencies.filter((dependency) => dependency.name !== "rho");
const allDependenciesReady =
visibleDependencies.length > 0 &&
visibleDependencies.every((dependency) => dependency.status === "pass");
const selectedDependency =
visibleDependencies.find((dependency) => dependency.name === selectedDependencyName) ||
visibleDependencies[0];
const shouldShowOnboarding = view === "onboarding";
const hasUsableProfiles = profiles.length > 0;
const didAutoSkipTools = useRef(false);
const prevOnboardingStep = useRef(onboardingStep);
// Event ids already seen by the poller (per session) so we only toast on the
// first appearance. null = not yet primed (first load won't toast).
const knownEventIds = useRef<Set<string> | null>(null);
// Last known role per project path, to detect when we get accepted into a repo.
const knownRoles = useRef<Map<string, string> | null>(null);
// Latest values for the once-registered focus listener, so it never has to
// re-register (re-registering churns Tauri's event map and races its cleanup).
const focusCtx = useRef({
onboardingStep,
selectedAccount,
isVerifyingProfile,
verify: verifyProfileForHandle
});
focusCtx.current = {
onboardingStep,
selectedAccount,
isVerifyingProfile,
verify: verifyProfileForHandle
};
// Re-check the GitHub proof against the live profile every time the user opens
// the Prepare step, so the "verified" badge reflects reality.
useEffect(() => {
const enteringProfile =
onboardingStep === "profile" && prevOnboardingStep.current !== "profile";
prevOnboardingStep.current = onboardingStep;
if (enteringProfile && selectedAccount?.has_rho_profile && !isVerifyingProfile) {
void verifyProfileForHandle(selectedAccount.login);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onboardingStep, selectedAccount?.login, selectedAccount?.has_rho_profile]);
// Re-check on app-window focus (e.g. after editing GitHub in the browser and
// switching back). Registered ONCE; reads current state via focusCtx ref.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
void getCurrentWindow()
.onFocusChanged(({ payload: focused }) => {
if (!focused) {
return;
}
const ctx = focusCtx.current;
if (
ctx.onboardingStep === "profile" &&
ctx.selectedAccount?.has_rho_profile &&
!ctx.isVerifyingProfile
) {
void ctx.verify(ctx.selectedAccount.login);
}
})
.then((fn) => {
if (cancelled) {
fn();
} else {
unlisten = fn;
}
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
window.localStorage.setItem("rho.desktop.theme", theme);
}, [theme]);
// Live per-step progress emitted by create_project / join_project.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
void listen<{ kind: string; index: number; total: number; label: string }>(
"project-progress",
(event) => setProgress(event.payload)
).then((fn) => {
if (cancelled) {
fn();
} else {
unlisten = fn;
}
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
// Poll for new PR/join events so the bell badge stays live and OS toasts fire
// even when the notifications page isn't open.
useEffect(() => {
if (status !== "ready" || !selectedIdentity) {
return;
}
void loadEvents();
const timer = window.setInterval(() => void loadEvents(), 45000);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, selectedIdentity]);
// Detect when we get accepted into a repo (role goes from outsider to member)
// and toast the joiner. Compares against the previous role snapshot.
useEffect(() => {
if (knownRoles.current === null) {
knownRoles.current = new Map(projects.map((project) => [project.path, project.role]));
return;
}
const memberRoles = ["owner", "admin", "member", "collaborator", "maintainer"];
for (const project of projects) {
const previous = knownRoles.current.get(project.path);
if (
previous !== undefined &&
!memberRoles.includes(previous) &&
memberRoles.includes(project.role)
) {
void notifyOs("You've joined a project", `You now have access to ${project.name}.`);
}
knownRoles.current.set(project.path, project.role);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects]);
// Debounced repo-name availability check while typing on the create sub-page.
useEffect(() => {
if (projectsSubview !== "create" || !createSlug.trim()) {
setNameCheck(null);
return;
}
const timer = window.setTimeout(() => void checkRepoName(), 500);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [createSlug, projectsSubview]);
// Debounced "can I join this repo?" check while typing on the join sub-page.
useEffect(() => {
if (projectsSubview !== "join" || !joinSlug.trim()) {
setJoinCheck(null);
return;
}
const timer = window.setTimeout(() => {
setIsCheckingJoin(true);
void invoke<{ readable: boolean; isRho: boolean; message: string }>("check_clone_repo", {
url: joinSlug.trim(),
profile: selection()
})
.then((result) => setJoinCheck(result))
.catch((cause) =>
setJoinCheck({
readable: false,
isRho: false,
message: cause instanceof Error ? cause.message : String(cause)
})
)
.finally(() => setIsCheckingJoin(false));
}, 500);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [joinSlug, projectsSubview]);
useEffect(() => {
if (
status === "ready" &&
allDependenciesReady &&
!didAutoSkipTools.current &&
onboardingStep === "tools"
) {
didAutoSkipTools.current = true;
setOnboardingStep("accounts");
}
}, [status, allDependenciesReady, onboardingStep]);
function selection(identity = selectedIdentity): ProfileSelection | undefined {
const profile = profiles.find((candidate) => candidate.identity === identity);
if (!profile && !identity) {
return undefined;
}
return {
identity: identity || profile?.identity,
rho_home: profile?.rho_home
};
}
function githubHandle(identity?: string): string {
return (
identity
?.replace("rho://id/github/", "")
.replace("github/", "")
.trim() || ""
);
}
function isAccountReady(account?: GitHubAccount): boolean {
return Boolean(
account?.has_rho_profile &&
account.has_local_private_keys &&
account.proof_verified &&
account.git_commit_binding
);
}
function dependencyTitle(name: string): string {
const titles: Record<string, string> = { gh: "GitHub CLI", git: "Git" };
return titles[name] || name;
}
function statusLabel(statusValue: CheckStatus): string {
if (statusValue === "pass") {
return "Ready";
}
if (statusValue === "warn") {
return "Needs attention";
}
return "Blocked";
}
function accountChecklist(account: GitHubAccount) {
return [
{
label: "Rho profile",
ok: account.has_rho_profile,
detail: account.has_rho_profile ? account.matching_identity : "Not created"
},
{
label: "Private keys",
ok: account.has_local_private_keys,
detail: account.has_local_private_keys ? "Signing and encryption keys found" : "Keys missing"
},
{
label: "GitHub proof",
ok: account.proof_verified,
detail: account.proof_verified ? "Verified" : account.proof_url || "Not verified"
},
{
label: "Commit author",
ok: Boolean(account.git_commit_binding),
detail: account.git_commit_binding
? `${account.git_commit_binding.commit_name} <${account.git_commit_binding.commit_email}>`
: "Not bound"
}
];
}
function dismissOnboarding() {
setView("projects");
}
function finishOnboarding() {
const ready = Boolean(selectedAccount?.has_rho_profile && selectedAccount?.proof_verified);
if (!ready) {
const proceed = window.confirm(
"Your profile setup isn't complete yet (GitHub proof not verified). Continue anyway?"
);
if (!proceed) {
return;
}
}
dismissOnboarding();
}
function resetOnboarding() {
setView("onboarding");
}
function chooseAccount(account: GitHubAccount) {
const profile = profileForHandle(account.login);
setSelectedIdentity(profile?.identity || account.matching_identity);
setProofIssue(null);
}
async function runChecks(identity = selectedIdentity) {
setIsChecking(true);
try {
const result = await invoke<DesktopCheck[]>("desktop_checks", {
profile: selection(identity)
});
setChecks(result);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
} finally {
setIsChecking(false);
}
}
async function refreshProfilesAndOnboarding(preferredHandle?: string) {
setIsLoadingOnboarding(true);
setOnboardingError("");
try {
const [discoveredProfiles, onboardingState] = await Promise.all([
invoke<DesktopProfile[]>("desktop_profiles"),
invoke<OnboardingState>("desktop_onboarding")
]);
setProfiles(discoveredProfiles);
setOnboarding(onboardingState);
if (preferredHandle) {
const profile = discoveredProfiles.find((candidate) => candidate.handle === preferredHandle);
if (profile) {
setSelectedIdentity(profile.identity);
}
} else if (!selectedIdentity && discoveredProfiles[0]) {
setSelectedIdentity(discoveredProfiles[0].identity);
}
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsLoadingOnboarding(false);
}
}
async function refreshDependencies() {
setIsCheckingDependencies(true);
setDependenciesError("");
try {
const dependencyState = await invoke<DesktopDependency[]>("desktop_dependencies");
setDependencies(dependencyState);
setSelectedDependencyName((current) => current || dependencyState[0]?.name || "");
} catch (cause) {
setDependenciesError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCheckingDependencies(false);
}
}
async function openProfileWindow() {
await invoke("open_profile_window", {
profile: selection()
});
}
async function openProfileWindowForHandle(handle: string) {
const profile = profiles.find((candidate) => candidate.handle === handle);
const profileSelection: ProfileSelection = {
identity: profile?.identity || `rho://id/github/${handle}`,
rho_home: profile?.rho_home || onboarding?.rho_home
};
await invoke("open_profile_window", {
profile: profileSelection
});
}
async function switchGhProfile() {
setIsSwitchingGh(true);
try {
const result = await invoke<CommandResult>("switch_github_profile", {
profile: selection()
});
setActionResult(formatCommandResult("rho gh switch", result));
await runChecks();
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsSwitchingGh(false);
}
}
async function switchGhHandle(handle: string) {
setIsSwitchingGh(true);
try {
const result = await invoke<CommandResult>("switch_github_profile", {
profile: {
identity: `rho://id/github/${handle}`,
rho_home: onboarding?.rho_home || selectedProfile?.rho_home
}
});
setActionResult(formatCommandResult(`rho gh switch ${handle}`, result));
await runChecks();
await refreshProfilesAndOnboarding(handle);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsSwitchingGh(false);
}
}
async function createProfileForHandle(handle: string) {
const cleanHandle = handle.trim();
if (!cleanHandle) {
setOnboardingError("Enter a GitHub handle first.");
return;
}
setIsCreatingProfile(cleanHandle);
setOnboardingError("");
try {
const result = await invoke<CommandResult>("create_github_profile", {
input: {
handle: cleanHandle,
displayName: cleanHandle,
rhoHome: onboarding?.rho_home
}
});
setActionResult(formatCommandResult(`rho id init ${cleanHandle}`, result));
setManualHandle("");
await refreshProfilesAndOnboarding(cleanHandle);
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCreatingProfile("");
}
}
async function createMissingDetectedProfiles() {
const missing = onboarding?.gh_accounts.filter((account) => !account.has_rho_profile) ?? [];
if (missing.length === 0) {
setOnboardingError("");
setActionResult("All detected GitHub accounts already have Rho profiles.");
return;
}
setOnboardingError("");
const results: string[] = [];
for (const account of missing) {
setIsCreatingProfile(account.login);
try {
const result = await invoke<CommandResult>("create_github_profile", {
input: {
handle: account.login,
displayName: account.login,
rhoHome: onboarding?.rho_home
}
});
results.push(formatCommandResult(`rho id init ${account.login}`, result));
} catch (cause) {
results.push(`${account.login}: ${cause instanceof Error ? cause.message : String(cause)}`);
}
}
setIsCreatingProfile("");
setActionResult(results.join("\n\n"));
await refreshProfilesAndOnboarding();
}
async function verifyProfileForHandle(handle: string) {
console.log(`[verify] start handle=${handle} rhoHome=${onboarding?.rho_home ?? "default"}`);
setIsVerifyingProfile(handle);
setOnboardingError("");
try {
const result = await invoke<CommandResult>("verify_github_profile", {
handle,
rhoHome: onboarding?.rho_home
});
setActionResult(formatCommandResult(`rho id verify-github ${handle}`, result));
if (result.ok) {
console.log(`[verify] ${handle} -> VERIFIED`, result);
setProofIssue(null);
} else {
const text = `${result.stderr}\n${result.stdout}`.toLowerCase();
const kind =
text.includes("invalid claim") || text.includes("does not match this key")
? "invalid"
: "missing";
console.warn(`[verify] ${handle} -> ${kind.toUpperCase()}`, result);
setProofIssue({ handle, kind });
}
await refreshProfilesAndOnboarding(handle);
} catch (cause) {
console.error(`[verify] ${handle} -> ERROR`, cause);
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsVerifyingProfile("");
}
}
async function bindCommitAuthorForHandle(handle: string) {
setIsBindingCommitAuthor(handle);
setOnboardingError("");
try {
const result = await invoke<CommandResult>("bind_github_commit_profile", {
handle,
rhoHome: onboarding?.rho_home
});
setActionResult(formatCommandResult(`bind commit author ${handle}`, result));
await runChecks();
await refreshProfilesAndOnboarding(handle);
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsBindingCommitAuthor("");
}
}
async function showProfileForHandle(handle: string) {
setOnboardingError("");
try {
const result = await invoke<CommandResult>("show_github_profile", {
handle,
rhoHome: onboarding?.rho_home
});
setActionResult(formatCommandResult(`rho id show ${handle}`, result));
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
}
}
async function checkProfileEnv() {
setIsCheckingEnv(true);
try {
const result = await invoke<CommandResult>("profile_rho_env", {
profile: selection()
});
setActionResult(formatCommandResult("rho env status", result));
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCheckingEnv(false);
}
}
async function loadProjects(identity = selectedIdentity) {
setIsLoadingProjects(true);
setProjectsError("");
try {
const result = await invoke<Project[]>("desktop_projects", {
profile: selection(identity)
});
setProjects(result);
if (selectedProjectPath && !result.some((project) => project.path === selectedProjectPath)) {
setSelectedProjectPath("");
setRepoDetail(null);
}
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsLoadingProjects(false);
}
}
async function addLocalProject() {
let path = localProjectPath.trim();
if (!path) {
// Empty box → open a native folder picker.
const picked = await openDialog({
directory: true,
multiple: false,
title: "Select a Rho project folder"
});
if (typeof picked !== "string") {
return;
}
path = picked;
setLocalProjectPath(picked);
}
setIsAddingProject(true);
setProjectsError("");
try {
const project = await invoke<Project>("add_local_project", {
path,
profile: selection()
});
setActionResult(`Added ${project.name}`);
setLocalProjectPath("");
await loadProjects();
await selectProject(project.path);
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsAddingProject(false);
}
}
async function cloneProject() {
if (!cloneUrl.trim()) {
setProjectsError("Enter a GitHub repository URL first.");
return;
}
setIsCloningProject(true);
setProjectsError("");
try {
const check = await invoke<{
slug: string;
readable: boolean;
isRho: boolean;
message: string;
}>("check_clone_repo", { url: cloneUrl, profile: selection() });
if (!check.readable || !check.isRho) {
setProjectsError(check.message);
setIsCloningProject(false);
return;
}
const project = await invoke<Project>("clone_github_project", {
url: cloneUrl,
destination: cloneDestination || undefined,
profile: selection()
});
setActionResult(
project.imported ? `Already imported ${project.name}` : `Cloned ${project.name}`
);
setCloneUrl("");
setCloneDestination("");
await loadProjects();
setProjectsSubview("list"); // pop back to the projects list
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCloningProject(false);
}
}
async function notifyOs(title: string, body: string) {
try {
await invoke("notify_os", { title, body });
} catch {
// toast is best-effort
}
}
async function loadEvents() {
try {
const result = await invoke<RepoEvent[]>("list_repo_events", { profile: selection() });
setEvents(result);
// Fire OS toasts for events we haven't seen before this session. Skip the
// very first load (knownEventIds null) so we don't toast the backlog.
if (knownEventIds.current === null) {
knownEventIds.current = new Set(result.map((event) => event.id));
} else {
for (const event of result) {
const isOpen = (event.state || "OPEN") === "OPEN";
if (
isOpen &&
!knownEventIds.current.has(event.id) &&
!seenEventIds.includes(event.id)
) {
void notifyOs(
`${event.projectName}: join request`,
`${event.author || "someone"} — ${event.title}`
);
}
knownEventIds.current.add(event.id);
}
}
} catch {
// events are best-effort (offline / no gh); leave the existing list.
}
}
function markAllEventsSeen() {
const ids = events.map((event) => event.id);
setSeenEventIds((previous) => {
const next = Array.from(new Set([...previous, ...ids]));
window.localStorage.setItem("rho.desktop.seenEvents", JSON.stringify(next));
return next;
});
}
async function checkRepoName() {
if (!createSlug.trim()) {
setNameCheck(null);
return;
}
setIsCheckingName(true);
try {
const result = await invoke<{
slug: string;
valid: boolean;
exists: boolean;
available: boolean;
message: string;
}>("check_repo_name", { slug: createSlug.trim(), profile: selection() });
setNameCheck(result);
} catch (cause) {
setNameCheck({
valid: false,
exists: false,
available: false,
message: cause instanceof Error ? cause.message : String(cause)
});
} finally {
setIsCheckingName(false);
}
}
async function createProject() {
if (!createSlug.trim()) {
setProjectsError("Enter a repository name (owner/repo).");
return;
}
if (!selectedIdentity) {
setProjectsError("Select a profile first.");
return;
}
setIsCreatingProject(true);
setProjectsError("");
try {
const result = await invoke<CommandResult>("create_project", {
input: { repoSlug: createSlug.trim(), public: createPublic, profile: selection() }
});
setActionResult(formatCommandResult("rho repo create", result));
if (result.ok) {
setCreateSlug("");
setNameCheck(null);
await loadProjects(); // only reload on success — it clears projectsError
setProjectsSubview("list"); // return to the list so the new project is visible
} else {
const message = result.stderr || "Create failed.";
if (/already exists/i.test(message)) {
// Correct the (stale) availability indicator.
setNameCheck({
valid: true,
exists: true,
available: false,
message: "Already exists on GitHub."
});
setCloneUrl(`https://github.com/${createSlug.trim()}`);
setProjectsError(
"That repo already exists on GitHub. It's filled into \"Import existing project\" below — clone it to install Rho into it."
);
} else {
setProjectsError(message);
}
}
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCreatingProject(false);
setProgress(null);
}
}
async function joinProject() {
if (!joinSlug.trim()) {
setProjectsError("Enter a project link or owner/repo to join.");
return;
}
if (!selectedIdentity) {
setProjectsError("Select a profile first.");
return;
}
setIsJoiningProject(true);
setProjectsError("");
try {
const result = await invoke<CommandResult>("join_project", {
input: { repoSlug: joinSlug.trim(), profile: selection() }
});
setActionResult(formatCommandResult("rho repo join", result));
if (result.ok) {
setJoinSlug("");
setJoinCheck(null);
await loadProjects(); // only reload on success — it clears projectsError
setProjectsSubview("list"); // return to the list so the new project is visible
} else {
setProjectsError(result.stderr || "Join failed.");
}
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsJoiningProject(false);
setProgress(null);
}
}
async function loadRemotes(path: string) {
try {
const result = await invoke<GitRemote[]>("list_git_remotes", { path });
setRemotes(result);
} catch {
setRemotes([]);
}
}
function viewProject(project: Project) {
setViewedProject(project);
setRemotes([]);
setProjectsSubview("detail");
void loadRemotes(project.path);
}
function openNotifications() {
setShowProfileSwitcher(false);
setView("notifications");
void loadEvents();
}
async function openReview(event: RepoEvent) {
setReviewEvent(event);
setReviewDetail(null);
setReviewError("");
setReviewAction("");
setView("review");
setReviewBusy(true);
try {
const detail = await invoke<PrReviewDetail>("pr_review_detail", {
path: event.projectPath,
prNumber: event.prNumber,
profile: selection()
});
setReviewDetail(detail);
} catch (cause) {
setReviewError(cause instanceof Error ? cause.message : String(cause));
} finally {
setReviewBusy(false);
}
}
function closeReview() {
setReviewEvent(null);
setReviewDetail(null);
setView("notifications");
}
async function decideReview(action: "accept" | "reject") {
if (!reviewEvent) {
return;
}
setReviewAction(action);
setReviewBusy(true);
setReviewError("");
try {
const command = action === "accept" ? "admit_join_request" : "reject_join_request";
const result = await invoke<CommandResult>(command, {
path: reviewEvent.projectPath,
prNumber: reviewEvent.prNumber,
profile: selection()
});
if (result.ok) {
// Mark this event seen and drop back to the notifications list.
setSeenEventIds((previous) => {
const next = Array.from(new Set([...previous, reviewEvent.id]));
window.localStorage.setItem("rho.desktop.seenEvents", JSON.stringify(next));
return next;
});
await loadEvents();
await loadProjects();
closeReview();
} else {
setReviewError(result.stderr || `${action} failed.`);
}
} catch (cause) {
setReviewError(cause instanceof Error ? cause.message : String(cause));
} finally {
setReviewBusy(false);
setReviewAction("");
}
}
async function openProjectsDir() {
try {
await invoke("open_projects_dir", { profile: selection() });
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
}
}
function forgetProject(path: string) {
setConfirmDialog({
message:
"Remove this project from Rho Desktop? Your files on disk are kept — this only hides it here.",
confirmLabel: "Remove",
onConfirm: () => void doForgetProject(path)
});
}
async function doForgetProject(path: string) {
setProjectsError("");
try {
await invoke("forget_project", { path, profile: selection() });
await loadProjects();
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
}
}
async function setupRhoRepo() {
if (!setupRepoPath.trim()) {
setProjectsError("Enter a repository folder path first.");
return;
}
if (!selectedIdentity) {
setProjectsError("Select a Rho profile before setting up a repo.");
return;
}
setIsSettingUpRhoRepo(true);
setProjectsError("");
try {
const result = await invoke<CommandResult>("setup_rho_repo", {
input: {
path: setupRepoPath,
repoId: setupRepoId || undefined,
remoteUrl: setupRemoteUrl || undefined,
profile: selection(),
protectOwnerInbox: setupProtectOwnerInbox
}
});
setActionResult(formatCommandResult("setup rho repo", result));
setSetupRepoPath("");
setSetupRepoId("");
setSetupRemoteUrl("");
await loadProjects();
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsSettingUpRhoRepo(false);
}
}
async function selectProject(path: string) {
setSelectedProjectPath(path);
setRepoDetailError("");
setIsLoadingRepoDetail(true);
try {
const detail = await invoke<RepoDetail>("repo_detail", {
path,
profile: selection()
});
setRepoDetail(detail);
} catch (cause) {
setRepoDetail(null);
setRepoDetailError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsLoadingRepoDetail(false);
}
}
async function openExternalUrl(url: string) {
try {
await invoke("open_external_url", { url });
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
}
}
async function copyValue(value: string, field: string) {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => setCopiedField((current) => (current === field ? "" : current)), 1500);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
}
}
function switchToProfile(identity: string) {
// Full reload bound to the chosen profile (via ?identity=) so there is no
// leftover in-memory state from the previous profile.
const url = new URL(window.location.href);
url.searchParams.set("identity", identity);
window.location.replace(url.toString());
}
function reonboard() {
setShowProfileSwitcher(false);
setView("onboarding");
// Skip the tool check when the dependencies are already satisfied.
setOnboardingStep(allDependenciesReady ? "accounts" : "tools");
}
function toggleTheme() {
setTheme((current) => (current === "dark" ? "light" : "dark"));
}
function removeProfile(identity: string) {
if (
!window.confirm(
"Remove this profile from Rho Desktop? Your keys and identity files are kept on disk — this only hides it here."
)
) {
return;
}
setHiddenProfiles((current) => {
const next = current.includes(identity) ? current : [...current, identity];
window.localStorage.setItem("rho.desktop.hiddenProfiles", JSON.stringify(next));
return next;
});
}
function openProjects() {
setShowProfileSwitcher(false);
setView("projects");
setProjectsSubview("list"); // folder icon always pops back to the main list
}
function openSettings() {
setShowProfileSwitcher(false);
setView("settings");
}
async function openProfileProcess(identity: string) {
try {
await invoke("open_profile_process", { identity });
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
}
}
function isAccountSetUp(account: GitHubAccount): boolean {
return Boolean(
account.has_rho_profile && account.has_local_private_keys && account.proof_verified
);
}
async function captureScreenshotToClipboard(): Promise<boolean> {
try {
const { default: html2canvas } = await import("html2canvas-pro");
const canvas = await html2canvas(document.body, {
backgroundColor: null,
logging: false,
scale: Math.min(window.devicePixelRatio || 1, 2)
});
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((value) => resolve(value), "image/png")
);
if (!blob || typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
return false;
}
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
return true;
} catch {
return false;
}
}
async function reportBug() {
const captured = await captureScreenshotToClipboard();
const recent = (actionResult || error || "none").slice(0, 800);
const body = [
"## What happened?",
"",
"## Steps to reproduce",
"1. ",
"",
"---",
captured
? "<!-- A screenshot was copied to your clipboard — paste it here with Cmd/Ctrl+V -->"
: "<!-- Tip: attach a screenshot here -->",
"",
"### Context",
`- Rho Desktop: ${version || "unknown"}`,
`- Screen: ${onboardingStep}`,
`- Profile: ${selectedIdentity || "none"}`,
`- Platform: ${navigator.userAgent}`,
"",
"### Last output",
"```",
recent,
"```"
].join("\n");
const url =
"https://github.com/madhavajay/rho/issues/new" +
`?title=${encodeURIComponent("[Desktop] ")}` +
`&body=${encodeURIComponent(body)}`;
setActionResult(
captured
? "Screenshot copied to clipboard — paste it into the issue with Cmd/Ctrl+V."
: "Opening bug report (screenshot capture unavailable)."
);
void openExternalUrl(url);
}
async function signInWithGitHub() {
setIsOpeningTerminal(true);
setOnboardingError("");
try {
await invoke("open_terminal_command", { command: "gh auth login" });
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsOpeningTerminal(false);
}
}
async function openProjectFolder(path: string) {
try {
const result = await invoke<CommandResult>("open_project_folder", { path });
if (!result.ok) {
setActionResult(formatCommandResult("open folder", result));
}
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
}
}
async function runRepoAction(
action: string,
overrides: { target?: string; requestId?: string } = {}
) {
if (!repoDetail) {
return;
}
setIsRunningRepoAction(action);
try {
const result = await invoke<CommandResult>("run_repo_cli_action", {
input: {
path: repoDetail.project.path,
profile: selection(),
action,
target: (overrides.target ?? repoActionTargetHandle) || undefined,
requestId: (overrides.requestId ?? repoActionRequestId) || undefined
}
});
setActionResult(formatCommandResult(action.replace(/_/g, " "), result));
await loadProjects();
await selectProject(repoDetail.project.path);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsRunningRepoAction("");
}
}
async function commitRepoChanges() {
if (!repoDetail) {
return;
}
if (!commitMessage.trim()) {
setActionResult("Enter a commit message first.");
return;
}
setIsCommittingRepo(true);
try {
const result = await invoke<CommandResult>("commit_repo_changes", {
input: {
path: repoDetail.project.path,
profile: selection(),
message: commitMessage
}
});
setActionResult(formatCommandResult("rho commit", result));
setCommitMessage("");
await loadProjects();
await selectProject(repoDetail.project.path);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCommittingRepo(false);
}
}
async function sendProjectMessage() {
if (!repoDetail) {
return;
}
if (!messageRecipient.trim()) {
setActionResult("Enter a recipient GitHub handle first.");
return;
}
if (!messageText.trim()) {
setActionResult("Enter a message first.");
return;
}
setIsSendingMessage(true);
try {
const result = await invoke<CommandResult>("send_project_message", {
input: {
path: repoDetail.project.path,
profile: selection(),
to: messageRecipient,
thread: messageThread || undefined,
text: messageText,
messageType: "chat"
}
});
setActionResult(formatCommandResult("send message", result));
setMessageThread("");
setMessageText("");
await loadProjects();
await selectProject(repoDetail.project.path);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsSendingMessage(false);
}
}
async function runGitAction(action: string, branch?: string) {
if (!repoDetail) {
return;
}
setIsRunningGitAction(action);
try {
const result = await invoke<CommandResult>("run_git_repo_action", {
input: {
path: repoDetail.project.path,
profile: selection(),
action,
branch: (branch ?? branchName) || undefined
}
});
setActionResult(formatCommandResult(action.replace(/_/g, " "), result));
await loadProjects();
await selectProject(repoDetail.project.path);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsRunningGitAction("");
}
}
function formatCommandResult(label: string, result: CommandResult): string {
const body = [result.stdout, result.stderr].filter(Boolean).join("\n");
const statusText = result.ok ? "ok" : `exit ${result.code ?? "unknown"}`;
return `${label}: ${statusText}${body ? `\n${body}` : ""}`;
}
function shortPath(path: string): string {
return path.length > 70 ? `...${path.slice(-67)}` : path;
}
function profileForHandle(handle: string): DesktopProfile | undefined {
return profiles.find((profile) => profile.handle === handle);
}
function requestIdFromPath(path: string): string {
return path.split("/").find((part) => part.startsWith("req-")) || "";
}
function keysFolder(account: GitHubAccount): string | undefined {
const file = account.signing_key_path || account.encryption_key_path;
if (!file) {
return undefined;
}
const idx = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\"));
return idx > 0 ? file.slice(0, idx) : undefined;
}
const settingsContent = (
<>
<header className="window-titlebar">
<div>
<h1>Settings</h1>
<p>Rho Desktop {version}</p>
</div>
</header>
<div className="settings-body">
<div className="empty-state">
<h2>No settings yet</h2>
<p>App settings will live here. For now, manage profiles from the avatar in the sidebar.</p>
</div>
</div>
</>
);
const ownedProjects = projects.filter((project) => /owner|admin/i.test(project.role));
const memberProjects = projects.filter((project) => !/owner|admin/i.test(project.role));
const renderProjectGroup = (title: string, list: Project[]) => {
if (list.length === 0) {
return null;
}
return (
<div className="project-group">
<div className="accounts-section-label">{title}</div>
<div className="project-list">
{list.map((project) => (
<div
key={project.id}
className="project-row project-row-clickable"
data-state={project.warning ? "warn" : project.dirty ? "dirty" : "ok"}
role="button"
tabIndex={0}
onClick={() => viewProject(project)}
>
<div className="project-row-icons">
<button
type="button"
className="row-icon"
title="Open folder"
onClick={(event) => {
event.stopPropagation();
void openProjectFolder(project.path);
}}
disabled={!project.exists}
>
<FolderIcon />
</button>
<button
type="button"
className="row-icon"
title="Forget project"
onClick={(event) => {
event.stopPropagation();
forgetProject(project.path);
}}
>
<CloseIcon />
</button>
</div>
<div className="project-row-main">
<strong>{project.name}</strong>
<span>{project.rho_project_id || project.path}</span>
</div>
<div className="project-row-meta">
<span>{project.role}</span>
<span>{project.is_rho ? "rho repo" : "not rho"}</span>
<span>{project.current_branch || "no branch"}</span>
{project.status_summary ? <span>{project.status_summary}</span> : null}
</div>
{project.warning ? <p className="project-warning">{project.warning}</p> : null}
<div className="project-row-actions">
<button
type="button"
className="primary-action"
onClick={(event) => {
event.stopPropagation();
viewProject(project);
}}
>
View
</button>
</div>
</div>
))}
</div>
</div>
);
};
const projectsContent = (
<>
<header className="window-titlebar">
<div>
<h1>
{projectsSubview === "create"
? "Create a project"
: projectsSubview === "join"
? "Join a project"
: projectsSubview === "detail"
? viewedProject?.name || "Project"
: "Projects"}
</h1>
<p>
{projectsSubview === "create"
? "Creates a new Rho-backed GitHub repo on your account."
: projectsSubview === "join"
? "Paste a project link to fork it and request access."
: projectsSubview === "detail"
? viewedProject?.rho_project_id || viewedProject?.path || ""
: `Repositories for ${selectedHandle || "this profile"}.`}
</p>
</div>
<div className="titlebar-actions">
{projectsSubview === "list" ? (
<>
<button
type="button"
className="primary-action"
onClick={() => {
setProjectsSubview("create");
setProjectsError("");
}}
>
Create
</button>
<button
type="button"
onClick={() => {
setProjectsSubview("join");
setProjectsError("");
}}
>
Join
</button>
<button type="button" onClick={() => void openProjectsDir()}>
Open
</button>
<button type="button" onClick={() => void loadProjects()} disabled={isLoadingProjects}>
{isLoadingProjects ? "Refreshing" : "Refresh"}
</button>
</>
) : (
<>
<button type="button" onClick={() => void openProjectsDir()}>
Open
</button>
<button
type="button"
onClick={() => {
setProjectsSubview("list");
setProjectsError("");
}}
>
Back
</button>
</>
)}
</div>
</header>
<div className="settings-body">
{projectsError ? <p className="error">{projectsError}</p> : null}
{projectsSubview === "list" ? (
<>
{projects.length === 0 ? (
<div className="empty-state">
<h2>No projects yet</h2>
<p>Create one, or join an existing project by link.</p>
</div>
) : (
<>
{renderProjectGroup("Projects you manage", ownedProjects)}
{renderProjectGroup("Projects you're in", memberProjects)}
</>
)}
</>
) : projectsSubview === "detail" ? (
viewedProject ? (
<>
<div className="project-row-meta">
<span>{viewedProject.role}</span>
<span>{viewedProject.is_rho ? "rho repo" : "not rho"}</span>
<span>{viewedProject.current_branch || "no branch"}</span>
{viewedProject.status_summary ? <span>{viewedProject.status_summary}</span> : null}
</div>
{viewedProject.warning ? (
<p className="project-warning">{viewedProject.warning}</p>
) : null}
<section className="project-form-card">
<h2>
Remotes
{remotes.some((remote) => remote.name === "upstream") ? (
<span className="fork-tag">
<ForkIcon size={13} /> fork
</span>
) : null}
</h2>
{remotes.length === 0 ? (
<p className="guide-muted">No git remotes.</p>
) : (
remotes.map((remote) => (
<div key={remote.name} className="remote-row">
<span className="remote-name">
{remote.name === "upstream"
? "upstream (main repo)"
: remote.name === "origin"
? "origin (your fork)"
: remote.name}
</span>
<a
className="dependency-link"
href={remote.webUrl}
onClick={(event) => {
event.preventDefault();
void openExternalUrl(remote.webUrl);
}}
>
{remote.webUrl || remote.url}
</a>
<button
type="button"
className="copy-btn"
title="Copy URL"
onClick={() => void copyValue(remote.webUrl, `remote-${remote.name}`)}
>
<ClipboardIcon copied={copiedField === `remote-${remote.name}`} />
</button>
</div>
))
)}
</section>
<section className="project-form-card">
<h2>Events</h2>
{events.filter((event) => event.projectPath === viewedProject.path).length === 0 ? (
<p className="guide-muted">No open pull requests for this project.</p>
) : (
events
.filter((event) => event.projectPath === viewedProject.path)
.map((event) => (
<button
key={event.id}
type="button"
className="event-row"
data-unread={!seenEventIds.includes(event.id)}
onClick={() => void openReview(event)}
>
<div className="event-row-main">
<strong>PR #{event.prNumber}</strong>
<span>{event.title}</span>
<span className="event-row-meta">by {event.author || "unknown"}</span>
</div>
{(event.state || "OPEN") !== "OPEN" ? (
<span className={`event-state event-state-${(event.state || "").toLowerCase()}`}>
{event.state === "MERGED" ? "✓ accepted" : "rejected"}
</span>
) : !seenEventIds.includes(event.id) ? (
<span className="event-dot" aria-hidden="true" />
) : null}
</button>
))
)}
</section>
<div className="project-row-actions">
<button
type="button"
onClick={() => void openProjectFolder(viewedProject.path)}
disabled={!viewedProject.exists}
>
Open Folder
</button>
<button type="button" onClick={() => forgetProject(viewedProject.path)}>
Forget
</button>
</div>
</>
) : (
<div className="empty-state">
<h2>No project selected</h2>
<p>Go back and pick a project.</p>
</div>
)
) : projectsSubview === "create" ? (
<>
<section className="project-form-card">
<h2>New project</h2>
<p>This repository will be created on your GitHub account.</p>
<div className="project-form-row">
<span className="owner-prefix">{selectedHandle || "you"}/</span>
<input
value={createSlug}
onChange={(event) => setCreateSlug(event.target.value)}
placeholder="repo-name"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => void checkRepoName()}
disabled={isCheckingName || !createSlug.trim()}
>
{isCheckingName ? "Checking…" : "Check"}
</button>
</div>
{nameCheck ? (
<p className={nameCheck.available ? "name-ok" : "name-bad"}>
{nameCheck.available ? "✓ " : "✕ "}
{nameCheck.message}
</p>
) : null}
<div className="project-form-row">
<label className="inline-check">
<input
type="checkbox"
checked={createPublic}
onChange={(event) => setCreatePublic(event.target.checked)}
/>
Public
</label>
<button
type="button"
className="primary-action"
onClick={() => void createProject()}
disabled={
isCreatingProject ||
!selectedIdentity ||
!createSlug.trim() ||
(nameCheck ? !nameCheck.available : false)
}
>
{isCreatingProject ? "Creating…" : "Create"}
</button>
</div>
</section>
<section className="project-form-card">
<h2>Import existing project</h2>
<p>Clone a GitHub repo you already have, or register a local checkout.</p>
<div className="project-form-row">
<input
value={cloneUrl}
onChange={(event) => setCloneUrl(event.target.value)}
placeholder="Clone a GitHub URL"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<button type="button" onClick={() => void cloneProject()} disabled={isCloningProject}>
{isCloningProject ? "Cloning…" : "Clone"}
</button>
</div>
<div className="project-form-row">
<input
value={localProjectPath}
onChange={(event) => setLocalProjectPath(event.target.value)}
placeholder="Local repo path"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<button type="button" onClick={() => void addLocalProject()} disabled={isAddingProject}>
{isAddingProject ? "Adding…" : "Add Local"}
</button>
</div>
</section>
</>
) : (
<>
<section className="project-form-card">
<h2>Join by link</h2>
<p>Paste a GitHub link or owner/repo — Rho forks it and opens a join PR.</p>
<div className="project-form-row">
<input
value={joinSlug}
onChange={(event) => setJoinSlug(event.target.value)}
placeholder="https://github.com/owner/repo"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<button
type="button"
className="primary-action"
onClick={() => void joinProject()}
disabled={
isJoiningProject ||
!selectedIdentity ||
isCheckingJoin ||
(joinCheck ? !(joinCheck.readable && joinCheck.isRho) : !joinSlug.trim())
}
>
{isJoiningProject ? "Joining…" : isCheckingJoin ? "Checking…" : "Join"}
</button>
</div>
{joinCheck ? (
<p className={joinCheck.readable && joinCheck.isRho ? "name-ok" : "name-bad"}>
{joinCheck.readable && joinCheck.isRho ? "✓ " : "✕ "}
{joinCheck.message}
</p>
) : null}
</section>
<section className="project-form-card">
<h2>Re-add a local checkout</h2>
<p>Already have this project on disk (forgotten or backed up)? Point Rho at it.</p>
<div className="project-form-row">
<input
value={localProjectPath}
onChange={(event) => setLocalProjectPath(event.target.value)}
placeholder="Local repo path"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<button type="button" onClick={() => void addLocalProject()} disabled={isAddingProject}>
{isAddingProject ? "Adding…" : "Add Local"}
</button>
</div>
</section>
</>
)}
</div>
</>
);
const busyOverlay =
isCreatingProject || isJoiningProject || isCloningProject ? (
<div className="busy-overlay">
<div className="busy-card">
<h2>
{isCreatingProject
? "Creating project"
: isJoiningProject
? "Joining project"
: "Cloning project"}
</h2>
{progress ? (
<>
<div className="progress-track">
<div
className="progress-fill"
style={{ width: `${Math.round((progress.index / progress.total) * 100)}%` }}
/>
</div>
<p className="progress-label">
Step {progress.index} of {progress.total}: {progress.label}…
</p>
</>
) : (
<>
<span className="busy-spinner" aria-hidden="true" />
<p>Starting…</p>
</>
)}
</div>
</div>
) : null;
const confirmOverlay = confirmDialog ? (
<div className="modal-backdrop" onClick={() => setConfirmDialog(null)}>
<div className="modal-panel" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<h2>Are you sure?</h2>
</div>
<div className="confirm-body">
<p>{confirmDialog.message}</p>
</div>
<div className="modal-foot confirm-foot">
<button type="button" onClick={() => setConfirmDialog(null)}>
Cancel
</button>
<button
type="button"
className="primary-action"
onClick={() => {
const action = confirmDialog.onConfirm;
setConfirmDialog(null);
action();
}}
>
{confirmDialog.confirmLabel}
</button>
</div>
</div>
</div>
) : null;
const unreadCount = events.filter((event) => !seenEventIds.includes(event.id)).length;
const notificationsContent = (
<>
<header className="window-titlebar">
<div>
<h1>Notifications</h1>
<p>Open pull requests and join requests across your projects.</p>
</div>
<div className="titlebar-actions">
{unreadCount > 0 ? (
<button type="button" onClick={markAllEventsSeen}>
Mark all read
</button>
) : null}
<button type="button" onClick={() => void loadEvents()}>
Refresh
</button>
</div>
</header>
<div className="notifications-list">
{events.length === 0 ? (
<div className="empty-state">
<h2>No notifications</h2>
<p>Open PRs across your projects show here.</p>
</div>
) : (
events.map((event) => (
<button
key={event.id}
type="button"
className="event-row"
data-unread={!seenEventIds.includes(event.id)}
onClick={() => void openReview(event)}
>
<div className="event-row-main">
<strong>
{event.projectName} · PR #{event.prNumber}
</strong>
<span>{event.title}</span>
<span className="event-row-meta">by {event.author || "unknown"}</span>
</div>
{(event.state || "OPEN") !== "OPEN" ? (
<span className={`event-state event-state-${(event.state || "").toLowerCase()}`}>
{event.state === "MERGED" ? "✓ accepted" : "rejected"}
</span>
) : !seenEventIds.includes(event.id) ? (
<span className="event-dot" aria-hidden="true" />
) : null}
</button>
))
)}
</div>
</>
);
// Only a maintainer who isn't the requester can accept/reject a join request.
const reviewProject = reviewEvent
? projects.find((project) => project.path === reviewEvent.projectPath)
: undefined;
const reviewResolved = !!reviewEvent && (reviewEvent.state || "OPEN") !== "OPEN";
const canApprove =
!!reviewEvent &&
!reviewResolved &&
reviewEvent.author !== selectedHandle &&
["owner", "admin", "maintainer"].includes(reviewProject?.role || "");
const reviewContent = (
<>
<header className="window-titlebar">
<div>
<h1>Review join request</h1>
<p>{reviewEvent ? `${reviewEvent.projectName} · PR #${reviewEvent.prNumber}` : ""}</p>
</div>
<div className="titlebar-actions">
<button type="button" onClick={closeReview}>
Back
</button>
</div>
</header>
<div className="review-page">
{reviewBusy && !reviewDetail ? (
<p className="guide-muted">Loading…</p>
) : reviewDetail ? (
<>
<section className="review-card">
<div className="review-requester">
{reviewDetail.authorLogin ? (
<Avatar login={reviewDetail.authorLogin} size={64} />
) : null}
<div className="review-requester-info">
<strong>{reviewDetail.authorLogin || "unknown"}</strong>
{reviewDetail.authorProfileUrl ? (
<a
className="dependency-link"
href={reviewDetail.authorProfileUrl}
onClick={(domEvent) => {
domEvent.preventDefault();
void openExternalUrl(reviewDetail.authorProfileUrl);
}}
>
{reviewDetail.authorProfileUrl}
</a>
) : null}
</div>
</div>
<dl className="review-meta">
<div>
<dt>Repository</dt>
<dd>{reviewDetail.repoSlug || reviewDetail.projectName}</dd>
</div>
<div>
<dt>Pull request</dt>
<dd>
<a
className="dependency-link"
href={reviewDetail.url}
onClick={(domEvent) => {
domEvent.preventDefault();
void openExternalUrl(reviewDetail.url);
}}
>
{reviewDetail.title} (#{reviewDetail.prNumber})
</a>
</dd>
</div>
{reviewDetail.body ? (
<div>
<dt>Message</dt>
<dd className="review-body">{reviewDetail.body}</dd>
</div>
) : null}
</dl>
</section>
{reviewError ? <p className="name-bad">{reviewError}</p> : null}
{canApprove ? (
<div className="review-actions">
<button
type="button"
className="primary-action"
disabled={reviewBusy}
onClick={() => void decideReview("accept")}
>
{reviewBusy && reviewAction === "accept" ? (
<>
<span className="btn-spinner" aria-hidden="true" /> Accepting…
</>
) : (
"Accept"
)}
</button>
<button
type="button"
className="danger-action"
disabled={reviewBusy}
onClick={() => void decideReview("reject")}
>
{reviewBusy && reviewAction === "reject" ? (
<>
<span className="btn-spinner" aria-hidden="true" /> Rejecting…
</>
) : (
"Reject"
)}
</button>
</div>
) : reviewResolved ? (
<p className="guide-muted">
{reviewEvent && reviewEvent.state === "MERGED"
? "This request was accepted and merged."
: "This request was closed."}
</p>
) : (
<p className="guide-muted">
{reviewEvent && reviewEvent.author === selectedHandle
? "This is your own request — waiting for a maintainer to review it."
: "Only a project maintainer can accept or reject this request."}
</p>
)}
</>
) : (
<p className="name-bad">{reviewError || "Could not load this request."}</p>
)}
</div>
</>
);
const profileSwitcherOverlay = showProfileSwitcher ? (
<div className="modal-backdrop" onClick={() => setShowProfileSwitcher(false)}>
<div className="modal-panel" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<h2>Profiles</h2>
<button type="button" onClick={() => setShowProfileSwitcher(false)}>
Close
</button>
</div>
<div className="switcher-list">
{profiles
.filter((profile) => !hiddenProfiles.includes(profile.identity))
.map((profile) => (
<div
key={profile.identity}
className="switcher-row"
data-current={profile.identity === selectedIdentity}
>
<Avatar login={profile.handle} size={38} />
<div className="switcher-info">
<strong>{profile.handle}</strong>
<span>{profile.identity}</span>
</div>
{profile.identity === selectedIdentity ? (
<span className="switcher-current">current</span>
) : null}
<div className="switcher-actions">
<button
type="button"
onClick={() => switchToProfile(profile.identity)}
disabled={profile.identity === selectedIdentity}
>
Switch
</button>
<button type="button" onClick={() => void openProfileProcess(profile.identity)}>
New Window
</button>
<button
type="button"
onClick={() => removeProfile(profile.identity)}
disabled={profile.identity === selectedIdentity}
>
Remove
</button>
</div>
</div>
))}
{profiles.filter((profile) => !hiddenProfiles.includes(profile.identity)).length === 0 ? (
<p className="guide-muted">No local profiles yet.</p>
) : null}
</div>
<div className="modal-foot">
<button type="button" onClick={reonboard}>
Set up another profile
</button>
</div>
</div>
</div>
) : null;
return (
<main className="app-shell desktop-wallpaper">
{busyOverlay}
{confirmOverlay}
{profileSwitcherOverlay}
<section className="desktop-window">
<AppRail
active={shouldShowOnboarding ? "setup" : view}
onReportBug={reportBug}
onOpenSettings={openSettings}
onOpenProfiles={() => setShowProfileSwitcher(true)}
onOpenProjects={openProjects}
onOpenNotifications={openNotifications}
unreadCount={unreadCount}
onToggleTheme={toggleTheme}
theme={theme}
currentLogin={selectedHandle}
/>
<section className="onboarding-workspace">
{status !== "ready" ? (
<header className="window-titlebar">
<div>
<h1>Rho Desktop</h1>
<p>{status === "error" ? error : "Loading desktop state."}</p>
</div>
</header>
) : !shouldShowOnboarding ? (
view === "settings" ? (
settingsContent
) : view === "notifications" ? (
notificationsContent
) : view === "review" ? (
reviewContent
) : (
projectsContent
)
) : (
<>
<header className="window-titlebar">
<div>
<h1>
{onboardingStep === "tools"
? "Set up Rho Desktop"
: onboardingStep === "accounts"
? "Choose a GitHub account"
: "Prepare your profile"}
</h1>
<p>
{onboardingStep === "tools"
? "Step 1: check required command line tools before creating or opening a profile."
: onboardingStep === "accounts"
? "Step 2: pick a detected GitHub account, or sign in with another."
: "Step 3: finish setting up the selected account."}
</p>
</div>
</header>
{onboardingStep === "tools" ? (
<div className="dependency-screen">
{dependenciesError ? <p className="error">{dependenciesError}</p> : null}
<section className="dependency-panel">
<div className="dependency-list" aria-label="Dependencies">
{visibleDependencies.map((dependency) => (
<button
key={dependency.name}
type="button"
className="dependency-item"
data-active={selectedDependency?.name === dependency.name}
data-state={dependency.status}
onClick={() => setSelectedDependencyName(dependency.name)}
>
<span className="dependency-dot" aria-hidden="true" />
<span className="dependency-item-name">{dependencyTitle(dependency.name)}</span>
<span className="dependency-item-status">{statusLabel(dependency.status)}</span>
</button>
))}
{visibleDependencies.length === 0 ? (
<div className="empty-state">
<h2>No dependencies loaded</h2>
<p>Click Check Again to reload the dependency manifest.</p>
</div>
) : null}
</div>
<div className="dependency-detail">
{selectedDependency ? (
<>
<div className="dependency-detail-heading">
<div>
<h2>{dependencyTitle(selectedDependency.name)}</h2>
<p>{selectedDependency.description}</p>
</div>
<span data-state={selectedDependency.status}>
{statusLabel(selectedDependency.status)}
</span>
</div>
<dl className="dependency-facts">
<div>
<dt>Path</dt>
<dd>{selectedDependency.path || "Not found"}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{selectedDependency.version || selectedDependency.detail}</dd>
</div>
{selectedDependency.website ? (
<div>
<dt>Website</dt>
<dd>
<a
href={selectedDependency.website}
className="dependency-link"
onClick={(event) => {
event.preventDefault();
void openExternalUrl(selectedDependency.website!);
}}
>
{selectedDependency.website}
</a>
</dd>
</div>
) : null}
</dl>
<div className="install-command-list">
<h2>Install Options</h2>
{selectedDependency.install_commands.map((command) => (
<code key={command}>{command}</code>
))}
{selectedDependency.install_commands.length === 0 ? (
<p>No install command is configured for this platform.</p>
) : null}
</div>
</>
) : (
<div className="empty-state">
<h2>Select a dependency</h2>
<p>Dependency details will appear here.</p>
</div>
)}
</div>
</section>
<div className="stage-actions split-actions">
<button
type="button"
onClick={() => void refreshDependencies()}
disabled={isCheckingDependencies}
>
{isCheckingDependencies ? "Checking" : "Check Again"}
</button>
{!allDependenciesReady ? (
<button
type="button"
onClick={() => {
setToolsSkipped(true);
setOnboardingStep("accounts");
}}
>
Skip
</button>
) : null}
<button
type="button"
className="primary-action"
disabled={!allDependenciesReady}
onClick={() => {
setToolsSkipped(false);
setOnboardingStep("accounts");
}}
>
Continue
</button>
</div>
</div>
) : onboardingStep === "accounts" ? (
<div className="dependency-screen">
<div className="accounts-screen">
{toolsSkipped ? (
<p className="error">
You skipped the tool check. If account actions fail, go back and confirm Git and
the GitHub CLI are installed.
</p>
) : null}
{onboardingError ? <p className="error">{onboardingError}</p> : null}
{onboarding && onboarding.gh_accounts.length > 0 ? (
(() => {
const setUp = onboarding.gh_accounts.filter(isAccountSetUp);
const available = onboarding.gh_accounts.filter(
(account) => !isAccountSetUp(account)
);
return (
<>
{setUp.length > 0 ? (
<>
<div className="accounts-section-label">Already set up</div>
<div className="account-pick-list">
{setUp.map((account) => (
<div
key={`${account.host}-${account.login}`}
className="account-pick account-pick-static"
data-state="ready"
>
<Avatar login={account.login} size={40} />
<span className="account-pick-info">
<strong>{account.login}</strong>
<span>Rho profile ready{account.active ? " · active in gh" : ""}</span>
</span>
<span className="account-pick-status">ready</span>
</div>
))}
</div>
<div className="accounts-divider" />
</>
) : null}
<div className="accounts-section-label">Choose a GitHub account</div>
{available.length > 0 ? (
<div className="account-pick-list">
{available.map((account) => (
<button
key={`${account.host}-${account.login}`}
type="button"
className="account-pick"
data-state={isAccountReady(account) ? "ready" : "setup"}
onClick={() => {
chooseAccount(account);
setOnboardingStep("profile");
}}
>
<Avatar login={account.login} size={40} />
<span className="account-pick-info">
<strong>{account.login}</strong>
<span>
{account.has_rho_profile
? "Rho profile ready"
: "No Rho profile yet"}
{account.active ? " · active in gh" : ""}
</span>
</span>
<span className="account-pick-status">
{isAccountReady(account) ? "ready" : "needs setup"}
</span>
<span className="account-pick-chevron" aria-hidden="true">
›
</span>
</button>
))}
</div>
) : (
<div className="accounts-empty-available">
<p>All detected accounts are already set up.</p>
<p>To add another, run:</p>
<div className="copy-field">
<code>gh auth login</code>
<button
type="button"
className="copy-btn"
title="Copy command"
onClick={() => void copyValue("gh auth login", "gh-login")}
>
<ClipboardIcon copied={copiedField === "gh-login"} />
</button>
</div>
<p>
Then come back here and press Refresh once you have authed a new
account.
</p>
</div>
)}
<button
type="button"
className="account-add"
onClick={() => void signInWithGitHub()}
disabled={isOpeningTerminal}
>
{isOpeningTerminal ? "Opening Terminal…" : "Run: gh auth login"}
</button>
</>
);
})()
) : (
<div className="empty-state">
<h2>No GitHub accounts found</h2>
<p>
Sign in with the GitHub CLI to create or log in to an account, then refresh.
</p>
<div className="empty-actions">
<button
type="button"
className="primary-action"
onClick={() => void signInWithGitHub()}
disabled={isOpeningTerminal}
>
{isOpeningTerminal ? "Opening Terminal…" : "Sign in with GitHub"}
</button>
</div>
</div>
)}
</div>
<div className="stage-actions split-actions">
{!allDependenciesReady ? (
<button
type="button"
style={{ marginRight: "auto" }}
onClick={() => setOnboardingStep("tools")}
>
Back
</button>
) : null}
<button
type="button"
onClick={() => void refreshProfilesAndOnboarding()}
disabled={isLoadingOnboarding}
>
{isLoadingOnboarding ? "Refreshing" : "Refresh"}
</button>
</div>
</div>
) : (
<div className="dependency-screen">
<div className="accounts-screen">
{onboardingError ? <p className="error">{onboardingError}</p> : null}
{selectedAccount ? (
<div className="profile-prepare">
<div className="profile-prepare-head">
<Avatar login={selectedAccount.login} size={44} />
<div className="profile-prepare-id">
<strong>{selectedAccount.login}</strong>
<span>{selectedAccount.matching_identity}</span>
</div>
</div>
{!selectedAccount.has_rho_profile ? (
<div className="guide-callout">
<p>Create your Rho profile to generate keys and your GitHub proof.</p>
<button
type="button"
className="primary-action"
onClick={() => void createProfileForHandle(selectedAccount.login)}
disabled={isCreatingProfile === selectedAccount.login}
>
{isCreatingProfile === selectedAccount.login
? "Creating…"
: "Create Rho Profile"}
</button>
</div>
) : null}
<ol className="guide-steps">
<li className="guide-step" data-ok={true}>
<span className="guide-step-num">✓</span>
<div className="guide-step-body">
<h3>Your Rho identity</h3>
<p>This is globally unique, derived from your GitHub handle.</p>
<div className="copy-field">
<code>{selectedAccount.matching_identity}</code>
<button
type="button"
className="copy-btn"
title="Copy identity"
onClick={() =>
void copyValue(selectedAccount.matching_identity, "identity")
}
>
<ClipboardIcon copied={copiedField === "identity"} />
</button>
</div>
</div>
</li>
<li className="guide-step" data-ok={selectedAccount.has_local_private_keys}>
<span className="guide-step-num">
{selectedAccount.has_local_private_keys ? "✓" : "2"}
</span>
<div className="guide-step-body">
<h3>Private keys</h3>
<p>
We generated your signing (Ed25519) and encryption (X25519) keys. They
stay on this machine and are never shared.
</p>
{keysFolder(selectedAccount) ? (
<button
type="button"
className="guide-folder"
onClick={() => void openProjectFolder(keysFolder(selectedAccount)!)}
>
<FolderIcon />
<span>{keysFolder(selectedAccount)}</span>
</button>
) : (
<p className="guide-muted">
Keys appear here once your Rho profile is created.
</p>
)}
</div>
</li>
<li
className="guide-step"
data-ok={selectedAccount.proof_verified}
data-missing={
!selectedAccount.proof_verified &&
proofIssue?.handle === selectedAccount.login
}
>
<span className="guide-step-num">
{selectedAccount.proof_verified
? "✓"
: !selectedAccount.proof_verified &&
proofIssue?.handle === selectedAccount.login
? "✕"
: "3"}
</span>
<div className="guide-step-body">
<h3>
GitHub proof
{selectedAccount.proof_verified ? (
<span className="guide-ok-pill">verified</span>
) : proofIssue?.handle === selectedAccount.login ? (
<span className="guide-missing-pill">
{proofIssue.kind === "invalid" ? "invalid" : "missing"}
</span>
) : null}
</h3>
<p>
To prove you hold this private key, publish this proof on your public
GitHub profile — paste it into your bio, website, or social links.
Anywhere on the page works. Then click Re-verify.
</p>
{selectedAccount.proof_url ? (
<div className="copy-field">
<code>{selectedAccount.proof_url}</code>
<button
type="button"
className="copy-btn"
title="Copy proof"
onClick={() => void copyValue(selectedAccount.proof_url!, "proof")}
>
<ClipboardIcon copied={copiedField === "proof"} />
</button>
</div>
) : (
<p className="guide-muted">
Your proof appears once your Rho profile is created.
</p>
)}
<div className="guide-actions">
<button
type="button"
onClick={() =>
void openExternalUrl(`https://github.com/${selectedAccount.login}`)
}
>
Open GitHub Profile
</button>
<button
type="button"
className="primary-action verify-button"
data-loading={isVerifyingProfile === selectedAccount.login}
onClick={() => void verifyProfileForHandle(selectedAccount.login)}
disabled={
!selectedAccount.has_rho_profile ||
isVerifyingProfile === selectedAccount.login
}
>
{isVerifyingProfile === selectedAccount.login ? (
<span className="btn-spinner" aria-label="Checking" />
) : (
"Re-verify"
)}
</button>
</div>
</div>
</li>
</ol>
</div>
) : (
<div className="empty-state">
<h2>No account selected</h2>
<p>Go back and pick a GitHub account.</p>
</div>
)}
</div>
<div className="stage-actions split-actions">
<button
type="button"
style={{ marginRight: "auto" }}
onClick={() => setOnboardingStep("accounts")}
>
Back
</button>
<button type="button" className="primary-action" onClick={finishOnboarding}>
Finish
</button>
</div>
</div>
)}
</>
)}
</section>
</section>
</main>
);
}