mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
feat(skills): rework detail page into overview/files tabs
- tabs directly under the breadcrumb header: overview (default) and files - overview: identity block + rendered SKILL.md as the main column, right rail with metadata card (source/creator/updated, inline name+description edit toggle) and used-by panel with bind/unbind - files: file tree + viewer/editor unchanged; SKILL.md "edit" jumps here - header kebab menu (copy skill ID, delete); page-level save bar shared by both tabs; tab state persisted in ?tab= - file tree: ARIA tree roles + roving-tabindex keyboard navigation - drop the old right sidebar (metadata dl, permissions paragraph) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
@@ -78,21 +78,31 @@ function getFileIcon(name: string) {
|
||||
// Tree node renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TreeItemContext {
|
||||
selectedPath: string;
|
||||
focusPath: string;
|
||||
collapsed: ReadonlySet<string>;
|
||||
onSelect: (path: string) => void;
|
||||
onToggleDir: (path: string) => void;
|
||||
onFocusItem: (path: string) => void;
|
||||
registerItem: (path: string, el: HTMLButtonElement | null) => void;
|
||||
}
|
||||
|
||||
function TreeNodeItem({
|
||||
node,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
ctx,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
selectedPath: string;
|
||||
onSelect: (path: string) => void;
|
||||
ctx: TreeItemContext;
|
||||
depth?: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isSelected = node.path === selectedPath;
|
||||
const isSelected = node.path === ctx.selectedPath;
|
||||
// Roving tabindex: exactly one item in the tree is tabbable.
|
||||
const tabIndex = node.path === ctx.focusPath ? 0 : -1;
|
||||
|
||||
if (node.isDirectory) {
|
||||
const expanded = !ctx.collapsed.has(node.path);
|
||||
const FolderIcon = expanded ? FolderOpen : Folder;
|
||||
const ChevronIcon = expanded ? ChevronDown : ChevronRight;
|
||||
|
||||
@@ -100,7 +110,13 @@ function TreeNodeItem({
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="treeitem"
|
||||
aria-expanded={expanded}
|
||||
aria-selected={false}
|
||||
tabIndex={tabIndex}
|
||||
ref={(el) => ctx.registerItem(node.path, el)}
|
||||
onClick={() => ctx.onToggleDir(node.path)}
|
||||
onFocus={() => ctx.onFocusItem(node.path)}
|
||||
className="flex w-full items-center gap-1.5 py-1 text-left text-xs hover:bg-accent/50 rounded-sm"
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
@@ -109,13 +125,12 @@ function TreeNodeItem({
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div>
|
||||
<div role="group">
|
||||
{node.children.map((child) => (
|
||||
<TreeNodeItem
|
||||
key={child.path}
|
||||
node={child}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
ctx={ctx}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
@@ -130,7 +145,12 @@ function TreeNodeItem({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.path)}
|
||||
role="treeitem"
|
||||
aria-selected={isSelected}
|
||||
tabIndex={tabIndex}
|
||||
ref={(el) => ctx.registerItem(node.path, el)}
|
||||
onClick={() => ctx.onSelect(node.path)}
|
||||
onFocus={() => ctx.onFocusItem(node.path)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 py-1 text-left text-xs rounded-sm",
|
||||
isSelected
|
||||
@@ -159,7 +179,83 @@ export function FileTree({
|
||||
onSelect: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useT("skills");
|
||||
const tree = buildTree(filePaths);
|
||||
const tree = useMemo(() => buildTree(filePaths), [filePaths]);
|
||||
// Directories start expanded; the set tracks user-collapsed ones.
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
||||
const [focusedPath, setFocusedPath] = useState<string | null>(null);
|
||||
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
// Visible items in document order, with parent path for ArrowLeft.
|
||||
const visible = useMemo(() => {
|
||||
const out: { node: FileTreeNode; parent: string | null }[] = [];
|
||||
const walk = (nodes: FileTreeNode[], parent: string | null) => {
|
||||
for (const n of nodes) {
|
||||
out.push({ node: n, parent });
|
||||
if (n.isDirectory && !collapsed.has(n.path)) walk(n.children, n.path);
|
||||
}
|
||||
};
|
||||
walk(tree, null);
|
||||
return out;
|
||||
}, [tree, collapsed]);
|
||||
|
||||
// The single tabbable item: last focused if still visible, else selection,
|
||||
// else the first item.
|
||||
const focusPath =
|
||||
(focusedPath && visible.some((v) => v.node.path === focusedPath)
|
||||
? focusedPath
|
||||
: null) ??
|
||||
(visible.some((v) => v.node.path === selectedPath)
|
||||
? selectedPath
|
||||
: visible[0]?.node.path ?? "");
|
||||
|
||||
const toggleDir = (path: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const focusItem = (path: string) => {
|
||||
setFocusedPath(path);
|
||||
itemRefs.current.get(path)?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const idx = visible.findIndex((v) => v.node.path === focusPath);
|
||||
if (idx < 0) return;
|
||||
const { node, parent } = visible[idx]!;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
if (idx + 1 < visible.length) focusItem(visible[idx + 1]!.node.path);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
if (idx > 0) focusItem(visible[idx - 1]!.node.path);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (node.isDirectory) {
|
||||
if (collapsed.has(node.path)) toggleDir(node.path);
|
||||
else if (node.children.length > 0)
|
||||
focusItem(node.children[0]!.path);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (node.isDirectory && !collapsed.has(node.path))
|
||||
toggleDir(node.path);
|
||||
else if (parent) focusItem(parent);
|
||||
break;
|
||||
case "Home":
|
||||
if (visible.length > 0) focusItem(visible[0]!.node.path);
|
||||
break;
|
||||
case "End":
|
||||
if (visible.length > 0) focusItem(visible[visible.length - 1]!.node.path);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
@@ -170,15 +266,28 @@ export function FileTree({
|
||||
);
|
||||
}
|
||||
|
||||
const ctx: TreeItemContext = {
|
||||
selectedPath,
|
||||
focusPath,
|
||||
collapsed,
|
||||
onSelect,
|
||||
onToggleDir: toggleDir,
|
||||
onFocusItem: setFocusedPath,
|
||||
registerItem: (path, el) => {
|
||||
if (el) itemRefs.current.set(path, el);
|
||||
else itemRefs.current.delete(path);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-1 px-1">
|
||||
<div
|
||||
role="tree"
|
||||
aria-label={t(($) => $.file_tree.aria_label)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="py-1 px-1"
|
||||
>
|
||||
{tree.map((node) => (
|
||||
<TreeNodeItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<TreeNodeItem key={node.path} node={node} ctx={ctx} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user