From fa91f98d77648ddcdf11072043b7c5cb6cf81e4c Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 1 Oct 2026 12:02:52 +0200 Subject: [PATCH 1/2] Deck list: a deck's row wears the project-roadmap mark --- src/plugin/DeckList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugin/DeckList.tsx b/src/plugin/DeckList.tsx index 445d7ad..d606238 100644 --- a/src/plugin/DeckList.tsx +++ b/src/plugin/DeckList.tsx @@ -21,7 +21,7 @@ import { KebabHorizontalIcon, PencilIcon, PlusIcon, - ProjectIcon, + ProjectRoadmapIcon, TrashIcon, } from '@primer/octicons-react'; import { deckId, type DeckEntry } from '../registry/catalog'; @@ -121,7 +121,7 @@ const DeckRow = ({ entry, selected }: { entry: DeckEntry; selected: boolean }): }} > - + Date: Thu, 1 Oct 2026 15:45:27 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Decks:=20a=20host=20says=20whether=20the=20?= =?UTF-8?q?reader=20may=20rename=20and=20delete=20(configureDecksManagemen?= =?UTF-8?q?t)=20=E2=80=94=20refused=20from=20the=20row's=20menu,=20the=20p?= =?UTF-8?q?alette,=20the=20keystroke=20and=20the=20commands=20an=20agent?= =?UTF-8?q?=20calls,=20with=20the=20reason=20said;=20the=20row's=20two=20a?= =?UTF-8?q?ctions=20are=20disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/store.test.ts | 36 ++++++++++++++++++++++ src/plugin/DeckList.tsx | 61 ++++++++++++++++++++----------------- src/plugin/store.ts | 43 ++++++++++++++++++++++++-- 3 files changed, 110 insertions(+), 30 deletions(-) diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts index c08e19f..2d3bc22 100644 --- a/src/__tests__/store.test.ts +++ b/src/__tests__/store.test.ts @@ -14,6 +14,8 @@ import { cancelRename, closeDeck, configureDecksBackend, + configureDecksManagement, + DECKS_MANAGEMENT_REFUSAL, renameDeck, getDecksState, openDeck, @@ -206,3 +208,37 @@ describe('revealed', () => { expect(getDecksState().revealed).toBe(before + 3); // addDeck opened it, then openDeck }); }); + +describe('managing decks, when the host says the reader may not', () => { + it('refuses rename and delete by every way in, and says why', async () => { + await addDeck({ collection: 'talks', slug: 'kept', spec: spec('Kept') }); + const id = 'talks/kept'; + expect(getDecksState().canManage).toBe(true); + configureDecksManagement(false); + // The dialogs do not open… + beginRename(id); + beginDelete(id); + expect(getDecksState().renaming).toBeUndefined(); + expect(getDecksState().deleting).toBeUndefined(); + expect(getDecksState().error).toBe(DECKS_MANAGEMENT_REFUSAL); + // …and the writes an agent or a command calls are refused too. + await expect(removeDeck(id)).rejects.toThrow(DECKS_MANAGEMENT_REFUSAL); + await expect(renameDeck(id, { title: 'Other' })).rejects.toThrow(DECKS_MANAGEMENT_REFUSAL); + expect(deckById(id)).toBeDefined(); + // Allowed again, the same calls go through. + configureDecksManagement(true); + beginDelete(id); + expect(getDecksState().deleting).toBe(id); + expect(await removeDeck(id)).toBe(true); + }); + + it('closes a dialog already open when management is taken away', async () => { + await addDeck({ collection: 'talks', slug: 'open-dialog', spec: spec('Open dialog') }); + const id = 'talks/open-dialog'; + beginRename(id); + expect(getDecksState().renaming).toBe(id); + configureDecksManagement(false); + expect(getDecksState().renaming).toBeUndefined(); + configureDecksManagement(true); + }); +}); diff --git a/src/plugin/DeckList.tsx b/src/plugin/DeckList.tsx index d606238..2f90a83 100644 --- a/src/plugin/DeckList.tsx +++ b/src/plugin/DeckList.tsx @@ -45,34 +45,39 @@ const FAMILY_OF_ONE = ''; * trailing action is a sibling of the row's button inside the `li`, and it * anchors a controlled menu. */ -const RowActions = ({ id, title }: { id: string; title: string }): JSX.Element => ( - - - - - - - beginRename(id)}> - - - - Rename… - - beginDelete(id)}> - - - - Delete… - - - - -); +const RowActions = ({ id, title }: { id: string; title: string }): JSX.Element => { + // Shown to everybody, pressable by whoever may manage decks: a reader who + // may not sees what there would be to do, and that it is not theirs. + const { canManage } = useDecksState(); + return ( + + + + + + + beginRename(id)}> + + + + Rename… + + beginDelete(id)}> + + + + Delete… + + + + + ); +}; /** * One deck: a button that opens it, and the kebab beside it — siblings, so diff --git a/src/plugin/store.ts b/src/plugin/store.ts index e5b2ebd..4dbfb67 100644 --- a/src/plugin/store.ts +++ b/src/plugin/store.ts @@ -49,6 +49,13 @@ export type DecksState = { backendUrl?: string; /** The last thing the backend said that went wrong, for the list to show. */ error?: string; + /** + * Whether the reader may rename and delete decks. True unless the host + * says otherwise (`configureDecksManagement`): a host with accounts turns + * it off for whoever is not an administrator. It gates every way in — the + * row's menu, the palette, the keystroke, and the commands an agent calls. + */ + canManage: boolean; /** * Bumped whenever someone asks to *see* decks — a deck opened, the list * shown — even when nothing else changed. A host with its own notion of @@ -59,7 +66,12 @@ export type DecksState = { revealed: number; }; -let state: DecksState = { slide: 1, creating: false, revealed: 0 }; +const FIRST_FRAME: DecksState = { slide: 1, creating: false, revealed: 0, canManage: true }; + +let state: DecksState = FIRST_FRAME; + +/** What a reader who may not manage decks is told, wherever they tried from. */ +export const DECKS_MANAGEMENT_REFUSAL = 'Only an administrator can rename or delete a deck.'; const listeners = new Set<() => void>(); const set = (patch: Partial): void => { @@ -103,6 +115,10 @@ export const beginNewDeck = (): void => set({ creating: true }); export const cancelNewDeck = (): void => set({ creating: false }); /** Open the rename dialog for a deck — the open one when none is named. */ export const beginRename = (id?: string): void => { + if (!state.canManage) { + set({ error: DECKS_MANAGEMENT_REFUSAL }); + return; + } const target = id ?? state.selected; if (target && deckById(target)) { set({ renaming: target }); @@ -111,6 +127,10 @@ export const beginRename = (id?: string): void => { export const cancelRename = (): void => set({ renaming: undefined }); /** Open the delete confirmation for a deck — the open one when none is named. */ export const beginDelete = (id?: string): void => { + if (!state.canManage) { + set({ error: DECKS_MANAGEMENT_REFUSAL }); + return; + } const target = id ?? state.selected; if (target && deckById(target)) { set({ deleting: target }); @@ -119,10 +139,23 @@ export const beginDelete = (id?: string): void => { export const cancelDelete = (): void => set({ deleting: undefined }); export const configureDecksBackend = (backendUrl: string | undefined): void => set({ backendUrl: backendUrl?.replace(/\/+$/, '') || undefined }); +/** + * Say whether the reader may rename and delete decks. A host calls it when + * it knows who is reading; a dialog already open for someone who may not is + * closed. + */ +export const configureDecksManagement = (allowed: boolean): void => { + if (state.canManage === allowed) { + return; + } + set( + allowed ? { canManage: true } : { canManage: false, renaming: undefined, deleting: undefined }, + ); +}; /** For tests: back to the first frame. */ export const resetDecksState = (): void => { - state = { slide: 1, creating: false, revealed: 0 }; + state = FIRST_FRAME; listeners.forEach((listener) => listener()); }; @@ -368,6 +401,9 @@ export type RenameInput = { * a backend that is down leaves a message in the list, as every write does. */ export const renameDeck = async (id: string, input: RenameInput): Promise => { + if (!state.canManage) { + throw new Error(DECKS_MANAGEMENT_REFUSAL); + } const current = deckById(id); if (!current) { throw new Error(`There is no deck ${id} to rename.`); @@ -384,6 +420,9 @@ export const renameDeck = async (id: string, input: RenameInput): Promise => { + if (!state.canManage) { + throw new Error(DECKS_MANAGEMENT_REFUSAL); + } if (!deckById(id)) { return false; }