Skip to content

Commit

Projects: pinned and recent projects, per person per workspace

Each person pins up to eight projects in a workspace, in an order they choose, and the projects they open are remembered (their latest 100), so a workspace's sidebar can show their pins and then what they opened last instead of every project. Kept by project id in the projects service's D1 (migration 0008_pins.sql), so renames and transfers keep them; projects that are hidden or gone are left out, and purged ones take their pins and visits with them. New RPC methods: shortcuts, pin, unpin, reorder_pins and visited. A person's own only: workspace and agent tokens are refused.

syntaqxcommitted Parentfb3a1daBrowse files
6 files+287−00/6 viewed
+5−0
638638 addDependency: (actor, workspace, slug, on, as) => call("add_dependency", { actor, workspace, slug, on, as }),
639639 removeDependency: (actor, workspace, slug, on) => call("remove_dependency", { actor, workspace, slug, on }),
640640 graph: (projectId) => call("graph", { projectId }),
641+ shortcuts: (workspace, viewer) => call("shortcuts", { workspace, viewer }),
642+ pin: (actor, workspace, slug, position) => call("pin", { actor, workspace, slug, position: position ?? null }),
643+ unpin: (actor, workspace, slug) => call("unpin", { actor, workspace, slug }),
644+ reorderPins: (actor, workspace, slugs) => call("reorder_pins", { actor, workspace, slugs }),
645+ visited: (actor, projectId) => call("visited", { actor, projectId }),
641646 };
642647 }
+21−0
100100 usedBy: { id: string; slug: string; workspace: string; as: string | null }[];
101101 };
102102
103+/**
104+ * What a person keeps at hand in a workspace: the projects they pinned, in
105+ * their order, then the ones they opened last that they have not pinned.
106+ * Only projects they can still see.
107+ */
108+export type ProjectShortcuts = { pinned: Project[]; recent: Project[] };
109+
103110 export interface ProjectsApi {
104111 /** A workspace's projects, by name. Members, or anyone for public repositories. */
105112 list(workspace: string, viewer: Viewer): Promise<Result<Project[]>>;
128135 removeDependency(actor: User, workspace: string, slug: string, on: string): Promise<Result<Dependencies>>;
129136 /** For services: a project's dependencies by id. */
130137 graph(projectId: string): Promise<ProjectGraph>;
138+ /** A person's pinned and recent projects in a workspace. Empty for anyone else. */
139+ shortcuts(workspace: string, viewer: Viewer): Promise<ProjectShortcuts>;
140+ /**
141+ * Pins a project the person can see, at `position` (0 first) or at the
142+ * end; pinning one already pinned moves it. A person's own, at most 8 a
143+ * workspace. Returns their pins, in order.
144+ */
145+ pin(actor: User, workspace: string, slug: string, position?: number | null): Promise<Result<Project[]>>;
146+ /** Unpins it. Returns their pins, in order. */
147+ unpin(actor: User, workspace: string, slug: string): Promise<Result<Project[]>>;
148+ /** Puts their pins in this order: every pinned project's slug, once. */
149+ reorderPins(actor: User, workspace: string, slugs: string[]): Promise<Result<Project[]>>;
150+ /** The person opened the project: it leads their recent ones. */
151+ visited(actor: User, projectId: string): Promise<void>;
131152 }
+25−0
1+-- What each person keeps at hand in a workspace: the projects they pinned,
2+-- in the order they put them, and the ones they opened last. Keyed by the
3+-- project's id, so a rename or a transfer keeps them; the workspace is
4+-- the project's own, read through `projects`.
5+
6+CREATE TABLE pins (
7+ user_id TEXT NOT NULL,
8+ project_id TEXT NOT NULL,
9+ -- 0 first, within the person's pins in the project's workspace.
10+ position INTEGER NOT NULL,
11+ pinned_at TEXT NOT NULL,
12+ PRIMARY KEY (user_id, project_id)
13+);
14+CREATE INDEX pins_by_project ON pins (project_id);
15+
16+-- The last time each person opened each project: kept for their latest
17+-- few, written after the page is sent.
18+CREATE TABLE visits (
19+ user_id TEXT NOT NULL,
20+ project_id TEXT NOT NULL,
21+ visited_at TEXT NOT NULL,
22+ PRIMARY KEY (user_id, project_id)
23+);
24+CREATE INDEX visits_by_user ON visits (user_id, visited_at DESC);
25+CREATE INDEX visits_by_project ON visits (project_id);
+147−0
1717 import { NEEDS, repoRef } from "./access";
1818 import { effectiveDescription, ownDescription } from "./description";
1919 import { type KindFacts, type RootFiles, MANIFESTS, deploysSetting, detectKind, goFilesToRead, resolveKind } from "./kind";
20+import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins";
2021 import { renameStatements } from "./rename";
2122 import { moveStatements, slugOf, strandedQuery } from "./transfer";
2223
4647 type Project,
4748 type ProjectEcosystem,
4849 type ProjectGraph,
50+ type ProjectShortcuts,
4951 type Repo,
5052 type Result,
5153 type ServiceBinding,
435437 await this.db.prepare("UPDATE projects SET deployments_on = ? WHERE id = ?").bind(a.enabled ? 1 : 0, a.projectId).run();
436438 }
437439
440+ // ---- Pinned and recent ---------------------------------------------------
441+
442+ /** The person's pins in the workspace, in order, as rows: hidden projects left out. */
443+ private async pinRows(userId: string, workspace: string): Promise<Row[]> {
444+ const rows = await this.db
445+ .prepare(
446+ `SELECT p.* FROM pins n JOIN projects p ON p.id = n.project_id
447+ WHERE n.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY n.position, n.pinned_at`,
448+ )
449+ .bind(userId, workspace.toLowerCase())
450+ .all<Row>();
451+ return rows.results;
452+ }
453+
454+ /** Writes the person's pins in the workspace in this order, by project id. */
455+ private async writePins(userId: string, order: string[], dropped: string[] = []): Promise<void> {
456+ const at = now();
457+ await this.db.batch([
458+ ...dropped.map((id) => this.db.prepare("DELETE FROM pins WHERE user_id = ? AND project_id = ?").bind(userId, id)),
459+ ...order.map((id, position) =>
460+ this.db
461+ .prepare(
462+ `INSERT INTO pins (user_id, project_id, position, pinned_at) VALUES (?, ?, ?, ?)
463+ ON CONFLICT (user_id, project_id) DO UPDATE SET position = excluded.position`,
464+ )
465+ .bind(userId, id, position, at),
466+ ),
467+ ]);
468+ }
469+
470+ /** The person behind a call about their own pins, or why there is none. */
471+ private person(viewer: Viewer): Result<User> {
472+ if (!viewer) return fail("unauthenticated", "Sign in to pin projects.");
473+ if (viewer.kind && viewer.kind !== "user") return fail("forbidden", "Pins are a person's own: use a personal access token.");
474+ return ok(viewer);
475+ }
476+
477+ /** A project the person can see, for pinning. */
478+ private async pinnable(actor: User, workspace: string, slug: string): Promise<Result<Row>> {
479+ const row = await this.row(workspace, slug);
480+ if (!row || !this.visible(row, actor)) return fail("not_found", "There is no such project.");
481+ return ok(row);
482+ }
483+
484+ async shortcuts(a: { workspace: string; viewer: Viewer }): Promise<ProjectShortcuts> {
485+ const who = this.person(a.viewer);
486+ if (!who.ok) return { pinned: [], recent: [] };
487+ const workspace = a.workspace.toLowerCase();
488+ const [pinned, visited] = await Promise.all([
489+ this.pinRows(who.value.id, workspace),
490+ this.db
491+ .prepare(
492+ `SELECT p.* FROM visits v JOIN projects p ON p.id = v.project_id
493+ WHERE v.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY v.visited_at DESC LIMIT ?`,
494+ )
495+ .bind(who.value.id, workspace, MAX_PINS + MAX_RECENT)
496+ .all<Row>(),
497+ ]);
498+ const shown = (row: Row) => this.visible(row, who.value);
499+ const pins = pinned.filter(shown);
500+ return {
501+ pinned: pins.map(toProject),
502+ recent: recentAfterPins(visited.results.filter(shown), pins.map((row) => row.id)).map(toProject),
503+ };
504+ }
505+
506+ async pin(a: { actor: User; workspace: string; slug: string; position?: number | null }): Promise<Result<Project[]>> {
507+ const who = this.person(a.actor);
508+ if (!who.ok) return who;
509+ const found = await this.pinnable(who.value, a.workspace, a.slug);
510+ if (!found.ok) return found;
511+ const current = await this.pinRows(who.value.id, found.value.workspace);
512+ const order = placePin(
513+ current.map((row) => row.id),
514+ found.value.id,
515+ a.position,
516+ );
517+ if (!order) return fail("conflict", `You can pin ${MAX_PINS} projects in a workspace. Unpin one first.`);
518+ await this.writePins(who.value.id, order);
519+ return ok((await this.pinRows(who.value.id, found.value.workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
520+ }
521+
522+ async unpin(a: { actor: User; workspace: string; slug: string }): Promise<Result<Project[]>> {
523+ const who = this.person(a.actor);
524+ if (!who.ok) return who;
525+ const row = await this.row(a.workspace, a.slug);
526+ if (!row) return fail("not_found", "There is no such project.");
527+ const rest = (await this.pinRows(who.value.id, row.workspace)).map((pinned) => pinned.id).filter((id) => id !== row.id);
528+ await this.writePins(who.value.id, rest, [row.id]);
529+ return ok((await this.pinRows(who.value.id, row.workspace)).filter((pinned) => this.visible(pinned, who.value)).map(toProject));
530+ }
531+
532+ async reorderPins(a: { actor: User; workspace: string; slugs: string[] }): Promise<Result<Project[]>> {
533+ const who = this.person(a.actor);
534+ if (!who.ok) return who;
535+ if (!Array.isArray(a.slugs)) return fail("invalid", "Give the pinned projects' slugs, in order.");
536+ const workspace = a.workspace.toLowerCase();
537+ const current = await this.pinRows(who.value.id, workspace);
538+ const idOf = new Map(current.map((row) => [row.slug, row.id]));
539+ const wanted = a.slugs.map((slug) => idOf.get(String(slug).toLowerCase()) ?? String(slug));
540+ const order = reorderPins(
541+ current.map((row) => row.id),
542+ wanted,
543+ );
544+ if (!order.ok) return fail("invalid", order.message);
545+ await this.writePins(who.value.id, order.order);
546+ return ok((await this.pinRows(who.value.id, workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
547+ }
548+
549+ async visited(a: { actor: User; projectId: string }): Promise<void> {
550+ const who = this.person(a.actor);
551+ if (!who.ok || !a.projectId) return;
552+ await this.db.batch([
553+ this.db
554+ .prepare(
555+ `INSERT INTO visits (user_id, project_id, visited_at) VALUES (?, ?, ?)
556+ ON CONFLICT (user_id, project_id) DO UPDATE SET visited_at = excluded.visited_at`,
557+ )
558+ .bind(who.value.id, a.projectId, now()),
559+ // Only their latest few are kept.
560+ this.db
561+ .prepare(
562+ `DELETE FROM visits WHERE user_id = ?1 AND project_id NOT IN
563+ (SELECT project_id FROM visits WHERE user_id = ?1 ORDER BY visited_at DESC LIMIT ?2)`,
564+ )
565+ .bind(who.value.id, MAX_VISITS),
566+ ]);
567+ }
568+
438569 // ---- App or library --------------------------------------------------
439570
440571 /**
768899 this.db
769900 .prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`)
770901 .bind(event.data.repoId),
902+ // Pins and visits of what is gone go with it.
903+ this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId),
904+ this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId),
771905 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
772906 ]);
773907 return;
849983 const ids = "SELECT id FROM projects WHERE workspace = ?1";
850984 await this.db.batch([
851985 this.db.prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`).bind(slug),
986+ this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug),
987+ this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(slug),
852988 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
853989 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
854990 ]);
8961032 return Response.json(await service.removeDependency(args));
8971033 case "graph":
8981034 return Response.json(await service.graph(args));
1035+ case "shortcuts":
1036+ return Response.json(await service.shortcuts(args));
1037+ case "pin":
1038+ return Response.json(await service.pin(args));
1039+ case "unpin":
1040+ return Response.json(await service.unpin(args));
1041+ case "reorder_pins":
1042+ return Response.json(await service.reorderPins(args));
1043+ case "visited":
1044+ await service.visited(args);
1045+ return Response.json(null);
8991046 case "context_for_repo":
9001047 return Response.json(await service.contextForRepo(args));
9011048 default:
+44−0
1+import { test } from "node:test";
2+import assert from "node:assert/strict";
3+
4+import { MAX_PINS, placePin, recentAfterPins, reorderPins } from "./pins.ts";
5+
6+test("a pin goes at the end, or where it is asked for", () => {
7+ assert.deepEqual(placePin([], "a"), ["a"]);
8+ assert.deepEqual(placePin(["a", "b"], "c"), ["a", "b", "c"]);
9+ assert.deepEqual(placePin(["a", "b"], "c", 0), ["c", "a", "b"]);
10+ assert.deepEqual(placePin(["a", "b"], "c", 1), ["a", "c", "b"]);
11+ // Past either end lands at that end.
12+ assert.deepEqual(placePin(["a", "b"], "c", 99), ["a", "b", "c"]);
13+ assert.deepEqual(placePin(["a", "b"], "c", -3), ["c", "a", "b"]);
14+});
15+
16+test("pinning one already pinned moves it, never twice", () => {
17+ assert.deepEqual(placePin(["a", "b", "c"], "c", 0), ["c", "a", "b"]);
18+ assert.deepEqual(placePin(["a", "b", "c"], "a"), ["b", "c", "a"]);
19+});
20+
21+test("a workspace holds a limited number of pins", () => {
22+ const full = Array.from({ length: MAX_PINS }, (_, i) => `p${i}`);
23+ assert.equal(placePin(full, "new"), null);
24+ // Moving one that is already there is still fine.
25+ assert.deepEqual(placePin(full, "p3", 0)?.[0], "p3");
26+ assert.equal(placePin(full.slice(1), "new")?.length, MAX_PINS);
27+});
28+
29+test("reordering names every pin once and nothing else", () => {
30+ assert.deepEqual(reorderPins(["a", "b", "c"], ["c", "a", "b"]), { ok: true, order: ["c", "a", "b"] });
31+ assert.equal(reorderPins(["a", "b"], ["a", "a"]).ok, false);
32+ assert.equal(reorderPins(["a", "b"], ["a"]).ok, false);
33+ assert.equal(reorderPins(["a", "b"], ["a", "b", "z"]).ok, false);
34+ assert.deepEqual(reorderPins([], []), { ok: true, order: [] });
35+});
36+
37+test("recent leaves out what is pinned and keeps the latest few", () => {
38+ const visited = ["a", "b", "c", "d", "e", "f", "g"].map((id) => ({ id }));
39+ assert.deepEqual(
40+ recentAfterPins(visited, ["b", "d"]).map((p) => p.id),
41+ ["a", "c", "e", "f", "g"],
42+ );
43+ assert.deepEqual(recentAfterPins(visited, [], 2).map((p) => p.id), ["a", "b"]);
44+});
+45−0
1+/**
2+ * Pinned and recent projects: what each person keeps at hand in a
3+ * workspace. Pins are theirs to order, up to `MAX_PINS` a workspace;
4+ * recent is the projects they opened last that they have not pinned.
5+ * Pure, so the rules are tested apart from the service.
6+ */
7+
8+/** Pins a person may have in one workspace: what its sidebar shows. */
9+export const MAX_PINS = 8;
10+/** Recent projects shown, after the pins. */
11+export const MAX_RECENT = 5;
12+/** Visits kept per person, across their workspaces. */
13+export const MAX_VISITS = 100;
14+
15+/**
16+ * The pins' order after pinning `id`: at `position` (0 first) when given,
17+ * else at the end. Pinning one already pinned moves it. Null when the
18+ * workspace's pins are full.
19+ */
20+export function placePin(order: string[], id: string, position?: number | null): string[] | null {
21+ const rest = order.filter((pinned) => pinned !== id);
22+ if (rest.length >= MAX_PINS) return null;
23+ const at = position == null || !Number.isFinite(position) ? rest.length : Math.max(0, Math.min(rest.length, Math.trunc(position)));
24+ return [...rest.slice(0, at), id, ...rest.slice(at)];
25+}
26+
27+/**
28+ * The pins in the order asked for. Every pin is named once and nothing
29+ * else, or the reason it cannot be.
30+ */
31+export function reorderPins(order: string[], wanted: string[]): { ok: true; order: string[] } | { ok: false; message: string } {
32+ const pinned = new Set(order);
33+ const named = new Set(wanted);
34+ if (named.size !== wanted.length) return { ok: false, message: "Name each pinned project once." };
35+ const stranger = wanted.find((id) => !pinned.has(id));
36+ if (stranger) return { ok: false, message: `${stranger} is not pinned.` };
37+ if (named.size !== pinned.size) return { ok: false, message: "Name every pinned project, in the order you want them." };
38+ return { ok: true, order: [...wanted] };
39+}
40+
41+/** The recent projects to show: latest first, leaving out the pinned, at most `MAX_RECENT`. */
42+export function recentAfterPins<T extends { id: string }>(visited: T[], pinned: Iterable<string>, limit = MAX_RECENT): T[] {
43+ const skip = new Set(pinned);
44+ return visited.filter((project) => !skip.has(project.id)).slice(0, limit);
45+}