mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
(feat): Open Child Folders as Apps (port from conductor) (#3615)
* feat(app): open child folders in preferred editor * fix(app): hide unavailable desktop open targets * fix(app): test desktop targets without JSDOM * refactor(app): isolate open-in-editor feature
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
Copy,
|
||||
CopyPlus,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FilePlus,
|
||||
FileText,
|
||||
FolderMinus,
|
||||
@@ -51,6 +52,8 @@ interface FileActionsContextMenuContentProps {
|
||||
fileKind: "file" | "directory";
|
||||
fileExists?: boolean;
|
||||
onOpenFile?: () => void;
|
||||
onOpenInEditor?: () => void;
|
||||
editorTargetName?: string;
|
||||
onOpenToSide?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onCopyRelativePath?: () => void;
|
||||
@@ -76,6 +79,8 @@ export function FileActionsContextMenuContent({
|
||||
fileKind,
|
||||
fileExists = true,
|
||||
onOpenFile,
|
||||
onOpenInEditor,
|
||||
editorTargetName,
|
||||
onOpenToSide,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
@@ -93,6 +98,19 @@ export function FileActionsContextMenuContent({
|
||||
testIDPrefix,
|
||||
}: FileActionsContextMenuContentProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const openInEditorAction = useMemo<FileAction | null>(
|
||||
() =>
|
||||
fileKind === "directory" && onOpenInEditor && editorTargetName
|
||||
? {
|
||||
key: "open-in-editor",
|
||||
group: "open",
|
||||
label: t("workspace.fileActions.openIn", { target: editorTargetName }),
|
||||
icon: ExternalLink,
|
||||
onSelect: onOpenInEditor,
|
||||
}
|
||||
: null,
|
||||
[editorTargetName, fileKind, onOpenInEditor, t],
|
||||
);
|
||||
const actions = useMemo<FileAction[]>(() => {
|
||||
const availableFile = fileKind === "file" && fileExists;
|
||||
const specs: Array<FileAction | null> = [
|
||||
@@ -132,6 +150,7 @@ export function FileActionsContextMenuContent({
|
||||
onSelect: onOpenFile,
|
||||
}
|
||||
: null,
|
||||
openInEditorAction,
|
||||
optionalFileAction(availableFile, onOpenToSide, {
|
||||
key: "open-to-side",
|
||||
group: "open",
|
||||
@@ -242,6 +261,7 @@ export function FileActionsContextMenuContent({
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onOpenFile,
|
||||
openInEditorAction,
|
||||
onOpenToSide,
|
||||
onRename,
|
||||
onReveal,
|
||||
|
||||
@@ -59,8 +59,8 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||
import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
@@ -77,6 +77,7 @@ import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-dra
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "name" },
|
||||
@@ -113,6 +114,8 @@ interface TreeRowItemProps {
|
||||
onSelectEntry: (entry: ExplorerEntry) => void;
|
||||
onCopyPath: (path: string) => void;
|
||||
onCopyRelativePath: (path: string) => void;
|
||||
onOpenInEditor?: (entry: ExplorerEntry) => void;
|
||||
editorTargetName?: string;
|
||||
onRevealEntry?: (entry: ExplorerEntry) => void;
|
||||
revealTargetName?: string;
|
||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||
@@ -237,6 +240,8 @@ function TreeRowItem({
|
||||
onSelectEntry,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onOpenInEditor,
|
||||
editorTargetName,
|
||||
onRevealEntry,
|
||||
revealTargetName,
|
||||
onDownloadEntry,
|
||||
@@ -290,6 +295,10 @@ function TreeRowItem({
|
||||
onCopyRelativePath(entry.path);
|
||||
}, [onCopyRelativePath, entry.path]);
|
||||
|
||||
const handleOpenInEditor = useCallback(() => {
|
||||
onOpenInEditor?.(entry);
|
||||
}, [entry, onOpenInEditor]);
|
||||
|
||||
const handleReveal = useCallback(() => {
|
||||
onRevealEntry?.(entry);
|
||||
}, [onRevealEntry, entry]);
|
||||
@@ -366,6 +375,8 @@ function TreeRowItem({
|
||||
</ContextMenuTrigger>
|
||||
<FileActionsContextMenuContent
|
||||
fileKind={entry.kind}
|
||||
onOpenInEditor={isDirectory && onOpenInEditor ? handleOpenInEditor : undefined}
|
||||
editorTargetName={editorTargetName}
|
||||
onCopyPath={handleCopy}
|
||||
onCopyRelativePath={handleCopyRelativePath}
|
||||
onReveal={onRevealEntry ? handleReveal : undefined}
|
||||
@@ -439,6 +450,10 @@ export function FileExplorerPane({
|
||||
isLocalExecution: isLocalDaemon,
|
||||
});
|
||||
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
|
||||
const openDirectoryInEditor = useOpenDirectoryInEditor({
|
||||
serverId,
|
||||
workspaceDirectory: normalizedWorkspaceRoot,
|
||||
});
|
||||
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
|
||||
const fsEntryOpsEnabled = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true,
|
||||
@@ -608,6 +623,11 @@ export function FileExplorerPane({
|
||||
[fileManagerTarget, normalizedWorkspaceRoot, t, toast],
|
||||
);
|
||||
|
||||
const handleOpenDirectoryInEditor = useCallback(
|
||||
(entry: ExplorerEntry) => openDirectoryInEditor?.open(entry.path),
|
||||
[openDirectoryInEditor],
|
||||
);
|
||||
|
||||
const handleDownloadEntry = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (entry.kind !== "file") {
|
||||
@@ -959,6 +979,8 @@ export function FileExplorerPane({
|
||||
onSelectEntry={handleSelectEntry}
|
||||
onCopyPath={handleCopyPath}
|
||||
onCopyRelativePath={handleCopyRelativePath}
|
||||
onOpenInEditor={openDirectoryInEditor ? handleOpenDirectoryInEditor : undefined}
|
||||
editorTargetName={openDirectoryInEditor?.targetName}
|
||||
onRevealEntry={fileManagerTarget ? handleRevealEntry : undefined}
|
||||
revealTargetName={fileManagerTarget?.label}
|
||||
onDownloadEntry={handleDownloadEntry}
|
||||
@@ -979,6 +1001,7 @@ export function FileExplorerPane({
|
||||
handleCollapseDirectory,
|
||||
handleCopyPath,
|
||||
handleCopyRelativePath,
|
||||
handleOpenDirectoryInEditor,
|
||||
handleDeleteEntry,
|
||||
handleDownloadEntry,
|
||||
handleDraftCommit,
|
||||
@@ -992,6 +1015,7 @@ export function FileExplorerPane({
|
||||
handleSelectEntry,
|
||||
isDirectoryLoading,
|
||||
fileManagerTarget,
|
||||
openDirectoryInEditor,
|
||||
selectedEntryPath,
|
||||
onAddToChat,
|
||||
onOpenFileToSide,
|
||||
@@ -1424,6 +1448,8 @@ function TreeRowDispatcher({
|
||||
onSelectEntry,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onOpenInEditor,
|
||||
editorTargetName,
|
||||
onRevealEntry,
|
||||
revealTargetName,
|
||||
onDownloadEntry,
|
||||
@@ -1446,6 +1472,8 @@ function TreeRowDispatcher({
|
||||
onSelectEntry: (entry: ExplorerEntry) => void;
|
||||
onCopyPath: (path: string) => void | Promise<void>;
|
||||
onCopyRelativePath: (path: string) => void | Promise<void>;
|
||||
onOpenInEditor?: (entry: ExplorerEntry) => void;
|
||||
editorTargetName?: string;
|
||||
onRevealEntry?: (entry: ExplorerEntry) => void;
|
||||
revealTargetName?: string;
|
||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||
@@ -1477,6 +1505,8 @@ function TreeRowDispatcher({
|
||||
onSelectEntry={onSelectEntry}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyRelativePath={onCopyRelativePath}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
editorTargetName={editorTargetName}
|
||||
onRevealEntry={onRevealEntry}
|
||||
revealTargetName={revealTargetName}
|
||||
onDownloadEntry={onDownloadEntry}
|
||||
|
||||
@@ -416,6 +416,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "افتح الملف",
|
||||
openIn: "فتح في {{target}}",
|
||||
openToSide: "فتح إلى الجانب",
|
||||
copyPath: "نسخ المسار",
|
||||
copyRelativePath: "نسخ المسار النسبي",
|
||||
|
||||
@@ -414,6 +414,7 @@ export const en = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Open file",
|
||||
openIn: "Open in {{target}}",
|
||||
openToSide: "Open to the side",
|
||||
copyPath: "Copy path",
|
||||
copyRelativePath: "Copy relative path",
|
||||
|
||||
@@ -420,6 +420,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Abrir archivo",
|
||||
openIn: "Abrir en {{target}}",
|
||||
openToSide: "Abrir al lado",
|
||||
copyPath: "Copiar ruta",
|
||||
copyRelativePath: "Copiar ruta relativa",
|
||||
|
||||
@@ -420,6 +420,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Ouvrir le fichier",
|
||||
openIn: "Ouvrir dans {{target}}",
|
||||
openToSide: "Ouvrir sur le côté",
|
||||
copyPath: "Copier le chemin",
|
||||
copyRelativePath: "Copier le chemin relatif",
|
||||
|
||||
@@ -420,6 +420,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "ファイルを開く",
|
||||
openIn: "{{target}}で開く",
|
||||
openToSide: "横に開く",
|
||||
copyPath: "パスをコピー",
|
||||
copyRelativePath: "相対パスをコピー",
|
||||
|
||||
@@ -417,6 +417,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "파일 열기",
|
||||
openIn: "{{target}}에서 열기",
|
||||
openToSide: "옆에 열기",
|
||||
copyPath: "경로 복사",
|
||||
copyRelativePath: "상대 경로 복사",
|
||||
|
||||
@@ -420,6 +420,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Abrir arquivo",
|
||||
openIn: "Abrir no {{target}}",
|
||||
openToSide: "Abrir ao lado",
|
||||
copyPath: "Copiar caminho",
|
||||
copyRelativePath: "Copiar caminho relativo",
|
||||
|
||||
@@ -419,6 +419,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Открыть файл",
|
||||
openIn: "Открыть в {{target}}",
|
||||
openToSide: "Открыть сбоку",
|
||||
copyPath: "Копировать путь",
|
||||
copyRelativePath: "Копировать относительный путь",
|
||||
|
||||
@@ -416,6 +416,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "打开文件",
|
||||
openIn: "在 {{target}} 中打开",
|
||||
openToSide: "在侧边打开",
|
||||
copyPath: "复制路径",
|
||||
copyRelativePath: "复制相对路径",
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { WorkspaceActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button";
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectDesktopOpenTargets } from "./desktop-open-targets";
|
||||
|
||||
const cachedTargets = [
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
kind: "editor" as const,
|
||||
icon: { kind: "symbol" as const, name: "terminal" as const },
|
||||
},
|
||||
];
|
||||
|
||||
describe("selectDesktopOpenTargets", () => {
|
||||
it("hides cached targets when listing is unavailable", () => {
|
||||
expect(
|
||||
selectDesktopOpenTargets({
|
||||
canListTargets: false,
|
||||
targets: cachedTargets,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns cached targets when listing is available", () => {
|
||||
expect(
|
||||
selectDesktopOpenTargets({
|
||||
canListTargets: true,
|
||||
targets: cachedTargets,
|
||||
}),
|
||||
).toEqual(cachedTargets);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,21 @@ interface AvailableDesktopEditorBridge {
|
||||
openTarget: NonNullable<DesktopEditorBridge["openTarget"]>;
|
||||
}
|
||||
|
||||
interface SelectDesktopOpenTargetsInput {
|
||||
canListTargets: boolean;
|
||||
targets: DesktopOpenTarget[] | undefined;
|
||||
}
|
||||
|
||||
export function selectDesktopOpenTargets({
|
||||
canListTargets,
|
||||
targets,
|
||||
}: SelectDesktopOpenTargetsInput): DesktopOpenTarget[] {
|
||||
if (!canListTargets) {
|
||||
return [];
|
||||
}
|
||||
return targets ?? [];
|
||||
}
|
||||
|
||||
function getDesktopEditorBridge(): AvailableDesktopEditorBridge | null {
|
||||
const bridge = getDesktopHost()?.editor;
|
||||
if (!bridge?.listTargets || !bridge.openTarget) {
|
||||
@@ -67,9 +82,13 @@ export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) {
|
||||
retry: false,
|
||||
queryFn: listDesktopOpenTargets,
|
||||
});
|
||||
const targets = selectDesktopOpenTargets({
|
||||
canListTargets,
|
||||
targets: query.data,
|
||||
});
|
||||
|
||||
return {
|
||||
targets: query.data ?? [],
|
||||
targets,
|
||||
isAvailable: canListTargets,
|
||||
};
|
||||
}
|
||||
|
||||
+13
-7
@@ -23,7 +23,7 @@ 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 { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { getForgePresentation } from "@/git/forge";
|
||||
@@ -59,11 +59,11 @@ function renderForgeOpenTargetIcon(icon: string): ReactElement {
|
||||
interface OpenTargetMenuItemProps {
|
||||
target: OpenTarget;
|
||||
isPreferred: boolean;
|
||||
onOpen: (target: OpenTarget) => void;
|
||||
onSelect: (target: OpenTarget) => void;
|
||||
}
|
||||
|
||||
function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemProps) {
|
||||
const handleSelect = useCallback(() => onOpen(target), [onOpen, target]);
|
||||
function OpenTargetMenuItem({ target, isPreferred, onSelect }: OpenTargetMenuItemProps) {
|
||||
const handleSelect = useCallback(() => onSelect(target), [onSelect, target]);
|
||||
const trailing = useMemo(
|
||||
() => (isPreferred ? <ThemedCheckIcon size={16} uniProps={mutedColorMapping} /> : undefined),
|
||||
[isPreferred],
|
||||
@@ -178,10 +178,16 @@ export function WorkspaceOpenInEditorButton({
|
||||
|
||||
const handleOpenTarget = useCallback(
|
||||
(target: OpenTarget) => {
|
||||
void updatePreferredEditor(target.id).catch(() => undefined);
|
||||
openMutation.mutate(target);
|
||||
},
|
||||
[openMutation, updatePreferredEditor],
|
||||
[openMutation],
|
||||
);
|
||||
|
||||
const handleSelectTarget = useCallback(
|
||||
(target: OpenTarget) => {
|
||||
void updatePreferredEditor(target.id).catch(() => undefined);
|
||||
},
|
||||
[updatePreferredEditor],
|
||||
);
|
||||
|
||||
const primaryPressableStyle = useCallback(
|
||||
@@ -267,7 +273,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
key={target.id}
|
||||
target={target}
|
||||
isPreferred={target.id === effectivePreferredEditorId}
|
||||
onOpen={handleOpenTarget}
|
||||
onSelect={handleSelectTarget}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner";
|
||||
|
||||
interface UseOpenDirectoryInEditorInput {
|
||||
serverId: string;
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
interface OpenDirectoryInEditorAction {
|
||||
targetName: string;
|
||||
open: (directoryPath: string) => void;
|
||||
}
|
||||
|
||||
export function useOpenDirectoryInEditor({
|
||||
serverId,
|
||||
workspaceDirectory,
|
||||
}: UseOpenDirectoryInEditorInput): OpenDirectoryInEditorAction | null {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isLocalExecution = useIsLocalDaemon(serverId);
|
||||
const { preferredEditorId } = usePreferredEditor();
|
||||
const { targets, isAvailable } = useDesktopOpenTargets({ isLocalExecution });
|
||||
const editorTargets = useMemo(
|
||||
() => targets.filter((target) => target.kind === "editor"),
|
||||
[targets],
|
||||
);
|
||||
const preferredTarget = useMemo(() => {
|
||||
const preferredId = resolvePreferredEditorId(
|
||||
editorTargets.map((target) => target.id),
|
||||
preferredEditorId,
|
||||
);
|
||||
return editorTargets.find((target) => target.id === preferredId) ?? null;
|
||||
}, [editorTargets, preferredEditorId]);
|
||||
|
||||
const open = useCallback(
|
||||
(directoryPath: string) => {
|
||||
if (!preferredTarget) {
|
||||
return;
|
||||
}
|
||||
const target = planWorkspaceOpenTargets({
|
||||
workspaceDirectory,
|
||||
directoryPath,
|
||||
desktopTargets: [preferredTarget],
|
||||
canUseDesktopBridge: isAvailable,
|
||||
isLocalExecution,
|
||||
}).find((candidate) => candidate.source === "desktop");
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
void openDesktopTarget(target.openInput).catch((cause: unknown) => {
|
||||
toast.error(
|
||||
cause instanceof Error ? cause.message : t("sidebar.project.actions.openFolderFailed"),
|
||||
);
|
||||
});
|
||||
},
|
||||
[isAvailable, isLocalExecution, preferredTarget, t, toast, workspaceDirectory],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => (preferredTarget ? { targetName: preferredTarget.label, open } : null),
|
||||
[open, preferredTarget],
|
||||
);
|
||||
}
|
||||
+32
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planWorkspaceOpenTargets } from "./open-target-planner";
|
||||
import { planWorkspaceOpenTargets } from "./planner";
|
||||
|
||||
const desktopTargets = [
|
||||
{
|
||||
@@ -84,6 +84,37 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("plans a nested directory as the editor workspace", () => {
|
||||
const targets = planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/specs",
|
||||
directoryPath: "repos/sample-android-app",
|
||||
desktopTargets: [
|
||||
{
|
||||
id: "android-studio",
|
||||
label: "Android Studio",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
],
|
||||
canUseDesktopBridge: true,
|
||||
isLocalExecution: true,
|
||||
});
|
||||
|
||||
expect(targets).toEqual([
|
||||
{
|
||||
source: "desktop",
|
||||
id: "android-studio",
|
||||
label: "Android Studio",
|
||||
editorId: "android-studio",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
openInput: {
|
||||
editorId: "android-studio",
|
||||
workspacePath: "/specs/repos/sample-android-app",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes custom target ids through as strings", () => {
|
||||
const targets = planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
+18
-2
@@ -33,6 +33,7 @@ export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedForge
|
||||
|
||||
export interface PlanWorkspaceOpenTargetsInput {
|
||||
workspaceDirectory: string;
|
||||
directoryPath?: string | null;
|
||||
activeFile?: WorkspaceFileLocation | null;
|
||||
resolvedActiveFile?: ResolvedWorkspaceFilePaths | null;
|
||||
desktopTargets: readonly DesktopOpenTarget[];
|
||||
@@ -61,6 +62,7 @@ function resolveActiveFileForOpenTargets(
|
||||
|
||||
function planDesktopOpenTargets(input: {
|
||||
workspaceDirectory: string;
|
||||
directoryPath?: string | null;
|
||||
activeFile?: WorkspaceFileLocation | null;
|
||||
resolvedFile: ResolvedWorkspaceFilePaths | null;
|
||||
desktopTargets: readonly DesktopOpenTarget[];
|
||||
@@ -71,6 +73,20 @@ function planDesktopOpenTargets(input: {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolvedDirectory =
|
||||
input.directoryPath === undefined || input.directoryPath === null
|
||||
? null
|
||||
: resolveWorkspaceFilePaths({
|
||||
path: input.directoryPath,
|
||||
workspaceRoot: input.workspaceDirectory,
|
||||
});
|
||||
if (input.directoryPath !== undefined && input.directoryPath !== null) {
|
||||
if (!resolvedDirectory?.relativePath) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const workspacePath = resolvedDirectory?.absolutePath ?? input.workspaceDirectory;
|
||||
|
||||
return input.desktopTargets.map((target) => {
|
||||
if (!input.resolvedFile) {
|
||||
return {
|
||||
@@ -79,7 +95,7 @@ function planDesktopOpenTargets(input: {
|
||||
label: target.label,
|
||||
editorId: target.id,
|
||||
icon: target.icon,
|
||||
openInput: { editorId: target.id, workspacePath: input.workspaceDirectory },
|
||||
openInput: { editorId: target.id, workspacePath },
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -90,7 +106,7 @@ function planDesktopOpenTargets(input: {
|
||||
icon: target.icon,
|
||||
openInput: {
|
||||
editorId: target.id,
|
||||
workspacePath: input.workspaceDirectory,
|
||||
workspacePath,
|
||||
filePath: input.resolvedFile.absolutePath,
|
||||
...(input.activeFile?.lineStart ? { line: input.activeFile.lineStart } : {}),
|
||||
},
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
|
||||
import { expandFolder, openFileExplorer } from "../../app/e2e/support/helpers/file-explorer";
|
||||
import { installDesktopRuntime } from "./support/runtime";
|
||||
import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings";
|
||||
|
||||
@@ -35,7 +37,7 @@ async function readEditorOpenRecords(recordPath: string): Promise<EditorOpenReco
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void> {
|
||||
async function selectEditorTarget(page: Page, targetId: string): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -66,6 +68,89 @@ async function expectEditorOpened(input: {
|
||||
}
|
||||
|
||||
test.describe("Workspace open in editor", () => {
|
||||
test("opens a nested folder in the preferred editor", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||
await rm(recordPath, { force: true });
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
editorTargets: [
|
||||
{
|
||||
id: "android-studio",
|
||||
label: "Android Studio",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
],
|
||||
editorRecordPath: recordPath,
|
||||
});
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-nested-editor-target-" });
|
||||
const childFolderName = "sample-android-app";
|
||||
const childFolderPath = path.join(workspace.repoPath, "repos", childFolderName);
|
||||
await mkdir(childFolderPath, { recursive: true });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "repos");
|
||||
await page.getByText(childFolderName, { exact: true }).click({ button: "right" });
|
||||
const openInEditor = page.getByTestId(/file-explorer-row-\d+-open-in-editor$/);
|
||||
await expect(openInEditor).toBeVisible({ timeout: 5_000 });
|
||||
await openInEditor.click();
|
||||
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "android-studio",
|
||||
path: childFolderPath,
|
||||
afterCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("selects an editor without opening it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||
await rm(recordPath, { force: true });
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
editorTargets: [
|
||||
{
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
{
|
||||
id: "zed",
|
||||
label: "Zed",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
],
|
||||
editorRecordPath: recordPath,
|
||||
});
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-editor-selection-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await selectEditorTarget(page, "zed");
|
||||
const primaryButton = page.getByTestId("workspace-open-in-editor-primary");
|
||||
await expect(primaryButton).toHaveAccessibleName("Open workspace in Zed");
|
||||
await expect(primaryButton).toBeEnabled();
|
||||
expect(await readEditorOpenRecords(recordPath)).toEqual([]);
|
||||
|
||||
await primaryButton.click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "zed",
|
||||
path: workspace.repoPath,
|
||||
afterCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the selected editor target after leaving and returning to the workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -97,7 +182,8 @@ test.describe("Workspace open in editor", () => {
|
||||
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await chooseEditorTarget(page, "vscode");
|
||||
await selectEditorTarget(page, "vscode");
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
|
||||
Reference in New Issue
Block a user