Public: anyone can read this; the crew can change it.

A moved repository's page says "Moved from alice/demo" with the date, for 30 days after the move #10121

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10109-a-moved-repository-s-page-says-moved-fro → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/10109-a-moved-repository-s-page-says-moved-fro was merged into main as 4e009c8.

Review

Sign in to review this change.
7 files changed +123 −3
docs/design/areas/code.md +2
@@ -8,3 +8,5 @@
88 - `.rail-facts`, `.rail-fact` — the facts in a repository's About rail (`aboutCard` in `src/web/pages/repo.ts`): a `dl` of label-over-value pairs, 12px apart, no rules between them, values wrapping anywhere. The rail is narrower than `.status-row`'s 168px label column, so it stacks instead.
99
1010 - `.card.bare` — a card with its frame taken off (`card({ extra: "bare" })`): no border, shadow, fill or padding, no rules between rows, head and foot as plain lines. The repository page's file listing, README, Following panel and fork line use it; the markup, and so every hook, stays the same.
11
12 - `.moved-from` (#10109) — the one muted line "Moved from <old path> on <date>" under a repository head for 30 days after a move; the path wraps anywhere, so no sideways scroll at 390px.
docs/features/repo-transfer.md +7
@@ -33,3 +33,10 @@ the old path takes the path back.
3333
3434 Not yet: git remotes under the old path, runs and previews follow in the next
3535 step.
36
37 For 30 days after a move, the repository's page says where it came from: one
38 muted line, "Moved from alice/demo on 2026-11-09", under the head, for someone
39 arriving by an old link. The terminal says the same on the About card (`I` on
40 the repository's screen) as a "moved from" row. Both read the newest
41 `repo_transfer` row through `recentMove` in `src/core/transfer.ts`; after 30
42 days, or for a repository never moved, neither shows anything.
src/core/transfer.ts +24
@@ -166,3 +166,27 @@ export function transfersOf(db: Database, repoId: string): TransferRecord[] {
166166 .all(repoId)
167167 .map((r) => ({ from: r.from_path, to: r.to_path, actorId: r.actor_id, at: r.created_at }));
168168 }
169
170 /** How long after a move the repository's page still says where it came from. */
171 export const MOVED_NOTE_DAYS = 30;
172
173 /**
174 * Where the repository was moved from, when its newest move is under
175 * {@link MOVED_NOTE_DAYS} days old; undefined for one never moved or moved
176 * longer ago. `now` is the clock, so a test can set it.
177 */
178 export function recentMove(
179 db: Database,
180 repoId: string,
181 now: number = Date.now(),
182 ): { from: string; at: string } | undefined {
183 const row = db
184 .query<{ from_path: string; created_at: string }, [string]>(
185 "SELECT from_path, created_at FROM repo_transfer WHERE repo_id = ?1 ORDER BY id DESC LIMIT 1",
186 )
187 .get(repoId);
188 if (row === null) return undefined;
189 const age = now - Date.parse(row.created_at);
190 if (Number.isNaN(age) || age >= MOVED_NOTE_DAYS * 86_400_000) return undefined;
191 return { from: row.from_path, at: row.created_at };
192 }
src/tui/screens/about.ts +9
@@ -16,7 +16,9 @@
1616 */
1717
1818 import { sourcesScreen } from "./sources.ts";
19 import type { Database } from "bun:sqlite";
1920 import { audienceFor } from "../../core/audience.ts";
21 import { recentMove } from "../../core/transfer.ts";
2022 import { isPinned } from "../../core/pins.ts";
2123 import { parentReleases, releasesSentence } from "../../core/parent_releases.ts";
2224 import { running } from "../../kernel/version.ts";
@@ -126,6 +128,7 @@ export function aboutScreen(
126128 repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true;
127129 const factRows = (): [string, string][] => [
128130 ...archivedRow(repo),
131 ...movedFromRow(f.db, repo),
129132 ...movingRow(f, repo),
130133 ...outsidersRow(repo),
131134 ...seatRows(f, repo),
@@ -758,6 +761,12 @@ function descriptionRefusal(value: string): string | undefined {
758761 return undefined;
759762 }
760763
764 /** "Moved from <old path> on <date>" for 30 days after a move: the web page's line, from the same function. */
765 export function movedFromRow(db: Database, repo: Repo | undefined, now: number = Date.now()): [string, string][] {
766 const move = repo === undefined ? undefined : recentMove(db, repo.id, now);
767 return move === undefined ? [] : [["moved from", `${move.from} on ${move.at.slice(0, 10)}`]];
768 }
769
761770 /** One line when an admin has put the repository away. */
762771 function archivedRow(repo: Repo | undefined): [string, string][] {
763772 if (repo === undefined || repo.archivedAt === null) return [];
src/web/pages/repo.ts +10 −2
@@ -90,6 +90,7 @@ import { RENDERERS, rendererFor } from "../render/renderers.ts";
9090 import { mediaHref } from "./media.ts";
9191 import { browseBase, localScope, type RepoScope } from "../scope.ts";
9292 import { repoForkOf } from "../../core/origins.ts";
93 import { recentMove } from "../../core/transfer.ts";
9394 import {
9495 badge,
9596 btn,
@@ -1284,6 +1285,13 @@ export async function repoFiles(ctx: Ctx): Promise<Response> {
12841285 return await codeHome(ctx, repo);
12851286 }
12861287
1288 /** "Moved from <old path> on <date>" for 30 days after a move; one muted line. */
1289 function movedLine(ctx: Ctx, repo: Repo): Html | null {

Both doors: this line exists only on the page. Give the TUI repository screen the same line from recentMove, with a test, and add docs/features/<name>.md.

1290 const move = recentMove(ctx.app.db, repo.id);
1291 if (move === undefined) return null;
1292 return html`<p class="muted small moved-from">Moved from ${move.from} on ${time(move.at, { date: true })}</p>`;
1293 }
1294
12871295 /**
12881296 * "Archived on <date>" with, for an admin, the Wake button. A repository put
12891297 * away still clones, pushes and links as before; this only says so.
@@ -1318,7 +1326,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
13181326
13191327 if (refs.branches.length === 0) {
13201328 const emptyRendering = performance.now();
1321 const body = html`${scopeHeader(ctx, scope, "code")}${archivedLine(ctx, repo)}${noApp}
1329 const body = html`${scopeHeader(ctx, scope, "code")}${archivedLine(ctx, repo)}${movedLine(ctx, repo)}${noApp}
13221330 <div class="mt">
13231331 ${empty({
13241332 icon: "repo",
@@ -1388,7 +1396,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
13881396 const rendering = performance.now();
13891397 const body = html`${scopeHeader(ctx, scope, "code", {
13901398 branchCount: refs.branches.length,
1391 })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${originAheadLine(ctx, repo)}${archivedLine(ctx, repo)}${noApp}
1399 })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${originAheadLine(ctx, repo)}${archivedLine(ctx, repo)}${movedLine(ctx, repo)}${noApp}
13921400 ${offerUpstream(repo, parent, offer)}
13931401 ${latest === null
13941402 ? ""
src/web/style/components.ts +1
@@ -484,6 +484,7 @@ a.row:hover .row-title { color: var(--accent); }
484484 .setting-list[data-origins] > li.has-after > .row-after, .setting-list[data-bets] > li.has-after > .row-after { flex-basis: 100%; display: block; min-width: 0; padding: 0 14px 12px; overflow-wrap: anywhere; }
485485 .list > li.has-after > .row-children { flex-basis: 100%; }
486486 .archived-list .row-title, .archived-list .row-meta { white-space: normal; overflow-wrap: anywhere; }
487 .moved-from { margin: 8px 0 0; overflow-wrap: anywhere; }

Box 2 needs a look at 390px on this pull with a long old path. None was taken; the wrapping is unverified.

487488 @media (max-width: 640px) {
488489 .archived-list .list > li.has-after > .row { flex-basis: 100%; }
489490 .archived-list .row-after { width: 100%; display: flex; justify-content: flex-end; gap: 8px; padding: 0 14px 10px; }
test/repo-transfer.test.ts +70 −1
@@ -17,7 +17,8 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1717 import { createApp as createAppRow, getAppByRepo } from "../src/core/apps.ts";
1818 import { migrationFiles } from "../src/core/schema.ts";
1919 import { createRepo as makeRepo, forkRepo, getRepo, getRepoById, type Repo } from "../src/core/repos.ts";
20 import { transferRepo, transfersOf } from "../src/core/transfer.ts";
20 import { movedFromRow } from "../src/tui/screens/about.ts";
21 import { recentMove, transferRepo, transfersOf } from "../src/core/transfer.ts";
2122 import { getUserByName } from "../src/core/users.ts";
2223 import { commitFiles } from "../src/git/write.ts";
2324 import { readFileSync } from "node:fs";
@@ -265,3 +266,71 @@ describe("transferring a repository", () => {
265266 expect(() => app.db.exec(sql)).not.toThrow();
266267 });
267268 });
269
270 describe("the moved-from line", () => {
271 test("a moved repository says where from for 30 days, a never-moved or older move says nothing", async () => {
272 const dataDir = await mkdtemp(join(tmpdir(), "forge-moved-line-"));
273 const app = createApp({ dataDir, port: 0 });
274 const servers = await serve(app);
275 try {
276 const mk = async (username: string) => {
277 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: PASSWORD, adminIfFirst: username === "alice" });
278 if (!u.ok) throw new Error(u.error);
279 return u.value;
280 };
281 const alice = await mk("alice");
282 const lab = await mk("lab");
283 const made = await makeRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "demo", visibility: "public" });
284 if (!made.ok) throw new Error(made.error);
285 const page = async (path: string) => (await fetch(`${servers.url}${path}`)).text();
286 expect(recentMove(app.db, made.value.id)).toBeUndefined();
287 expect(await page("/alice/demo")).not.toContain("Moved from");
288 const moved = await transferRepo(app.db, app.reposRoot, made.value, lab, alice.id);
289 expect(moved.ok).toBe(true);
290 const html = await page("/lab/demo");
291 expect(html).toContain("Moved from alice/demo on");
292 const move = recentMove(app.db, made.value.id)!;
293 expect(move.from).toBe("alice/demo");
294 const at = Date.parse(move.at);
295 expect(recentMove(app.db, made.value.id, at + 29 * 86_400_000)).toBeDefined();
296 expect(recentMove(app.db, made.value.id, at + 31 * 86_400_000)).toBeUndefined();
297 app.db.query("UPDATE repo_transfer SET created_at = '2020-01-01T00:00:00.000Z'").run();
298 expect(await page("/lab/demo")).not.toContain("Moved from");
299 app.db.query("UPDATE repo_transfer SET from_path = '<b>x</b>/demo', created_at = ?1").run(new Date().toISOString());
300 const escaped = await page("/lab/demo");
301 expect(escaped).toContain("&lt;b&gt;x&lt;/b&gt;/demo");
302 expect(escaped).not.toContain("<b>x</b>");
303 } finally {
304 await servers.stop();
305 await rm(dataDir, { recursive: true, force: true });
306 }
307 }, 30_000);
308 });
309
310 describe("the moved-from row on the terminal's About card", () => {
311 test("says where from for 30 days, through recentMove, and nothing otherwise", async () => {
312 const dataDir = await mkdtemp(join(tmpdir(), "forge-moved-row-"));
313 const app = createApp({ dataDir, port: 0 });
314 try {
315 const mk = async (username: string) => {
316 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: PASSWORD, adminIfFirst: username === "alice" });
317 if (!u.ok) throw new Error(u.error);
318 return u.value;
319 };
320 const alice = await mk("alice");
321 const lab = await mk("lab");
322 const made = await makeRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "demo", visibility: "public" });
323 if (!made.ok) throw new Error(made.error);
324 expect(movedFromRow(app.db, made.value)).toEqual([]);
325 const moved = await transferRepo(app.db, app.reposRoot, made.value, lab, alice.id);
326 expect(moved.ok).toBe(true);
327 const at = Date.parse(recentMove(app.db, made.value.id)!.at);
328 const row = movedFromRow(app.db, made.value, at + 86_400_000);
329 expect(row).toEqual([["moved from", `alice/demo on ${new Date(at).toISOString().slice(0, 10)}`]]);
330 expect(movedFromRow(app.db, made.value, at + 31 * 86_400_000)).toEqual([]);
331 expect(movedFromRow(app.db, undefined)).toEqual([]);
332 } finally {
333 await rm(dataDir, { recursive: true, force: true });
334 }
335 }, 30_000);
336 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs