Adds a Projects settings flow for inspecting and editing per-project paseo.json (worktree setup/teardown, scripts, terminals) directly from the app. Reads/writes go through new daemon RPCs with revision-based optimistic concurrency, and the screen always keeps its header and host picker visible so a hung host doesn't trap the user.
444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
import { Buffer } from "buffer";
|
|
|
|
type NullableString = string | null | undefined;
|
|
const BASE64_WORKSPACE_ID_PREFIX = "b64_";
|
|
|
|
function stripSearchAndHash(pathname: string): string {
|
|
const hashIndex = pathname.indexOf("#");
|
|
const queryIndex = pathname.indexOf("?");
|
|
const end = [hashIndex, queryIndex]
|
|
.filter((index) => index >= 0)
|
|
.reduce((min, index) => Math.min(min, index), pathname.length);
|
|
return pathname.slice(0, end);
|
|
}
|
|
|
|
function extractSearch(pathname: string): string {
|
|
const queryIndex = pathname.indexOf("?");
|
|
if (queryIndex < 0) {
|
|
return "";
|
|
}
|
|
const hashIndex = pathname.indexOf("#", queryIndex);
|
|
return hashIndex >= 0
|
|
? pathname.slice(queryIndex + 1, hashIndex)
|
|
: pathname.slice(queryIndex + 1);
|
|
}
|
|
|
|
function trimNonEmpty(value: NullableString): string | null {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function encodeSegment(value: string): string {
|
|
return encodeURIComponent(value);
|
|
}
|
|
|
|
function decodeSegment(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function toBase64UrlNoPad(input: string): string {
|
|
return Buffer.from(input, "utf8")
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/g, "");
|
|
}
|
|
|
|
function decodeBase64UrlNoPadUtf8(input: string): string | null {
|
|
const normalized = input.trim();
|
|
if (normalized.length === 0) {
|
|
return null;
|
|
}
|
|
if (!/^[A-Za-z0-9_-]+$/.test(normalized)) {
|
|
return null;
|
|
}
|
|
|
|
const base64 = normalized.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
|
|
|
let decoded: string;
|
|
try {
|
|
decoded = Buffer.from(padded, "base64").toString("utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return decoded;
|
|
}
|
|
|
|
function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
|
|
const normalized = input.trim();
|
|
const decoded = decodeBase64UrlNoPadUtf8(normalized);
|
|
if (!decoded) {
|
|
return null;
|
|
}
|
|
|
|
// Validate via round-trip to avoid false positives ("workspace-1" etc).
|
|
if (toBase64UrlNoPad(decoded) !== normalized) {
|
|
return null;
|
|
}
|
|
|
|
return decoded;
|
|
}
|
|
|
|
function normalizeWorkspaceId(value: string): string {
|
|
return value.trim();
|
|
}
|
|
|
|
function isUrlSafeWorkspaceId(value: string): boolean {
|
|
return /^[A-Za-z0-9._~-]+$/.test(value);
|
|
}
|
|
|
|
function isLegacyPathLikeWorkspaceValue(value: string): boolean {
|
|
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
|
|
}
|
|
|
|
export type WorkspaceOpenIntent =
|
|
| { kind: "agent"; agentId: string }
|
|
| { kind: "terminal"; terminalId: string }
|
|
| { kind: "file"; path: string }
|
|
| { kind: "draft"; draftId: string }
|
|
| { kind: "setup"; workspaceId: string };
|
|
|
|
export function parseWorkspaceOpenIntent(
|
|
value: string | null | undefined,
|
|
): WorkspaceOpenIntent | null {
|
|
const normalized = trimNonEmpty(value);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const separator = normalized.indexOf(":");
|
|
if (separator <= 0 || separator >= normalized.length - 1) {
|
|
return null;
|
|
}
|
|
|
|
const kind = normalized.slice(0, separator);
|
|
const payload = trimNonEmpty(normalized.slice(separator + 1));
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
|
|
if (kind === "agent") {
|
|
return { kind: "agent", agentId: payload };
|
|
}
|
|
if (kind === "terminal") {
|
|
return { kind: "terminal", terminalId: payload };
|
|
}
|
|
if (kind === "draft") {
|
|
return { kind: "draft", draftId: payload };
|
|
}
|
|
if (kind === "file") {
|
|
const decodedPath = decodeFilePathFromPathSegment(payload);
|
|
if (!decodedPath) {
|
|
return null;
|
|
}
|
|
return { kind: "file", path: decodedPath };
|
|
}
|
|
if (kind === "setup") {
|
|
const workspaceId = decodeWorkspaceIdFromPathSegment(payload);
|
|
if (!workspaceId) {
|
|
return null;
|
|
}
|
|
return { kind: "setup", workspaceId };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseHostWorkspaceOpenIntentFromPathname(
|
|
pathname: string,
|
|
): WorkspaceOpenIntent | null {
|
|
const search = extractSearch(pathname);
|
|
if (!search) {
|
|
return null;
|
|
}
|
|
return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open"));
|
|
}
|
|
|
|
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
|
|
const normalized = trimNonEmpty(workspaceId);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
const id = normalizeWorkspaceId(normalized);
|
|
if (isUrlSafeWorkspaceId(id)) {
|
|
return id;
|
|
}
|
|
return `${BASE64_WORKSPACE_ID_PREFIX}${toBase64UrlNoPad(id)}`;
|
|
}
|
|
|
|
export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null {
|
|
const normalizedSegment = trimNonEmpty(workspaceIdSegment);
|
|
if (!normalizedSegment) {
|
|
return null;
|
|
}
|
|
|
|
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
|
|
if (!decoded) {
|
|
return null;
|
|
}
|
|
|
|
if (decoded.startsWith(BASE64_WORKSPACE_ID_PREFIX)) {
|
|
const encodedPayload = decoded.slice(BASE64_WORKSPACE_ID_PREFIX.length);
|
|
const prefixedDecoded =
|
|
tryDecodeBase64UrlNoPadUtf8(encodedPayload) ?? decodeBase64UrlNoPadUtf8(encodedPayload);
|
|
return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null;
|
|
}
|
|
|
|
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
|
|
if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) {
|
|
return normalizeWorkspaceId(base64Decoded);
|
|
}
|
|
|
|
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
|
|
if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) {
|
|
return normalizeWorkspaceId(relaxedBase64Decoded);
|
|
}
|
|
|
|
return normalizeWorkspaceId(decoded);
|
|
}
|
|
|
|
export function encodeFilePathForPathSegment(filePath: string): string {
|
|
const normalized = trimNonEmpty(filePath);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
return toBase64UrlNoPad(normalized);
|
|
}
|
|
|
|
export function decodeFilePathFromPathSegment(filePathSegment: string): string | null {
|
|
const normalizedSegment = trimNonEmpty(filePathSegment);
|
|
if (!normalizedSegment) {
|
|
return null;
|
|
}
|
|
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
|
|
if (!decoded) {
|
|
return null;
|
|
}
|
|
return tryDecodeBase64UrlNoPadUtf8(decoded);
|
|
}
|
|
|
|
export function parseServerIdFromPathname(pathname: string): string | null {
|
|
const pathOnly = stripSearchAndHash(pathname);
|
|
const match = pathOnly.match(/^\/h\/([^/]+)(?:\/|$)/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const raw = match[1];
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
return trimNonEmpty(decodeSegment(raw));
|
|
}
|
|
|
|
export function parseHostAgentRouteFromPathname(
|
|
pathname: string,
|
|
): { serverId: string; agentId: string } | null {
|
|
const pathOnly = stripSearchAndHash(pathname);
|
|
const match = pathOnly.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const [, encodedServerId, encodedAgentId] = match;
|
|
if (!encodedServerId || !encodedAgentId) {
|
|
return null;
|
|
}
|
|
|
|
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
|
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
|
|
if (!serverId || !agentId) {
|
|
return null;
|
|
}
|
|
|
|
return { serverId, agentId };
|
|
}
|
|
|
|
export function parseHostWorkspaceRouteFromPathname(
|
|
pathname: string,
|
|
): { serverId: string; workspaceId: string } | null {
|
|
const pathOnly = stripSearchAndHash(pathname);
|
|
const match = pathOnly.match(/^\/h\/([^/]+)\/workspace\/([^/]+)\/?$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const serverId = trimNonEmpty(decodeSegment(match[1]));
|
|
if (!serverId) {
|
|
return null;
|
|
}
|
|
|
|
const rawWorkspaceId = match[2];
|
|
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
|
if (!workspaceId) {
|
|
return null;
|
|
}
|
|
return { serverId, workspaceId };
|
|
}
|
|
|
|
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string) {
|
|
const normalizedServerId = trimNonEmpty(serverId);
|
|
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
|
if (!normalizedServerId || !normalizedWorkspaceId) {
|
|
return "/" as const;
|
|
}
|
|
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
|
|
if (!encodedWorkspaceId) {
|
|
return "/" as const;
|
|
}
|
|
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}` as const;
|
|
}
|
|
|
|
export function buildHostWorkspaceOpenRoute(
|
|
serverId: string,
|
|
workspaceId: string,
|
|
openIntent: string,
|
|
) {
|
|
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
|
const normalizedOpenIntent = trimNonEmpty(openIntent);
|
|
if (base === "/" || !normalizedOpenIntent) {
|
|
return base;
|
|
}
|
|
return `${base}?open=${encodeURIComponent(normalizedOpenIntent)}` as const;
|
|
}
|
|
|
|
export function buildHostAgentDetailRoute(serverId: string, agentId: string, workspaceId?: string) {
|
|
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
|
if (normalizedWorkspaceId) {
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedAgentId) {
|
|
return "/" as const;
|
|
}
|
|
return buildHostWorkspaceOpenRoute(
|
|
serverId,
|
|
normalizedWorkspaceId,
|
|
`agent:${normalizedAgentId}`,
|
|
);
|
|
}
|
|
const normalizedServerId = trimNonEmpty(serverId);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedServerId || !normalizedAgentId) {
|
|
return "/" as const;
|
|
}
|
|
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}` as const;
|
|
}
|
|
|
|
export function buildHostRootRoute(serverId: string) {
|
|
const normalized = trimNonEmpty(serverId);
|
|
if (!normalized) {
|
|
return "/" as const;
|
|
}
|
|
return `/h/${encodeSegment(normalized)}` as const;
|
|
}
|
|
|
|
export function buildHostSessionsRoute(serverId: string) {
|
|
const base = buildHostRootRoute(serverId);
|
|
if (base === "/") {
|
|
return "/" as const;
|
|
}
|
|
return `${base}/sessions` as const;
|
|
}
|
|
|
|
export function buildHostOpenProjectRoute(serverId: string) {
|
|
const base = buildHostRootRoute(serverId);
|
|
if (base === "/") {
|
|
return "/" as const;
|
|
}
|
|
return `${base}/open-project` as const;
|
|
}
|
|
|
|
export function buildHostNewWorkspaceRoute(
|
|
serverId: string,
|
|
sourceDirectory: string,
|
|
options?: { displayName?: string },
|
|
) {
|
|
const base = buildHostRootRoute(serverId);
|
|
if (base === "/") {
|
|
return "/" as const;
|
|
}
|
|
const params = new URLSearchParams();
|
|
params.set("dir", sourceDirectory);
|
|
if (options?.displayName) {
|
|
params.set("name", options.displayName);
|
|
}
|
|
return `${base}/new?${params.toString()}` as const;
|
|
}
|
|
|
|
export const SETTINGS_SECTION_SLUGS = [
|
|
"general",
|
|
"shortcuts",
|
|
"integrations",
|
|
"permissions",
|
|
"diagnostics",
|
|
"about",
|
|
] as const;
|
|
|
|
export type SettingsSectionSlug = (typeof SETTINGS_SECTION_SLUGS)[number];
|
|
|
|
export function isSettingsSectionSlug(value: string): value is SettingsSectionSlug {
|
|
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
|
|
}
|
|
|
|
export function buildSettingsRoute() {
|
|
return "/settings" as const;
|
|
}
|
|
|
|
export function buildSettingsSectionRoute(section: SettingsSectionSlug) {
|
|
return `/settings/${section}` as const;
|
|
}
|
|
|
|
export function buildSettingsHostRoute(serverId: string) {
|
|
const normalized = trimNonEmpty(serverId);
|
|
if (!normalized) {
|
|
throw new Error("buildSettingsHostRoute requires a non-empty serverId");
|
|
}
|
|
return `/settings/hosts/${encodeSegment(normalized)}` as const;
|
|
}
|
|
|
|
export function buildProjectsSettingsRoute() {
|
|
return "/settings/projects" as const;
|
|
}
|
|
|
|
export function buildProjectSettingsRoute(projectKey: string) {
|
|
const normalized = trimNonEmpty(projectKey);
|
|
if (!normalized) {
|
|
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
|
|
}
|
|
return `/settings/projects/${encodeSegment(normalized)}` as const;
|
|
}
|
|
|
|
export function mapPathnameToServer(pathname: string, nextServerId: string) {
|
|
const normalized = trimNonEmpty(nextServerId);
|
|
if (!normalized) {
|
|
return "/" as const;
|
|
}
|
|
|
|
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
|
|
const base = buildHostRootRoute(normalized);
|
|
if (suffix.startsWith("settings")) {
|
|
return buildSettingsHostRoute(normalized);
|
|
}
|
|
if (suffix.startsWith("sessions")) {
|
|
return `${base}/sessions` as const;
|
|
}
|
|
if (suffix.startsWith("open-project")) {
|
|
return `${base}/open-project` as const;
|
|
}
|
|
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
|
if (workspaceRoute) {
|
|
return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId);
|
|
}
|
|
if (suffix.startsWith("agent/")) {
|
|
return `${base}/${suffix}` as const;
|
|
}
|
|
return base;
|
|
}
|