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": "編輯器和資料夾操作"
}