diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 66e4c71..a0b38e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,7 +41,7 @@ EntityConfig (설정 객체) 공통 UI Primitives 도메 │ type, icon, actions │────→│ EntityListPanel │ │ SkillInspector │ │ getKey, getLabel │ │ EntityInspector │ │ McpInspector │ │ groupBy?, trailing? │ │ Inspector primitives │ │ HookInspector │ -│ toDetailTarget │ │ (shadcn 스타일) │ │ ... │ +│ │ │ (shadcn 스타일) │ │ ... │ └──────────────────────┘ └───────────────────────┘ └───────────────────┘ ``` @@ -51,7 +51,7 @@ EntityConfig (설정 객체) 공통 UI Primitives 도메 |---------|------|------| | `EntityConfig` | 엔티티별 설정 객체 (아이콘, 액션, 라벨, 그룹화) — 리스트 전용 | `src/config/entity-registry.ts` | | `EntityListPanel` | 제네릭 리스트 렌더러 — flat list + groupBy 지원 | `src/components/board/EntityListPanel.tsx` | -| `EntityInspector` | 타입별 라우터 — target.type → 도메인 Inspector | `src/components/board/entity-inspector.tsx` | +| `EntityInspector` | 타입별 라우터 — `{ type, itemKey }` → 도메인 Inspector | `src/components/board/entity-inspector.tsx` | | `Inspector` primitives | shadcn 스타일 compound components (Inspector, InspectorHeader, InspectorBody 등) | `src/components/ui/inspector.tsx` | | `BoardLayout` | Notion 스타일 칸반 보드 — 스코프 행, Sheet 상세 | `src/components/board/BoardLayout.tsx` | @@ -180,7 +180,6 @@ interface EntityConfig { getScope?: (item: T) => string | undefined; groupBy?: (item: T) => string; trailing?: (item: T) => ReactNode; - toDetailTarget: (item: T) => DashboardDetailTarget; } ``` diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 705560c..a163282 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -37,6 +37,22 @@ - Record/object를 직접 접근할 수 있으면 헬퍼 만들지 말 것 (`agents[name].displayName` > `getAgentDisplayName(name)`) - 인덱스 파일(`index.ts`)로 래퍼만 만드는 패턴 지양 +## Hook Key 규칙 + +Hook의 고유 키는 `hookConfig.getKey()`가 정의하는 정규 형식을 따른다: + +``` +${scope}:${event}:${matcher ?? ""}:${command ?? prompt ?? ""} +``` + +- `scope`: `"user"` | `"project"` +- `event`: Hook 이벤트명 (`PreToolUse`, `SessionStart` 등) +- `matcher`: 정규식 문자열 (없으면 빈 문자열). Claude Code 공식 스키마에서 matcher group의 필터 +- `command`/`prompt`: 개별 hook handler 식별자 + +패널에서 key를 생성할 때 반드시 group(matcher)을 순회하여 정규 형식과 일치시킬 것. +`allHooks` 같은 flat 배열로 순회하면 matcher 정보가 유실된다. + ## Claude CLI 서버 호출 (서버 사이드) - `execFile` 대신 `spawn` + `stdio: ['ignore', 'pipe', 'pipe']` 사용 — Nitro dev 서버는 TTY 없음, `execFile`로 `claude` CLI 호출 시 stdin 대기로 hanging 발생 diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 9aa6f11..3607743 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -38,7 +38,7 @@ BoardLayout | 리스트 행 | `ListItem` / `ListSubItem` | `src/components/ui/list-item.tsx` | | 행 내부 구성 | `Item`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemActions` | `src/components/ui/item.tsx` | | 빈 상태 | `Empty`, `EmptyMedia`, `EmptyDescription` | `src/components/ui/empty.tsx` | -| Inspector | `Inspector`, `InspectorHeader`, `InspectorTitle`, `InspectorBody` | `src/components/ui/inspector.tsx` | +| Inspector | `Inspector`, `InspectorHeader`, `InspectorTitle`, `InspectorActions`, `InspectorBody`, `InspectorSkeleton` | `src/components/ui/inspector.tsx` | | 우클릭 메뉴 | `ItemContextMenu` / `SubItemContextMenu` | `src/components/ui/item-context-menu.tsx` | 패널 리스트 행은 항상 `size="xs"` 고정. @@ -68,8 +68,7 @@ lucide-react에서 엔티티 아이콘을 직접 import하면 사이드바와 ```tsx interface XxxPanelProps { scopeFilter?: string - onSelectItem?: (target: DashboardDetailTarget) => void - onAction?: (id: EntityActionId, target: NonNullable) => void + onSelectItem?: (selected: { type: string; key: string }) => void } ``` @@ -194,12 +193,40 @@ export function XxxPanel({ scopeFilter, onSelectItem, onAction }: XxxPanelProps) ## 새 엔티티 추가 시 업데이트 대상 -1. `src/components/board/types.ts` — `DashboardDetailTarget` 유니온 -2. `src/components/board/BoardLayout.tsx` — `allColumnDefs` + `renderPanel()` -3. `src/components/board/entity-inspector.tsx` — Inspector 라우팅 -4. `src/components/icons/entity-icons.tsx` — 아이콘 추가 -5. `messages/en/common.json` + `messages/ko/common.json` — `board_no_*` 메시지 -6. `src/config/entities/` — 엔티티 config 파일 +1. `src/components/board/BoardLayout.tsx` — `allColumnDefs` + `renderPanel()` +2. `src/components/board/entity-inspector.tsx` — Inspector 라우팅 +3. `src/components/icons/entity-icons.tsx` — 아이콘 추가 +4. `messages/en/common.json` + `messages/ko/common.json` — `board_no_*` 메시지 +5. `src/config/entities/` — 엔티티 config 파일 + +## Inspector 패턴 + +각 Inspector는 `itemKey: string` prop만 받고, 내부에서 React Query 훅으로 데이터를 직접 fetch한다. +액션 처리(delete, open-in-editor)도 Inspector 내부에서 담당한다. + +```tsx +export function XxxInspector({ itemKey }: { itemKey: string }) { + const { data: items = [] } = useXxxQuery() + const item = items.find((i) => xxxConfig.getKey(i) === itemKey) + + if (!item) return + + return ( + + + + + + + + ... + + ) +} +``` + +- `InspectorSkeleton` — 아이템 로딩 중 표시. 아이템을 찾을 수 없는 경우에도 사용 +- `entity-inspector.tsx` — `type`에 따라 올바른 Inspector 컴포넌트로 라우팅 ## 참조 구현 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 119e4a1..85131c5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -124,6 +124,7 @@ Claude Code가 자체 설정 관리 GUI를 추가하면, 그걸 쓰면 된다. a ## Shipped +- **Inspector 아키텍처 단순화** (2026-03-18) — `DashboardDetailTarget` union / `toDetailTarget` 제거. Inspector가 `itemKey`만 받아 자체 fetch 및 액션 처리. `use-entity-action-handler.ts` 제거. - **Inspector 리디자인** (2026-03-18) — DetailPanel/EntityDetailPanel/DetailContent 시스템을 Inspector primitives + 도메인별 full Inspector로 전환. entity/ 해소, 도메인 디렉토리 이동. - **Commands in Skills 패널** (2026-03-18) — `.claude/commands/` 파일을 Skills 열에 통합 표시. `commandConfig` + `ENTITY_ICONS.command`(TerminalSquareIcon) 추가, namespace 트리 그룹핑(`GroupedList`), 디테일 패널 slash command 필드 + 복사 버튼, `getSlashCommand` 유틸. - **마켓플레이스 UI 리디자인** (2026-03-15) — skills.sh 연동 마켓플레이스 전면 재설계. SkillDetailView 통합, provider별 SKILL.md fetch, 글로벌+프로젝트 lock 합산 conflict 감지. diff --git a/package.json b/package.json index 6b05290..e9308ea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ohing504/agentfiles", "type": "module", - "packageManager": "pnpm@10.30.3", + "packageManager": "pnpm@10.32.1", "version": "0.3.0", "description": "Discover, understand, and manage your AI agent workflows", "author": "Youngsup Oh", diff --git a/src/components/agent/agent-inspector.tsx b/src/components/agent/agent-inspector.tsx index 97a1c88..48bc01b 100644 --- a/src/components/agent/agent-inspector.tsx +++ b/src/components/agent/agent-inspector.tsx @@ -1,50 +1,101 @@ +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" import { DetailField } from "@/components/DetailField" import { FileViewer } from "@/components/FileViewer" import { ENTITY_ICONS } from "@/components/icons/entity-icons" +import { useProjectContext } from "@/components/ProjectContext" import { EntityActionDropdown } from "@/components/ui/entity-action-menu" import { Inspector, InspectorActions, InspectorBody, InspectorHeader, + InspectorSkeleton, InspectorTitle, } from "@/components/ui/inspector" import { Separator } from "@/components/ui/separator" import { useAgentFileDetailQuery } from "@/hooks/use-agent-file-detail" +import { useAgentFiles } from "@/hooks/use-config" +import { usePluginsQuery } from "@/hooks/use-plugins" import { ENTITY_ACTIONS, type EntityActionId } from "@/lib/entity-actions" import { extractBody, formatDate } from "@/lib/format" +import { queryKeys } from "@/lib/query-keys" import { m } from "@/paraglide/messages" import { getLocale } from "@/paraglide/runtime" -import type { AgentFile } from "@/shared/types" interface AgentInspectorProps { - item: AgentFile - onClose?: () => void - onAction?: (actionId: EntityActionId) => void + itemKey: string } -export function AgentInspector({ - item, - onClose, - onAction, -}: AgentInspectorProps) { +export function AgentInspector({ itemKey }: AgentInspectorProps) { + const { query: agentsQuery } = useAgentFiles("agent") + const agents = agentsQuery.data ?? [] + const { activeProjectPath } = useProjectContext() + const queryClient = useQueryClient() + + const { data: plugins = [] } = usePluginsQuery(activeProjectPath) + const item = + agents.find((a) => a.path === itemKey) ?? + plugins + .flatMap((p) => p.contents?.agents ?? []) + .find((a) => a.path === itemKey) + const { data: itemDetail, isLoading: detailLoading } = - useAgentFileDetailQuery(item) + useAgentFileDetailQuery(item ?? null) + + if (!item) return const body = itemDetail?.content ? extractBody(itemDetail.content) : "" + const handleAction = async (actionId: EntityActionId) => { + switch (actionId) { + case "open-vscode": + case "open-cursor": { + const editor = actionId === "open-vscode" ? "code" : "cursor" + const { openInEditorFn } = await import("@/server/editor") + await openInEditorFn({ data: { filePath: item.path, editor } }) + break + } + case "delete": { + const { deleteItemFn } = await import("@/server/items") + await toast.promise( + deleteItemFn({ + data: { + type: "agent", + name: item.name, + scope: item.scope, + projectPath: activeProjectPath, + }, + }).then(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.agentFiles.all, + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.overview.all, + }) + }), + { + loading: m.toast_deleting({ name: item.name }), + success: m.toast_deleted({ name: item.name }), + error: (err: unknown) => + err instanceof Error ? err.message : m.toast_delete_failed(), + }, + ) + break + } + } + } + return ( - + - {onAction && ( - - )} + diff --git a/src/components/board/AgentsPanel.tsx b/src/components/board/AgentsPanel.tsx index de4c441..4626cd9 100644 --- a/src/components/board/AgentsPanel.tsx +++ b/src/components/board/AgentsPanel.tsx @@ -9,14 +9,13 @@ import { useAgentFiles } from "@/hooks/use-config" import type { EntityActionId } from "@/lib/entity-actions" import { ENTITY_ACTIONS } from "@/lib/entity-actions" import { m } from "@/paraglide/messages" -import type { DashboardDetailTarget } from "./types" interface AgentsPanelProps { scopeFilter?: string - onSelectItem?: (target: DashboardDetailTarget) => void + onSelectItem?: (selected: { type: string; key: string }) => void onAction?: ( id: EntityActionId, - target: NonNullable, + selected: { type: string; key: string }, ) => void } @@ -45,12 +44,12 @@ export function AgentsPanel({ return (
{filtered.map((file) => { - const target = { type: "agent" as const, agent: file } + const selected = { type: "agent", key: file.path } return ( onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={file.frontmatter?.name ?? file.name} > onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={file.frontmatter?.name ?? file.name} /> } - onClick={() => onSelectItem?.(target)} + onClick={() => onSelectItem?.(selected)} /> ) diff --git a/src/components/board/BoardLayout.tsx b/src/components/board/BoardLayout.tsx index fa451e2..2ab48de 100644 --- a/src/components/board/BoardLayout.tsx +++ b/src/components/board/BoardLayout.tsx @@ -24,7 +24,7 @@ import { PlusIcon, } from "lucide-react" import type { ElementType } from "react" -import { useMemo, useState } from "react" +import { useMemo, useRef, useState } from "react" import { toast } from "sonner" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import { useProjectContext } from "@/components/ProjectContext" @@ -71,9 +71,7 @@ import { EntityInspector } from "./entity-inspector" import { FilesPanel } from "./FilesPanel" import { LspServersPanel } from "./LspServersPanel" import { PluginsPanel } from "./PluginsPanel" -import type { DashboardDetailTarget } from "./types" import { useBoardConfig } from "./use-board-config" -import { useEntityActionHandler } from "./use-entity-action-handler" const COL_CLASS = "w-[280px] min-w-[280px] shrink-0" @@ -224,8 +222,13 @@ interface ColumnDef { export function BoardLayout() { const { activeProjectPath } = useProjectContext() - const [selected, setSelected] = useState(null) - const handleAction = useEntityActionHandler(() => setSelected(null)) + const [selected, setSelected] = useState<{ + type: string + key: string + } | null>(null) + // Keep last selection for Sheet close animation (content stays visible while fading out) + const lastSelected = useRef<{ type: string; key: string } | null>(null) + if (selected) lastSelected.current = selected const [collapsedScopes, setCollapsedScopes] = useState>(new Set()) const { boardConfig, toggleColumn, setColumnOrder } = useBoardConfig() @@ -376,7 +379,6 @@ export function BoardLayout() { const common = { scopeFilter: scope, onSelectItem: setSelected as (target: unknown) => void, - onAction: handleAction as (id: string, target: unknown) => void, } switch (colId) { case "files": @@ -386,7 +388,6 @@ export function BoardLayout() { ) @@ -397,7 +398,6 @@ export function BoardLayout() { items={directMcpServers} scopeFilter={scope} onSelectItem={common.onSelectItem} - onAction={common.onAction} emptyDescription={m.board_no_mcp()} isLoading={mcpLoading} renderTrailing={(server) => ( @@ -637,11 +637,12 @@ export function BoardLayout() { > Detail panel Detail panel - setSelected(null)} - onAction={handleAction} - /> + {lastSelected.current && ( + + )}
diff --git a/src/components/board/EntityListPanel.tsx b/src/components/board/EntityListPanel.tsx index 8c4c357..9007f42 100644 --- a/src/components/board/EntityListPanel.tsx +++ b/src/components/board/EntityListPanel.tsx @@ -15,8 +15,11 @@ interface EntityListPanelProps { items: T[] scopeFilter?: string selectedKey?: string - onSelectItem?: (target: unknown) => void - onAction?: (id: EntityActionId, target: unknown) => void + onSelectItem?: (selected: { type: string; key: string }) => void + onAction?: ( + id: EntityActionId, + selected: { type: string; key: string }, + ) => void /** 아이템별 trailing 위젯 (예: MCP Switch) */ renderTrailing?: (item: T) => React.ReactNode emptyDescription?: string @@ -100,14 +103,14 @@ export function EntityListPanel({ const key = config.getKey(item) const label = config.getLabel(item) const description = config.getDescription?.(item) - const target = config.toDetailTarget(item) + const selected = { type: config.type, key: config.getKey(item) } const resolvedActions = resolveActions(config.actions) return ( onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={label} > ({ renderTrailing?.(item) ?? ( onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={label} /> ) } - onClick={() => onSelectItem?.(target)} + onClick={() => onSelectItem?.(selected)} /> ) @@ -139,8 +142,11 @@ interface GroupedListProps { config: EntityConfig items: T[] selectedKey?: string - onSelectItem?: (target: unknown) => void - onAction?: (id: EntityActionId, target: unknown) => void + onSelectItem?: (selected: { type: string; key: string }) => void + onAction?: ( + id: EntityActionId, + selected: { type: string; key: string }, + ) => void renderTrailing?: (item: T) => React.ReactNode emptyDescription?: string } @@ -218,13 +224,13 @@ function GroupedList({ const key = config.getKey(item) const label = config.getLabel(item) const description = config.getDescription?.(item) - const target = config.toDetailTarget(item) + const selected = { type: config.type, key: config.getKey(item) } return ( onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={label} > ({ renderTrailing?.(item) ?? ( onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={label} /> ) } - onClick={() => onSelectItem?.(target)} + onClick={() => onSelectItem?.(selected)} /> ) @@ -265,13 +271,13 @@ function GroupedList({ const key = config.getKey(item) const label = config.getLabel(item) const description = config.getDescription?.(item) - const target = config.toDetailTarget(item) + const selected = { type: config.type, key: config.getKey(item) } return ( onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={label} > ({ renderTrailing?.(item) ?? ( onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={label} /> ) } - onClick={() => onSelectItem?.(target)} + onClick={() => onSelectItem?.(selected)} /> ) diff --git a/src/components/board/FilesPanel.tsx b/src/components/board/FilesPanel.tsx index 278a048..408d8c9 100644 --- a/src/components/board/FilesPanel.tsx +++ b/src/components/board/FilesPanel.tsx @@ -7,11 +7,10 @@ import { Empty, EmptyDescription, EmptyMedia } from "@/components/ui/empty" import { Skeleton } from "@/components/ui/skeleton" import { m } from "@/paraglide/messages" import { getFileTreeFn } from "@/server/files" -import type { DashboardDetailTarget } from "./types" interface FilesPanelProps { scopeFilter?: string - onSelectItem?: (target: DashboardDetailTarget) => void + onSelectItem?: (selected: { type: string; key: string }) => void } export function FilesPanel({ scopeFilter, onSelectItem }: FilesPanelProps) { @@ -34,7 +33,7 @@ export function FilesPanel({ scopeFilter, onSelectItem }: FilesPanelProps) { setSelectedPath(filePath) onSelectItem?.({ type: "file", - filePath, + key: filePath, }) } diff --git a/src/components/board/HooksPanel.tsx b/src/components/board/HooksPanel.tsx index d6e7258..b86d218 100644 --- a/src/components/board/HooksPanel.tsx +++ b/src/components/board/HooksPanel.tsx @@ -18,7 +18,6 @@ import type { HookScope, HooksSettings, } from "@/shared/types" -import type { DashboardDetailTarget } from "./types" /** Shorten a hook command for display: show basename for paths, full for short commands */ function shortenCommand(hook: HookEntry): string { @@ -33,10 +32,10 @@ function shortenCommand(hook: HookEntry): string { interface HooksPanelProps { scopeFilter?: string - onSelectItem?: (target: DashboardDetailTarget) => void + onSelectItem?: (selected: { type: string; key: string }) => void onAction?: ( id: EntityActionId, - target: NonNullable, + selected: { type: string; key: string }, ) => void } @@ -123,37 +122,36 @@ export function HooksPanel({ open={isOpen} onClick={() => toggleEvent(key)} > - {allHooks.map((hook, i) => { - const hookTarget = { - type: "hook" as const, - hook, - event, - matcher: groups[0]?.matcher, - scope, - } - const HookIcon = getHookIcon(hook) - return ( - onAction?.(id, hookTarget)} - itemName={hook.command ?? hook.type} - > - onAction?.(id, hookTarget)} - itemName={hook.command ?? hook.type} - /> - } - onClick={() => onSelectItem?.(hookTarget)} - /> - - ) - })} + {groups.flatMap((group) => + group.hooks.map((hook, hi) => { + const selected = { + type: "hook", + key: `${scope}:${event}:${group.matcher ?? ""}:${hook.command ?? hook.prompt ?? ""}`, + } + const HookIcon = getHookIcon(hook) + return ( + onAction?.(id, selected)} + itemName={hook.command ?? hook.type} + > + onAction?.(id, selected)} + itemName={hook.command ?? hook.type} + /> + } + onClick={() => onSelectItem?.(selected)} + /> + + ) + }), + )} ) })} diff --git a/src/components/board/McpDirectPanel.tsx b/src/components/board/McpDirectPanel.tsx index 8b6089f..0d5cf01 100644 --- a/src/components/board/McpDirectPanel.tsx +++ b/src/components/board/McpDirectPanel.tsx @@ -16,14 +16,13 @@ import type { EntityActionId } from "@/lib/entity-actions" import { ENTITY_ACTIONS } from "@/lib/entity-actions" import { getMcpIconClass } from "@/lib/mcp-status" import { m } from "@/paraglide/messages" -import type { DashboardDetailTarget } from "./types" interface McpDirectPanelProps { scopeFilter?: string - onSelectItem?: (target: DashboardDetailTarget) => void + onSelectItem?: (selected: { type: string; key: string }) => void onAction?: ( id: EntityActionId, - target: NonNullable, + selected: { type: string; key: string }, ) => void } @@ -54,12 +53,12 @@ export function McpDirectPanel({ return (
{filtered.map((server) => { - const target = { type: "mcp" as const, server } + const selected = { type: "mcp", key: `${server.scope}:${server.name}` } return ( onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={server.name} > onAction?.(id, target)} + onAction={(id) => onAction?.(id, selected)} itemName={server.name} /> } - onClick={() => onSelectItem?.(target)} + onClick={() => onSelectItem?.(selected)} /> ) diff --git a/src/components/board/PluginsPanel.tsx b/src/components/board/PluginsPanel.tsx index cccf7e5..4284dc7 100644 --- a/src/components/board/PluginsPanel.tsx +++ b/src/components/board/PluginsPanel.tsx @@ -22,14 +22,13 @@ import { getMcpIconClass } from "@/lib/mcp-status" import { cn } from "@/lib/utils" import { m } from "@/paraglide/messages" import type { McpConnectionStatus, Plugin } from "@/shared/types" -import type { DashboardDetailTarget } from "./types" interface PluginsPanelProps { scopeFilter?: string - onSelectItem?: (target: DashboardDetailTarget) => void + onSelectItem?: (selected: { type: string; key: string }) => void onAction?: ( id: EntityActionId, - target: NonNullable, + selected: { type: string; key: string }, ) => void /** Even = collapse all, odd = expand all. Changes trigger bulk toggle. */ collapseSignal?: number @@ -171,10 +170,10 @@ function PluginTreeItem({ expanded: boolean onToggle: () => void onExpand: () => void - onSelectItem?: (target: DashboardDetailTarget) => void + onSelectItem?: (selected: { type: string; key: string }) => void onAction?: ( id: EntityActionId, - target: NonNullable, + selected: { type: string; key: string }, ) => void statusMap?: Record }) { @@ -185,7 +184,7 @@ function PluginTreeItem({ const hookEntries = Object.entries(contents?.hooks ?? {}) const hasContents = hasPluginContents(plugin) - const pluginTarget = { type: "plugin" as const, plugin } + const pluginTarget = { type: "plugin", key: plugin.id } const trailing = ( @@ -274,7 +273,7 @@ function PluginTreeItem({ count={skills.length} /> {skills.map((s) => { - const t = { type: "skill" as const, skill: s } + const t = { type: "skill", key: s.path } const acts = ENTITY_ACTIONS.skill.filter(openOnlyFilter) return ( {agents.map((a) => { - const t = { type: "agent" as const, agent: a } + const t = { type: "agent", key: a.path } const acts = ENTITY_ACTIONS.agent.filter(openOnlyFilter) return ( {mcpServers.map((s) => { - const t = { type: "mcp" as const, server: s } + const t = { type: "mcp", key: `${plugin.scope}:${s.name}` } const acts = ENTITY_ACTIONS.mcp.filter(openOnlyFilter) return ( {hookEntries.map(([event, groups]) => { - const firstHook = groups?.[0]?.hooks?.[0] - const firstMatcher = groups?.[0]?.matcher + const firstGroup = groups?.[0] + const firstHook = firstGroup?.hooks?.[0] if (!firstHook) return null const t = { - type: "hook" as const, - hook: firstHook, - event, - matcher: firstMatcher, + type: "hook", + key: `${plugin.scope}:${event}:${firstGroup.matcher ?? ""}:${firstHook.command ?? firstHook.prompt ?? ""}`, } return ( void - onAction?: ( - actionId: EntityActionId, - target: NonNullable, - ) => void + type: string + itemKey: string } -export function EntityInspector({ - target, - onClose, - onAction, -}: EntityInspectorProps) { - const { activeProjectPath } = useProjectContext() - - if (!target) return null - - const wrapAction = onAction - ? (id: EntityActionId) => onAction(id, target) - : undefined - - switch (target.type) { +export function EntityInspector({ type, itemKey }: EntityInspectorProps) { + switch (type) { case "skill": - return ( - onAction(id, target)} - itemName={target.skill.frontmatter?.name ?? target.skill.name} - /> - ) - } - /> - ) + case "command": + return case "agent": - return ( - - ) + return case "mcp": - return ( - - ) - case "hook": - return ( - - ) + return case "plugin": - return ( - - ) - case "file": - return ( - - ) + return + case "hook": + return case "memory": - return ( - - ) + return + case "file": + return + default: + return null } } diff --git a/src/components/board/types.ts b/src/components/board/types.ts deleted file mode 100644 index f7a2f84..0000000 --- a/src/components/board/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { - AgentFile, - HookEntry, - HookScope, - McpServer, - MemoryFile, - Plugin, -} from "@/shared/types" - -export type DashboardDetailTarget = - | { type: "plugin"; plugin: Plugin } - | { type: "skill"; skill: AgentFile } - | { type: "agent"; agent: AgentFile } - | { type: "mcp"; server: McpServer } - | { - type: "hook" - hook: HookEntry - event: string - matcher?: string - scope?: HookScope - } - | { type: "memory"; file: MemoryFile } - | { type: "file"; filePath: string } - | null diff --git a/src/components/board/use-entity-action-handler.ts b/src/components/board/use-entity-action-handler.ts deleted file mode 100644 index 68556e9..0000000 --- a/src/components/board/use-entity-action-handler.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" -import { useProjectContext } from "@/components/ProjectContext" -import type { EntityActionId } from "@/lib/entity-actions" -import { queryKeys } from "@/lib/query-keys" -import { m } from "@/paraglide/messages" -import type { DashboardDetailTarget } from "./types" - -type NonNullTarget = NonNullable - -/** - * Returns an action handler for dashboard entity context-menu / dropdown actions. - * Accepts an optional `onAfterDelete` callback to clear selection when an item is removed. - */ -export function useEntityActionHandler(onAfterDelete?: () => void) { - const queryClient = useQueryClient() - const { activeProjectPath } = useProjectContext() - - return async (actionId: EntityActionId, target: NonNullTarget) => { - try { - switch (actionId) { - case "open-vscode": - case "open-cursor": { - const filePath = getFilePath(target) - if (!filePath) return - const editor = actionId === "open-vscode" ? "code" : "cursor" - const { openInEditorFn } = await import("@/server/editor") - await openInEditorFn({ data: { filePath, editor } }) - break - } - - case "open-folder": { - let dirPath: string | undefined - if (target.type === "skill" && target.skill.isSkillDir) { - dirPath = target.skill.path.replace(/\/SKILL\.md$/, "") - } else if (target.type === "memory") { - dirPath = target.file.path.replace(/\/[^/]+$/, "") - } - if (dirPath) { - const { openFolderFn } = await import("@/server/editor") - await openFolderFn({ data: { dirPath } }) - } - break - } - - case "delete": { - const itemName = getItemName(target) - onAfterDelete?.() - await toast.promise( - handleDelete(target, activeProjectPath).then(() => { - queryClient.invalidateQueries({ - queryKey: queryKeys.agentFiles.all, - }) - queryClient.invalidateQueries({ - queryKey: queryKeys.overview.all, - }) - }), - { - loading: m.toast_deleting({ name: itemName }), - success: m.toast_deleted({ name: itemName }), - error: (err: unknown) => - err instanceof Error ? err.message : m.toast_delete_failed(), - }, - ) - return - } - - case "remove-from-agent": { - if (target.type === "skill") { - const itemName = target.skill.name - onAfterDelete?.() - const { deleteItemFn } = await import("@/server/items") - await toast.promise( - deleteItemFn({ - data: { - type: "skill", - name: target.skill.name, - scope: target.skill.scope, - agent: "claude-code", - projectPath: activeProjectPath, - }, - }).then(() => { - queryClient.invalidateQueries({ - queryKey: queryKeys.agentFiles.all, - }) - queryClient.invalidateQueries({ - queryKey: queryKeys.overview.all, - }) - }), - { - loading: m.toast_removing({ name: itemName }), - success: m.toast_removed_from_agent({ name: itemName }), - error: (err: unknown) => - err instanceof Error ? err.message : m.toast_remove_failed(), - }, - ) - } - return - } - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Action failed") - } - } -} - -// --- helpers --- - -function getItemName(target: NonNullTarget): string { - switch (target.type) { - case "skill": - return target.skill.name - case "agent": - return target.agent.name - case "plugin": - return target.plugin.id - case "mcp": - return target.server.name - case "hook": - return target.event - case "memory": - return target.file.name - case "file": - return target.filePath - } -} - -function getFilePath(target: NonNullTarget): string | undefined { - switch (target.type) { - case "skill": - return target.skill.path - case "agent": - return target.agent.path - case "plugin": - return target.plugin.installPath - case "mcp": - return target.server.configPath - case "memory": - return target.file.path - case "hook": - // Hooks live inside settings.json — no single file to open - return undefined - } -} - -async function handleDelete(target: NonNullTarget, activeProjectPath?: string) { - switch (target.type) { - case "skill": - case "agent": { - const item = target.type === "skill" ? target.skill : target.agent - const { deleteItemFn } = await import("@/server/items") - await deleteItemFn({ - data: { - type: item.type, - name: item.name, - scope: item.scope, - projectPath: activeProjectPath, - }, - }) - break - } - - case "plugin": { - const { plugin } = target - const { uninstallPluginFn } = await import("@/server/plugins-fns") - await uninstallPluginFn({ - data: { - id: plugin.id, - scope: plugin.scope, - projectPath: plugin.projectPath, - }, - }) - break - } - - case "mcp": { - const { server } = target - const { removeMcpServerFn } = await import("@/server/mcp-fns") - await removeMcpServerFn({ - data: { - name: server.name, - scope: server.scope, - projectPath: activeProjectPath, - }, - }) - break - } - - case "hook": { - const scope = target.scope - if (!scope) return - const { removeHookFn } = await import("@/server/hooks") - await removeHookFn({ - data: { - event: target.event as Parameters< - typeof removeHookFn - >[0]["data"]["event"], - groupIndex: 0, - hookIndex: 0, - scope, - projectPath: activeProjectPath, - }, - }) - break - } - } -} diff --git a/src/components/file/file-inspector.tsx b/src/components/file/file-inspector.tsx index 31da5c8..bf795e4 100644 --- a/src/components/file/file-inspector.tsx +++ b/src/components/file/file-inspector.tsx @@ -17,48 +17,45 @@ import { } from "@/components/ui/inspector" import { Skeleton } from "@/components/ui/skeleton" import { useFileContentQuery } from "@/hooks/use-files" -import type { EntityActionId } from "@/lib/entity-actions" import { m } from "@/paraglide/messages" interface FileInspectorProps { - filePath: string - onClose?: () => void - onAction?: (actionId: EntityActionId) => void + itemKey: string } -export function FileInspector({ - filePath, - onClose, - onAction, -}: FileInspectorProps) { +export function FileInspector({ itemKey }: FileInspectorProps) { + const filePath = itemKey const fileName = filePath.split("/").pop() ?? filePath const isMarkdown = fileName.endsWith(".md") const { data, isLoading } = useFileContentQuery(filePath) + const handleOpenInEditor = async (editor: "code" | "cursor") => { + const { openInEditorFn } = await import("@/server/editor") + await openInEditorFn({ data: { filePath, editor } }) + } + return ( - + - {onAction && ( - - - - - - onAction("open-vscode")}> - - {m.common_open_vscode()} - - onAction("open-cursor")}> - - {m.common_open_cursor()} - - - - )} + + + + + + handleOpenInEditor("code")}> + + {m.common_open_vscode()} + + handleOpenInEditor("cursor")}> + + {m.common_open_cursor()} + + + diff --git a/src/components/hook/hook-inspector.tsx b/src/components/hook/hook-inspector.tsx index dc35bfa..797cd76 100644 --- a/src/components/hook/hook-inspector.tsx +++ b/src/components/hook/hook-inspector.tsx @@ -1,7 +1,10 @@ -import type React from "react" +import { useQueryClient } from "@tanstack/react-query" +import { useMemo } from "react" +import { toast } from "sonner" import { DetailField } from "@/components/DetailField" import { FileViewer } from "@/components/FileViewer" import { ENTITY_ICONS } from "@/components/icons/entity-icons" +import { useProjectContext } from "@/components/ProjectContext" import { EntityActionDropdown } from "@/components/ui/entity-action-menu" import { Inspector, @@ -12,56 +15,125 @@ import { } from "@/components/ui/inspector" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" -import { useHookScriptQuery } from "@/hooks/use-hooks" +import { useHookScriptQuery, useHooksQuery } from "@/hooks/use-hooks" import { ENTITY_ACTIONS, type EntityActionId } from "@/lib/entity-actions" +import { queryKeys } from "@/lib/query-keys" import { m } from "@/paraglide/messages" -import type { HookEntry } from "@/shared/types" +import type { HookEntry, HookScope } from "@/shared/types" -interface HookInspectorProps { - hook: HookEntry +interface HookItemResolved { + entry: HookEntry event: string matcher?: string - /** Resolved file path for script preview. Enables preview when provided. */ - resolvedFilePath?: string - /** For script file preview path resolution on the server. */ - activeProjectPath?: string | null - onClose?: () => void - onAction?: (actionId: EntityActionId) => void - /** Additional header actions */ - actions?: React.ReactNode + scope: HookScope + groupIndex: number + hookIndex: number +} + +interface HookInspectorProps { + itemKey: string } -export function HookInspector({ - hook, - event, - matcher, - resolvedFilePath, - activeProjectPath, - onClose, - onAction, - actions, -}: HookInspectorProps) { +export function HookInspector({ itemKey }: HookInspectorProps) { + const { data: globalHooks = {} } = useHooksQuery("user") + const { data: projectHooks = {} } = useHooksQuery("project") + const { activeProjectPath } = useProjectContext() + const queryClient = useQueryClient() + + // Build flat hook items with indices for deletion + const hookItem = useMemo(() => { + const allSources: Array<{ + hooks: typeof globalHooks + scope: HookScope + }> = [ + { hooks: globalHooks, scope: "user" }, + { hooks: projectHooks, scope: "project" }, + ] + + for (const { hooks, scope } of allSources) { + for (const [event, groups] of Object.entries(hooks)) { + if (!groups) continue + for (let gi = 0; gi < groups.length; gi++) { + const group = groups[gi] + for (let hi = 0; hi < group.hooks.length; hi++) { + const entry = group.hooks[hi] + const key = `${scope}:${event}:${group.matcher ?? ""}:${entry.command ?? entry.prompt ?? ""}` + if (key === itemKey) { + return { + entry, + event, + matcher: group.matcher, + scope, + groupIndex: gi, + hookIndex: hi, + } + } + } + } + } + } + return undefined + }, [globalHooks, projectHooks, itemKey]) + + const resolvedFilePath = + hookItem?.entry.type === "command" ? hookItem.entry.command : undefined + const scriptQuery = useHookScriptQuery( resolvedFilePath, activeProjectPath, !!resolvedFilePath, ) + if (!hookItem) return null + + const { entry: hook, event, matcher } = hookItem const title = hook.command ?? hook.prompt ?? event + const handleAction = async (actionId: EntityActionId) => { + switch (actionId) { + case "delete": { + const { removeHookFn } = await import("@/server/hooks") + await toast.promise( + removeHookFn({ + data: { + event: event as Parameters< + typeof removeHookFn + >[0]["data"]["event"], + groupIndex: hookItem.groupIndex, + hookIndex: hookItem.hookIndex, + scope: hookItem.scope, + projectPath: activeProjectPath, + }, + }).then(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.agentFiles.all, + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.overview.all, + }) + }), + { + loading: m.toast_deleting({ name: event }), + success: m.toast_deleted({ name: event }), + error: (err: unknown) => + err instanceof Error ? err.message : m.toast_delete_failed(), + }, + ) + break + } + } + } + return ( - + - {actions} - {onAction && ( - - )} + diff --git a/src/components/marketplace/skills-tab.tsx b/src/components/marketplace/skills-tab.tsx index 6d9ea70..3d97214 100644 --- a/src/components/marketplace/skills-tab.tsx +++ b/src/components/marketplace/skills-tab.tsx @@ -133,7 +133,12 @@ function SkillDetailPanel({ selected }: { selected: SelectedSkill | null }) { ) } - return + if ("path" in selected) { + return + } + return ( + + ) } export function SkillsTab() { diff --git a/src/components/mcp/mcp-inspector.tsx b/src/components/mcp/mcp-inspector.tsx index fea24ed..462b151 100644 --- a/src/components/mcp/mcp-inspector.tsx +++ b/src/components/mcp/mcp-inspector.tsx @@ -1,6 +1,8 @@ +import { useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" import { DetailField } from "@/components/DetailField" import { ENTITY_ICONS } from "@/components/icons/entity-icons" +import { useProjectContext } from "@/components/ProjectContext" import { Badge } from "@/components/ui/badge" import { EntityActionDropdown } from "@/components/ui/entity-action-menu" import { InlineCode } from "@/components/ui/inline-code" @@ -9,6 +11,7 @@ import { InspectorActions, InspectorBody, InspectorHeader, + InspectorSkeleton, InspectorTitle, } from "@/components/ui/inspector" import { Separator } from "@/components/ui/separator" @@ -18,10 +21,11 @@ import { useMcpQuery, useMcpStatusQuery, } from "@/hooks/use-mcp" -import type { EntityActionId } from "@/lib/entity-actions" -import { ENTITY_ACTIONS } from "@/lib/entity-actions" +import { usePluginsQuery } from "@/hooks/use-plugins" +import { ENTITY_ACTIONS, type EntityActionId } from "@/lib/entity-actions" +import { queryKeys } from "@/lib/query-keys" import { m } from "@/paraglide/messages" -import type { McpConnectionStatus, McpServer } from "@/shared/types" +import type { McpConnectionStatus } from "@/shared/types" const STATUS_BADGE_CLASS: Record< Exclude, @@ -81,34 +85,82 @@ function McpToggle({ } interface McpInspectorProps { - item: McpServer - onClose?: () => void - onAction?: (actionId: EntityActionId) => void + itemKey: string } -export function McpInspector({ - item: server, - onClose, - onAction, -}: McpInspectorProps) { +export function McpInspector({ itemKey }: McpInspectorProps) { + const { data: servers = [] } = useMcpQuery() const { data: statusMap } = useMcpStatusQuery() + const { activeProjectPath } = useProjectContext() + const queryClient = useQueryClient() + + // itemKey format: "${scope}:${name}" + // Also search in plugin-provided MCP servers + const { data: plugins = [] } = usePluginsQuery(activeProjectPath) + const server = + servers.find((s) => `${s.scope}:${s.name}` === itemKey) ?? + plugins + .flatMap((p) => + (p.contents?.mcpServers ?? []).map((s) => ({ ...s, scope: p.scope })), + ) + .find((s) => `${s.scope}:${s.name}` === itemKey) + + if (!server) return + const resolvedStatus: McpConnectionStatus = server.disabled ? "disabled" : (statusMap?.[server.name] ?? "unknown") + const handleAction = async (actionId: EntityActionId) => { + switch (actionId) { + case "open-vscode": + case "open-cursor": { + if (!server.configPath) return + const editor = actionId === "open-vscode" ? "code" : "cursor" + const { openInEditorFn } = await import("@/server/editor") + await openInEditorFn({ data: { filePath: server.configPath, editor } }) + break + } + case "delete": { + const { removeMcpServerFn } = await import("@/server/mcp-fns") + await toast.promise( + removeMcpServerFn({ + data: { + name: server.name, + scope: server.scope, + projectPath: activeProjectPath, + }, + }).then(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.mcpServers.all, + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.overview.all, + }) + }), + { + loading: m.toast_deleting({ name: server.name }), + success: m.toast_deleted({ name: server.name }), + error: (err: unknown) => + err instanceof Error ? err.message : m.toast_delete_failed(), + }, + ) + break + } + } + } + return ( - + - {onAction && ( - - )} + diff --git a/src/components/memory/memory-inspector.tsx b/src/components/memory/memory-inspector.tsx index 9c65f05..08dd47d 100644 --- a/src/components/memory/memory-inspector.tsx +++ b/src/components/memory/memory-inspector.tsx @@ -16,57 +16,66 @@ import { InspectorActions, InspectorBody, InspectorHeader, + InspectorSkeleton, InspectorTitle, } from "@/components/ui/inspector" import { Separator } from "@/components/ui/separator" -import type { EntityActionId } from "@/lib/entity-actions" +import { useMemoryFiles } from "@/hooks/use-config" import { formatBytes, formatDate } from "@/lib/format" import { m } from "@/paraglide/messages" import { getLocale } from "@/paraglide/runtime" -import type { MemoryFile } from "@/shared/types" interface MemoryInspectorProps { - item: MemoryFile - onClose?: () => void - onAction?: (actionId: EntityActionId) => void + itemKey: string } -export function MemoryInspector({ - item, - onClose, - onAction, -}: MemoryInspectorProps) { +export function MemoryInspector({ itemKey }: MemoryInspectorProps) { + const { data: memoryFiles = [] } = useMemoryFiles() + + const item = memoryFiles.find((f) => f.path === itemKey) + + if (!item) return + const fileName = item.name const isMarkdown = fileName.endsWith(".md") + const handleOpenInEditor = async (editor: "code" | "cursor") => { + const { openInEditorFn } = await import("@/server/editor") + await openInEditorFn({ data: { filePath: item.path, editor } }) + } + + const handleOpenFolder = async () => { + const dirPath = item.path.replace(/\/[^/]+$/, "") + const { openFolderFn } = await import("@/server/editor") + await openFolderFn({ data: { dirPath } }) + } + return ( - + - {onAction && ( - - - - - - onAction("open-vscode")}> - - {m.common_open_vscode()} - - onAction("open-cursor")}> - - {m.common_open_cursor()} - - onAction("open-folder")}> - - {m.memory_open_folder()} - - - - )} + + + + + + handleOpenInEditor("code")}> + + {m.common_open_vscode()} + + handleOpenInEditor("cursor")}> + + {m.common_open_cursor()} + + + + {m.memory_open_folder()} + + + diff --git a/src/components/plugin/plugin-inspector.tsx b/src/components/plugin/plugin-inspector.tsx index 796df8a..f67ab7a 100644 --- a/src/components/plugin/plugin-inspector.tsx +++ b/src/components/plugin/plugin-inspector.tsx @@ -1,5 +1,8 @@ +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" import { DetailField } from "@/components/DetailField" import { ENTITY_ICONS } from "@/components/icons/entity-icons" +import { useProjectContext } from "@/components/ProjectContext" import { Badge } from "@/components/ui/badge" import { EntityActionDropdown } from "@/components/ui/entity-action-menu" import { @@ -7,24 +10,27 @@ import { InspectorActions, InspectorBody, InspectorHeader, + InspectorSkeleton, InspectorTitle, } from "@/components/ui/inspector" -import type { EntityActionId } from "@/lib/entity-actions" -import { ENTITY_ACTIONS } from "@/lib/entity-actions" +import { usePluginsQuery } from "@/hooks/use-plugins" +import { ENTITY_ACTIONS, type EntityActionId } from "@/lib/entity-actions" +import { queryKeys } from "@/lib/query-keys" import { m } from "@/paraglide/messages" -import type { Plugin } from "@/shared/types" interface PluginInspectorProps { - item: Plugin - onClose?: () => void - onAction?: (actionId: EntityActionId) => void + itemKey: string } -export function PluginInspector({ - item, - onClose, - onAction, -}: PluginInspectorProps) { +export function PluginInspector({ itemKey }: PluginInspectorProps) { + const { activeProjectPath } = useProjectContext() + const { data: plugins = [] } = usePluginsQuery(activeProjectPath) + const queryClient = useQueryClient() + + const item = plugins.find((p) => p.id === itemKey) + + if (!item) return + const contents = item.contents const skillCount = contents?.skills.length ?? 0 const agentCount = contents?.agents.length ?? 0 @@ -32,18 +38,55 @@ export function PluginInspector({ const hookCount = Object.keys(contents?.hooks ?? {}).length const totalComponents = skillCount + agentCount + mcpCount + hookCount + const handleAction = async (actionId: EntityActionId) => { + switch (actionId) { + case "open-vscode": + case "open-cursor": { + if (!item.installPath) return + const editor = actionId === "open-vscode" ? "code" : "cursor" + const { openInEditorFn } = await import("@/server/editor") + await openInEditorFn({ data: { filePath: item.installPath, editor } }) + break + } + case "delete": { + const { uninstallPluginFn } = await import("@/server/plugins-fns") + await toast.promise( + uninstallPluginFn({ + data: { + id: item.id, + scope: item.scope, + projectPath: item.projectPath, + }, + }).then(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.agentFiles.all, + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.overview.all, + }) + }), + { + loading: m.toast_deleting({ name: item.id }), + success: m.toast_deleted({ name: item.id }), + error: (err: unknown) => + err instanceof Error ? err.message : m.toast_delete_failed(), + }, + ) + break + } + } + } + return ( - + - {onAction && ( - - )} + diff --git a/src/components/skill/skill-inspector.tsx b/src/components/skill/skill-inspector.tsx index cfc4823..2405f51 100644 --- a/src/components/skill/skill-inspector.tsx +++ b/src/components/skill/skill-inspector.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from "@tanstack/react-query" import { ChevronDownIcon, DownloadIcon, @@ -5,7 +6,6 @@ import { Loader2Icon, Trash2Icon, } from "lucide-react" -import type React from "react" import { toast } from "sonner" import { useAgentContext } from "@/components/AgentContext" import { CopyButton } from "@/components/animate-ui/components/buttons/copy" @@ -13,6 +13,7 @@ import { DetailField } from "@/components/DetailField" import { FileViewer } from "@/components/FileViewer" import { FrontmatterBadges } from "@/components/FrontmatterBadges" import { ENTITY_ICONS } from "@/components/icons/entity-icons" +import { useProjectContext } from "@/components/ProjectContext" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { @@ -21,18 +22,25 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { EntityActionDropdown } from "@/components/ui/entity-action-menu" import { Inspector, InspectorActions, InspectorBody, InspectorHeader, + InspectorSkeleton, InspectorTitle, } from "@/components/ui/inspector" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" +import { useAgentFiles } from "@/hooks/use-config" +import { usePluginsQuery } from "@/hooks/use-plugins" import { useSkillDetailQuery } from "@/hooks/use-skill-detail" import { useSkillActions } from "@/hooks/use-skills" +import { ENTITY_ACTIONS, type EntityActionId } from "@/lib/entity-actions" import { extractBody, formatDate, formatInstalls } from "@/lib/format" +import { queryKeys } from "@/lib/query-keys" +import { resolveInstallStatus } from "@/lib/skill-install-status" import { getSlashCommand } from "@/lib/slash-command" import { m } from "@/paraglide/messages" import { getLocale } from "@/paraglide/runtime" @@ -45,21 +53,50 @@ function isAgentFile(item: AgentFile | MarketplaceSkill): item is AgentFile { } interface SkillInspectorProps { - item: AgentFile | MarketplaceSkill - onClose?: () => void - /** Additional header actions (e.g. EntityActionDropdown from dashboard) */ - actions?: React.ReactNode + itemKey: string + /** For marketplace items that aren't in the local query data */ + marketplaceItem?: MarketplaceSkill } export function SkillInspector({ - item, - onClose, - actions, + itemKey, + marketplaceItem, }: SkillInspectorProps) { - const { data: skill, isLoading } = useSkillDetailQuery(item) + const { query: skillsQuery } = useAgentFiles("skill") + const { query: commandsQuery } = useAgentFiles("command") + const skills = skillsQuery.data ?? [] + const commands = commandsQuery.data ?? [] + const { activeProjectPath } = useProjectContext() + const queryClient = useQueryClient() + + // Find item from local queries or plugin contents by path + const localItem = + skills.find((s) => s.path === itemKey) ?? + commands.find((c) => c.path === itemKey) + + // Also search in plugin-provided skills + const { data: plugins = [] } = usePluginsQuery(activeProjectPath) + const pluginSkill = !localItem + ? plugins + .flatMap((p) => p.contents?.skills ?? []) + .find((s) => s.path === itemKey) + : undefined + + // Use local item if found, then plugin skill, then marketplace item + const item: AgentFile | MarketplaceSkill | undefined = + localItem ?? pluginSkill ?? marketplaceItem + + const { data: skill, isLoading } = useSkillDetailQuery( + item ?? + ({ + path: itemKey, + type: "skill", + name: "", + scope: "project", + } as AgentFile), + ) const { mainAgent } = useAgentContext() const { - getInstallStatus, getInstalledSource, getInstalledScope, isStillInstalled, @@ -67,15 +104,23 @@ export function SkillInspector({ removeMutation, } = useSkillActions() + if (!item) return + const agent: AgentType = agentTypeSchema.safeParse(mainAgent).success ? (mainAgent as AgentType) : "claude-code" - const installStatus: InstallStatus = isAgentFile(item) - ? isStillInstalled(item.name) - ? "installed" - : "not_installed" - : getInstallStatus(item) + const installStatus: InstallStatus = resolveInstallStatus({ + isAgentFile: isAgentFile(item), + scope: isAgentFile(item) ? item.scope : undefined, + isInStandaloneSkills: isAgentFile(item) + ? isStillInstalled(item.name) + : !!localItem, + lockSource: !isAgentFile(item) + ? getInstalledSource(item.skillId) + : undefined, + marketplaceSource: !isAgentFile(item) ? item.source : undefined, + }) const title = skill?.displayName ?? skill?.name ?? item.name @@ -123,6 +168,82 @@ export function SkillInspector({ }) } + const handleAction = async (actionId: EntityActionId) => { + if (!isAgentFile(item)) return + switch (actionId) { + case "open-vscode": + case "open-cursor": { + const editor = actionId === "open-vscode" ? "code" : "cursor" + const { openInEditorFn } = await import("@/server/editor") + await openInEditorFn({ data: { filePath: item.path, editor } }) + break + } + case "open-folder": { + if (item.isSkillDir) { + const dirPath = item.path.replace(/\/SKILL\.md$/, "") + const { openFolderFn } = await import("@/server/editor") + await openFolderFn({ data: { dirPath } }) + } + break + } + case "delete": { + const { deleteItemFn } = await import("@/server/items") + await toast.promise( + deleteItemFn({ + data: { + type: item.type, + name: item.name, + scope: item.scope, + projectPath: activeProjectPath, + }, + }).then(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.agentFiles.all, + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.overview.all, + }) + }), + { + loading: m.toast_deleting({ name: item.name }), + success: m.toast_deleted({ name: item.name }), + error: (err: unknown) => + err instanceof Error ? err.message : m.toast_delete_failed(), + }, + ) + break + } + case "remove-from-agent": { + const { deleteItemFn } = await import("@/server/items") + await toast.promise( + deleteItemFn({ + data: { + type: "skill", + name: item.name, + scope: item.scope, + agent: "claude-code", + projectPath: activeProjectPath, + }, + }).then(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.agentFiles.all, + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.overview.all, + }) + }), + { + loading: m.toast_removing({ name: item.name }), + success: m.toast_removed_from_agent({ name: item.name }), + error: (err: unknown) => + err instanceof Error ? err.message : m.toast_remove_failed(), + }, + ) + break + } + } + } + const conflictSource = !isAgentFile(item) && installStatus === "conflict" ? getInstalledSource(item.skillId) @@ -146,7 +267,7 @@ export function SkillInspector({ return ( - + {installStatus === "conflict" && ( @@ -190,7 +311,13 @@ export function SkillInspector({ {m.marketplace_remove()} )} - {actions} + {isAgentFile(item) && ( + + )} diff --git a/src/components/ui/inspector.tsx b/src/components/ui/inspector.tsx index a2d3f81..1976f9a 100644 --- a/src/components/ui/inspector.tsx +++ b/src/components/ui/inspector.tsx @@ -3,6 +3,7 @@ import { ChevronsRight } from "lucide-react" import type * as React from "react" import { CopyButton } from "@/components/animate-ui/components/buttons/copy" import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" import { cn } from "@/lib/utils" function Inspector({ @@ -96,10 +97,27 @@ function InspectorBody({ ) } +function InspectorSkeleton() { + return ( + + + + + +
+ + +
+
+
+ ) +} + export { Inspector, InspectorHeader, InspectorTitle, InspectorActions, InspectorBody, + InspectorSkeleton, } diff --git a/src/config/entities/agent-config.tsx b/src/config/entities/agent-config.tsx index 8542bd1..1492d61 100644 --- a/src/config/entities/agent-config.tsx +++ b/src/config/entities/agent-config.tsx @@ -1,4 +1,3 @@ -import type { DashboardDetailTarget } from "@/components/board/types" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" @@ -12,8 +11,4 @@ export const agentConfig: EntityConfig = { getLabel: (item) => item.name, getDescription: (item) => item.frontmatter?.description, getScope: (item) => item.scope, - toDetailTarget: (item): DashboardDetailTarget => ({ - type: "agent", - agent: item, - }), } diff --git a/src/config/entities/command-config.tsx b/src/config/entities/command-config.tsx index d6f60e0..71c0415 100644 --- a/src/config/entities/command-config.tsx +++ b/src/config/entities/command-config.tsx @@ -1,4 +1,3 @@ -import type { DashboardDetailTarget } from "@/components/board/types" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" @@ -13,8 +12,4 @@ export const commandConfig: EntityConfig = { getDescription: (item) => item.frontmatter?.description, getScope: (item) => item.scope, groupBy: (item) => item.namespace ?? "", - toDetailTarget: (item): DashboardDetailTarget => ({ - type: "skill", - skill: item, - }), } diff --git a/src/config/entities/file-config.tsx b/src/config/entities/file-config.tsx index fa369fc..712125d 100644 --- a/src/config/entities/file-config.tsx +++ b/src/config/entities/file-config.tsx @@ -1,5 +1,4 @@ import { FileIcon } from "lucide-react" -import type { DashboardDetailTarget } from "@/components/board/types" import type { EntityConfig } from "@/config/entity-registry" export interface FileItem { @@ -13,8 +12,4 @@ export const fileConfig: EntityConfig = { actions: ["open-vscode", "open-cursor"], getKey: (item) => item.path, getLabel: (item) => item.name, - toDetailTarget: (item): DashboardDetailTarget => ({ - type: "file", - filePath: item.path, - }), } diff --git a/src/config/entities/hook-config.tsx b/src/config/entities/hook-config.tsx index 8d491a5..67d1e6e 100644 --- a/src/config/entities/hook-config.tsx +++ b/src/config/entities/hook-config.tsx @@ -1,4 +1,3 @@ -import type { DashboardDetailTarget } from "@/components/board/types" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" @@ -20,11 +19,4 @@ export const hookConfig: EntityConfig = { getLabel: (item) => item.entry.command ?? item.entry.prompt ?? item.event, getScope: (item) => item.scope, groupBy: (item) => item.event, - toDetailTarget: (item): DashboardDetailTarget => ({ - type: "hook", - hook: item.entry, - event: item.event, - matcher: item.matcher, - scope: item.scope, - }), } diff --git a/src/config/entities/mcp-config.tsx b/src/config/entities/mcp-config.tsx index f9e6547..21abf29 100644 --- a/src/config/entities/mcp-config.tsx +++ b/src/config/entities/mcp-config.tsx @@ -1,4 +1,3 @@ -import type { DashboardDetailTarget } from "@/components/board/types" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" @@ -11,8 +10,4 @@ export const mcpConfig: EntityConfig = { getKey: (item) => `${item.scope}:${item.name}`, getLabel: (item) => item.name, getScope: (item) => item.scope, - toDetailTarget: (item): DashboardDetailTarget => ({ - type: "mcp", - server: item, - }), } diff --git a/src/config/entities/memory-config.tsx b/src/config/entities/memory-config.tsx index a758efb..ea52f9b 100644 --- a/src/config/entities/memory-config.tsx +++ b/src/config/entities/memory-config.tsx @@ -1,4 +1,3 @@ -import type { DashboardDetailTarget } from "@/components/board/types" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { formatBytes } from "@/lib/format" @@ -11,8 +10,4 @@ export const memoryConfig: EntityConfig = { getKey: (item) => item.path, getLabel: (item) => item.name, getDescription: (item) => item.description ?? formatBytes(item.size), - toDetailTarget: (item): DashboardDetailTarget => ({ - type: "memory", - file: item, - }), } diff --git a/src/config/entities/plugin-config.tsx b/src/config/entities/plugin-config.tsx index 47c933f..4ba55de 100644 --- a/src/config/entities/plugin-config.tsx +++ b/src/config/entities/plugin-config.tsx @@ -1,4 +1,3 @@ -import type { DashboardDetailTarget } from "@/components/board/types" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" @@ -12,8 +11,4 @@ export const pluginConfig: EntityConfig = { getLabel: (item) => item.name, getDescription: (item) => (item.version ? `v${item.version}` : undefined), getScope: (item) => item.scope, - toDetailTarget: (item): DashboardDetailTarget => ({ - type: "plugin", - plugin: item, - }), } diff --git a/src/config/entities/skill-config.tsx b/src/config/entities/skill-config.tsx index c48efcf..54e889b 100644 --- a/src/config/entities/skill-config.tsx +++ b/src/config/entities/skill-config.tsx @@ -1,4 +1,3 @@ -import type { DashboardDetailTarget } from "@/components/board/types" import { ENTITY_ICONS } from "@/components/icons/entity-icons" import type { EntityConfig } from "@/config/entity-registry" import { ENTITY_ACTIONS } from "@/lib/entity-actions" @@ -12,8 +11,4 @@ export const skillConfig: EntityConfig = { getLabel: (item) => item.frontmatter?.name ?? item.name, getDescription: (item) => item.frontmatter?.description, getScope: (item) => item.scope, - toDetailTarget: (item): DashboardDetailTarget => ({ - type: "skill", - skill: item, - }), } diff --git a/src/config/entity-registry.ts b/src/config/entity-registry.ts index 7a4710d..634bbcf 100644 --- a/src/config/entity-registry.ts +++ b/src/config/entity-registry.ts @@ -2,8 +2,6 @@ import type { LucideIcon } from "lucide-react" import type React from "react" import type { EntityActionId } from "@/lib/entity-actions" -// DashboardDetailTarget은 components/board/types.ts에 위치 - export interface EntityConfig { /** 엔티티 타입 식별자 */ type: string @@ -23,8 +21,6 @@ export interface EntityConfig { groupBy?: (item: T) => string /** 아이템 우측 trailing 위젯 */ trailing?: (item: T) => React.ReactNode - /** DashboardDetailTarget 생성 */ - toDetailTarget: (item: T) => unknown // 실제 전환 시 DashboardDetailTarget으로 변경 } /** 엔티티 레지스트리 */ diff --git a/src/lib/skill-install-status.test.ts b/src/lib/skill-install-status.test.ts new file mode 100644 index 0000000..0852377 --- /dev/null +++ b/src/lib/skill-install-status.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest" +import { resolveInstallStatus } from "./skill-install-status" + +describe("resolveInstallStatus", () => { + // ── AgentFile (로컬 파일) ── + + it("독립 스킬 파일(user scope)이면 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: true, + scope: "user", + isInStandaloneSkills: true, + }), + ).toBe("installed") + }) + + it("managed scope(플러그인 제공)이면 항상 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: true, + scope: "managed", + isInStandaloneSkills: false, + }), + ).toBe("installed") + }) + + it("독립 스킬에 없는 user scope AgentFile이면 not_installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: true, + scope: "user", + isInStandaloneSkills: false, + }), + ).toBe("not_installed") + }) + + it("project scope이고 독립 스킬에 있으면 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: true, + scope: "project", + isInStandaloneSkills: true, + }), + ).toBe("installed") + }) + + // ── MarketplaceSkill ── + + it("마켓플레이스 스킬 — 로컬에 없으면 not_installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: false, + isInStandaloneSkills: false, + }), + ).toBe("not_installed") + }) + + it("마켓플레이스 스킬 — 로컬에 있고 source 일치하면 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: false, + isInStandaloneSkills: true, + lockSource: "skills.sh/my-skill", + marketplaceSource: "skills.sh/my-skill", + }), + ).toBe("installed") + }) + + it("마켓플레이스 스킬 — 로컬에 있고 source 불일치하면 conflict", () => { + expect( + resolveInstallStatus({ + isAgentFile: false, + isInStandaloneSkills: true, + lockSource: "skills.sh/other-skill", + marketplaceSource: "skills.sh/my-skill", + }), + ).toBe("conflict") + }) + + it("마켓플레이스 스킬 — 로컬에 있고 lock 없으면 installed", () => { + expect( + resolveInstallStatus({ + isAgentFile: false, + isInStandaloneSkills: true, + lockSource: undefined, + marketplaceSource: "skills.sh/my-skill", + }), + ).toBe("installed") + }) +}) diff --git a/src/lib/skill-install-status.ts b/src/lib/skill-install-status.ts new file mode 100644 index 0000000..12c1ff1 --- /dev/null +++ b/src/lib/skill-install-status.ts @@ -0,0 +1,28 @@ +import type { InstallStatus } from "@/shared/types" + +interface ResolveParams { + /** AgentFile인지 MarketplaceSkill인지 */ + isAgentFile: boolean + /** 아이템의 scope (AgentFile 전용) */ + scope?: string + /** useAgentFiles("skill") 결과에 존재하는지 */ + isInStandaloneSkills: boolean + /** lock 파일의 source (MarketplaceSkill 전용) */ + lockSource?: string + /** 마켓플레이스 skill의 source (MarketplaceSkill 전용) */ + marketplaceSource?: string +} + +export function resolveInstallStatus(params: ResolveParams): InstallStatus { + if (params.isAgentFile) { + if (params.scope === "managed") return "installed" + return params.isInStandaloneSkills ? "installed" : "not_installed" + } + + // MarketplaceSkill + if (!params.isInStandaloneSkills) return "not_installed" + if (!params.lockSource) return "installed" + return params.lockSource === params.marketplaceSource + ? "installed" + : "conflict" +}