Skip to content

Commit cb2ba2c

Browse files
committed
Implement pinned charms
1 parent c2a8e5e commit cb2ba2c

File tree

3 files changed

+227
-39
lines changed

3 files changed

+227
-39
lines changed

typescript/packages/common-charm/src/charm.ts

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import {
2-
JSONSchema,
3-
Module,
4-
NAME,
5-
Recipe,
6-
TYPE,
7-
UI,
8-
} from "@commontools/builder";
1+
import { JSONSchema, Module, NAME, Recipe, TYPE, UI } from "@commontools/builder";
92
import {
103
type Cell,
114
createRef,
@@ -55,12 +48,17 @@ export const processSchema = {
5548
export class CharmManager {
5649
private space: Space;
5750
private charmsDoc: DocImpl<DocLink[]>;
51+
private pinned: DocImpl<DocLink[]>;
52+
5853
private charms: Cell<Cell<Charm>[]>;
54+
private pinnedCharms: Cell<Cell<Charm>[]>;
5955

6056
constructor(private spaceId: string) {
6157
this.space = getSpace(this.spaceId);
6258
this.charmsDoc = getDoc<DocLink[]>([], "charms", this.space);
59+
this.pinned = getDoc<DocLink[]>([], "pinned-charms", this.space);
6360
this.charms = this.charmsDoc.asCell([], undefined, charmListSchema);
61+
this.pinnedCharms = this.pinned.asCell([], undefined, charmListSchema);
6462
}
6563

6664
getReplica(): string | undefined {
@@ -71,6 +69,24 @@ export class CharmManager {
7169
return await storage.synced();
7270
}
7371

72+
async pin(charm: Cell<Charm>) {
73+
await storage.syncCell(this.pinned);
74+
this.pinnedCharms.push(charm);
75+
await idle();
76+
}
77+
78+
async unpin(charm: Cell<Charm>) {
79+
// probably wrong, should compare ids
80+
await storage.syncCell(this.pinned);
81+
this.pinnedCharms.set(this.pinnedCharms.get().filter((c) => c != charm));
82+
await idle();
83+
}
84+
85+
getPinned(): Cell<Cell<Charm>[]> {
86+
storage.syncCell(this.pinned);
87+
return this.pinnedCharms;
88+
}
89+
7490
getCharms(): Cell<Cell<Charm>[]> {
7591
// Start syncing if not already syncing. Will trigger a change to the list
7692
// once loaded.
@@ -150,11 +166,7 @@ export class CharmManager {
150166
path: string[] = [],
151167
schema?: JSONSchema,
152168
): Promise<Cell<T>> {
153-
return (await storage.syncCellById(this.space, id)).asCell(
154-
path,
155-
undefined,
156-
schema,
157-
);
169+
return (await storage.syncCellById(this.space, id)).asCell(path, undefined, schema);
158170
}
159171

160172
// Return Cell with argument content according to the schema of the charm.
@@ -163,9 +175,7 @@ export class CharmManager {
163175
const recipeId = source?.get()?.[TYPE];
164176
const recipe = getRecipe(recipeId);
165177
const argumentSchema = recipe?.argumentSchema;
166-
return source?.key("argument").asSchema(argumentSchema!) as
167-
| Cell<T>
168-
| undefined;
178+
return source?.key("argument").asSchema(argumentSchema!) as Cell<T> | undefined;
169179
}
170180

171181
// note: removing a charm doesn't clean up the charm's cells
@@ -175,9 +185,7 @@ export class CharmManager {
175185
const id = getEntityId(idOrCharm);
176186
if (!id) return false;
177187

178-
const newCharms = this.charms.get().filter((charm) =>
179-
getEntityId(charm)?.["/"] !== id?.["/"]
180-
);
188+
const newCharms = this.charms.get().filter((charm) => getEntityId(charm)?.["/"] !== id?.["/"]);
181189
if (newCharms.length !== this.charms.get().length) {
182190
this.charms.set(newCharms);
183191
await idle();
@@ -187,11 +195,7 @@ export class CharmManager {
187195
return false;
188196
}
189197

190-
async runPersistent(
191-
recipe: Recipe | Module,
192-
inputs?: any,
193-
cause?: any,
194-
): Promise<Cell<Charm>> {
198+
async runPersistent(recipe: Recipe | Module, inputs?: any, cause?: any): Promise<Cell<Charm>> {
195199
await idle();
196200

197201
// Fill in missing parameters from other charms. It's a simple match on
@@ -247,10 +251,7 @@ export class CharmManager {
247251

248252
await syncAllMentionedCells(inputs);
249253

250-
const doc = await storage.syncCellById(
251-
this.space,
252-
createRef({ recipe, inputs }, cause),
253-
);
254+
const doc = await storage.syncCellById(this.space, createRef({ recipe, inputs }, cause));
254255
const resultDoc = run(recipe, inputs, doc);
255256

256257
// FIXME(ja): should we add / sync explicitly here?
@@ -264,10 +265,7 @@ export class CharmManager {
264265
const recipeId = charm.getSourceCell()?.get()?.[TYPE];
265266
if (!recipeId) return Promise.resolve(undefined);
266267

267-
return Promise.all([
268-
this.syncRecipeCells(recipeId),
269-
this.syncRecipeBlobby(recipeId),
270-
]).then(
268+
return Promise.all([this.syncRecipeCells(recipeId), this.syncRecipeBlobby(recipeId)]).then(
271269
() => recipeId,
272270
);
273271
}

typescript/packages/jumble/src/components/commands.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,28 @@ export function getCommands(deps: CommandContext): CommandItem[] {
683683
message: "Are you sure you want to delete this charm?",
684684
handler: () => handleDeleteCharm(deps),
685685
},
686+
{
687+
id: "pin-charm",
688+
type: "action",
689+
title: "Pin Charm",
690+
group: "View",
691+
predicate: !!deps.focusedCharmId,
692+
handler: async () => {
693+
if (!deps.focusedCharmId || !deps.focusedReplicaId) {
694+
deps.setOpen(false);
695+
return;
696+
}
697+
698+
const charm = await deps.charmManager.get(deps.focusedCharmId);
699+
if (!charm) {
700+
console.error("Failed to load charm", deps.focusedCharmId);
701+
return;
702+
}
703+
704+
await deps.charmManager.pin(charm);
705+
deps.setOpen(false);
706+
},
707+
},
686708
{
687709
id: "view-detail",
688710
type: "action",

typescript/packages/jumble/src/views/CharmList.tsx

Lines changed: 175 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Cell } from "@commontools/runner";
1212
import ShapeLogo from "@/assets/ShapeLogo.tsx";
1313
import { MdOutlineStar } from "react-icons/md";
1414
import { useSyncedStatus } from "@/hooks/use-synced-status.ts";
15+
import { CharmRenderer } from "@/components/CharmRunner.tsx";
1516

1617
export interface CommonDataEvent extends CustomEvent {
1718
detail: {
@@ -96,9 +97,163 @@ function CharmPreview(
9697
);
9798
}
9899

100+
interface HoverPreviewProps {
101+
hoveredCharm: string | null;
102+
charms: Cell<Charm>[];
103+
position: { x: number; y: number };
104+
replicaName: string;
105+
}
106+
const HoverPreview = (
107+
{ hoveredCharm, charms, position, replicaName }: HoverPreviewProps,
108+
) => {
109+
// Find the charm that matches the hoveredCharm ID
110+
const charm = hoveredCharm
111+
? charms.find((c) => charmId(c) === hoveredCharm)
112+
: null;
113+
114+
if (!charm || !hoveredCharm) return null;
115+
116+
const id = charmId(charm);
117+
const name = charm.get()[NAME] || "Unnamed Charm";
118+
119+
return (
120+
<div
121+
className="fixed z-50 w-128 shadow-xl pointer-events-none"
122+
style={{
123+
left: `${position.x}px`,
124+
top: `${position.y}px`,
125+
transform: "translate(25%, 25%)",
126+
}}
127+
>
128+
<CommonCard className="p-2 shadow-xl bg-white">
129+
<h3 className="text-xl font-semibold text-gray-800 mb-4">
130+
{name + ` (#${id!.slice(-4)})`}
131+
</h3>
132+
<div className="w-full bg-gray-50 rounded border border-gray-100 min-h-[256px] pointer-events-none select-none">
133+
<CharmRenderer className="h-full" charm={charm} />
134+
</div>
135+
</CommonCard>
136+
</div>
137+
);
138+
};
139+
140+
interface CharmTableProps {
141+
charms: Cell<Charm>[];
142+
replicaName: string;
143+
charmManager: any;
144+
}
145+
146+
const CharmTable = (
147+
{ charms, replicaName, charmManager }: CharmTableProps,
148+
) => {
149+
const [hoveredCharm, setHoveredCharm] = useState<string | null>(null);
150+
const [previewPosition, setPreviewPosition] = useState({ x: 0, y: 0 });
151+
// Use a ref to cache the last hovered charm to prevent thrashing
152+
const hoveredCharmRef = useRef<string | null>(null);
153+
154+
const handleMouseMove = (e: React.MouseEvent, id: string) => {
155+
// Only update state if the hovered charm has changed
156+
if (hoveredCharmRef.current !== id) {
157+
hoveredCharmRef.current = id;
158+
setHoveredCharm(id);
159+
}
160+
161+
// Position the preview card relative to the cursor
162+
setPreviewPosition({
163+
x: e.clientX + 20, // offset to the right of cursor
164+
y: e.clientY - 100, // offset above the cursor
165+
});
166+
};
167+
168+
const handleMouseLeave = () => {
169+
hoveredCharmRef.current = null;
170+
setHoveredCharm(null);
171+
};
172+
173+
return (
174+
<div className="relative">
175+
<div className="overflow-x-auto shadow-md sm:rounded-lg">
176+
<table className="w-full text-sm text-left text-gray-500">
177+
<thead className="text-xs text-gray-700 uppercase bg-gray-50">
178+
<tr>
179+
<th scope="col" className="px-6 py-3">Name</th>
180+
<th scope="col" className="px-6 py-3">ID</th>
181+
<th scope="col" className="px-6 py-3">Actions</th>
182+
</tr>
183+
</thead>
184+
<tbody>
185+
{charms.map((charm) => {
186+
const id = charmId(charm);
187+
const name = charm.get()[NAME] || "Unnamed Charm";
188+
189+
return (
190+
<tr
191+
key={id}
192+
className="bg-white border-b hover:bg-gray-50 relative"
193+
onMouseMove={(e) => handleMouseMove(e, id!)}
194+
onMouseLeave={handleMouseLeave}
195+
>
196+
<td className="px-6 py-4 font-medium text-gray-900">
197+
<NavLink to={`/${replicaName}/${id}`}>
198+
{name}
199+
</NavLink>
200+
</td>
201+
<td className="px-6 py-4">
202+
<NavLink to={`/${replicaName}/${id}`}>
203+
#{id?.slice(-4)}
204+
</NavLink>
205+
</td>
206+
<td className="px-6 py-4">
207+
<button
208+
type="button"
209+
onClick={(e) => {
210+
e.preventDefault();
211+
if (
212+
globalThis.confirm(
213+
"Are you sure you want to remove this charm?",
214+
)
215+
) {
216+
charmManager.remove({ "/": id! });
217+
}
218+
}}
219+
className="text-gray-400 hover:text-red-500 transition-colors"
220+
>
221+
<svg
222+
xmlns="http://www.w3.org/2000/svg"
223+
width="20"
224+
height="20"
225+
viewBox="0 0 24 24"
226+
fill="none"
227+
stroke="currentColor"
228+
strokeWidth="2"
229+
>
230+
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
231+
</svg>
232+
</button>
233+
</td>
234+
</tr>
235+
);
236+
})}
237+
</tbody>
238+
</table>
239+
</div>
240+
241+
{hoveredCharm && (
242+
<HoverPreview
243+
hoveredCharm={hoveredCharm}
244+
charms={charms}
245+
position={previewPosition}
246+
replicaName={replicaName}
247+
/>
248+
)}
249+
</div>
250+
);
251+
};
252+
99253
export default function CharmList() {
100254
const { replicaName } = useParams<{ replicaName: string }>();
101255
const { charmManager } = useCharmManager();
256+
const [pinned] = useCell(charmManager.getPinned());
102257
const [charms] = useCell(charmManager.getCharms());
103258
const { isSyncing } = useSyncedStatus(charmManager);
104259

@@ -131,15 +286,28 @@ export default function CharmList() {
131286
}
132287

133288
return (
134-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 p-8">
135-
{replicaName &&
136-
charms.map((charm) => (
137-
<CharmPreview
138-
key={charmId(charm)}
139-
charm={charm}
289+
<div className="p-2">
290+
<h1>Pinned</h1>
291+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 p-8">
292+
{replicaName &&
293+
pinned.map((charm) => (
294+
<CharmPreview
295+
key={charmId(charm)}
296+
charm={charm}
297+
replicaName={replicaName}
298+
/>
299+
))}
300+
</div>
301+
<h1>All Charms</h1>
302+
<div className="p-8">
303+
{replicaName && (
304+
<CharmTable
305+
charms={charms}
140306
replicaName={replicaName}
307+
charmManager={charmManager}
141308
/>
142-
))}
309+
)}
310+
</div>
143311
</div>
144312
);
145313
}

0 commit comments

Comments
 (0)