Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-06-03 19:34:57 +07:00
43 changed files with 2610 additions and 955 deletions
@@ -1,11 +1,7 @@
import { SquareTerminal } from "lucide-react-native";
import { useMemo } from "react";
import { Image, type ImageSourcePropType } from "react-native";
import {
isKnownEditorTargetId,
type EditorTargetId,
type KnownEditorTargetId,
} from "@getpaseo/protocol/messages";
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
interface EditorAppIconProps {
editorId: EditorTargetId;
@@ -14,7 +10,7 @@ interface EditorAppIconProps {
}
/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
@@ -25,15 +21,16 @@ const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
};
/* eslint-enable @typescript-eslint/no-require-imports */
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
return isKnownEditorTargetId(editorId);
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
}
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
if (!hasBundledEditorAppIcon(editorId)) {
const source = EDITOR_APP_IMAGES[editorId];
if (!source) {
return <SquareTerminal size={size} color={color} />;
}
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
return <Image source={source} style={imageStyle} resizeMode="contain" />;
}
@@ -9,7 +9,7 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
import { Check, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
@@ -82,9 +82,18 @@ function QuestionOptionRow({
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
<Pressable
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
accessibilityRole="button"
accessibilityLabel={option.label}
accessibilityState={accessibilityState}
aria-selected={isSelected}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={optionLabelStyle}>{option.label}</Text>
@@ -102,8 +111,73 @@ function QuestionOptionRow({
);
}
interface QuestionNavButtonProps {
index: number;
total: number;
isActive: boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}
function QuestionNavButton({
index,
total,
isActive,
isResponding,
onSelect,
}: QuestionNavButtonProps) {
const { theme } = useUnistyles();
const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
const handlePress = useCallback(() => {
onSelect(index);
}, [index, onSelect]);
const pressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
return [
styles.questionNavButton,
{
backgroundColor:
isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border,
},
pressed && styles.optionItemPressed,
];
},
[
isActive,
theme.colors.border,
theme.colors.foregroundMuted,
theme.colors.surface1,
theme.colors.surface2,
],
);
const textStyle = useMemo(
() => [
styles.questionNavText,
{ color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted },
],
[isActive, theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Question ${index + 1} of ${total}`}
accessibilityState={accessibilityState}
aria-selected={isActive}
testID={`question-form-question-nav-${index + 1}`}
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
>
<Text style={textStyle}>{index + 1}</Text>
</Pressable>
);
}
interface QuestionOtherInputProps {
qIndex: number;
accessibilityLabel: string;
value: string;
placeholder: string;
isResponding: boolean;
@@ -113,6 +187,7 @@ interface QuestionOtherInputProps {
function QuestionOtherInput({
qIndex,
accessibilityLabel,
value,
placeholder,
isResponding,
@@ -149,6 +224,7 @@ function QuestionOtherInput({
<TextInput
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
accessibilityLabel={accessibilityLabel}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -163,15 +239,19 @@ function QuestionOtherInput({
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const questions = parseQuestionFormQuestions(permission.request.input);
const questions = useMemo(
() => parseQuestionFormQuestions(permission.request.input),
[permission.request.input],
);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
const current = selections[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
@@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
} else {
next.add(optIndex);
}
} else if (next.has(optIndex)) {
next.clear();
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
next.clear();
next.add(optIndex);
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
setSelections((prev) => ({ ...prev, [qIndex]: next }));
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, questions, selections],
);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
@@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}, []);
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
const resolvedActiveQuestionIndex = questions
? Math.min(activeQuestionIndex, questions.length - 1)
: 0;
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return;
@@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
});
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
const handleSelectQuestion = useCallback((index: number) => {
setActiveQuestionIndex(index);
}, []);
const dismissButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
@@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const submitDisabled = !allAnswered || isResponding;
const submitButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
{
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
opacity: submitDisabled ? 0.5 : 1,
},
pressed && !submitDisabled ? styles.optionItemPressed : null,
],
[
submitDisabled,
theme.colors.surface2,
theme.colors.surface1,
theme.colors.border,
theme.colors.borderAccent,
],
[submitDisabled, theme.colors.accent],
);
const containerStyle = useMemo(
@@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.questionText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const questionNavStyle = useMemo(
() => [styles.questionNav, isMobile && styles.questionNavMobile],
[isMobile],
);
const actionsContainerStyle = useMemo(
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
[isMobile],
@@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const submitActionTextColor = allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted;
const submitActionTextColor = theme.colors.accentForeground;
const submitActionTextStyle = useMemo(
() => [styles.actionText, { color: submitActionTextColor }],
[submitActionTextColor],
@@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
const dismissLabel = resolveDismissLabel(questions);
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
return (
<View style={containerStyle}>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
const showTextInput = questionShowsTextInput(q);
return (
<View key={q.question} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={questionTextStyle}>{q.question}</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
{q.options.length > 0 ? (
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={qIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={q.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={qIndex}
value={otherText}
placeholder={getQuestionInputPlaceholder(q)}
<View style={containerStyle} testID="question-form-card">
<View style={styles.questionTopRow}>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>
<View style={questionNavStyle} testID="question-form-question-nav">
{questions.map((question, qIndex) => {
const isActive = qIndex === resolvedActiveQuestionIndex;
return (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
isActive={isActive}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
onSelect={handleSelectQuestion}
/>
) : null}
</View>
);
})}
);
})}
</View>
</View>
{activeQuestion ? (
<View key={activeQuestion.question} style={styles.questionBlock}>
{activeQuestion.options.length > 0 ? (
<View style={styles.optionsWrap}>
{activeQuestion.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={resolvedActiveQuestionIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={activeQuestion.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={resolvedActiveQuestionIndex}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder(activeQuestion)}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
/>
) : null}
</View>
) : null}
<View style={actionsContainerStyle}>
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
<Pressable
style={dismissButtonStyle}
onPress={handleDeny}
disabled={isResponding}
accessibilityRole="button"
accessibilityLabel={dismissLabel}
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
@@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
)}
</Pressable>
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
<Pressable
style={submitButtonStyle}
onPress={handleSubmit}
disabled={submitDisabled}
accessibilityRole="button"
accessibilityLabel="Submit"
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
<Check size={14} color={submitActionTextColor} />
@@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({
questionBlock: {
gap: theme.spacing[2],
},
questionTopRow: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[3],
},
questionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
flex: 1,
},
questionText: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
lineHeight: 22,
},
optionsWrap: {
gap: theme.spacing[1],
},
questionNav: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1],
},
questionNavMobile: {
paddingRight: theme.spacing[1],
},
questionNavButton: {
minWidth: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
borderWidth: theme.borderWidth[1],
},
questionNavText: {
fontSize: theme.fontSize.xs,
fontWeight: "700",
},
optionItem: {
flexDirection: "row",
alignItems: "center",
@@ -8,11 +8,7 @@ import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
@@ -21,21 +17,6 @@ const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
};
function formatUpdateMessage(ops: readonly SkillOp[]): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
@@ -75,15 +56,8 @@ export function IntegrationsSection() {
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: "Update Paseo skills?",
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
confirmLabel: "Update",
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillsStatus, updateSkills]);
}, [isSkillsWorking, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
+19
View File
@@ -51,6 +51,24 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopEditorTargetDescriptor {
id: string;
label: string;
kind: "editor" | "file-manager";
}
export interface DesktopEditorOpenTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
export interface DesktopEditorBridge {
listTargets?: () => Promise<DesktopEditorTargetDescriptor[]>;
openTarget?: (input: DesktopEditorOpenTargetInput) => Promise<void>;
}
export interface DesktopWebUtilsBridge {
getPathForFile?: (file: File) => string;
}
@@ -111,6 +129,7 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
editor?: DesktopEditorBridge;
webUtils?: DesktopWebUtilsBridge;
menu?: DesktopMenuBridge;
browser?: DesktopBrowserBridge;
+80 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
import {
buildGitHubBlobUrl,
buildGitHubBranchTreeUrl,
parseGitHubRepoFromRemote,
} from "./github-url";
describe("parseGitHubRepoFromRemote", () => {
it.each([
@@ -51,3 +55,78 @@ describe("buildGitHubBranchTreeUrl", () => {
).toBeNull();
});
});
describe("buildGitHubBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a line range anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
});
+47
View File
@@ -20,3 +20,50 @@ export function buildGitHubBranchTreeUrl(input: {
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
return `https://github.com/${repo}/tree/${encodedBranch}`;
}
function normalizeGitHubBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildGitHubBlobUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeGitHubBlobPath(input.path);
if (!repo || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += `#L${input.lineStart}`;
if (input.lineEnd && input.lineEnd > input.lineStart) {
url += `-L${input.lineEnd}`;
}
}
return url;
}
@@ -20,6 +20,12 @@ describe("resolvePreferredEditorId", () => {
);
});
it("keeps custom script target ids as plain strings", () => {
expect(resolvePreferredEditorId(["script:open-in-nvim", "cursor"], "script:open-in-nvim")).toBe(
"script:open-in-nvim",
);
});
it("returns null when no editors are available", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EditorTargetIdSchema, type EditorTargetId } from "@getpaseo/protocol/messages";
import type { EditorTargetId } from "@/workspace/editor-targets";
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
@@ -11,8 +11,7 @@ async function loadPreferredEditor(): Promise<EditorTargetId | null> {
if (!stored) {
return null;
}
const parsed = EditorTargetIdSchema.safeParse(stored);
return parsed.success ? parsed.data : null;
return stored.trim() || null;
}
export function resolvePreferredEditorId(
@@ -6,10 +6,9 @@ import {
View,
type PressableStateCallbackType,
} from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { EditorTargetDescriptorPayload } from "@getpaseo/protocol/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
@@ -21,18 +20,20 @@ import {
import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { buildGitHubBranchTreeUrl } from "@/git/github-url";
import { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open";
import { planWorkspaceOpenTargets } from "@/workspace/open-target-planner";
import type { Theme } from "@/styles/theme";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
cwd: string;
activeFile?: WorkspaceFileLocation | null;
hideLabels?: boolean;
}
@@ -40,7 +41,6 @@ interface OpenTarget {
id: string;
label: string;
icon: ReactElement;
requiresLocalDaemon: boolean;
onOpen: () => Promise<void> | void;
}
@@ -80,95 +80,71 @@ function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemP
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
activeFile,
hideLabels,
}: WorkspaceOpenInEditorButtonProps) {
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } =
useDesktopOpenTargets({
isLocalExecution: isLocalDaemon,
});
const shouldQueryWorkspace =
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],
enabled: shouldLoadEditorTargets,
staleTime: 60_000,
retry: false,
queryFn: async () => {
if (!client) {
return [];
}
try {
const payload = await client.listAvailableEditors();
return payload.error ? [] : payload.editors;
} catch {
return [];
}
},
});
const availableEditors = useMemo(
() => availableEditorsQuery.data ?? [],
[availableEditorsQuery.data],
const resolvedFile = useMemo(
() =>
activeFile ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: cwd }) : null,
[activeFile, cwd],
);
const activeFileName = useMemo(
() => resolvedFile?.absolutePath.split("/").findLast(Boolean) ?? null,
[resolvedFile],
);
const canResolveWorkspace = isWeb && cwd.trim().length > 0 && isAbsolutePath(cwd);
const shouldQueryCheckout = canResolveWorkspace && isConnected;
const { status: checkoutStatus } = useCheckoutStatusQuery({
serverId,
cwd: shouldQueryWorkspace ? cwd : "",
cwd: shouldQueryCheckout ? cwd : "",
});
const editorTargets = useMemo<OpenTarget[]>(
const targets = useMemo<OpenTarget[]>(
() =>
availableEditors.map((editor) => ({
id: editor.id,
label: editor.label,
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: true,
onOpen: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.openInEditor(cwd, editor.id);
if (payload.error) {
throw new Error(payload.error);
}
},
})),
[availableEditors, client, cwd],
);
const githubTarget = useMemo<OpenTarget | null>(() => {
if (!checkoutStatus?.isGit) {
return null;
}
const url = buildGitHubBranchTreeUrl({
remoteUrl: checkoutStatus.remoteUrl,
branch: checkoutStatus.currentBranch,
});
if (!url) {
return null;
}
return {
id: "github",
label: "GitHub",
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: false,
onOpen: () => openExternalUrl(url),
};
}, [checkoutStatus]);
const targets = useMemo(
() =>
filterTargetsForDaemonLocation(
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
{
isLocalDaemon,
},
),
[editorTargets, githubTarget, isLocalDaemon],
planWorkspaceOpenTargets({
workspaceDirectory: cwd,
activeFile,
resolvedActiveFile: resolvedFile,
desktopTargets: desktopOpenTargets,
canUseDesktopBridge: isDesktopOpenAvailable,
isLocalExecution: isLocalDaemon,
checkoutStatus,
}).map((target) => {
if (target.source === "github") {
return {
id: target.id,
label: target.label,
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
onOpen: () => openExternalUrl(target.url),
};
}
return {
id: target.id,
label: target.label,
icon: <ThemedEditorAppIcon editorId={target.id} size={16} uniProps={mutedColorMapping} />,
onOpen: () => openDesktopTarget(target.openInput),
};
}),
[
activeFile,
checkoutStatus,
cwd,
desktopOpenTargets,
isDesktopOpenAvailable,
isLocalDaemon,
resolvedFile,
],
);
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
@@ -216,7 +192,7 @@ export function WorkspaceOpenInEditorButton({
}
}, [primaryOption, handleOpenTarget]);
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
if (!canResolveWorkspace || !primaryOption || targets.length === 0) {
return null;
}
@@ -229,7 +205,11 @@ export function WorkspaceOpenInEditorButton({
onPress={handlePrimaryPress}
disabled={openMutation.isPending}
accessibilityRole="button"
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
accessibilityLabel={
activeFileName
? `Open ${activeFileName} in ${primaryOption.label}`
: `Open workspace in ${primaryOption.label}`
}
>
{openMutation.isPending ? (
<ThemedActivityIndicator
@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
describe("filterTargetsForDaemonLocation", () => {
const targets = [
{ id: "cursor", requiresLocalDaemon: true },
{ id: "vscode", requiresLocalDaemon: true },
{ id: "github", requiresLocalDaemon: false },
];
it("keeps local app targets and URL targets for the local daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
});
it("hides local app targets for a remote daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
{ id: "github", requiresLocalDaemon: false },
]);
});
it("preserves target order after filtering", () => {
expect(
filterTargetsForDaemonLocation(
[
{ id: "github", requiresLocalDaemon: false },
{ id: "finder", requiresLocalDaemon: true },
{ id: "docs", requiresLocalDaemon: false },
],
{ isLocalDaemon: false },
),
).toEqual([
{ id: "github", requiresLocalDaemon: false },
{ id: "docs", requiresLocalDaemon: false },
]);
});
});
@@ -1,13 +0,0 @@
export interface WorkspaceOpenTargetAvailability {
requiresLocalDaemon: boolean;
}
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
targets: readonly Target[],
input: { isLocalDaemon: boolean },
): Target[] {
if (input.isLocalDaemon) {
return [...targets];
}
return targets.filter((target) => !target.requiresLocalDaemon);
}
@@ -193,6 +193,31 @@ function getWorkspaceScripts(
return workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS;
}
interface WorkspaceFileLocationFields {
path: string | null;
lineStart?: number;
lineEnd?: number;
}
function getWorkspaceFileLocationFields(
tab: WorkspaceTabDescriptor | null,
): WorkspaceFileLocationFields {
const target = tab?.target;
if (target?.kind !== "file") {
return { path: null };
}
return { path: target.path, lineStart: target.lineStart, lineEnd: target.lineEnd };
}
function buildWorkspaceFileLocation(
fields: WorkspaceFileLocationFields,
): WorkspaceFileLocation | null {
if (fields.path === null) {
return null;
}
return { path: fields.path, lineStart: fields.lineStart, lineEnd: fields.lineEnd };
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
@@ -2851,6 +2876,19 @@ function WorkspaceScreenContent({
});
const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]);
const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor);
const activeFilePath = activeFileFields.path;
const activeFileLineStart = activeFileFields.lineStart;
const activeFileLineEnd = activeFileFields.lineEnd;
const activeFileLocation = useMemo<WorkspaceFileLocation | null>(
() =>
buildWorkspaceFileLocation({
path: activeFilePath,
lineStart: activeFileLineStart,
lineEnd: activeFileLineEnd,
}),
[activeFileLineEnd, activeFileLineStart, activeFilePath],
);
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = useMemo(
() => !isMobile && !canRenderDesktopPaneSplits,
@@ -3083,10 +3121,11 @@ function WorkspaceScreenContent({
hideLabels={showCompactButtonLabels}
/>
) : null}
{!isMobile ? (
{!isMobile && workspaceDirectory ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
cwd={workspaceDirectory}
activeFile={activeFileLocation}
hideLabels={showCompactButtonLabels}
/>
) : null}
@@ -3193,6 +3232,8 @@ function WorkspaceScreenContent({
workspaceDescriptor,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
activeFileLocation,
liveTerminalIds,
handleScriptTerminalStarted,
handleViewScriptTerminal,
@@ -0,0 +1,71 @@
import { useQuery } from "@tanstack/react-query";
import { getDesktopHost, type DesktopEditorBridge } from "@/desktop/host";
export type DesktopOpenTargetKind = "editor" | "file-manager";
export type DesktopOpenMode = "open" | "reveal";
export interface DesktopOpenTarget {
id: string;
label: string;
kind: DesktopOpenTargetKind;
}
export interface OpenDesktopTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: DesktopOpenMode;
}
interface AvailableDesktopEditorBridge {
listTargets: NonNullable<DesktopEditorBridge["listTargets"]>;
openTarget: NonNullable<DesktopEditorBridge["openTarget"]>;
}
function getDesktopEditorBridge(): AvailableDesktopEditorBridge | null {
const bridge = getDesktopHost()?.editor;
if (!bridge?.listTargets || !bridge.openTarget) {
return null;
}
return {
listTargets: bridge.listTargets,
openTarget: bridge.openTarget,
};
}
export function hasDesktopOpenTargetsBridge(): boolean {
return getDesktopEditorBridge() !== null;
}
export async function listDesktopOpenTargets(): Promise<DesktopOpenTarget[]> {
const bridge = getDesktopEditorBridge();
if (!bridge) {
return [];
}
return await bridge.listTargets();
}
export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise<void> {
const bridge = getDesktopEditorBridge();
if (!bridge) {
throw new Error("Desktop editor bridge is unavailable");
}
await bridge.openTarget(input);
}
export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) {
const hasBridge = hasDesktopOpenTargetsBridge();
const canListTargets = hasBridge && input.isLocalExecution;
const query = useQuery({
queryKey: ["desktop-open-targets"],
enabled: canListTargets,
staleTime: 60_000,
retry: false,
queryFn: listDesktopOpenTargets,
});
return {
targets: query.data ?? [],
isAvailable: canListTargets,
};
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { isKnownEditorTargetId } from "./editor-targets";
describe("editor target ids", () => {
it("recognizes built-ins without typing custom ids out of the system", () => {
const customEditorId: string = "script:open-in-nvim";
expect(isKnownEditorTargetId("vscode")).toBe(true);
expect(isKnownEditorTargetId(customEditorId)).toBe(false);
});
});
@@ -0,0 +1,15 @@
export type EditorTargetId = string;
const KNOWN_EDITOR_TARGET_IDS: ReadonlySet<string> = new Set([
"cursor",
"vscode",
"webstorm",
"zed",
"finder",
"explorer",
"file-manager",
]);
export function isKnownEditorTargetId(editorId: EditorTargetId): boolean {
return KNOWN_EDITOR_TARGET_IDS.has(editorId);
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
resolveWorkspaceFilePaths,
workspaceFileLocationsEqual,
} from ".";
@@ -61,3 +62,124 @@ describe("workspace file tab targets", () => {
).toBe(false);
});
});
describe("resolveWorkspaceFilePaths", () => {
it("joins workspace-relative paths against the workspace root", () => {
expect(
resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("derives the relative path for an absolute file inside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "/Users/me/repo/src/app.ts",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("keeps the absolute path but drops the relative path when outside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "/etc/hosts",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/etc/hosts",
relativePath: null,
});
});
it("normalizes Windows separators in the file path", () => {
expect(
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("normalizes dot segments in workspace-relative paths", () => {
expect(
resolveWorkspaceFilePaths({ path: "./src/../app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/app.ts",
relativePath: "app.ts",
});
});
it("rejects workspace-relative paths that escape the workspace", () => {
expect(
resolveWorkspaceFilePaths({ path: "../outside.ts", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
expect(
resolveWorkspaceFilePaths({ path: "src/../../outside.ts", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
});
it("derives the relative path for a Windows absolute file inside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\me\\repo\\src\\app.ts",
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toEqual({
absolutePath: "C:/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("returns null for home-relative paths that cannot be anchored", () => {
expect(
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
expect(resolveWorkspaceFilePaths({ path: "~", workspaceRoot: "/Users/me/repo" })).toBeNull();
});
it("treats filenames that merely start with ~ as workspace-relative", () => {
expect(resolveWorkspaceFilePaths({ path: "~env.ts", workspaceRoot: "/Users/me/repo" })).toEqual(
{
absolutePath: "/Users/me/repo/~env.ts",
relativePath: "~env.ts",
},
);
});
it("matches Windows workspace paths case-insensitively", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\Me\\Repo\\src\\app.ts",
workspaceRoot: "c:\\users\\me\\repo",
}),
).toEqual({
absolutePath: "C:/Users/Me/Repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("treats a case-only difference from the Windows root as the root itself", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\Me\\Repo",
workspaceRoot: "c:\\users\\me\\repo",
}),
).toBeNull();
});
it("returns null when the workspace root is not absolute", () => {
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "repo" })).toBeNull();
});
it("returns null when the resolved file equals the workspace root", () => {
expect(
resolveWorkspaceFilePaths({ path: "/Users/me/repo", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
});
});
@@ -1,3 +1,5 @@
import { isAbsolutePath } from "@/utils/path";
export type OpenFileDisposition = "main" | "side";
export interface WorkspaceFileLocation {
@@ -57,3 +59,120 @@ function normalizeLineNumber(value: number | null | undefined): number | undefin
? Math.floor(value)
: undefined;
}
function trimTrailingSlashes(value: string): string {
if (value === "/" || /^\/+$/.test(value)) {
return "/";
}
if (/^[A-Za-z]:\/+$/.test(value)) {
return `${value.slice(0, 2)}/`;
}
return value.replace(/\/+$/, "");
}
function normalizePathSegments(value: string, rejectEscape: boolean): string | null {
const segments: string[] = [];
for (const segment of value.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
if (rejectEscape) {
return null;
}
continue;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/");
}
function normalizeAbsolutePath(value: string): string | null {
const normalizedInput = trimTrailingSlashes(value);
if (!isAbsolutePath(normalizedInput)) {
return null;
}
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
if (drivePath) {
const normalizedBody = normalizePathSegments(drivePath[2], false);
return trimTrailingSlashes(`${drivePath[1]}/${normalizedBody}`);
}
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
const normalizedBody = normalizePathSegments(normalizedInput.replace(/^\/+/, ""), false);
return trimTrailingSlashes(`${prefix}${normalizedBody}`);
}
function normalizeRelativePath(value: string): string | null {
const normalized = normalizePathSegments(value.replace(/^\/+/, ""), true);
return normalized || null;
}
function isWindowsPath(value: string): boolean {
return /^[A-Za-z]:\//.test(value);
}
function pathsEqual(left: string, right: string): boolean {
return isWindowsPath(left) || isWindowsPath(right)
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function startsWithPath(value: string, prefix: string): boolean {
return isWindowsPath(value) || isWindowsPath(prefix)
? value.toLowerCase().startsWith(prefix.toLowerCase())
: value.startsWith(prefix);
}
export interface ResolvedWorkspaceFilePaths {
/** Absolute path on the host, suitable for opening in an editor / file manager. */
absolutePath: string;
/** Path relative to the workspace root, or null when the file lives outside it. */
relativePath: string | null;
}
/**
* Resolves a file tab's path (which may be workspace-relative) against the workspace
* root. Returns null when an absolute host path cannot be derived — e.g. a `~`-relative
* path or a relative path with no workspace root to anchor it.
*/
export function resolveWorkspaceFilePaths(input: {
path: string;
workspaceRoot: string;
}): ResolvedWorkspaceFilePaths | null {
const filePath = input.path.trim().replace(/\\/g, "/");
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
if (!filePath || !workspaceRoot) {
return null;
}
if (isAbsolutePath(filePath)) {
const normalizedFile = normalizeAbsolutePath(filePath);
if (!normalizedFile) {
return null;
}
if (pathsEqual(normalizedFile, workspaceRoot)) {
return null;
}
const prefix = `${workspaceRoot}/`;
const relativePath = startsWithPath(normalizedFile, prefix)
? normalizedFile.slice(prefix.length)
: null;
return { absolutePath: normalizedFile, relativePath };
}
if (filePath === "~" || filePath.startsWith("~/")) {
return null;
}
const relativePath = normalizeRelativePath(filePath);
if (!relativePath) {
return null;
}
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { planWorkspaceOpenTargets } from "./open-target-planner";
const desktopTargets = [
{ id: "vscode", label: "VS Code", kind: "editor" as const },
{ id: "finder", label: "Finder", kind: "file-manager" as const },
];
const checkoutStatus = {
isGit: true,
remoteUrl: "git@github.com:getpaseo/paseo.git",
currentBranch: "main",
};
describe("planWorkspaceOpenTargets", () => {
it("plans editor targets with active-file absolute path and cwd", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 },
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo/src/app.ts", cwd: "/repo" },
});
});
it("plans file-manager targets with active-file absolute path and reveal mode", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts" },
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo/src/app.ts", mode: "reveal" },
});
});
it("plans no active file as opening the workspace folder", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo" },
});
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo" },
});
});
it("passes custom target ids through as strings", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts" },
desktopTargets: [{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }],
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets).toEqual([
{
source: "desktop",
id: "script:open-in-nvim",
label: "Open in Neovim",
editorId: "script:open-in-nvim",
openInput: {
editorId: "script:open-in-nvim",
path: "/repo/src/app.ts",
cwd: "/repo",
},
},
]);
});
it("keeps GitHub target independent and uses blob and tree URLs", () => {
const blobTargets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 },
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus,
});
const treeTargets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus,
});
expect(blobTargets).toEqual([
{
source: "github",
id: "github",
label: "GitHub",
url: "https://github.com/getpaseo/paseo/blob/main/src/app.ts#L3-L5",
},
]);
expect(treeTargets).toEqual([
{
source: "github",
id: "github",
label: "GitHub",
url: "https://github.com/getpaseo/paseo/tree/main",
},
]);
});
it("suppresses desktop targets when Electron bridge is unavailable", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: false,
isLocalExecution: true,
checkoutStatus,
});
expect(targets.map((target) => target.id)).toEqual(["github"]);
});
it("suppresses desktop targets for remote execution paths", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: false,
checkoutStatus,
});
expect(targets.map((target) => target.id)).toEqual(["github"]);
});
});
@@ -0,0 +1,146 @@
import { buildGitHubBlobUrl, buildGitHubBranchTreeUrl } from "@/git/github-url";
import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets";
import {
type ResolvedWorkspaceFilePaths,
resolveWorkspaceFilePaths,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
interface CheckoutStatusForOpenTarget {
isGit: boolean;
remoteUrl?: string | null;
currentBranch?: string | null;
}
export interface PlannedDesktopOpenTarget {
source: "desktop";
id: string;
label: string;
editorId: string;
openInput: OpenDesktopTargetInput;
}
export interface PlannedGitHubOpenTarget {
source: "github";
id: "github";
label: "GitHub";
url: string;
}
export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedGitHubOpenTarget;
export interface PlanWorkspaceOpenTargetsInput {
workspaceDirectory: string;
activeFile?: WorkspaceFileLocation | null;
resolvedActiveFile?: ResolvedWorkspaceFilePaths | null;
desktopTargets: readonly DesktopOpenTarget[];
canUseDesktopBridge: boolean;
isLocalExecution: boolean;
checkoutStatus?: CheckoutStatusForOpenTarget | null;
}
function resolveActiveFileForOpenTargets(
input: Pick<
PlanWorkspaceOpenTargetsInput,
"activeFile" | "resolvedActiveFile" | "workspaceDirectory"
>,
): ResolvedWorkspaceFilePaths | null {
if (input.resolvedActiveFile !== undefined) {
return input.resolvedActiveFile;
}
return input.activeFile
? resolveWorkspaceFilePaths({
path: input.activeFile.path,
workspaceRoot: input.workspaceDirectory,
})
: null;
}
function planDesktopOpenTargets(input: {
workspaceDirectory: string;
resolvedFile: ResolvedWorkspaceFilePaths | null;
desktopTargets: readonly DesktopOpenTarget[];
canUseDesktopBridge: boolean;
isLocalExecution: boolean;
}): PlannedDesktopOpenTarget[] {
if (!input.canUseDesktopBridge || !input.isLocalExecution) {
return [];
}
return input.desktopTargets.map((target) => {
if (!input.resolvedFile) {
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: { editorId: target.id, path: input.workspaceDirectory },
};
}
if (target.kind === "editor") {
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
cwd: input.workspaceDirectory,
},
};
}
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
mode: "reveal",
},
};
});
}
function planGitHubOpenTarget(input: {
activeFile?: WorkspaceFileLocation | null;
resolvedFile: ResolvedWorkspaceFilePaths | null;
checkoutStatus?: CheckoutStatusForOpenTarget | null;
}): PlannedGitHubOpenTarget | null {
if (!input.checkoutStatus?.isGit) {
return null;
}
const url = input.resolvedFile?.relativePath
? buildGitHubBlobUrl({
remoteUrl: input.checkoutStatus.remoteUrl,
branch: input.checkoutStatus.currentBranch,
path: input.resolvedFile.relativePath,
lineStart: input.activeFile?.lineStart,
lineEnd: input.activeFile?.lineEnd,
})
: buildGitHubBranchTreeUrl({
remoteUrl: input.checkoutStatus.remoteUrl,
branch: input.checkoutStatus.currentBranch,
});
if (!url) {
return null;
}
return {
source: "github",
id: "github",
label: "GitHub",
url,
};
}
export function planWorkspaceOpenTargets(
input: PlanWorkspaceOpenTargetsInput,
): PlannedWorkspaceOpenTarget[] {
const resolvedFile = resolveActiveFileForOpenTargets(input);
const desktopTargets = planDesktopOpenTargets({ ...input, resolvedFile });
const githubTarget = planGitHubOpenTarget({ ...input, resolvedFile });
return githubTarget ? [...desktopTargets, githubTarget] : desktopTargets;
}