feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
This commit is contained in:
@@ -3,13 +3,23 @@
|
||||
* @description Generic list + card for markdown-frontmatter config artifacts
|
||||
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
|
||||
* items and the open/edit/delete handlers, nothing here reaches for an API.
|
||||
* A right-click on a card opens `FileItemMenu` — the same handlers (Delete
|
||||
* still goes through the caller's confirm) plus Copy path; the Claude Code and
|
||||
* Codex config pages reuse that menu for their other file-backed cards.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
|
||||
import type { CcArtifactType, CcMdItem } from "../../lib/api";
|
||||
import {
|
||||
ContextMenu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
useContextMenu,
|
||||
type MenuPoint,
|
||||
} from "../ContextMenu";
|
||||
import { ScopeBadge } from "./ScopeBadge";
|
||||
import { Empty, SkeletonRows } from "./configPrimitives";
|
||||
|
||||
@@ -30,7 +40,65 @@ interface MdItemListProps {
|
||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
||||
}
|
||||
|
||||
const fileOf = (item: CcMdItem) => item.file || `${item.path}/SKILL.md`;
|
||||
const typeOf = (kind: MdItemListProps["kind"]): CcArtifactType =>
|
||||
kind === "outputStyles" ? "output-styles" : kind;
|
||||
|
||||
/** Right-click menu of a file-backed config card: the card's own Open / Edit /
|
||||
* Delete handlers — Delete opens whatever confirm the button opens — plus a
|
||||
* harmless Copy path. Labels default to the Claude Code page's. */
|
||||
export function FileItemMenu({
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
path,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
labels,
|
||||
}: MenuPoint & {
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
labels?: { open: string; edit: string; delete: string; copyPath: string };
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const l = labels ?? {
|
||||
open: t("common.viewSource"),
|
||||
edit: t("edit.editButton"),
|
||||
delete: t("edit.deleteButton"),
|
||||
copyPath: t("common.copyPath"),
|
||||
};
|
||||
const pick = (fn: () => void) => () => {
|
||||
onClose();
|
||||
fn();
|
||||
};
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId="config-item-menu">
|
||||
<MenuItem testId="config-item-menu-open" onSelect={pick(onOpen)}>
|
||||
{l.open}
|
||||
</MenuItem>
|
||||
<MenuItem testId="config-item-menu-edit" onSelect={pick(onEdit)}>
|
||||
{l.edit}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="config-item-menu-copy"
|
||||
onSelect={pick(() => void navigator.clipboard.writeText(path))}
|
||||
>
|
||||
{l.copyPath}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger testId="config-item-menu-delete" onSelect={pick(onDelete)}>
|
||||
{l.delete}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
|
||||
const ctx = useContextMenu<CcMdItem>();
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return null;
|
||||
const q = search.toLowerCase();
|
||||
@@ -56,9 +124,27 @@ export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: Md
|
||||
onOpen={onOpen}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onMenu={ctx.open}
|
||||
kind={kind}
|
||||
/>
|
||||
))}
|
||||
{ctx.menu && (
|
||||
<FileItemMenu
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
onClose={ctx.close}
|
||||
path={fileOf(ctx.menu.item)}
|
||||
onOpen={() => onOpen(fileOf(ctx.menu!.item))}
|
||||
onEdit={() => {
|
||||
const it = ctx.menu!.item;
|
||||
onEdit(typeOf(kind), { scope: it.scope, name: it.name, filePath: fileOf(it) });
|
||||
}}
|
||||
onDelete={() => {
|
||||
const it = ctx.menu!.item;
|
||||
onDelete(typeOf(kind), it.scope, it.name, fileOf(it));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,14 +162,15 @@ interface MdItemCardProps {
|
||||
name: string | undefined,
|
||||
path: string
|
||||
) => void;
|
||||
onMenu: (e: MouseEvent, item: CcMdItem) => void;
|
||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
||||
}
|
||||
|
||||
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
|
||||
function MdItemCard({ item, onOpen, onEdit, onDelete, onMenu, kind }: MdItemCardProps) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
|
||||
const artifactType = typeOf(kind);
|
||||
|
||||
const filePath = item.file || `${item.path}/SKILL.md`;
|
||||
const filePath = fileOf(item);
|
||||
const description =
|
||||
item.frontmatter.description ||
|
||||
item.preview
|
||||
@@ -91,7 +178,10 @@ function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
|
||||
<div
|
||||
onContextMenu={(e) => onMenu(e, item)}
|
||||
className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @file Covers MdItemList's right-click menu: each entry calls the same
|
||||
* handler, with the same arguments, as the card button it repeats — Delete
|
||||
* included, so the caller's confirm modal stays the only way to delete.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MdItemList } from "../MdItemList";
|
||||
import type { CcMdItem } from "../../../lib/api";
|
||||
|
||||
const item = {
|
||||
name: "my-skill",
|
||||
scope: "user",
|
||||
path: "/home/.claude/skills/my-skill",
|
||||
file: "",
|
||||
frontmatter: { description: "Does things" },
|
||||
preview: "",
|
||||
} as unknown as CcMdItem;
|
||||
|
||||
function setup() {
|
||||
const handlers = { onOpen: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() };
|
||||
render(<MdItemList items={[item]} search="" kind="skills" {...handlers} />);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("my-skill") });
|
||||
expect(screen.getByTestId("config-item-menu")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
const path = "/home/.claude/skills/my-skill/SKILL.md";
|
||||
|
||||
describe("MdItemList — right-click menu", () => {
|
||||
it("Open, Edit and Delete call the card buttons' handlers", async () => {
|
||||
const user = userEvent.setup();
|
||||
const h = setup();
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByTestId("config-item-menu-open"));
|
||||
expect(h.onOpen).toHaveBeenCalledWith(path);
|
||||
expect(screen.queryByTestId("config-item-menu")).toBeNull();
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByTestId("config-item-menu-edit"));
|
||||
expect(h.onEdit).toHaveBeenCalledWith("skills", {
|
||||
scope: "user",
|
||||
name: "my-skill",
|
||||
filePath: path,
|
||||
});
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByTestId("config-item-menu-delete"));
|
||||
expect(h.onDelete).toHaveBeenCalledWith("skills", "user", "my-skill", path);
|
||||
|
||||
// The buttons still do the same thing.
|
||||
await user.click(screen.getByRole("button", { name: /delete/i }));
|
||||
expect(h.onDelete).toHaveBeenLastCalledWith("skills", "user", "my-skill", path);
|
||||
});
|
||||
|
||||
it("Copy path writes the item's file path", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByTestId("config-item-menu-copy"));
|
||||
expect(writeText).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user