feat(backend,frontend): lot utilisateur #346 #347 #348 — exécution workspace plugin contrôlée par l'hôte, réordonnancement des onglets projet par glisser-déposer, Final restauré après stop d'agent CLI custom (QA verte)
#346 — capacités plugin SCM: le manifeste permissions.workspaceExec (contrat SDK 8ff0f47) est appliqué côté hôte — domain PluginWorkspaceExec Rule/Effect + audit sink, port WorkspaceToolRunner, adapter LocalWorkspaceToolRunner sandboxé (concurrence 4, timeout, bornes stdout/stderr, résolution bare executable sans shell), service application + DTO backend + commandes app-tauri, gateway frontend execTool exposée au runtime plugin — fin des lectures ad hoc du store interne .git par les plugins. #347 — onglets projet réordonnables par glisser-déposer (Tabs DnD, onReorder, persistance de l'ordre via useProjects). #348 — stop d'agent CLI custom: le message Final vert réapparaît au tour suivant (override terminal stop armé correctement, reset de l'interaction, refresh de la délégation requester). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -59,6 +59,8 @@ import type {
|
||||
PluginToolchainDiagnosticRequest,
|
||||
PluginToolchainGateway,
|
||||
PluginWorkspaceGateway,
|
||||
PluginWorkspaceExecInput,
|
||||
PluginWorkspaceExecResult,
|
||||
PluginWorkspacePathInput,
|
||||
PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput,
|
||||
@ -221,6 +223,9 @@ export class WebPluginGateway implements PluginGateway {
|
||||
|
||||
/** Web stub: public plugin workspace services are only meaningful where plugins run. */
|
||||
export class WebPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||
async execTool(_input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
|
||||
return unsupportedOnWeb("Plugin workspace tool execution");
|
||||
}
|
||||
async readText(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
||||
return unsupportedOnWeb("Plugin workspace access");
|
||||
}
|
||||
|
||||
@ -160,6 +160,8 @@ import type {
|
||||
PluginToolchainDiagnosticRequest,
|
||||
PluginToolchainGateway,
|
||||
PluginWorkspaceGateway,
|
||||
PluginWorkspaceExecInput,
|
||||
PluginWorkspaceExecResult,
|
||||
PluginWorkspacePathInput,
|
||||
PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput,
|
||||
@ -4553,6 +4555,10 @@ function basename(path: string): string {
|
||||
export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||
private readonly files = new Map<string, Map<string, Uint8Array>>();
|
||||
|
||||
async execTool(_input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
|
||||
throw new Error("Plugin workspace tool execution is unavailable in the mock adapter");
|
||||
}
|
||||
|
||||
private bucket(projectId: string): Map<string, Uint8Array> {
|
||||
let files = this.files.get(projectId);
|
||||
if (!files) {
|
||||
|
||||
@ -16,6 +16,8 @@ import type {
|
||||
import type {
|
||||
PluginProjectStructureQuery,
|
||||
PluginWorkspaceGateway,
|
||||
PluginWorkspaceExecInput,
|
||||
PluginWorkspaceExecResult,
|
||||
PluginWorkspacePathInput,
|
||||
PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput,
|
||||
@ -74,4 +76,8 @@ export class TauriPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure> {
|
||||
return invoke<PluginProjectStructure>("plugin_query_project_structure", { input });
|
||||
}
|
||||
|
||||
execTool(input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
|
||||
return invoke<PluginWorkspaceExecResult>("plugin_workspace_exec_tool", { input });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1564,6 +1564,7 @@ describe("CustomAgentChatView", () => {
|
||||
it("cancels only the current request and keeps the structured session usable", async () => {
|
||||
let resolveCancel: (result: CancelAgentChatResult) => void = () => {};
|
||||
let firstTurnChunk: (chunk: ReplyChunk) => void = () => {};
|
||||
let reattachedChunk: (chunk: ReplyChunk) => void = () => {};
|
||||
let rejectFirstTurn: (reason: unknown) => void = () => {};
|
||||
const system = new MockSystemGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
@ -1581,10 +1582,12 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
reattachAgentChat: vi.fn(
|
||||
async (sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||||
reattachedChunk = onChunk;
|
||||
return { sessionId, scrollback: [] };
|
||||
},
|
||||
),
|
||||
sendAgentChat: vi
|
||||
.fn()
|
||||
.mockImplementationOnce((_sessionId: string, _prompt: string, onChunk) => {
|
||||
@ -1594,6 +1597,41 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
})
|
||||
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
|
||||
reattachedChunk({ kind: "final", content: "Stale cancelled answer" });
|
||||
onChunk({
|
||||
kind: "progress",
|
||||
progress: {
|
||||
source: "providerNative",
|
||||
kind: "tool",
|
||||
stage: "completed",
|
||||
label: "web_search",
|
||||
provider: "codex",
|
||||
nativeEvent: "item.completed",
|
||||
},
|
||||
});
|
||||
onChunk({
|
||||
kind: "progress",
|
||||
progress: {
|
||||
source: "providerNative",
|
||||
kind: "message",
|
||||
stage: "completed",
|
||||
label: "message intermédiaire",
|
||||
text: "Complete intermediate answer",
|
||||
provider: "codex",
|
||||
nativeEvent: "item.completed",
|
||||
},
|
||||
});
|
||||
onChunk({
|
||||
kind: "progress",
|
||||
progress: {
|
||||
source: "providerNative",
|
||||
kind: "turn",
|
||||
stage: "completed",
|
||||
label: "tour terminé côté provider",
|
||||
provider: "codex",
|
||||
nativeEvent: "turn.completed",
|
||||
},
|
||||
});
|
||||
onChunk({ kind: "final", content: "Next answer" });
|
||||
}),
|
||||
stopLiveAgent: vi.fn(),
|
||||
@ -1729,7 +1767,11 @@ describe("CustomAgentChatView", () => {
|
||||
"next turn",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(await screen.findByText("web_search")).toBeTruthy();
|
||||
expect(await screen.findByText("Complete intermediate answer")).toBeTruthy();
|
||||
expect(await screen.findByText("Next answer")).toBeTruthy();
|
||||
expect(screen.queryByText("Stale cancelled answer")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["alreadyCancelled", "alreadyCompleted"] as const)(
|
||||
|
||||
@ -1503,10 +1503,17 @@ export function CustomAgentChatView({
|
||||
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
|
||||
activeInteractionIdRef.current = null;
|
||||
}
|
||||
stopTerminalOverrideRef.current = true;
|
||||
setStopTerminalOverride(true);
|
||||
setRequesterDelegation({
|
||||
currentAgentBusy: false,
|
||||
delegationTargetName: null,
|
||||
});
|
||||
setBusy(false);
|
||||
void refreshRequesterDelegation();
|
||||
}
|
||||
},
|
||||
[projectId, updateChatViewState],
|
||||
[projectId, refreshRequesterDelegation, updateChatViewState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -2315,7 +2322,6 @@ export function CustomAgentChatView({
|
||||
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
||||
const displayText = text || "Pièce jointe";
|
||||
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
|
||||
ignoreUnscopedChunksAfterStopRef.current = false;
|
||||
stopTerminalOverrideRef.current = false;
|
||||
setStopTerminalOverride(false);
|
||||
activeInteractionIdRef.current = interactionId;
|
||||
|
||||
@ -25,6 +25,7 @@ function renderTabs(props: Partial<React.ComponentProps<typeof ProjectTabs>> = {
|
||||
activeTabId={props.activeTabId ?? "a"}
|
||||
onSelect={props.onSelect ?? vi.fn()}
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
onReorder={props.onReorder}
|
||||
projectsPanelOpen={props.projectsPanelOpen ?? false}
|
||||
onOpenProjectsPanel={onOpenProjectsPanel}
|
||||
activityByProjectId={props.activityByProjectId}
|
||||
@ -94,3 +95,44 @@ describe("ProjectTabs project activity dots (#159)", () => {
|
||||
expect(screen.getByTitle(/beta.*échange en cours/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectTabs reordering (#347)", () => {
|
||||
it("reorders tabs horizontally by drag-and-drop without selecting or closing them", () => {
|
||||
const onReorder = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderTabs({ onReorder, onSelect, onClose });
|
||||
const alpha = screen.getByRole("tab", { name: /alpha/ });
|
||||
const beta = screen.getByRole("tab", { name: /beta/ });
|
||||
const alphaContainer = alpha.parentElement!;
|
||||
const betaContainer = beta.parentElement!;
|
||||
const data = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
effectAllowed: "none",
|
||||
dropEffect: "none",
|
||||
setData: (type: string, value: string) => data.set(type, value),
|
||||
getData: (type: string) => data.get(type) ?? "",
|
||||
};
|
||||
|
||||
fireEvent.dragStart(betaContainer, { dataTransfer });
|
||||
fireEvent.dragOver(alphaContainer, { dataTransfer });
|
||||
fireEvent.drop(alphaContainer, { dataTransfer, clientX: 0 });
|
||||
|
||||
expect(onReorder).toHaveBeenCalledWith(["b", "a"]);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the focused tab with Alt+Arrow and announces the change", () => {
|
||||
const onReorder = vi.fn();
|
||||
renderTabs({ onReorder });
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("tab", { name: /alpha/ }), {
|
||||
key: "ArrowRight",
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
expect(onReorder).toHaveBeenCalledWith(["b", "a"]);
|
||||
expect(screen.getByText("alpha déplacé.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,6 +21,7 @@ export interface ProjectTabsProps {
|
||||
activeTabId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
onReorder?: (orderedIds: string[]) => void;
|
||||
/** Whether the Projects panel is currently open (in any placement). */
|
||||
projectsPanelOpen: boolean;
|
||||
/** Open the Projects panel (floating) — create/switch project. */
|
||||
@ -36,6 +37,7 @@ export function ProjectTabs({
|
||||
activeTabId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onReorder,
|
||||
projectsPanelOpen,
|
||||
onOpenProjectsPanel,
|
||||
activityByProjectId = {},
|
||||
@ -72,6 +74,7 @@ export function ProjectTabs({
|
||||
value={activeTabId}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
)}
|
||||
{/* Add-project affordance (#46): always a bordered `secondary` button so
|
||||
|
||||
@ -1071,6 +1071,7 @@ export function ProjectsView() {
|
||||
activeTabId={vm.activeTabId}
|
||||
onSelect={(id) => vm.activateTab(id)}
|
||||
onClose={(id) => void vm.closeTab(id)}
|
||||
onReorder={(orderedIds) => void vm.reorderTabs(orderedIds)}
|
||||
activityByProjectId={projectActivity}
|
||||
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
||||
onOpenProjectsPanel={() => {
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* real `DIProvider`. Covers create → list/tab, open → tab, close → remove,
|
||||
* duplicate root (INVALID) and unknown id (NOT_FOUND) error handling.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
@ -76,6 +76,10 @@ function tablist() {
|
||||
return screen.getByRole("tablist");
|
||||
}
|
||||
|
||||
function tabLabels() {
|
||||
return within(tablist()).getAllByRole("tab").map((tab) => tab.textContent?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a panel floating via the single « Panneaux » menu (#26): click the menu,
|
||||
* click the panel entry (opens its placement submenu), then pick « Flottant ».
|
||||
@ -165,6 +169,56 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("reorders project tabs, persists the order, and preserves the active tab", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
const beta = await project.createProject("beta", "/p/b");
|
||||
project.setOpenProjectRestoreOrder([alpha.id, beta.id]);
|
||||
const saveOrder = vi.spyOn(project, "saveOpenProjectRestoreOrder");
|
||||
renderView(project);
|
||||
|
||||
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
|
||||
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
|
||||
fireEvent.keyDown(screen.getByRole("tab", { name: "beta" }), {
|
||||
key: "ArrowLeft",
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(tabLabels()).toEqual(["beta", "alpha"]));
|
||||
expect(saveOrder).toHaveBeenCalledWith([beta.id, alpha.id]);
|
||||
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
|
||||
await expect(project.readOpenProjectRestoreState()).resolves.toMatchObject({
|
||||
entries: [
|
||||
expect.objectContaining({ projectId: beta.id, position: 0 }),
|
||||
expect.objectContaining({ projectId: alpha.id, position: 1 }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls a tab reorder back when persistence fails", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
const beta = await project.createProject("beta", "/p/b");
|
||||
project.setOpenProjectRestoreOrder([alpha.id, beta.id]);
|
||||
let rejectSave: (reason: unknown) => void = () => {};
|
||||
vi.spyOn(project, "saveOpenProjectRestoreOrder").mockImplementationOnce(
|
||||
() => new Promise((_resolve, reject) => { rejectSave = reject; }),
|
||||
);
|
||||
renderView(project);
|
||||
|
||||
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
|
||||
fireEvent.keyDown(screen.getByRole("tab", { name: "beta" }), {
|
||||
key: "ArrowLeft",
|
||||
altKey: true,
|
||||
});
|
||||
await waitFor(() => expect(tabLabels()).toEqual(["beta", "alpha"]));
|
||||
rejectSave({ code: "STORE", message: "could not persist tab order" });
|
||||
|
||||
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
|
||||
expect(screen.getByRole("alert").textContent).toContain("could not persist tab order");
|
||||
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows one restore warning, details its cause, and retries at the retained position", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
|
||||
@ -40,6 +40,8 @@ export interface ProjectsViewModel {
|
||||
openProject: (projectId: string) => Promise<void>;
|
||||
/** Closes a tab (does not delete the project from the registry). */
|
||||
closeTab: (projectId: string) => Promise<void>;
|
||||
/** Reorders open tabs optimistically and persists their restore order. */
|
||||
reorderTabs: (orderedProjectIds: string[]) => Promise<void>;
|
||||
/** Removes a project from IdeA's registry without deleting its files. */
|
||||
removeProject: (projectId: string) => Promise<void>;
|
||||
/** Retries one failed restore, or every failure when omitted. */
|
||||
@ -68,6 +70,9 @@ export function useProjects(): ProjectsViewModel {
|
||||
const [restoreFailures, setRestoreFailures] = useState<OpenProjectRestoreFailure[]>([]);
|
||||
const [restoreNotificationVisible, setRestoreNotificationVisible] = useState(false);
|
||||
const initializationStarted = useRef(false);
|
||||
const openTabsRef = useRef<Project[]>([]);
|
||||
const reorderInFlight = useRef(false);
|
||||
openTabsRef.current = openTabs;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
@ -174,6 +179,43 @@ export function useProjects(): ProjectsViewModel {
|
||||
[project],
|
||||
);
|
||||
|
||||
const reorderTabs = useCallback(
|
||||
async (orderedProjectIds: string[]) => {
|
||||
if (reorderInFlight.current) return;
|
||||
const previous = openTabsRef.current;
|
||||
if (
|
||||
orderedProjectIds.length !== previous.length ||
|
||||
new Set(orderedProjectIds).size !== previous.length ||
|
||||
orderedProjectIds.some((id) => !previous.some((tab) => tab.id === id))
|
||||
) return;
|
||||
if (orderedProjectIds.every((id, index) => id === previous[index]?.id)) return;
|
||||
const byId = new Map(previous.map((tab) => [tab.id, tab]));
|
||||
const next = orderedProjectIds.map((id) => byId.get(id)!);
|
||||
reorderInFlight.current = true;
|
||||
openTabsRef.current = next;
|
||||
setOpenTabs(next);
|
||||
setError(null);
|
||||
try {
|
||||
await project.saveOpenProjectRestoreOrder(orderedProjectIds);
|
||||
} catch (e) {
|
||||
setOpenTabs((current) => {
|
||||
const currentById = new Map(current.map((tab) => [tab.id, tab]));
|
||||
const restored = previous
|
||||
.filter((tab) => currentById.has(tab.id))
|
||||
.map((tab) => currentById.get(tab.id)!);
|
||||
const previousIds = new Set(previous.map((tab) => tab.id));
|
||||
const merged = [...restored, ...current.filter((tab) => !previousIds.has(tab.id))];
|
||||
openTabsRef.current = merged;
|
||||
return merged;
|
||||
});
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
reorderInFlight.current = false;
|
||||
}
|
||||
},
|
||||
[project],
|
||||
);
|
||||
|
||||
const reconcileRemovedProject = useCallback(
|
||||
(projectId: string, nextProjects: Project[]) => {
|
||||
setProjects(nextProjects);
|
||||
@ -290,6 +332,7 @@ export function useProjects(): ProjectsViewModel {
|
||||
createProject,
|
||||
openProject,
|
||||
closeTab,
|
||||
reorderTabs,
|
||||
removeProject,
|
||||
retryRestore,
|
||||
removeFromRestore,
|
||||
|
||||
@ -64,6 +64,8 @@ export {
|
||||
type WorkspaceBinaryFile,
|
||||
type WorkspaceDirEntry,
|
||||
type WorkspaceDirectoryListing,
|
||||
type WorkspaceExecToolRequest,
|
||||
type WorkspaceExecToolResult,
|
||||
type WorkspaceResolvedPath,
|
||||
type WorkspaceStat,
|
||||
type WorkspaceStructureQuery,
|
||||
|
||||
@ -268,6 +268,7 @@ describe("loadPlugins", () => {
|
||||
"workspace",
|
||||
]);
|
||||
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
||||
"execTool",
|
||||
"getCurrentProject",
|
||||
"getProjectRoot",
|
||||
"listDirectory",
|
||||
|
||||
@ -125,6 +125,16 @@ function gateways(overrides: {
|
||||
...overrides.window,
|
||||
};
|
||||
const pluginWorkspace: PluginWorkspaceGateway = {
|
||||
execTool: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "ok\n",
|
||||
stderr: "",
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
durationMs: 4,
|
||||
})),
|
||||
readText: vi.fn(async ({ path }) => ({ path, content: "file text" })),
|
||||
readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
|
||||
writeText: vi.fn(),
|
||||
@ -332,6 +342,21 @@ describe("createPluginServices", () => {
|
||||
expect(g.project.updateProjectContext).toHaveBeenCalledWith("project-1", "next context");
|
||||
});
|
||||
|
||||
it("injects plugin and project identity into workspace execTool", async () => {
|
||||
const g = gateways();
|
||||
const services = createPluginServices(g, { pluginId: "dev.acme.plugin" });
|
||||
|
||||
await expect(
|
||||
services.workspace.execTool({ command: "tool", args: ["status"] }),
|
||||
).resolves.toMatchObject({ exitCode: 0, stdout: "ok\n", timedOut: false });
|
||||
expect(g.pluginWorkspace.execTool).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pluginId: "dev.acme.plugin",
|
||||
projectId: "project-1",
|
||||
command: "tool",
|
||||
args: ["status"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("maps background tasks through the work-state gateway and wraps output attachment", async () => {
|
||||
const detach = vi.fn();
|
||||
const onData = vi.fn();
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
PluginConfigGateway,
|
||||
PluginEventGateway,
|
||||
PluginProjectStructureQuery,
|
||||
PluginWorkspaceExecInput as HostPluginWorkspaceExecInput,
|
||||
PluginTaskGateway,
|
||||
PluginToolchainGateway,
|
||||
PluginWorkspaceGateway,
|
||||
@ -52,6 +53,31 @@ export interface WorkspaceService {
|
||||
stat(path: string, projectId?: string): Promise<WorkspaceStat>;
|
||||
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
|
||||
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
|
||||
/** Runs one manifest-allowlisted binary directly, without a visible task. */
|
||||
execTool(request: WorkspaceExecToolRequest): Promise<WorkspaceExecToolResult>;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecToolRequest {
|
||||
projectId?: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
maxStdoutBytes?: number;
|
||||
maxStderrBytes?: number;
|
||||
stdin?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceExecToolResult {
|
||||
exitCode: number | null;
|
||||
signal?: string;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
stdoutTruncated: boolean;
|
||||
stderrTruncated: boolean;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceResolvedPath {
|
||||
@ -651,6 +677,33 @@ export function createPluginServices(
|
||||
};
|
||||
return gateways.pluginWorkspace.queryProjectStructure(input);
|
||||
},
|
||||
async execTool(request) {
|
||||
if (!scope.pluginId) throw new Error("pluginId is required for workspace.execTool");
|
||||
const project = await requireProject(request.projectId);
|
||||
const input: HostPluginWorkspaceExecInput = {
|
||||
pluginId: scope.pluginId,
|
||||
projectId: project.id,
|
||||
command: request.command,
|
||||
args: request.args,
|
||||
cwd: request.cwd,
|
||||
env: request.env,
|
||||
timeoutMs: request.timeoutMs,
|
||||
maxStdoutBytes: request.maxStdoutBytes,
|
||||
maxStderrBytes: request.maxStderrBytes,
|
||||
stdin: request.stdin,
|
||||
};
|
||||
const result = await gateways.pluginWorkspace.execTool(input);
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
...(result.signal ? { signal: result.signal } : {}),
|
||||
timedOut: result.timedOut,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
stdoutTruncated: result.stdoutTruncated,
|
||||
stderrTruncated: result.stderrTruncated,
|
||||
durationMs: result.durationMs,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function listTasks(projectId?: string): Promise<BackgroundTaskStatus[]> {
|
||||
|
||||
@ -1746,6 +1746,30 @@ export interface PluginProjectStructureQuery {
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceExecInput {
|
||||
pluginId: string;
|
||||
projectId: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
maxStdoutBytes?: number;
|
||||
maxStderrBytes?: number;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceExecResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
stdoutTruncated: boolean;
|
||||
stderrTruncated: boolean;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceGateway {
|
||||
readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile>;
|
||||
readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile>;
|
||||
@ -1754,6 +1778,7 @@ export interface PluginWorkspaceGateway {
|
||||
listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing>;
|
||||
stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat>;
|
||||
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure>;
|
||||
execTool(input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult>;
|
||||
}
|
||||
|
||||
export interface PluginRunCommandInput {
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { IconButton } from "./IconButton";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties, DragEvent, KeyboardEvent, ReactNode } from "react";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface ThemedTabStyle extends CSSProperties {
|
||||
@ -37,17 +38,53 @@ export interface TabsProps {
|
||||
onSelect: (id: string) => void;
|
||||
/** When provided, each tab shows a close (×) control. */
|
||||
onClose?: (id: string) => void;
|
||||
/** When provided, tabs can be reordered with drag-and-drop or Alt+Arrow. */
|
||||
onReorder?: (orderedIds: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A themed tab strip with selection and optional per-tab close. */
|
||||
export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) {
|
||||
export function Tabs({ items, value, onSelect, onClose, onReorder, className }: TabsProps) {
|
||||
const { tr } = useI18n();
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
const reorder = (id: string, targetId: string, after: boolean) => {
|
||||
if (!onReorder || id === targetId) return;
|
||||
const orderedIds = items.map((item) => item.id).filter((itemId) => itemId !== id);
|
||||
const targetIndex = orderedIds.indexOf(targetId);
|
||||
if (targetIndex < 0) return;
|
||||
orderedIds.splice(targetIndex + (after ? 1 : 0), 0, id);
|
||||
if (orderedIds.every((itemId, index) => itemId === items[index]?.id)) return;
|
||||
onReorder(orderedIds);
|
||||
const moved = items.find((item) => item.id === id)?.label ?? id;
|
||||
setAnnouncement(tr(`${moved} déplacé.`, `${moved} moved.`));
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>, targetId: string) => {
|
||||
event.preventDefault();
|
||||
const id = draggedId ?? event.dataTransfer.getData("text/plain");
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
reorder(id, targetId, rect.width > 0 && event.clientX >= rect.left + rect.width / 2);
|
||||
setDraggedId(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
|
||||
if (!onReorder || !event.altKey || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
|
||||
const index = items.findIndex((item) => item.id === id);
|
||||
const targetIndex = index + (event.key === "ArrowLeft" ? -1 : 1);
|
||||
const target = items[targetIndex];
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
reorder(id, target.id, event.key === "ArrowRight");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn("flex flex-wrap items-stretch gap-1", className)}
|
||||
>
|
||||
<span aria-live="polite" className="sr-only">{announcement}</span>
|
||||
{items.map((tab) => {
|
||||
const active = tab.id === value;
|
||||
const themedStyle: ThemedTabStyle | undefined = tab.themeColor
|
||||
@ -62,9 +99,25 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
draggable={Boolean(onReorder)}
|
||||
onDragStart={(event) => {
|
||||
if (!onReorder) return;
|
||||
setDraggedId(tab.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", tab.id);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!onReorder) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={(event) => handleDrop(event, tab.id)}
|
||||
onDragEnd={() => setDraggedId(null)}
|
||||
style={themedStyle}
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
|
||||
onReorder && "cursor-grab active:cursor-grabbing",
|
||||
draggedId === tab.id && "opacity-60",
|
||||
active
|
||||
? cn(
|
||||
"border-border-strong",
|
||||
@ -83,6 +136,8 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
onKeyDown={(event) => handleKeyDown(event, tab.id)}
|
||||
aria-keyshortcuts={onReorder ? "Alt+ArrowLeft Alt+ArrowRight" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-1 text-sm focus-visible:outline-none",
|
||||
active ? "font-semibold text-content" : "text-muted hover:text-content",
|
||||
@ -96,6 +151,8 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
|
||||
size="sm"
|
||||
aria-label={tr(`fermer ${tab.label}`, `close ${tab.label}`)}
|
||||
onClick={() => onClose(tab.id)}
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.stopPropagation()}
|
||||
className="opacity-60 group-hover:opacity-100"
|
||||
>
|
||||
×
|
||||
|
||||
Reference in New Issue
Block a user