fix: move folder action into editor dropdown (#3904)

This commit is contained in:
José Almeida
2026-09-24 11:21:02 +01:00
committed by GitHub
parent c1ba43c64e
commit 6e0c81df18
20 changed files with 648 additions and 132 deletions
@@ -0,0 +1,80 @@
"use client";
import { useRef, type ReactNode } from "react";
import { IconChevronDown, IconFolderOpen, IconLoader2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { useTaskFolderAction } from "@/hooks/use-task-folder-action";
import { TaskFolderPicker } from "./task-folder-picker";
export function EditorActionsDropdown({
sessionId,
children,
}: {
sessionId: string | null;
children: ReactNode;
}) {
const { t } = useTranslation();
const action = useTaskFolderAction(sessionId);
const triggerRef = useRef<HTMLButtonElement>(null);
const selectedSessionRef = useRef<string | null>(null);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={triggerRef}
size="sm"
variant="outline"
className="rounded-none border-0 border-l px-2 cursor-pointer focus-visible:ring-inset"
data-testid="editors-menu-list"
aria-label={t("task:editorActions")}
disabled={!sessionId}
>
<IconChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
const selectedSession = selectedSessionRef.current;
selectedSessionRef.current = null;
if (!selectedSession || selectedSession !== sessionId) return;
event.preventDefault();
triggerRef.current?.focus();
action.open(triggerRef.current ?? undefined);
}}
>
{children}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={action.disabled}
onSelect={() => {
selectedSessionRef.current = sessionId;
}}
>
{action.isLoading ? (
<IconLoader2 className="size-4 animate-spin" aria-hidden />
) : (
<IconFolderOpen className="size-4" aria-hidden />
)}
{t("editors:openFolder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span role="status" className="sr-only">
{action.isLoading ? t("editors:openingFolder") : ""}
</span>
<TaskFolderPicker action={action} />
</>
);
}
@@ -1,16 +1,25 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { OpenTaskFolderButton } from "./open-task-folder-button";
import { EditorsMenu } from "./editors-menu";
import type { EditorOption } from "@/lib/types/http";
const fixtures = vi.hoisted(() => ({
folderOpeningAvailable: true,
editors: [] as EditorOption[],
openEditor: vi.fn(),
worktrees: [] as { id: string; repositoryId: string; path: string; branch: string }[],
openSessionFolder: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/hooks/domains/settings/use-editors", () => ({
useEditors: () => ({ folderOpeningAvailable: fixtures.folderOpeningAvailable }),
useEditors: () => ({
editors: fixtures.editors,
folderOpeningAvailable: fixtures.folderOpeningAvailable,
}),
}));
vi.mock("@/hooks/use-open-session-in-editor", () => ({
useOpenSessionInEditor: () => ({ open: fixtures.openEditor, isLoading: false }),
}));
vi.mock("@/lib/api", () => ({ openSessionFolder: fixtures.openSessionFolder }));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: fixtures.toast }) }));
@@ -19,18 +28,22 @@ vi.mock("@/hooks/domains/session/use-session-worktrees", () => ({
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (select: (state: unknown) => unknown) =>
select({ repositories: { itemsByWorkspaceId: {} } }),
select({
repositories: { itemsByWorkspaceId: {} },
userSettings: { defaultEditorId: "editor-1" },
}),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: false, isFinePointer: true }),
}));
const OPEN_FOLDER_LABEL = "Open folder";
const EDITOR_MENU_TRIGGER_TEST_ID = "editors-menu-list";
function view(sessionId: string | null) {
return (
<TooltipProvider>
<OpenTaskFolderButton sessionId={sessionId} />
<EditorsMenu activeSessionId={sessionId} embeddedVscodeSupported={false} />
</TooltipProvider>
);
}
@@ -38,16 +51,30 @@ function view(sessionId: string | null) {
beforeEach(() => {
vi.clearAllMocks();
fixtures.worktrees = [];
fixtures.editors = [];
fixtures.folderOpeningAvailable = true;
fixtures.openSessionFolder.mockResolvedValue({ success: true });
});
afterEach(cleanup);
// @covers AC-TASKS-OPEN-FOLDER-001.1, AC-TASKS-OPEN-FOLDER-001.2, AC-TASKS-OPEN-FOLDER-001.3
async function openMenu() {
fireEvent.keyDown(screen.getByTestId(EDITOR_MENU_TRIGGER_TEST_ID), { key: "ArrowDown" });
return screen.findByRole("menu");
}
async function chooseFolder() {
await openMenu();
fireEvent.click(screen.getByRole("menuitem", { name: OPEN_FOLDER_LABEL }));
await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
}
describe("task folder action", () => {
it("opens without any editor configuration", async () => {
render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
expect((screen.getByTestId(EDITOR_MENU_TRIGGER_TEST_ID) as HTMLButtonElement).disabled).toBe(
false,
);
await chooseFolder();
await waitFor(() =>
expect(fixtures.openSessionFolder).toHaveBeenCalledWith(
"s1",
@@ -58,14 +85,14 @@ describe("task folder action", () => {
});
it("disables opening without a session", () => {
render(view(null));
expect(
(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }) as HTMLButtonElement).disabled,
).toBe(true);
expect((screen.getByTestId(EDITOR_MENU_TRIGGER_TEST_ID) as HTMLButtonElement).disabled).toBe(
true,
);
});
it("sends the only worktree explicitly", async () => {
fixtures.worktrees = [{ id: "wt-1", repositoryId: "r1", path: "/repo-one", branch: "main" }];
render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
await chooseFolder();
await waitFor(() =>
expect(fixtures.openSessionFolder).toHaveBeenCalledWith(
"s1",
@@ -80,9 +107,9 @@ describe("task folder action", () => {
{ id: "wt-2", repositoryId: "r2", path: "/repo-two", branch: "feature" },
];
render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
await chooseFolder();
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /repo-two/ }));
fireEvent.click(await screen.findByRole("button", { name: /repo-two/ }));
await waitFor(() =>
expect(fixtures.openSessionFolder).toHaveBeenCalledWith(
"s1",
@@ -97,8 +124,8 @@ describe("task folder action", () => {
{ id: "wt-2", repositoryId: "r2", path: "/repo-two", branch: "feature" },
];
const { rerender } = render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
expect(screen.getByRole("dialog")).toBeTruthy();
await chooseFolder();
expect(await screen.findByRole("dialog")).toBeTruthy();
rerender(view("s2"));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
@@ -111,8 +138,8 @@ describe("task folder action", () => {
{ id: "wt-2", repositoryId: "r2", path: "/repo-two", branch: "feature" },
];
render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await chooseFolder();
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
});
@@ -124,26 +151,74 @@ describe("task folder action", () => {
}),
);
render(view("s1"));
const button = screen.getByRole("button", { name: OPEN_FOLDER_LABEL });
fireEvent.click(button);
expect((button as HTMLButtonElement).disabled).toBe(true);
await chooseFolder();
await openMenu();
expect(
screen.getByRole("menuitem", { name: OPEN_FOLDER_LABEL }).getAttribute("aria-disabled"),
).toBe("true");
await act(async () => {
finish({ success: true });
});
expect((button as HTMLButtonElement).disabled).toBe(false);
expect(
screen.getByRole("menuitem", { name: OPEN_FOLDER_LABEL }).getAttribute("aria-disabled"),
).not.toBe("true");
});
});
it("blocks the picker and requests when the host opener is missing", () => {
it("blocks the picker and requests when the host opener is missing", async () => {
fixtures.folderOpeningAvailable = false;
fixtures.worktrees = [
{ id: "wt-1", repositoryId: "r1", path: "/one", branch: "main" },
{ id: "wt-2", repositoryId: "r2", path: "/two", branch: "main" },
];
render(view("s1"));
const button = screen.getByRole("button", { name: OPEN_FOLDER_LABEL }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
await openMenu();
const button = screen.getByRole("menuitem", { name: OPEN_FOLDER_LABEL });
expect(button.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(button);
expect(screen.queryByRole("dialog")).toBeNull();
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
});
it("reports folder errors and permits retry without launching an editor", async () => {
fixtures.openSessionFolder.mockRejectedValueOnce(new Error("host failure"));
render(view("s1"));
await chooseFolder();
await waitFor(() =>
expect(fixtures.toast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Failed to open folder", variant: "error" }),
),
);
await chooseFolder();
await waitFor(() => expect(fixtures.openSessionFolder).toHaveBeenCalledTimes(2));
expect(fixtures.openEditor).not.toHaveBeenCalled();
});
it("keeps editor launching available when the folder opener is unavailable", async () => {
fixtures.folderOpeningAvailable = false;
fixtures.editors = [
{
id: "editor-1",
type: "editor",
name: "Test editor",
kind: "custom",
installed: true,
enabled: true,
},
];
render(view("s1"));
fireEvent.click(screen.getByTestId("editors-menu-open"));
expect(fixtures.openEditor).toHaveBeenCalledWith({ editorId: "editor-1", worktreeId: undefined });
await openMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Test editor" }));
expect(fixtures.openEditor).toHaveBeenCalledTimes(2);
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
});
// @covers AC-TASKS-OPEN-FOLDER-001.1
it("announces both editor and folder actions on the dropdown trigger", () => {
render(view("s1"));
expect(screen.getByRole("button", { name: "Editor and folder actions" })).toBe(
screen.getByTestId(EDITOR_MENU_TRIGGER_TEST_ID),
);
});
+16 -28
View File
@@ -1,7 +1,8 @@
"use client";
import { useMemo } from "react";
import { IconChevronDown, IconCode, IconLoader2 } from "@tabler/icons-react";
import { EditorActionsDropdown } from "./editor-actions-dropdown";
import { IconCode, IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
@@ -202,33 +203,20 @@ export function EditorsMenu({ activeSessionId, embeddedVscodeSupported }: Editor
worktreeOptions={worktreeOptions}
onOpen={(worktreeId) => openWith(resolvedEditorId, worktreeId)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 border-l px-2 cursor-pointer focus-visible:ring-inset"
data-testid="editors-menu-list"
disabled={!activeSessionId || enabledEditors.length === 0}
>
<IconChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{enabledEditors.length === 0 ? (
<DropdownMenuItem disabled>{t("task:noEditorsAvailable")}</DropdownMenuItem>
) : (
enabledEditors.map((editor: EditorOption) => (
<EditorMenuEntry
key={editor.id}
editor={editor}
worktreeOptions={worktreeOptions}
onOpen={openWith}
/>
))
)}
</DropdownMenuContent>
</DropdownMenu>
<EditorActionsDropdown sessionId={activeSessionId}>
{enabledEditors.length === 0 ? (
<DropdownMenuItem disabled>{t("task:noEditorsAvailable")}</DropdownMenuItem>
) : (
enabledEditors.map((editor: EditorOption) => (
<EditorMenuEntry
key={editor.id}
editor={editor}
worktreeOptions={worktreeOptions}
onOpen={openWith}
/>
))
)}
</EditorActionsDropdown>
</div>
);
}
@@ -1,46 +0,0 @@
"use client";
import { IconFolderOpen, IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTranslation } from "react-i18next";
import { useTaskFolderAction } from "@/hooks/use-task-folder-action";
import { TaskFolderPicker } from "./task-folder-picker";
export function OpenTaskFolderButton({ sessionId }: { sessionId: string | null }) {
const { t } = useTranslation();
const action = useTaskFolderAction(sessionId);
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={action.disabled ? 0 : -1} className="inline-flex">
<Button
variant="outline"
size="icon"
className="size-7 cursor-pointer"
aria-label={t("editors:openFolder")}
aria-busy={action.isLoading}
data-testid="open-task-folder"
disabled={action.disabled}
onClick={(event) => action.open(event.currentTarget)}
>
{action.isLoading ? (
<IconLoader2 className="size-4 animate-spin" aria-hidden />
) : (
<IconFolderOpen className="size-4" aria-hidden />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{sessionId ? t("editors:openFolderOnHost") : t("task:selectASessionToOpenIts")}
</TooltipContent>
</Tooltip>
<span role="status" className="sr-only">
{action.isLoading ? t("editors:openingFolder") : ""}
</span>
<TaskFolderPicker action={action} />
</>
);
}
@@ -8,7 +8,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { PageTopbar, type ParentCrumb } from "@/components/page-topbar";
import { useOfficeProject } from "@/hooks/use-office-workspace-data";
import { TaskTopBarTitle } from "@/components/task/task-top-bar-title";
import { OpenTaskFolderButton } from "@/components/task/open-task-folder-button";
import { EditorsMenu } from "@/components/task/editors-menu";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { TaskRightPanelsToggle } from "@/components/task/task-right-panels-toggle";
@@ -357,7 +356,6 @@ function TopbarToolsGroup({
activeSessionId={activeSessionId ?? null}
embeddedVscodeSupported={embeddedVscodeSupported ?? false}
/>
<OpenTaskFolderButton sessionId={activeSessionId ?? null} />
</div>
)}
{showDebugToggle && (
@@ -7,7 +7,7 @@ import {
import { SessionPage } from "../../pages/session-page";
// @covers AC-TASKS-OPEN-FOLDER-001.1, AC-TASKS-OPEN-FOLDER-001.3
test("task tools open the folder beside the IDE action and recover from errors", async ({
test("editor dropdown opens the folder without configured editors and recovers from errors", async ({
testPage,
apiClient,
seedData,
@@ -24,7 +24,9 @@ test("task tools open the folder beside the IDE action and recover from errors",
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await mockFolderAvailability(testPage, true);
await testPage.route("**/api/v1/editors", (route) =>
route.fulfill({ json: { editors: [], folder_opening_available: true } }),
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
@@ -39,22 +41,28 @@ test("task tools open the folder beside the IDE action and recover from errors",
json: attempts === 1 ? { error: "failed to open folder" } : { success: true },
});
});
const folder = testPage.getByTestId("open-task-folder");
await expect(testPage.getByTestId("open-task-folder")).toHaveCount(0);
await expect(testPage.getByTestId("editors-menu-open")).toBeDisabled();
const trigger = testPage.getByTestId("editors-menu-list");
await expect(trigger).toHaveAccessibleName("Editor and folder actions");
await expect(trigger).toBeEnabled();
expect((await trigger.boundingBox())!.height).toBeCloseTo(28, 0);
await trigger.click();
const folder = testPage.getByRole("menuitem", { name: "Open folder", exact: true });
await expect(folder).toBeVisible();
await expect(folder).toHaveAccessibleName("Open folder");
const editorBox = await testPage.getByTestId("editors-menu-list").boundingBox();
const folderBox = await folder.boundingBox();
expect(folderBox!.height).toBeCloseTo(28, 0);
expect(folderBox!.x).toBeGreaterThanOrEqual(editorBox!.x + editorBox!.width);
expect(folderBox!.x - editorBox!.x - editorBox!.width).toBeLessThan(20);
await testPage.screenshot({ path: test.info().outputPath("desktop-folder-shortcut.png") });
await testPage.screenshot({
path: test.info().outputPath("desktop-folder-menu.png"),
animations: "disabled",
});
await folder.click();
await expect(testPage.getByText("Failed to open folder", { exact: true })).toBeVisible();
await trigger.click();
await folder.click();
await expect.poll(() => attempts).toBe(2);
expect(
requests.every((url) => url.endsWith(`/task-sessions/${task.session_id}/open-folder`)),
).toBe(true);
await trigger.click();
await expect(folder).toBeEnabled();
});
@@ -99,17 +107,23 @@ test("desktop folder picker opens only the selected repository", async ({
payloads.push(route.request().postDataJSON());
await route.fulfill({ json: { success: true } });
});
const trigger = testPage.getByTestId("open-task-folder");
const trigger = testPage.getByTestId("editors-menu-list");
await trigger.focus();
await testPage.keyboard.press("Enter");
await testPage.getByRole("menuitem", { name: "Open folder", exact: true }).focus();
await testPage.keyboard.press("Enter");
const picker = testPage.getByRole("dialog", { name: "Choose a folder" });
await expect(picker).toBeVisible();
await testPage.screenshot({ path: test.info().outputPath("desktop-folder-picker.png") });
await testPage.screenshot({
path: test.info().outputPath("desktop-folder-picker.png"),
animations: "disabled",
});
await picker.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(picker).not.toBeVisible();
await expect(trigger).toBeFocused();
expect(payloads).toEqual([]);
await trigger.click();
await testPage.getByRole("menuitem", { name: "Open folder", exact: true }).click();
await picker.getByRole("button", { name: /Folder second repo/ }).click();
await expect.poll(() => payloads).toEqual([{ worktree_id: worktreeId }]);
await expect(picker).not.toBeVisible();
@@ -142,7 +156,8 @@ test("missing host folder opener disables the action before any picker opens", a
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
await expect(testPage.getByTestId("open-task-folder")).toBeDisabled();
await testPage.getByTestId("editors-menu-list").click();
await expect(testPage.getByRole("menuitem", { name: "Open folder", exact: true })).toBeDisabled();
await expect(testPage.getByRole("dialog", { name: "Choose a folder" })).not.toBeVisible();
expect(requests).toBe(0);
});
+2 -1
View File
@@ -2373,5 +2373,6 @@
"checkoutOptions.credentialsUnsupported": "Use Kandev-managed Git credentials for advanced checkout options.",
"toolPayloadRemovedAt": "Tool details removed on {{date}}.",
"toolPayloadRemoved": "Tool details removed.",
"openTaskSwitcherFor": "Open task switcher: {{title}}"
"openTaskSwitcherFor": "Open task switcher: {{title}}",
"editorActions": "Editor and folder actions"
}
+2 -1
View File
@@ -2373,5 +2373,6 @@
"checkoutOptions.credentialsUnsupported": "高度なチェックアウトオプションには、Kandev 管理の Git 認証情報を使って。",
"toolPayloadRemovedAt": "ツール詳細は {{date}} に削除済み。",
"toolPayloadRemoved": "ツール詳細は削除済み。",
"openTaskSwitcherFor": "タスク切替を開く: {{title}}"
"openTaskSwitcherFor": "タスク切替を開く: {{title}}",
"editorActions": "エディターとフォルダーの操作"
}
+2 -1
View File
@@ -2373,5 +2373,6 @@
"checkoutOptions.credentialsUnsupported": "Ũśē Ķàńďēv-ḿàńàĝēď Ĝĩţ ćŕēďēńţĩàĺś ƒōŕ àďvàńćēď ćĥēćķōũţ ōƥţĩōńś.",
"toolPayloadRemovedAt": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď ōń {{date}}.",
"toolPayloadRemoved": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď.",
"openTaskSwitcherFor": "Ōƥēń ţàśķ śŵĩţćĥēŕ: {{title}}"
"openTaskSwitcherFor": "Ōƥēń ţàśķ śŵĩţćĥēŕ: {{title}}",
"editorActions": "Ēďĩţōŕ àńď ƒōĺďēŕ àćţĩōńś"
}
+2 -1
View File
@@ -2373,5 +2373,6 @@
"checkoutOptions.credentialsUnsupported": "Utilize credenciais Git geridas pelo Kandev para as opções avançadas de obtenção.",
"toolPayloadRemovedAt": "Detalhes da ferramenta removidos em {{date}}.",
"toolPayloadRemoved": "Detalhes da ferramenta removidos.",
"openTaskSwitcherFor": "Abrir seletor de tarefas: {{title}}"
"openTaskSwitcherFor": "Abrir seletor de tarefas: {{title}}",
"editorActions": "Ações do editor e da pasta"
}
+2 -1
View File
@@ -2373,5 +2373,6 @@
"checkoutOptions.credentialsUnsupported": "请使用由 Kandev 管理的 Git 凭据启用高级检出选项。",
"toolPayloadRemovedAt": "工具详细数据已于 {{date}} 删除。",
"toolPayloadRemoved": "工具详细数据已删除。",
"openTaskSwitcherFor": "打开任务切换器:{{title}}"
"openTaskSwitcherFor": "打开任务切换器:{{title}}",
"editorActions": "编辑器和文件夹操作"
}
+2 -1
View File
@@ -2373,5 +2373,6 @@
"checkoutOptions.credentialsUnsupported": "請使用由 Kandev 管理的 Git 認證資料啓用高級檢出選項。",
"toolPayloadRemovedAt": "工具詳細數據已於 {{date}} 刪除。",
"toolPayloadRemoved": "工具詳細數據已刪除。",
"openTaskSwitcherFor": "開啟任務切換器:{{title}}"
"openTaskSwitcherFor": "開啟任務切換器:{{title}}",
"editorActions": "編輯器和資料夾操作"
}
+2 -1
View File
@@ -2373,5 +2373,6 @@
"checkoutOptions.credentialsUnsupported": "請使用由 Kandev 管理的 Git 認證資訊啟用高階檢出選項。",
"toolPayloadRemovedAt": "工具詳細資料已於 {{date}} 刪除。",
"toolPayloadRemoved": "工具詳細資料已刪除。",
"openTaskSwitcherFor": "開啟任務切換器:{{title}}"
"openTaskSwitcherFor": "開啟任務切換器:{{title}}",
"editorActions": "編輯器和資料夾操作"
}
+181
View File
@@ -0,0 +1,181 @@
---
created: 2026-09-22
status: implemented
requirements:
- REQ-TASKS-OPEN-FOLDER-001
system_design:
- ../../specs/tasks/system-design/open-task-folder.md
legacy_specs: []
---
# Implementation plan: Editor dropdown folder action
## Overview
One sequential work order relocates the standalone button added in PR #3784
(commit `32d640f26`) into the usual editor dropdown. Estimate: 45-90 minutes
including focused browser verification. Tasks owns this capability because the
selected task session determines the target workspace.
Confirmed intent: remove the added standalone control and expose folder opening
inside the editor dropdown. Keep existing native opening, host availability,
worktree selection, and Files-menu access. This is a targeted UI relocation, not
a wholesale revert of PR #3784. No unresolved product decisions.
## Scope
In scope: dropdown composition and focus, removal of unused button, migration of
its regression tests, desktop browser flow, phone regression and public usage copy.
Out of scope: backend changes, editor preferences, archived-task visibility,
remote mounting, and redesigning phone task navigation.
## Technical approach
Follow the [design](../../specs/tasks/system-design/open-task-folder.md).
Reuse the existing folder action and responsive picker. Decouple dropdown
availability from enabled-editor count while preserving primary editor behavior.
Extract a focused presentation component only if needed for component limits.
Update `docs/public/developer-tools.md` with the new menu path during implementation;
leave published instructions matching shipped behavior during this design turn.
No ADR: the existing ownership, transport and native-launch boundaries remain.
## ASCII UI preview
### UI-01: Desktop editor dropdown, expanded
Before: `[Open in editor | v] [Folder]`
```text
[Open in editor | v]
+------------------------+
| Existing editor entries|
|------------------------|
| Open folder |
+------------------------+
-> Choose a folder (multiple worktrees only)
```
### UI-02: Phone Files workspace actions, expanded
```text
[Files ...]
[Add repositories ]
[Open workspace folder ]
-> [Choose a folder ]
[repo-a / branch-a ]
[repo-b / branch-b ]
```
Structural requirements: no standalone toolbar folder button; folder row follows
editor choices, independently enabled. No editors: disabled editor primary action
and no-editors row, usable dropdown and available folder action. No session:
dropdown disabled. Unknown/unavailable opener or pending request: folder row disabled.
Opening has a localized live status; errors produce a retryable localized toast.
Desktop controls remain 28px. Phone retains its visible Files trigger and existing
inset menu/picker, 44px targets, one internal scroller and safe-area spacing; no
horizontal page overflow. This temporary choice uses the shipped
`MobilePickerSheet` exemplar. Menu closes before picker opens; dismissal restores
the persistent trigger. Icons and spacing are illustrative.
Maps to AC-TASKS-OPEN-FOLDER-001.1 through .4 and desktop/mobile folder E2E suites.
## Tests
Migrate `open-task-folder-button.test.tsx` coverage into `editors-menu.test.tsx`
before removing the old suite. Add failing tests for placement/removal, zero
editors, keyboard activation, host unavailability, busy state, errors/retry,
single/multiple worktrees, cancellation/focus restoration and session changes.
Preserve editor launching/default behavior. Use the hook suite for duplicate
suppression and payload failures (AC .1-.3, .5). Update all three desktop folder
browser scenarios to enter through the dropdown, add zero-editor coverage,
and assert absence of the separate control. Retain phone folder scenarios for
selection and unavailable hosts, with touch geometry and no-overflow checks
(AC .4). Stub native launch HTTP responses; do not open the host GUI in CI.
Inspect desktop and phone screenshots against UI-01/UI-02.
## Work orders
- [x] [Task 01: Relocate folder action](task-01-relocate-folder-action.md)
## Verification commands
From repository root; install workspace dependencies from `apps/` if missing.
The managed E2E runner builds the backend, Vite assets and plugin fixtures;
separate build commands are unnecessary. Ensure Go is on PATH and set
PLAYWRIGHT_BROWSERS_PATH to a writable installed browser cache when required.
```bash
(cd apps/web && pnpm exec vitest run components/task/editors-menu.test.tsx components/task/editors-menu-availability.test.ts components/task/editor-worktree-options.test.ts components/task/task-top-bar.test.tsx hooks/use-open-session-folder.test.ts components/task/file-browser-toolbar.test.tsx)
(cd apps/web && NODE_OPTIONS=--max-old-space-size=4096 pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm e2e:run tests/task/open-task-folder.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-open-task-folder.spec.ts)
node scripts/validate-public-docs.mjs
node --test scripts/validate-public-docs.test.mjs
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Verification results
Implemented on 2026-09-23.
- RED: the migrated test failed because the editor dropdown was disabled with zero
editors; the remaining folder-menu regressions failed because its folder entry
did not exist. The error/retry regression also failed before implementation.
- GREEN: `pnpm exec vitest run` with the six suites listed above passed 49 tests.
All eight original folder-button behaviors are retained in the migrated suite,
plus error/retry and independent editor-launch coverage.
- `NODE_OPTIONS=--max-old-space-size=4096 pnpm run typecheck` passed. The default
2 GiB heap exhausted memory; rerunning with 4 GiB resolved that environment limit.
- Changed-file ESLint passed for both editor components, the migrated test,
task topbar and desktop E2E spec.
- Managed E2E built the Go runtime/helpers, production Vite assets and plugin
fixture. Desktop folder suite passed 3/3; unchanged phone folder suite passed
2/2. Final runs reused those fresh artifacts with `--no-build`, Go on PATH,
and `PLAYWRIGHT_BROWSERS_PATH=/tmp/kandev-folder-browsers`.
- `node scripts/validate-public-docs.mjs` passed (47 pages), and its test file
passed 62 tests. Spec catalog validation (299 decisions, 1108 specifications),
full spec lint and diff whitespace checks passed.
### Localization baseline exception
`pnpm run i18n:check` fails on 32 missing Japanese keys: 22 `executors:sshReachability*`
and 10 `task:launchWarning*` entries. `git diff --quiet HEAD -- apps/web/src/locales`
confirms all catalogs are unchanged. This change reuses existing localized copy.
The remaining five i18n checks were run directly and passed: Trans indices,
inline plurals, module-scope translations, em dashes, and non-JSX copy.
This unrelated catalog failure is retained as an explicit validation exception.
### Browser evidence and limits
Screenshots retained in `/tmp/editor-folder-evidence/`: desktop folder menu,
desktop repository picker and mobile repository picker. Phone evidence shows
an inset sheet, contained 44px rows and no horizontal overflow. Desktop tests
verify the removed button, 28px dropdown trigger, menu reachability without
editors, selected-worktree payload, keyboard use, cancellation focus and retry.
Native opening is HTTP-stubbed; these tests do not assert Finder/Explorer window
visibility. No backend native-launch behavior changed.
## Risks
The editor-count guard can accidentally hide folder opening. Dropdown focus
restoration can dismiss a newly opened picker unless opening waits for menu close.
The original package records an unrelated source-attachment E2E baseline failure;
this package targets the folder suites. Native window visibility remains outside
HTTP-stubbed browser evidence.
## PR review remediation (2026-09-24)
Claude's inline finding and summary suggestion identified the editor-only
accessible name on a menu that also opens folders. The trigger now announces
"Editor and folder actions" through `task:editorActions` in all six locales and
the pseudo locale; Traditional Chinese values use the repository converter.
The new focused regression failed before the change.
Post-fix checks: `pnpm exec vitest run components/task/editors-menu.test.tsx`
passes 11 tests; changed-file ESLint passes; a fresh managed production build and
`pnpm e2e:run --host tests/task/open-task-folder.spec.ts` pass all 3 browser tests,
including the accessible-name assertion. The local full i18n check still reports
the previously recorded Japanese keys, which are already fixed on current main;
none of its findings concern the new label.
@@ -0,0 +1,186 @@
---
id: "01-relocate-folder-action"
title: "Relocate folder action into editor dropdown"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-OPEN-FOLDER-001
acceptance_criteria:
- AC-TASKS-OPEN-FOLDER-001.1
- AC-TASKS-OPEN-FOLDER-001.2
- AC-TASKS-OPEN-FOLDER-001.3
- AC-TASKS-OPEN-FOLDER-001.4
- AC-TASKS-OPEN-FOLDER-001.5
system_design:
- ../../specs/tasks/system-design/open-task-folder.md
---
# Task 01: Relocate folder action
## Summary
Replace the separate folder button with a folder item in the existing editor
menu. Reuse native opening and the responsive repository picker.
## In scope
Menu wiring/focus, removal of unused button, migrated and expanded regression
coverage, public usage copy and targeted verification.
## Out of scope
Backend/API changes, new native integrations, editor preferences and mobile
navigation redesign.
## Acceptance
- Folder opening is reachable through the editor dropdown with zero configured editors; no separate toolbar folder button remains.
- Existing worktree, unavailable/busy, failure/retry, cancellation and editor behavior pass focused regression tests, including phone Files access.
- Browser evidence matches the previews and public documentation names the new entry point.
## ASCII UI preview
See [combined preview](plan.md#ascii-ui-preview).
### UI-01: Desktop editor dropdown, expanded
Before: `[Open in editor | v] [Folder]`
```text
[Open in editor | v]
+------------------------+
| Existing editor entries|
|------------------------|
| Open folder |
+------------------------+
-> Choose a folder (multiple worktrees only)
```
### UI-02: Phone Files workspace actions, expanded
```text
[Files ...]
[Add repositories ]
[Open workspace folder ]
-> [Choose a folder ]
[repo-a / branch-a ]
[repo-b / branch-b ]
```
Structural requirements: no standalone toolbar folder button; folder row follows
editor choices, independently enabled. No editors: disabled editor primary action
and no-editors row, usable dropdown and available folder action. No session:
dropdown disabled. Unknown/unavailable opener or pending request: folder row disabled.
Opening has a localized live status; errors produce a retryable localized toast.
Desktop controls remain 28px. Phone retains its visible Files trigger and existing
inset menu/picker, 44px targets, one internal scroller and safe-area spacing; no
horizontal page overflow. This temporary choice uses the shipped
`MobilePickerSheet` exemplar. Menu closes before picker opens; dismissal restores
the persistent trigger. Icons and spacing are illustrative.
Maps to AC-TASKS-OPEN-FOLDER-001.1 through .4 and desktop/mobile folder E2E suites.
## Verification
Use TDD: migrate/add failing regression tests before changing production files.
Install dependencies from `apps/` if missing. The managed E2E runner builds
the backend, Vite assets and plugin fixtures. Ensure Go is on PATH and point
PLAYWRIGHT_BROWSERS_PATH at an installed browser cache. Run from repository root:
```bash
(cd apps/web && pnpm exec vitest run components/task/editors-menu.test.tsx components/task/editors-menu-availability.test.ts components/task/editor-worktree-options.test.ts components/task/task-top-bar.test.tsx hooks/use-open-session-folder.test.ts components/task/file-browser-toolbar.test.tsx)
(cd apps/web && NODE_OPTIONS=--max-old-space-size=4096 pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm e2e:run tests/task/open-task-folder.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-open-task-folder.spec.ts)
node scripts/validate-public-docs.mjs
node --test scripts/validate-public-docs.test.mjs
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Files likely touched
- `apps/web/components/task/editors-menu.tsx`, new `editor-actions-dropdown.tsx`, and `editors-menu.test.tsx`.
- `apps/web/components/task/task-top-bar.tsx` and its test.
- Remove `apps/web/components/task/open-task-folder-button.tsx` and migrate its test coverage before deleting its test file.
- `apps/web/e2e/tests/task/open-task-folder.spec.ts`; retain `mobile-open-task-folder.spec.ts`, updating only if regression evidence requires it.
- `docs/public/developer-tools.md`; locale catalogs only if existing localized copy cannot be reused.
## Dependencies
None. Shared `useTaskFolderAction`, `TaskFolderPicker`, and folder transport are shipped.
## Risks
Avoid focusing removed menu items or gating folder access on editor configuration.
Preserve independent editor/folder loading and per-session request suppression.
## Parallelism
`sequential`
## Inputs
[Requirements](../../specs/tasks/requirements/open-task-folder.md),
[design](../../specs/tasks/system-design/open-task-folder.md),
`WorkspaceActionsMenu` close-to-picker pattern in `file-browser-toolbar.tsx`,
and existing folder button/unit and desktop/phone E2E tests.
## Results
Implemented on 2026-09-23.
- RED: the migrated test failed because the editor dropdown was disabled with zero
editors; the remaining folder-menu regressions failed because its folder entry
did not exist. The error/retry regression also failed before implementation.
- GREEN: `pnpm exec vitest run` with the six suites listed above passed 49 tests.
All eight original folder-button behaviors are retained in the migrated suite,
plus error/retry and independent editor-launch coverage.
- `NODE_OPTIONS=--max-old-space-size=4096 pnpm run typecheck` passed. The default
2 GiB heap exhausted memory; rerunning with 4 GiB resolved that environment limit.
- Changed-file ESLint passed for both editor components, the migrated test,
task topbar and desktop E2E spec.
- Managed E2E built the Go runtime/helpers, production Vite assets and plugin
fixture. Desktop folder suite passed 3/3; unchanged phone folder suite passed
2/2. Final runs reused those fresh artifacts with `--no-build`, Go on PATH,
and `PLAYWRIGHT_BROWSERS_PATH=/tmp/kandev-folder-browsers`.
- `node scripts/validate-public-docs.mjs` passed (47 pages), and its test file
passed 62 tests. Spec catalog validation (299 decisions, 1108 specifications),
full spec lint and diff whitespace checks passed.
### Localization baseline exception
`pnpm run i18n:check` fails on 32 missing Japanese keys: 22 `executors:sshReachability*`
and 10 `task:launchWarning*` entries. `git diff --quiet HEAD -- apps/web/src/locales`
confirms all catalogs are unchanged. This change reuses existing localized copy.
The remaining five i18n checks were run directly and passed: Trans indices,
inline plurals, module-scope translations, em dashes, and non-JSX copy.
This unrelated catalog failure is retained as an explicit validation exception.
### Browser evidence and limits
Screenshots retained in `/tmp/editor-folder-evidence/`: desktop folder menu,
desktop repository picker and mobile repository picker. Phone evidence shows
an inset sheet, contained 44px rows and no horizontal overflow. Desktop tests
verify the removed button, 28px dropdown trigger, menu reachability without
editors, selected-worktree payload, keyboard use, cancellation focus and retry.
Native opening is HTTP-stubbed; these tests do not assert Finder/Explorer window
visibility. No backend native-launch behavior changed.
## PR review remediation (2026-09-24)
Claude's inline finding and summary suggestion identified the editor-only
accessible name on a menu that also opens folders. The trigger now announces
"Editor and folder actions" through `task:editorActions` in all six locales and
the pseudo locale; Traditional Chinese values use the repository converter.
The new focused regression failed before the change.
Post-fix checks: `pnpm exec vitest run components/task/editors-menu.test.tsx`
passes 11 tests; changed-file ESLint passes; a fresh managed production build and
`pnpm e2e:run --host tests/task/open-task-folder.spec.ts` pass all 3 browser tests,
including the accessible-name assertion. The local full i18n check still reports
the previously recorded Japanese keys, which are already fixed on current main;
none of its findings concern the new label.
+6
View File
@@ -172,3 +172,9 @@ CI remediation: the desktop source-attachment suite also assumes an installed
folder opener. It now explicitly stubs capability and native opening, matching
the phone fixture and preserving real backend discovery tests. Both previously
failing browser cases pass locally (2/2); targeted lint and typecheck pass.
## Follow-up relocation (2026-09-22)
The [editor dropdown package](../editor-dropdown-folder/plan.md) supersedes this
package's standalone desktop button placement. This completed record and its test
results describe PR #3784; they are not validation of the pending relocation.
@@ -201,3 +201,9 @@ CI remediation: the desktop source-attachment suite also assumes an installed
folder opener. It now explicitly stubs capability and native opening, matching
the phone fixture and preserving real backend discovery tests. Both previously
failing browser cases pass locally (2/2); targeted lint and typecheck pass.
## Follow-up relocation (2026-09-22)
The [editor dropdown package](../editor-dropdown-folder/plan.md) supersedes this
package's standalone desktop button placement. This completed record and its test
results describe PR #3784; they are not validation of the pending relocation.
+1 -1
View File
@@ -205,7 +205,7 @@ re-picks their engine and language: preferences are not carried over.
## Files and editor integrations
To open a task folder in your file manager, select **Open folder** beside the IDE control in the task toolbar. On a phone, use **Files → Workspace actions → Open workspace folder**. For tasks with several worktrees, choose the repository and branch to open.
To open a task folder in your file manager, open the **Open in editor** dropdown in the task toolbar and select **Open folder**. On a phone, use **Files → Workspace actions → Open workspace folder**. For tasks with several worktrees, choose the repository and branch to open.
This opens Finder on macOS, the default file manager on Linux, or Explorer on Windows on the machine running Kandev. A browser connected to a remote Kandev instance does not open that folder on your own device. The host needs a desktop session and an available file manager.
@@ -10,7 +10,7 @@ owners:
## Overview
Expose the existing native folder-opening capability beside the task's IDE control.
Expose the existing native folder-opening capability inside the task's Open in editor dropdown.
Tasks owns this shortcut because its target is the selected task session's workspace.
The existing workspace-actions contract remains in
[Attach workspace sources](attach-workspace-sources.md).
@@ -23,7 +23,7 @@ The existing workspace-actions contract remains in
#### Acceptance criteria
- **AC-TASKS-OPEN-FOLDER-001.1:** For an unarchived task with a selected session, desktop task tools shall expose an accessible folder action immediately beside Open in IDE, independent of editor configuration. With one worktree, activation shall open that worktree; with no worktrees, existing repository-root fallback shall apply.
- **AC-TASKS-OPEN-FOLDER-001.1:** For an unarchived task with a selected session, desktop task tools shall expose an accessible Open folder action inside the Open in editor dropdown, independent of editor configuration. There shall be no separate task-toolbar folder button. The dropdown shall remain reachable with no configured editors; its primary editor action shall retain its existing enablement. With one worktree, activation shall open that worktree; with no worktrees, existing repository-root fallback shall apply.
- **AC-TASKS-OPEN-FOLDER-001.2:** With multiple session worktrees, the action shall offer repository/branch choices and open only the selected worktree. An invalid selection shall fail without opening a different directory.
- **AC-TASKS-OPEN-FOLDER-001.3:** Without a selected session or an installed host folder-opening executable, the shortcut and Files-menu action shall be disabled and shall not open a picker. Unknown or failed capability discovery shall also disable opening. While opening, it shall prevent repeat activation and show progress. Failed requests shall show a localized error and permit retry. Opening shall not launch an agent or modify task/editor preferences.
- **AC-TASKS-OPEN-FOLDER-001.4:** Phone users shall retain a visible Files-menu folder action with the same selection and error behavior, reachable through touch targets of at least 44px, without horizontal page overflow. Desktop controls shall retain 28px sizing. Labels and errors shall be localized.
@@ -36,4 +36,5 @@ new editor settings, arbitrary file/path selection, and changing archived-task t
## Implementation plans
- [Open task folder](../../../plans/open-task-folder/plan.md)
- [Editor dropdown folder action](../../../plans/editor-dropdown-folder/plan.md) (dropdown relocation)
- [Open task folder](../../../plans/open-task-folder/plan.md) (original delivery)
@@ -9,7 +9,7 @@ requirements:
## Purpose and boundaries
Add a discoverable session shortcut using the existing editors service folder operation.
Consolidate the session folder action into the editor dropdown using the existing editors service folder operation.
This extends task workspace access; it introduces no persistence or new OS integration.
See [requirements](../requirements/open-task-folder.md) and
[workspace actions](attach-workspace-sources.md).
@@ -22,13 +22,28 @@ See [requirements](../requirements/open-task-folder.md) and
## Components
`apps/web/components/task/task-top-bar.tsx` renders `EditorsMenu` in
`TopbarToolsGroup`. Add an independent folder control immediately after `EditorsMenu`,
inside the existing unarchived-task branch. Do not tie it to enabled editors.
Use `open-task-folder-button.tsx` and shared `useTaskFolderAction` for selection
and opener focus. The existing `useOpenSessionFolder` owns native-launch requests,
immediate duplicate-request suppression, loading state, and localized errors. Reuse `buildWorktreeOptions` and `useSessionWorktrees`
for repository/branch labels rather than duplicating target resolution.
`apps/web/components/task/task-top-bar.tsx` keeps `EditorsMenu` in
`TopbarToolsGroup`, inside the existing unarchived-task branch. The standalone
folder button is removed.
`apps/web/components/task/editors-menu.tsx` delegates the dropdown shell to
`EditorActionsDropdown` in `editor-actions-dropdown.tsx`, which renders a separated
Open folder row after the editor entries. Reuse `useTaskFolderAction` and `TaskFolderPicker`; retain
`useOpenSessionFolder` for requests, per-session duplicate suppression, loading,
and localized errors. Folder opening never changes the default editor.
The dropdown trigger requires a session, not a configured editor. With zero
editors, retain the disabled no-editors row and primary editor button while
allowing access to the folder row. Disable the folder row using the shared
folder action's capability/loading state; it must not disable editor entries.
The dropdown trigger uses the localized `task:editorActions` name, explicitly
announcing both editor and folder actions.
Close the dropdown before opening the folder picker. Follow
`WorkspaceActionsMenu` in `file-browser-toolbar.tsx`: queue selection in a ref,
activate from `onCloseAutoFocus`, and pass the persistent dropdown trigger to
`action.open`. Cancel restores that trigger, never an unmounted menu item.
Mount the picker outside dropdown content. Reuse `buildWorktreeOptions` and
`useSessionWorktrees` for labels and selected-session target resolution.
The phone Files workspace-actions menu already offers Open workspace folder.
Reuse that entry and its responsive menu treatment; route multiple-worktree choices
@@ -62,7 +77,7 @@ Remote/headless host limitations remain those of the existing action.
## Presentation and failures
Reuse translated folder copy when possible, adding a localized host-location hint
where needed. The control has an accessible name, keyboard activation, tooltip,
where needed. The dropdown trigger and folder row have accessible names and keyboard activation,
loading indicator, and disabled no-session/loading/unavailable states. Pending
folder launches are shared per session across all controls and clear only when
the initiating request settles; another session remains independent. Catch rejected requests
@@ -95,8 +110,12 @@ items. The shared editors store retains the capability; loaded editor items with
an unknown capability still trigger discovery. Each effect reads the live store
before claiming discovery so simultaneous consumers share one request/retry chain. Transient failure gets one delayed
retry while controls remain disabled; repeated failure settles to false. Unknown,
failed, or false discovery disables folder-opening controls in the task toolbar,
failed, or false discovery disables the folder row in the editor dropdown,
Files menu, and `file-actions-dropdown.tsx`, plus `useOpenSessionFolder`. Editor
entries retain their existing `editors` and `useOpenSessionInEditor` behavior.
No desktop dialog or mobile bottom sheet opens while unavailable. This checks
executable installation, like IDE discovery; launch-time failures still show errors.
## Implementation plan
[Editor dropdown folder action](../../../plans/editor-dropdown-folder/plan.md) records the implemented relocation. The original plan preserves historical validation evidence.