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:
@ -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