feat(artifacts): Share again takes a skill's newest version; a same-named upload updates it
A shared skill followed its owner's ~/.claude/skills folder only, so one uploaded from a browser (or living in a plugin) stayed at its first snapshot for good. Share again (a skill's ⋯ menu and detail sheet) now takes the owner's copy on the server — its source folder, else the same-named skill of a plugin they installed at user scope — and shares it; with no copy it asks for the folder and uploads it onto that row (`id`). An upload whose skill is named like one the uploader already has replaces that row's version instead of adding a twin. Installers are then offered Update, as before.
This commit is contained in:
@@ -28,6 +28,12 @@
|
||||
"unshare": "Unshare",
|
||||
"shareDone": "Shared to the Hub",
|
||||
"unshareDone": "Removed from the Hub",
|
||||
"reshare": "Share again",
|
||||
"reshareDone": "New version shared — people who installed it are offered Update",
|
||||
"reshareSame": "Already the newest version — shared",
|
||||
"reshareAskTitle": "Choose the {{name}} folder again",
|
||||
"reshareAskBody": "This server has no copy of {{name}}. Choose the skill's folder (the one holding SKILL.md): it replaces this version, and people who installed it are offered Update. For a .zip, upload it through Add skill → Upload: a skill with the same name updates your existing one.",
|
||||
"resharePick": "Choose folder",
|
||||
"open": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"preview": "Preview",
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
"unshare": "Bỏ chia sẻ",
|
||||
"shareDone": "Đã chia sẻ lên Hub",
|
||||
"unshareDone": "Đã gỡ khỏi Hub",
|
||||
"reshare": "Share lại",
|
||||
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
||||
"reshareSame": "Đã là bản mới nhất — đã share",
|
||||
"reshareAskTitle": "Chọn lại thư mục {{name}}",
|
||||
"reshareAskBody": "Server không có bản của {{name}}. Chọn thư mục của skill (thư mục chứa SKILL.md): bản này sẽ được thay, và người đã cài sẽ thấy nút Cập nhật. Với file .zip, tải lên qua Thêm skill → Tải lên: skill trùng tên sẽ cập nhật mục bạn đã có.",
|
||||
"resharePick": "Chọn thư mục",
|
||||
"open": "Mở",
|
||||
"copyLink": "Sao chép link",
|
||||
"preview": "Xem",
|
||||
|
||||
+12
-5
@@ -3324,16 +3324,17 @@ export const api = {
|
||||
* several skills at once (one artifact each). `path` keeps a folder's layout. */
|
||||
uploadSkills: (
|
||||
files: { file: File; path: string }[],
|
||||
fields: { name?: string; title?: string; description?: string } = {}
|
||||
/** `id`: Share again — the upload replaces that skill row's version. */
|
||||
fields: { name?: string; title?: string; description?: string; id?: string } = {}
|
||||
) => {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append("files", f.file, f.file.name);
|
||||
form.append("paths", JSON.stringify(files.map((f) => f.path)));
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
return request<{ items: (Artifact & { replaced?: boolean; changed?: boolean })[] }>(
|
||||
"/artifacts/skill/upload",
|
||||
{ method: "POST", body: form }
|
||||
);
|
||||
},
|
||||
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
|
||||
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
|
||||
@@ -3372,6 +3373,12 @@ export const api = {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** POST /api/artifacts/:id/reshare — Share again: the owner's skill folder on
|
||||
* the server becomes the new version (409 ENOSOURCE when it has none). */
|
||||
reshare: (id: number) =>
|
||||
request<{ item: Artifact; changed: boolean }>(`/artifacts/${id}/reshare`, {
|
||||
method: "POST",
|
||||
}),
|
||||
/** `kept`: a shared one left your library but stays in the Hub. */
|
||||
remove: (id: number) =>
|
||||
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
* **Installed** instead of the Install button; **Uninstall** (a skill's
|
||||
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in
|
||||
* the card's ⋯ menu. A skill follows its owner's folder (the server takes a
|
||||
* change there as a new snapshot when the Hub is listed); an installed copy
|
||||
* change there as a new snapshot when the Hub is listed; **Share again**
|
||||
* takes its folder on the server — or a plugin's copy — on a click, else
|
||||
* asks for the folder and uploads it onto the same row); an installed copy
|
||||
* that differs from the newest version shows **Update** (a pack: "Update N"),
|
||||
* which replaces it after a confirmation, the old copy moved aside. Editing a skill
|
||||
* edits its SKILL.md (name, description, instructions — a new snapshot);
|
||||
@@ -277,6 +279,53 @@ export function Artifacts() {
|
||||
}
|
||||
};
|
||||
|
||||
// Share again: the server's copy of the skill, else the folder picked again
|
||||
// (uploaded onto the same row). Either way installers are offered Update.
|
||||
const reshareInput = useRef<HTMLInputElement>(null);
|
||||
const reshareRow = useRef<Artifact | null>(null);
|
||||
useEffect(() => {
|
||||
reshareInput.current?.setAttribute("webkitdirectory", "");
|
||||
}, []);
|
||||
const reshared = (changed: boolean) => {
|
||||
toast.success(t(changed ? "reshareDone" : "reshareSame"));
|
||||
load();
|
||||
};
|
||||
const reshare = async (a: Artifact) => {
|
||||
try {
|
||||
const r = await api.artifacts.reshare(a.id);
|
||||
reshared(r.changed);
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== "ENOSOURCE") return toast.error(err);
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("reshareAskTitle", { name: a.source ?? a.title }),
|
||||
message: t("reshareAskBody", { name: a.source ?? a.title }),
|
||||
confirmLabel: t("resharePick"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
reshareRow.current = a;
|
||||
reshareInput.current?.click();
|
||||
}
|
||||
};
|
||||
const reshareUpload = async (list: FileList | null) => {
|
||||
const a = reshareRow.current;
|
||||
reshareRow.current = null;
|
||||
if (!a || !list?.length) return;
|
||||
try {
|
||||
const r = await api.artifacts.uploadSkills(
|
||||
Array.from(list).map((file) => ({
|
||||
file,
|
||||
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||||
})),
|
||||
{ id: String(a.id) }
|
||||
);
|
||||
reshared(r.items.some((i) => i.changed !== false));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const [renaming, setRenaming] = useState<Artifact | null>(null);
|
||||
|
||||
const remove = async (a: Artifact) => {
|
||||
@@ -442,6 +491,7 @@ export function Artifacts() {
|
||||
const actionsFor = (a: Artifact): CardActions => ({
|
||||
onOpen: () => setDetailId(a.id),
|
||||
onShare: () => void share(a),
|
||||
onReshare: () => void reshare(a),
|
||||
onRename: () => {
|
||||
setDetailId(null);
|
||||
setRenaming(a);
|
||||
@@ -572,6 +622,17 @@ export function Artifacts() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
|
||||
<input
|
||||
ref={reshareInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="artifact-reshare-folder"
|
||||
onChange={(e) => {
|
||||
void reshareUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
@@ -1066,6 +1127,8 @@ function EmptyState({
|
||||
type CardActions = {
|
||||
onOpen: () => void;
|
||||
onShare: () => void;
|
||||
/** A skill of your own: its newest version, shared (installers get Update). */
|
||||
onReshare: () => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
onInstall: () => void;
|
||||
@@ -1237,6 +1300,7 @@ function ArtifactMenu({
|
||||
menu,
|
||||
onOpen,
|
||||
onShare,
|
||||
onReshare,
|
||||
onRename,
|
||||
onDelete,
|
||||
onInstall,
|
||||
@@ -1297,6 +1361,9 @@ function ArtifactMenu({
|
||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{server && a.kind === "skill" && (
|
||||
<MenuItem onSelect={pick(onReshare)}>{t("reshare")}</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
||||
{!hubOnly && (
|
||||
<MenuItem danger onSelect={pick(onDelete)}>
|
||||
@@ -1447,6 +1514,7 @@ function DetailDialog({
|
||||
canInstall,
|
||||
onClose,
|
||||
onShare,
|
||||
onReshare,
|
||||
onRename,
|
||||
onDelete,
|
||||
onInstall,
|
||||
@@ -1611,6 +1679,11 @@ function DetailDialog({
|
||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
||||
</button>
|
||||
)}
|
||||
{server && a.kind === "skill" && (
|
||||
<button type="button" className={secondary} onClick={onReshare}>
|
||||
{t("reshare")}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={secondary} onClick={onRename}>
|
||||
{t("rename")}
|
||||
</button>
|
||||
|
||||
@@ -46,6 +46,7 @@ vi.mock("../../lib/api", () => ({
|
||||
addPlugin: vi.fn(),
|
||||
inspectMarket: vi.fn(),
|
||||
uploadSkills: vi.fn(),
|
||||
reshare: vi.fn(),
|
||||
writeSkill: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
candidates: vi.fn(),
|
||||
@@ -394,6 +395,47 @@ describe("Artifacts", () => {
|
||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||
});
|
||||
|
||||
it("Share again takes the server's copy, else uploads the picked folder onto that skill", async () => {
|
||||
vi.mocked(api.artifacts.uploadSkills).mockClear();
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: true,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "sg-flow", source: "sg-flow", shared: true })],
|
||||
});
|
||||
vi.mocked(api.artifacts.reshare).mockResolvedValueOnce({
|
||||
item: item({ id: 2 }),
|
||||
changed: true,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
await menuAction(user, "Share again", card);
|
||||
await waitFor(() => expect(api.artifacts.reshare).toHaveBeenCalledWith(2));
|
||||
expect(api.artifacts.uploadSkills).not.toHaveBeenCalled();
|
||||
|
||||
// No copy on the server: the folder is asked for and lands on THIS row.
|
||||
vi.mocked(api.artifacts.reshare).mockRejectedValueOnce(
|
||||
Object.assign(new Error("no copy"), { code: "ENOSOURCE" })
|
||||
);
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.uploadSkills).mockResolvedValueOnce({
|
||||
items: [{ ...item({ id: 2 }), changed: true }],
|
||||
});
|
||||
await menuAction(user, "Share again", card);
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
const md = new File(["---\nname: sg-flow\n---\n"], "SKILL.md");
|
||||
await user.upload(screen.getByTestId("artifact-reshare-folder"), md);
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.uploadSkills).toHaveBeenCalledWith([{ file: md, path: "SKILL.md" }], {
|
||||
id: "2",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
|
||||
Reference in New Issue
Block a user