feat(web): open the IDE at a file tree node's path (#2074)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
fetchAndOpenFile,
|
||||
} from "./file-browser-hooks";
|
||||
import { resolveFileBrowserPaths } from "./file-browser-path";
|
||||
import { FileTreeEditorProvider } from "./file-tree-editor-menu";
|
||||
import { getVisiblePaths, moveNodesInTree, computeMoveTargets } from "./file-tree-utils";
|
||||
|
||||
type FileBrowserHeaderProps = {
|
||||
@@ -585,39 +586,41 @@ export function FileBrowser({
|
||||
});
|
||||
const { openFolder, copied, copyPath, search, treeState, fullPath, displayPath } = data;
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full"
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
onMouseDown={handleClickOutside}
|
||||
>
|
||||
<FileBrowserHeader
|
||||
treeLoaded={Boolean(treeState.tree && treeState.loadState === "loaded")}
|
||||
search={search}
|
||||
displayPath={displayPath}
|
||||
fullPath={fullPath}
|
||||
copied={copied}
|
||||
expandedPathsSize={treeState.expandedPaths.size}
|
||||
onCopyPath={copyPath}
|
||||
onStartCreate={onCreateFile ? handlers.handleStartCreate : undefined}
|
||||
onOpenFolder={openFolder}
|
||||
onCollapseAll={treeState.collapseAll}
|
||||
showCreateButton={Boolean(onCreateFile)}
|
||||
onAddSources={onAddSources}
|
||||
addSourcesButtonRef={addSourcesButtonRef}
|
||||
addSourcesDisabledReason={addSourcesDisabledReason}
|
||||
/>
|
||||
<FileBrowserTreeContent
|
||||
scrollAreaRef={scrollAreaRef}
|
||||
data={data}
|
||||
handlers={handlers}
|
||||
multiSelect={multiSelect}
|
||||
dnd={dnd}
|
||||
activeFilePath={activeFilePath}
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</div>
|
||||
<FileTreeEditorProvider sessionId={sessionId} treeRootName={treeState.tree?.name}>
|
||||
<div
|
||||
className="flex flex-col h-full"
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
onMouseDown={handleClickOutside}
|
||||
>
|
||||
<FileBrowserHeader
|
||||
treeLoaded={Boolean(treeState.tree && treeState.loadState === "loaded")}
|
||||
search={search}
|
||||
displayPath={displayPath}
|
||||
fullPath={fullPath}
|
||||
copied={copied}
|
||||
expandedPathsSize={treeState.expandedPaths.size}
|
||||
onCopyPath={copyPath}
|
||||
onStartCreate={onCreateFile ? handlers.handleStartCreate : undefined}
|
||||
onOpenFolder={openFolder}
|
||||
onCollapseAll={treeState.collapseAll}
|
||||
showCreateButton={Boolean(onCreateFile)}
|
||||
onAddSources={onAddSources}
|
||||
addSourcesButtonRef={addSourcesButtonRef}
|
||||
addSourcesDisabledReason={addSourcesDisabledReason}
|
||||
/>
|
||||
<FileBrowserTreeContent
|
||||
scrollAreaRef={scrollAreaRef}
|
||||
data={data}
|
||||
handlers={handlers}
|
||||
multiSelect={multiSelect}
|
||||
dnd={dnd}
|
||||
activeFilePath={activeFilePath}
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</div>
|
||||
</FileTreeEditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
treeContainsPath,
|
||||
countFilesInTree,
|
||||
} from "./file-tree-utils";
|
||||
import {
|
||||
OpenInEditorMenuItems,
|
||||
canOpenNodeInEditor,
|
||||
useFileTreeEditorActions,
|
||||
} from "./file-tree-editor-menu";
|
||||
|
||||
type GitFileStatus = FileInfo["status"] | undefined;
|
||||
|
||||
@@ -169,8 +174,12 @@ export function FileContextMenu({
|
||||
selectedPaths?: Set<string>;
|
||||
}) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const editorActions = useFileTreeEditorActions();
|
||||
const isBulk = selectedCount > 1;
|
||||
const needsConfirmation = node.is_dir || isBulk;
|
||||
// A bulk selection would make a single-node "Open in <editor>" ambiguous.
|
||||
const showOpenInEditor = !isBulk && canOpenNodeInEditor(editorActions, node);
|
||||
const hasFileActions = !!onDeleteFile || !!onRenameFile || !!onDownloadFile;
|
||||
|
||||
const handleConfirmDelete = useCallback(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
@@ -204,13 +213,15 @@ export function FileContextMenu({
|
||||
}
|
||||
}, [tree, setTree, node.path, onDeleteFile, needsConfirmation]);
|
||||
|
||||
if (!onDeleteFile && !onRenameFile && !onDownloadFile) return <>{children}</>;
|
||||
if (!hasFileActions && !showOpenInEditor) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{showOpenInEditor && <OpenInEditorMenuItems node={node} />}
|
||||
{showOpenInEditor && hasFileActions && <ContextMenuSeparator />}
|
||||
<FileContextMenuItems
|
||||
node={node}
|
||||
isBulk={isBulk}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileTreeNode } from "@/lib/types/backend";
|
||||
import type { EditorOption } from "@/lib/types/http";
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { FileContextMenu } from "./file-context-menu";
|
||||
import { FileTreeEditorContext, type FileTreeEditorActions } from "./file-tree-editor-menu";
|
||||
|
||||
const FILE_NODE: FileTreeNode = {
|
||||
name: "README.md",
|
||||
path: "apps/web/README.md",
|
||||
is_dir: false,
|
||||
size: 0,
|
||||
};
|
||||
const DIR_NODE: FileTreeNode = { name: "web", path: "apps/web", is_dir: true, size: 0 };
|
||||
const VSCODE_ID = "vscode";
|
||||
const OPEN_ITEM = "file-tree-open-in-editor";
|
||||
|
||||
function editor(id: string, name: string): EditorOption {
|
||||
return { id, type: id, name, kind: "built_in", installed: true, enabled: true };
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderMenu({
|
||||
node = FILE_NODE,
|
||||
actions,
|
||||
selectedCount,
|
||||
}: {
|
||||
node?: FileTreeNode;
|
||||
actions: FileTreeEditorActions | null;
|
||||
selectedCount?: number;
|
||||
}) {
|
||||
render(
|
||||
<FileTreeEditorContext.Provider value={actions}>
|
||||
<FileContextMenu
|
||||
node={node}
|
||||
tree={null}
|
||||
setTree={() => {}}
|
||||
onDeleteFile={vi.fn().mockResolvedValue(true)}
|
||||
onStartRename={() => {}}
|
||||
selectedCount={selectedCount}
|
||||
selectedPaths={selectedCount ? new Set(["a", "b", "c"]) : undefined}
|
||||
>
|
||||
<div data-testid="file-row">row</div>
|
||||
</FileContextMenu>
|
||||
</FileTreeEditorContext.Provider>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("file-row"));
|
||||
}
|
||||
|
||||
function actionsWith(editors: EditorOption[], defaultEditorId: string) {
|
||||
const openInEditor = vi.fn();
|
||||
const resolveTarget = vi.fn(() => ({ filePath: "resolved" }));
|
||||
return {
|
||||
actions: { editors, defaultEditorId, resolveTarget, openInEditor },
|
||||
openInEditor,
|
||||
resolveTarget,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Open in editor context menu items", () => {
|
||||
it("opens the node path in the default editor", () => {
|
||||
const { actions, openInEditor } = actionsWith([editor(VSCODE_ID, "VS Code")], VSCODE_ID);
|
||||
renderMenu({ actions });
|
||||
|
||||
fireEvent.click(screen.getByTestId(OPEN_ITEM));
|
||||
|
||||
expect(openInEditor).toHaveBeenCalledWith(FILE_NODE, VSCODE_ID);
|
||||
});
|
||||
|
||||
it("offers directories the same open action", () => {
|
||||
const { actions, openInEditor } = actionsWith([editor(VSCODE_ID, "VS Code")], VSCODE_ID);
|
||||
renderMenu({ node: DIR_NODE, actions });
|
||||
|
||||
fireEvent.click(screen.getByTestId(OPEN_ITEM));
|
||||
|
||||
expect(openInEditor).toHaveBeenCalledWith(DIR_NODE, VSCODE_ID);
|
||||
});
|
||||
|
||||
it("labels the primary item with the default editor and lists the rest in a submenu", () => {
|
||||
const { actions } = actionsWith(
|
||||
[editor(VSCODE_ID, "VS Code"), editor("cursor", "Cursor")],
|
||||
"cursor",
|
||||
);
|
||||
renderMenu({ actions });
|
||||
|
||||
expect(screen.getByTestId(OPEN_ITEM).textContent).toContain("Open in Cursor");
|
||||
expect(screen.getByTestId("file-tree-open-in-other-editor")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the submenu when only one editor is available", () => {
|
||||
const { actions } = actionsWith([editor(VSCODE_ID, "VS Code")], VSCODE_ID);
|
||||
renderMenu({ actions });
|
||||
|
||||
expect(screen.queryByTestId("file-tree-open-in-other-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the open action when no editors are available", () => {
|
||||
renderMenu({
|
||||
actions: {
|
||||
editors: [],
|
||||
defaultEditorId: "",
|
||||
resolveTarget: () => ({ filePath: "resolved" }),
|
||||
openInEditor: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId(OPEN_ITEM)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the open action for a node that resolves to no worktree", () => {
|
||||
const { actions } = actionsWith([editor(VSCODE_ID, "VS Code")], VSCODE_ID);
|
||||
renderMenu({ actions: { ...actions, resolveTarget: () => null } });
|
||||
|
||||
expect(screen.queryByTestId(OPEN_ITEM)).toBeNull();
|
||||
expect(screen.getByText("Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the open action outside a file browser that provides editors", () => {
|
||||
renderMenu({ actions: null });
|
||||
|
||||
expect(screen.queryByTestId(OPEN_ITEM)).toBeNull();
|
||||
expect(screen.getByText("Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the open action while a bulk selection is active", () => {
|
||||
const { actions } = actionsWith([editor(VSCODE_ID, "VS Code")], VSCODE_ID);
|
||||
renderMenu({ actions, selectedCount: 3 });
|
||||
|
||||
expect(screen.queryByTestId(OPEN_ITEM)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { IconCode } from "@tabler/icons-react";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import { useEditors } from "@/hooks/domains/settings/use-editors";
|
||||
import { useOpenSessionInEditor } from "@/hooks/use-open-session-in-editor";
|
||||
import { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { EditorOption } from "@/lib/types/http";
|
||||
import type { FileTreeNode } from "@/lib/types/backend";
|
||||
import {
|
||||
getAvailableTaskTopbarEditors,
|
||||
resolveTaskTopbarEditorId,
|
||||
} from "@/components/task/editors-menu-availability";
|
||||
import { resolveFileTreeEditorTarget, type FileTreeEditorTarget } from "./file-tree-editor-target";
|
||||
|
||||
export type FileTreeEditorActions = {
|
||||
editors: EditorOption[];
|
||||
defaultEditorId: string;
|
||||
/** `null` when the node has no worktree the editors API can resolve against. */
|
||||
resolveTarget: (node: FileTreeNode) => FileTreeEditorTarget | null;
|
||||
openInEditor: (node: FileTreeNode, editorId: string) => void;
|
||||
};
|
||||
|
||||
export const FileTreeEditorContext = createContext<FileTreeEditorActions | null>(null);
|
||||
|
||||
export function useFileTreeEditorActions(): FileTreeEditorActions | null {
|
||||
return useContext(FileTreeEditorContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the Files panel context menus with the session's available editors
|
||||
* and an opener that resolves a tree node path to the editors API's
|
||||
* `{worktreeId, filePath}` pair. Provided once per browser rather than per row
|
||||
* so every tree node shares a single set of editor subscriptions.
|
||||
*/
|
||||
export function FileTreeEditorProvider({
|
||||
sessionId,
|
||||
treeRootName,
|
||||
children,
|
||||
}: {
|
||||
sessionId: string;
|
||||
/** Name of the tree's root directory, used to tell which root it is served from. */
|
||||
treeRootName?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { editors } = useEditors();
|
||||
const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId);
|
||||
const worktrees = useSessionWorktrees(sessionId);
|
||||
// Published by the task page from the session status; the Files panel is
|
||||
// rendered inside the dock layout and cannot reach that state directly.
|
||||
const embeddedVscodeSupported = useAppStore(
|
||||
(state) => state.embeddedVscodeSupport.bySessionId[sessionId] ?? false,
|
||||
);
|
||||
const openEditor = useOpenSessionInEditor(sessionId);
|
||||
|
||||
// `open` is a fresh closure on every render; keep the context value stable by
|
||||
// calling through a ref instead of depending on it directly.
|
||||
const openRef = useRef(openEditor.open);
|
||||
useEffect(() => {
|
||||
openRef.current = openEditor.open;
|
||||
});
|
||||
|
||||
const availableEditors = useMemo(
|
||||
() => getAvailableTaskTopbarEditors(editors, embeddedVscodeSupported),
|
||||
[editors, embeddedVscodeSupported],
|
||||
);
|
||||
|
||||
const resolveTarget = useCallback(
|
||||
(node: FileTreeNode) => resolveFileTreeEditorTarget(node.path, worktrees, treeRootName),
|
||||
[worktrees, treeRootName],
|
||||
);
|
||||
|
||||
const openInEditor = useCallback(
|
||||
(node: FileTreeNode, editorId: string) => {
|
||||
const target = resolveTarget(node);
|
||||
if (!editorId || !target) return;
|
||||
void openRef.current({
|
||||
editorId,
|
||||
filePath: target.filePath,
|
||||
worktreeId: target.worktreeId,
|
||||
isDirectory: node.is_dir,
|
||||
});
|
||||
},
|
||||
[resolveTarget],
|
||||
);
|
||||
|
||||
const value = useMemo<FileTreeEditorActions>(
|
||||
() => ({
|
||||
editors: availableEditors,
|
||||
defaultEditorId: resolveTaskTopbarEditorId(defaultEditorId, availableEditors),
|
||||
resolveTarget,
|
||||
openInEditor,
|
||||
}),
|
||||
[availableEditors, defaultEditorId, resolveTarget, openInEditor],
|
||||
);
|
||||
|
||||
return <FileTreeEditorContext.Provider value={value}>{children}</FileTreeEditorContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Open in <editor>" entries for a single tree node, plus a submenu with the
|
||||
* remaining editors when more than one is available.
|
||||
*/
|
||||
export function OpenInEditorMenuItems({ node }: { node: FileTreeNode }) {
|
||||
const actions = useFileTreeEditorActions();
|
||||
if (!actions) return null;
|
||||
const { editors, defaultEditorId, openInEditor } = actions;
|
||||
const primary = editors.find((editor) => editor.id === defaultEditorId);
|
||||
if (!primary) return null;
|
||||
const others = editors.filter((editor) => editor.id !== primary.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
data-testid="file-tree-open-in-editor"
|
||||
onSelect={() => openInEditor(node, primary.id)}
|
||||
>
|
||||
<IconCode className="h-3.5 w-3.5" />
|
||||
Open in {primary.name}
|
||||
</ContextMenuItem>
|
||||
{others.length > 0 && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger data-testid="file-tree-open-in-other-editor">
|
||||
Open in other editor
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{others.map((editor) => (
|
||||
<ContextMenuItem key={editor.id} onSelect={() => openInEditor(node, editor.id)}>
|
||||
{editor.name}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function canOpenNodeInEditor(
|
||||
actions: FileTreeEditorActions | null,
|
||||
node: FileTreeNode,
|
||||
): boolean {
|
||||
if (!actions) return false;
|
||||
if (!actions.editors.some((editor) => editor.id === actions.defaultEditorId)) return false;
|
||||
return actions.resolveTarget(node) !== null;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EditorOption } from "@/lib/types/http";
|
||||
|
||||
// vi.mock factories are registered above module initialization, so the fixtures
|
||||
// they close over are hoisted too. Mutable state lives on a property so a test
|
||||
// can switch it after the factory has captured the container.
|
||||
const fixtures = vi.hoisted(() => ({
|
||||
editors: [
|
||||
{
|
||||
id: "vscode",
|
||||
type: "vscode",
|
||||
name: "VS Code",
|
||||
kind: "built_in",
|
||||
installed: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "embedded",
|
||||
type: "internal_vscode",
|
||||
name: "VS Code (Embedded)",
|
||||
kind: "internal_vscode",
|
||||
installed: true,
|
||||
enabled: true,
|
||||
},
|
||||
] as EditorOption[],
|
||||
embeddedVscodeSupported: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/settings/use-editors", () => ({
|
||||
useEditors: () => ({ editors: fixtures.editors, loaded: true, loading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/session/use-session-worktrees", () => ({
|
||||
useSessionWorktrees: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-open-session-in-editor", () => ({
|
||||
useOpenSessionInEditor: () => ({ open: vi.fn(), status: "idle", isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
userSettings: { defaultEditorId: "vscode" },
|
||||
embeddedVscodeSupport: { bySessionId: { "session-1": fixtures.embeddedVscodeSupported } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FileTreeEditorProvider, useFileTreeEditorActions } from "./file-tree-editor-menu";
|
||||
|
||||
function Probe({ onEditors }: { onEditors: (names: string[]) => void }) {
|
||||
const actions = useFileTreeEditorActions();
|
||||
onEditors((actions?.editors ?? []).map((editor) => editor.name));
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderProvider() {
|
||||
const seen: string[][] = [];
|
||||
render(
|
||||
<FileTreeEditorProvider sessionId="session-1" treeRootName="repo">
|
||||
<Probe onEditors={(names) => seen.push(names)} />
|
||||
</FileTreeEditorProvider>,
|
||||
);
|
||||
return seen[seen.length - 1];
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("FileTreeEditorProvider embedded-editor availability", () => {
|
||||
it("offers the embedded editor when the session's executor supports it", () => {
|
||||
fixtures.embeddedVscodeSupported = true;
|
||||
|
||||
expect(renderProvider()).toEqual(["VS Code", "VS Code (Embedded)"]);
|
||||
});
|
||||
|
||||
it("omits the embedded editor when the session's executor does not support it", () => {
|
||||
fixtures.embeddedVscodeSupported = false;
|
||||
|
||||
expect(renderProvider()).toEqual(["VS Code"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFileTreeEditorTarget } from "./file-tree-editor-target";
|
||||
|
||||
const PRIMARY = { id: "wt-1", path: "/tmp/task/kandev" };
|
||||
const SIBLING = { id: "wt-2", path: "/tmp/task/kandev-feature-x" };
|
||||
const README_PATH = "apps/web/README.md";
|
||||
/** The tree is served from inside the primary worktree. */
|
||||
const WORKTREE_ROOT = "kandev";
|
||||
/** The tree is served from the task root that holds the worktrees. */
|
||||
const TASK_ROOT = "task";
|
||||
|
||||
describe("resolveFileTreeEditorTarget", () => {
|
||||
it("passes the node path through for a session with no known worktree", () => {
|
||||
expect(resolveFileTreeEditorTarget(README_PATH, [], "e2e-repo")).toEqual({
|
||||
filePath: README_PATH,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the node path through when the tree root is not known yet", () => {
|
||||
expect(resolveFileTreeEditorTarget(README_PATH, [PRIMARY], undefined)).toEqual({
|
||||
filePath: README_PATH,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores worktrees with no recorded path", () => {
|
||||
expect(resolveFileTreeEditorTarget(README_PATH, [{ id: "wt-9" }], TASK_ROOT)).toEqual({
|
||||
filePath: README_PATH,
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the worktree the tree is rooted in", () => {
|
||||
expect(resolveFileTreeEditorTarget(README_PATH, [PRIMARY], WORKTREE_ROOT)).toEqual({
|
||||
filePath: README_PATH,
|
||||
worktreeId: "wt-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the worktree directory segment when the tree is rooted at the task root", () => {
|
||||
expect(
|
||||
resolveFileTreeEditorTarget(`kandev-feature-x/${README_PATH}`, [PRIMARY, SIBLING], TASK_ROOT),
|
||||
).toEqual({ filePath: README_PATH, worktreeId: "wt-2" });
|
||||
});
|
||||
|
||||
it("resolves a worktree directory node to that worktree's root", () => {
|
||||
expect(resolveFileTreeEditorTarget("kandev", [PRIMARY, SIBLING], TASK_ROOT)).toEqual({
|
||||
filePath: "",
|
||||
worktreeId: "wt-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches worktree directories recorded with Windows separators", () => {
|
||||
const windowsSibling = { id: "wt-3", path: "C:\\tasks\\demo\\kandev-fix" };
|
||||
expect(
|
||||
resolveFileTreeEditorTarget(
|
||||
`kandev-fix/${README_PATH}`,
|
||||
[PRIMARY, windowsSibling],
|
||||
TASK_ROOT,
|
||||
),
|
||||
).toEqual({ filePath: README_PATH, worktreeId: "wt-3" });
|
||||
});
|
||||
|
||||
// Attaching any source through "Add Repositories to workspace" rebinds the
|
||||
// session to the task root even when it adds no worktree, so a single-worktree
|
||||
// task can still serve a task-rooted tree.
|
||||
it("returns null for an attached folder that belongs to no worktree", () => {
|
||||
expect(resolveFileTreeEditorTarget("design-notes/spec.md", [PRIMARY], TASK_ROOT)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a task-rooted entry that matches no worktree", () => {
|
||||
expect(resolveFileTreeEditorTarget("docs/README.md", [PRIMARY], TASK_ROOT)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export type FileTreeEditorTarget = {
|
||||
/** Path relative to the resolved worktree root, as the editors API expects. */
|
||||
filePath: string;
|
||||
worktreeId?: string;
|
||||
};
|
||||
|
||||
type WorktreeLike = { id: string; path?: string };
|
||||
|
||||
function pathBasename(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Files-panel tree path onto the `{worktreeId, filePath}` pair the
|
||||
* editors API resolves against, or `null` when the node is not addressable by
|
||||
* that API.
|
||||
*
|
||||
* The Files tree is served either from inside a worktree — in which case node
|
||||
* paths are already worktree-relative — or from the task workspace root, where
|
||||
* the first segment names a workspace entry. A task reaches the second shape
|
||||
* both by holding several worktrees and by having any source attached through
|
||||
* *Add Repositories to workspace*, which rebinds the session to the task root
|
||||
* even when it adds no worktree. The worktree count therefore cannot tell the
|
||||
* two apart; the tree's own root directory name can.
|
||||
*
|
||||
* Entries that are not worktrees (an attached plain folder) have no worktree to
|
||||
* resolve against, and the editors API rejects paths that escape the worktree,
|
||||
* so those nodes return `null` and callers hide the action.
|
||||
*/
|
||||
export function resolveFileTreeEditorTarget(
|
||||
nodePath: string,
|
||||
worktrees: WorktreeLike[],
|
||||
treeRootName?: string,
|
||||
): FileTreeEditorTarget | null {
|
||||
const known = worktrees.filter((worktree) => worktree.path);
|
||||
// Repository-backed sessions without a worktree fall back to the repository
|
||||
// checkout server-side, and an unloaded tree has no root to compare against.
|
||||
// Both keep the pass-through behaviour rather than hiding the action.
|
||||
if (known.length === 0 || !treeRootName) return { filePath: nodePath };
|
||||
|
||||
const rooted = known.find((worktree) => pathBasename(worktree.path!) === treeRootName);
|
||||
if (rooted) return { filePath: nodePath, worktreeId: rooted.id };
|
||||
|
||||
const separatorIndex = nodePath.indexOf("/");
|
||||
const head = separatorIndex === -1 ? nodePath : nodePath.slice(0, separatorIndex);
|
||||
const rest = separatorIndex === -1 ? "" : nodePath.slice(separatorIndex + 1);
|
||||
const match = known.find((worktree) => pathBasename(worktree.path!) === head);
|
||||
if (!match) return null;
|
||||
return { filePath: rest, worktreeId: match.id };
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEmbeddedVscodeSupported } from "./task-page-editor-capability";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
// Hoisted so the vi.mock factory below, which is registered above module
|
||||
// initialization, never closes over an uninitialized binding.
|
||||
const { setEmbeddedVscodeSupport } = vi.hoisted(() => ({
|
||||
setEmbeddedVscodeSupport: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) => selector({ setEmbeddedVscodeSupport }),
|
||||
}));
|
||||
|
||||
import {
|
||||
getEmbeddedVscodeSupported,
|
||||
useEmbeddedVscodeSupport,
|
||||
} from "./task-page-editor-capability";
|
||||
|
||||
describe("getEmbeddedVscodeSupported", () => {
|
||||
it("fails closed while session capability data is missing", () => {
|
||||
@@ -12,3 +27,34 @@ describe("getEmbeddedVscodeSupported", () => {
|
||||
expect(getEmbeddedVscodeSupported({ capabilities: { embedded_vscode: false } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const SUPPORTED = { capabilities: { embedded_vscode: true } };
|
||||
const UNSUPPORTED = { capabilities: { embedded_vscode: false } };
|
||||
|
||||
describe("useEmbeddedVscodeSupport", () => {
|
||||
it("returns and publishes the capability for the active session", () => {
|
||||
setEmbeddedVscodeSupport.mockClear();
|
||||
const { result } = renderHook(() => useEmbeddedVscodeSupport("session-1", SUPPORTED));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(setEmbeddedVscodeSupport).toHaveBeenCalledWith("session-1", true);
|
||||
});
|
||||
|
||||
it("republishes when the session's capability changes", () => {
|
||||
setEmbeddedVscodeSupport.mockClear();
|
||||
const { rerender } = renderHook(({ status }) => useEmbeddedVscodeSupport("session-1", status), {
|
||||
initialProps: { status: SUPPORTED },
|
||||
});
|
||||
rerender({ status: UNSUPPORTED });
|
||||
|
||||
expect(setEmbeddedVscodeSupport).toHaveBeenLastCalledWith("session-1", false);
|
||||
});
|
||||
|
||||
it("fails closed and publishes nothing without an active session", () => {
|
||||
setEmbeddedVscodeSupport.mockClear();
|
||||
const { result } = renderHook(() => useEmbeddedVscodeSupport(null, SUPPORTED));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(setEmbeddedVscodeSupport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
|
||||
type SessionEditorCapabilityStatus = {
|
||||
capabilities?: {
|
||||
embedded_vscode?: boolean;
|
||||
@@ -9,3 +12,26 @@ export function getEmbeddedVscodeSupported(
|
||||
): boolean {
|
||||
return status?.capabilities?.embedded_vscode === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the session's embedded-editor capability and publishes it to the
|
||||
* store.
|
||||
*
|
||||
* The capability is fetched per session by `useSessionResumption` and held in
|
||||
* that hook's local state, which only the task page can read. Panels rendered
|
||||
* inside the layout — the Files tree context menu — need the same answer to
|
||||
* decide whether to offer the embedded editor, and they read the store rather
|
||||
* than taking props through the dock layout.
|
||||
*/
|
||||
export function useEmbeddedVscodeSupport(
|
||||
sessionId: string | null | undefined,
|
||||
status: SessionEditorCapabilityStatus | null | undefined,
|
||||
): boolean {
|
||||
const supported = getEmbeddedVscodeSupported(status);
|
||||
const setEmbeddedVscodeSupport = useAppStore((state) => state.setEmbeddedVscodeSupport);
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
setEmbeddedVscodeSupport(sessionId, supported);
|
||||
}, [sessionId, supported, setEmbeddedVscodeSupport]);
|
||||
return supported;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Layout } from "react-resizable-panels";
|
||||
import { TaskArchivedProvider } from "./task-archived-context";
|
||||
import { SessionCommands } from "@/components/session-commands";
|
||||
import { TaskPRShortcut } from "@/components/task/task-pr-shortcut";
|
||||
import { getEmbeddedVscodeSupported } from "@/components/task/task-page-editor-capability";
|
||||
import { useEmbeddedVscodeSupport } from "@/components/task/task-page-editor-capability";
|
||||
import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs";
|
||||
import {
|
||||
buildDebugEntries,
|
||||
@@ -226,7 +226,7 @@ export function TaskPageInner({
|
||||
}: TaskPageInnerProps) {
|
||||
const taskProps = resolveTaskProps(task, repository);
|
||||
const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null);
|
||||
const embeddedVscodeSupported = getEmbeddedVscodeSupported(resumption.sessionStatus);
|
||||
const embeddedVscode = useEmbeddedVscodeSupport(effectiveSessionId, resumption.sessionStatus);
|
||||
const activeSessionMetadata = useAppStore((state) =>
|
||||
effectiveSessionId ? (state.taskSessions.items[effectiveSessionId]?.metadata ?? null) : null,
|
||||
);
|
||||
@@ -252,7 +252,7 @@ export function TaskPageInner({
|
||||
remote,
|
||||
sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId,
|
||||
agentctlReady: agentctlStatus.isReady,
|
||||
embeddedVscodeSupported,
|
||||
embeddedVscodeSupported: embeddedVscode,
|
||||
officeTaskHref,
|
||||
onTaskUnarchived,
|
||||
});
|
||||
|
||||
@@ -69,6 +69,28 @@ describe("useOpenSessionInEditor", () => {
|
||||
expect(mockOpenSessionInEditor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gotos the file in the embedded editor when a file path is returned", async () => {
|
||||
mockOpenSessionInEditor.mockResolvedValueOnce({
|
||||
url: "internal://vscode?goto=apps/web/app.ts",
|
||||
});
|
||||
const { result } = renderHook(() => useOpenSessionInEditor("session-1"));
|
||||
|
||||
await result.current.open({ editorId: "editor-1", filePath: "apps/web/app.ts" });
|
||||
|
||||
expect(mockOpenInternalVscode).toHaveBeenCalled();
|
||||
expect(mockOpenFileInVscode).toHaveBeenCalledWith("session-1", "apps/web/app.ts", 0, 0);
|
||||
});
|
||||
|
||||
it("skips the embedded-editor goto for a folder, which has nothing to open", async () => {
|
||||
mockOpenSessionInEditor.mockResolvedValueOnce({ url: "internal://vscode?goto=apps/web" });
|
||||
const { result } = renderHook(() => useOpenSessionInEditor("session-1"));
|
||||
|
||||
await result.current.open({ editorId: "editor-1", filePath: "apps/web", isDirectory: true });
|
||||
|
||||
expect(mockOpenInternalVscode).toHaveBeenCalled();
|
||||
expect(mockOpenFileInVscode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports editor API failures instead of leaving the button silently idle", async () => {
|
||||
mockOpenSessionInEditor.mockRejectedValueOnce(new Error("workspace path not found"));
|
||||
const { result } = renderHook(() => useOpenSessionInEditor("session-1"));
|
||||
|
||||
@@ -13,6 +13,8 @@ type OpenEditorOptions = {
|
||||
editorId?: string;
|
||||
editorType?: string;
|
||||
worktreeId?: string;
|
||||
/** `filePath` points at a folder, so the embedded editor has nothing to goto. */
|
||||
isDirectory?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,19 @@ function parseInternalVscodeURL(url: string): { file: string; line: number; col:
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveals the embedded editor panel and, for a file target, gotos it through
|
||||
* the backend Remote CLI. Folder targets have nothing to goto — the panel is
|
||||
* already rooted at the workspace.
|
||||
*/
|
||||
function openInternalVscode(sessionId: string, url: string, options?: OpenEditorOptions) {
|
||||
const goto_ = parseInternalVscodeURL(url);
|
||||
useDockviewStore.getState().openInternalVscode(null);
|
||||
if (goto_ && !options?.isDirectory) {
|
||||
openFileInVscode(sessionId, goto_.file, goto_.line, goto_.col);
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenSessionInEditor(sessionId?: string | null) {
|
||||
const { toast } = useToast();
|
||||
const request = useRequest(async (options?: OpenEditorOptions) => {
|
||||
@@ -57,13 +72,7 @@ export function useOpenSessionInEditor(sessionId?: string | null) {
|
||||
if (response?.url) {
|
||||
// Intercept internal VS Code URLs — handle in-app instead of opening a tab.
|
||||
if (response.url.startsWith("internal://vscode")) {
|
||||
const goto_ = parseInternalVscodeURL(response.url);
|
||||
useDockviewStore.getState().openInternalVscode(null);
|
||||
|
||||
// Open the file via the backend Remote CLI if goto params are provided
|
||||
if (goto_ && sessionId) {
|
||||
openFileInVscode(sessionId, goto_.file, goto_.line, goto_.col);
|
||||
}
|
||||
openInternalVscode(sessionId, response.url, options);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export const defaultState = {
|
||||
sessionMcpStatus: defaultSessionRuntimeState.sessionMcpStatus,
|
||||
promptUsage: defaultSessionRuntimeState.promptUsage,
|
||||
sessionPollMode: defaultSessionRuntimeState.sessionPollMode,
|
||||
embeddedVscodeSupport: defaultSessionRuntimeState.embeddedVscodeSupport,
|
||||
githubStatus: defaultGitHubState.githubStatus,
|
||||
githubAppRegistrations: defaultGitHubState.githubAppRegistrations,
|
||||
taskPRs: defaultGitHubState.taskPRs,
|
||||
@@ -302,6 +303,10 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState {
|
||||
sessionMcpStatus: { ...defaultState.sessionMcpStatus, ...initialState.sessionMcpStatus },
|
||||
promptUsage: { ...defaultState.promptUsage, ...initialState.promptUsage },
|
||||
sessionPollMode: { ...defaultState.sessionPollMode, ...initialState.sessionPollMode },
|
||||
embeddedVscodeSupport: {
|
||||
...defaultState.embeddedVscodeSupport,
|
||||
...initialState.embeddedVscodeSupport,
|
||||
},
|
||||
...mergeGitHubState(initialState),
|
||||
taskPRs: { ...defaultState.taskPRs, ...initialState.taskPRs },
|
||||
taskIssues: { ...defaultState.taskIssues, ...initialState.taskIssues },
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { createSessionRuntimeSlice, purgeSessionRuntimeState } from "./session-runtime-slice";
|
||||
import type { SessionRuntimeSlice } from "./types";
|
||||
|
||||
function makeStore() {
|
||||
return create<SessionRuntimeSlice>()(immer<SessionRuntimeSlice>(createSessionRuntimeSlice));
|
||||
}
|
||||
|
||||
describe("embeddedVscodeSupport", () => {
|
||||
let store: ReturnType<typeof makeStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = makeStore();
|
||||
});
|
||||
|
||||
it("defaults to no recorded support so consumers fail closed", () => {
|
||||
expect(store.getState().embeddedVscodeSupport.bySessionId["session-1"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("records support per session", () => {
|
||||
store.getState().setEmbeddedVscodeSupport("session-1", true);
|
||||
store.getState().setEmbeddedVscodeSupport("session-2", false);
|
||||
|
||||
expect(store.getState().embeddedVscodeSupport.bySessionId["session-1"]).toBe(true);
|
||||
expect(store.getState().embeddedVscodeSupport.bySessionId["session-2"]).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces a stale value when the session's executor capability changes", () => {
|
||||
store.getState().setEmbeddedVscodeSupport("session-1", true);
|
||||
store.getState().setEmbeddedVscodeSupport("session-1", false);
|
||||
|
||||
expect(store.getState().embeddedVscodeSupport.bySessionId["session-1"]).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the entry when the session's runtime state is purged", () => {
|
||||
store.getState().setEmbeddedVscodeSupport("session-1", true);
|
||||
store.getState().setEmbeddedVscodeSupport("session-2", true);
|
||||
|
||||
store.setState((draft) => {
|
||||
purgeSessionRuntimeState(draft, "session-1");
|
||||
});
|
||||
|
||||
expect(store.getState().embeddedVscodeSupport.bySessionId["session-1"]).toBeUndefined();
|
||||
expect(store.getState().embeddedVscodeSupport.bySessionId["session-2"]).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -216,6 +216,7 @@ function purgePerSessionRuntime(state: SessionRuntimeSliceState, sessionId: stri
|
||||
delete state.sessionTodos.bySessionId[sessionId];
|
||||
delete state.prepareProgress.bySessionId[sessionId];
|
||||
delete state.sessionPollMode.bySessionId[sessionId];
|
||||
delete state.embeddedVscodeSupport.bySessionId[sessionId];
|
||||
}
|
||||
|
||||
/** Process status + output for every process owned by the session. */
|
||||
@@ -282,6 +283,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = {
|
||||
userShells: { byEnvironmentId: {}, loading: {}, loaded: {} },
|
||||
prepareProgress: { bySessionId: {} },
|
||||
sessionPollMode: { bySessionId: {} },
|
||||
embeddedVscodeSupport: { bySessionId: {} },
|
||||
workspaceFilesRefresh: { bySessionId: {} },
|
||||
};
|
||||
|
||||
@@ -592,6 +594,10 @@ export const createSessionRuntimeSlice: StateCreator<
|
||||
set((draft) => {
|
||||
draft.sessionModels.bySessionId[sessionId] = data;
|
||||
}),
|
||||
setEmbeddedVscodeSupport: (sessionId, supported) =>
|
||||
set((draft) => {
|
||||
draft.embeddedVscodeSupport.bySessionId[sessionId] = supported;
|
||||
}),
|
||||
setSessionMCPStatus: (sessionId, history) =>
|
||||
set((draft) => {
|
||||
draft.sessionMcpStatus.bySessionId[sessionId] = history;
|
||||
|
||||
@@ -377,6 +377,16 @@ export type SessionPollModeState = {
|
||||
bySessionId: Record<string, SessionPollMode>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether each session's executor can run the embedded code-server. Resolved
|
||||
* from the session status by the task page and published here so panels that
|
||||
* offer editors — the Files tree context menu — can honour the same capability
|
||||
* as the task top bar.
|
||||
*/
|
||||
export type EmbeddedVscodeSupportState = {
|
||||
bySessionId: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type SessionRuntimeSliceState = {
|
||||
terminal: TerminalState;
|
||||
shell: ShellState;
|
||||
@@ -397,6 +407,7 @@ export type SessionRuntimeSliceState = {
|
||||
userShells: UserShellsState;
|
||||
prepareProgress: PrepareProgressState;
|
||||
sessionPollMode: SessionPollModeState;
|
||||
embeddedVscodeSupport: EmbeddedVscodeSupportState;
|
||||
workspaceFilesRefresh: { bySessionId: Record<string, number> };
|
||||
};
|
||||
|
||||
@@ -473,6 +484,7 @@ export type SessionRuntimeSliceActions = {
|
||||
patch: Partial<Omit<UserShellInfo, "terminalId">>,
|
||||
) => void;
|
||||
setSessionPollMode: (sessionId: string, mode: SessionPollMode) => void;
|
||||
setEmbeddedVscodeSupport: (sessionId: string, supported: boolean) => void;
|
||||
};
|
||||
|
||||
export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions;
|
||||
|
||||
@@ -160,6 +160,7 @@ export type AppState = KanbanSlice & {
|
||||
sessionMcpStatus: (typeof defaultSessionRuntimeState)["sessionMcpStatus"];
|
||||
promptUsage: (typeof defaultSessionRuntimeState)["promptUsage"];
|
||||
sessionPollMode: (typeof defaultSessionRuntimeState)["sessionPollMode"];
|
||||
embeddedVscodeSupport: (typeof defaultSessionRuntimeState)["embeddedVscodeSupport"];
|
||||
|
||||
// GitHub slice
|
||||
githubStatus: (typeof defaultGitHubState)["githubStatus"];
|
||||
@@ -474,6 +475,7 @@ export type AppState = KanbanSlice & {
|
||||
patch: Partial<Omit<UserShellInfo, "terminalId">>,
|
||||
) => void;
|
||||
setSessionPollMode: (sessionId: string, mode: SessionPollMode) => void;
|
||||
setEmbeddedVscodeSupport: (sessionId: string, supported: boolean) => void;
|
||||
/* prettier-ignore */ setSidebarActiveView: UIA["setSidebarActiveView"];
|
||||
createSidebarView: UIA["createSidebarView"];
|
||||
updateSidebarDraft: UIA["updateSidebarDraft"];
|
||||
|
||||
@@ -132,6 +132,8 @@ For an idle, non-archived repository-backed task, **Files → Workspace actions
|
||||
|
||||
The task **Files** panel browses, searches, opens, and edits task-worktree files. Kandev rejects file paths that escape the resolved worktree. A session with one worktree opens that worktree directly in a host editor. When a session has several worktrees, the editor button asks which repository or worktree to open, and each configured editor in the adjacent menu expands to the same repository-and-branch picker. Check that selection before launching an editor from a multi-repository task. Older API clients that omit `worktree_id` retain the first-worktree fallback.
|
||||
|
||||
Open the context menu on any file or folder in the Files tree — right-click on desktop, long-press on touch — for **Open in \<editor\>**, which launches your default editor at that path instead of at the worktree root. When more than one editor is configured, **Open in other editor** lists the rest. When the tree is rooted above the worktrees — a multi-worktree task, or any task that has had sources attached — Kandev resolves the clicked path back to its own worktree, so no picker is needed. The action is hidden for entries that belong to no worktree, such as an attached plain folder, because the editor launch is resolved against a worktree. It is also hidden while several files are selected, because it applies to a single path.
|
||||
|
||||
**VS Code (Embedded)** runs code-server inside the active task environment and displays it in a workbench panel. Opening it starts code-server independently of the agent process. It launches with `--auth none` and binds `0.0.0.0` on a random port inside that runtime; network/firewall isolation is therefore important, especially for Local and SSH environments. If the binary is absent, agentctl attempts to download it into `~/.kandev/tools/code-server`; first use needs a supported execution platform and access to the [code-server release](https://github.com/coder/code-server/releases/tag/v4.130.0). Native Windows Local and Worktree sessions do not support this integration. Linux-backed Local Docker, Sprites, and supported SSH sessions can offer it even when the Kandev app runs on Windows. The task-detail topbar follows the active session's executor capability, never the visitor's browser platform; other configured editors remain available. Use the panel error and task-environment logs when installation, startup, or proxying fails.
|
||||
|
||||
Use the workbench top bar's split-editor action to open the selected session worktree in the default editor. Its menu lets you choose another configured editor. A file's **Open with** menu can also open a specific editor, copy the path, or ask the operating system to show the folder.
|
||||
|
||||
Reference in New Issue
Block a user