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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10109-a-moved-repository-s-page-says-moved-fro → main 3 commits fork 2 threads · 2 unresolved
7 files
changed +123
−3
docs/design/areas/code.md +2
| @@ -8,3 +8,5 @@ | ||
| 8 | 8 | - `.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. |
| 9 | 9 | |
| 10 | 10 | - `.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. | ||
| 33 | 33 | |
| 34 | 34 | Not yet: git remotes under the old path, runs and previews follow in the next |
| 35 | 35 | 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[] { | ||
| 166 | 166 | .all(repoId) |
| 167 | 167 | .map((r) => ({ from: r.from_path, to: r.to_path, actorId: r.actor_id, at: r.created_at })); |
| 168 | 168 | } |
| 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 @@ | ||
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | 18 | import { sourcesScreen } from "./sources.ts"; |
| 19 | import type { Database } from "bun:sqlite"; | |
| 19 | 20 | import { audienceFor } from "../../core/audience.ts"; |
| 21 | import { recentMove } from "../../core/transfer.ts"; | |
| 20 | 22 | import { isPinned } from "../../core/pins.ts"; |
| 21 | 23 | import { parentReleases, releasesSentence } from "../../core/parent_releases.ts"; |
| 22 | 24 | import { running } from "../../kernel/version.ts"; |
| @@ -126,6 +128,7 @@ export function aboutScreen( | ||
| 126 | 128 | repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true; |
| 127 | 129 | const factRows = (): [string, string][] => [ |
| 128 | 130 | ...archivedRow(repo), |
| 131 | ...movedFromRow(f.db, repo), | |
| 129 | 132 | ...movingRow(f, repo), |
| 130 | 133 | ...outsidersRow(repo), |
| 131 | 134 | ...seatRows(f, repo), |
| @@ -758,6 +761,12 @@ function descriptionRefusal(value: string): string | undefined { | ||
| 758 | 761 | return undefined; |
| 759 | 762 | } |
| 760 | 763 | |
| 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 | ||
| 761 | 770 | /** One line when an admin has put the repository away. */ |
| 762 | 771 | function archivedRow(repo: Repo | undefined): [string, string][] { |
| 763 | 772 | 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"; | ||
| 90 | 90 | import { mediaHref } from "./media.ts"; |
| 91 | 91 | import { browseBase, localScope, type RepoScope } from "../scope.ts"; |
| 92 | 92 | import { repoForkOf } from "../../core/origins.ts"; |
| 93 | import { recentMove } from "../../core/transfer.ts"; | |
| 93 | 94 | import { |
| 94 | 95 | badge, |
| 95 | 96 | btn, |
| @@ -1284,6 +1285,13 @@ export async function repoFiles(ctx: Ctx): Promise<Response> { | ||
| 1284 | 1285 | return await codeHome(ctx, repo); |
| 1285 | 1286 | } |
| 1286 | 1287 | |
| 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 { | |
| 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 | ||
| 1287 | 1295 | /** |
| 1288 | 1296 | * "Archived on <date>" with, for an admin, the Wake button. A repository put |
| 1289 | 1297 | * 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> { | ||
| 1318 | 1326 | |
| 1319 | 1327 | if (refs.branches.length === 0) { |
| 1320 | 1328 | 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} | |
| 1322 | 1330 | <div class="mt"> |
| 1323 | 1331 | ${empty({ |
| 1324 | 1332 | icon: "repo", |
| @@ -1388,7 +1396,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1388 | 1396 | const rendering = performance.now(); |
| 1389 | 1397 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 1390 | 1398 | 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} | |
| 1392 | 1400 | ${offerUpstream(repo, parent, offer)} |
| 1393 | 1401 | ${latest === null |
| 1394 | 1402 | ? "" |
src/web/style/components.ts +1
| @@ -484,6 +484,7 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 484 | 484 | .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; } |
| 485 | 485 | .list > li.has-after > .row-children { flex-basis: 100%; } |
| 486 | 486 | .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. |
||
| 487 | 488 | @media (max-width: 640px) { |
| 488 | 489 | .archived-list .list > li.has-after > .row { flex-basis: 100%; } |
| 489 | 490 | .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"; | ||
| 17 | 17 | import { createApp as createAppRow, getAppByRepo } from "../src/core/apps.ts"; |
| 18 | 18 | import { migrationFiles } from "../src/core/schema.ts"; |
| 19 | 19 | 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"; | |
| 21 | 22 | import { getUserByName } from "../src/core/users.ts"; |
| 22 | 23 | import { commitFiles } from "../src/git/write.ts"; |
| 23 | 24 | import { readFileSync } from "node:fs"; |
| @@ -265,3 +266,71 @@ describe("transferring a repository", () => { | ||
| 265 | 266 | expect(() => app.db.exec(sql)).not.toThrow(); |
| 266 | 267 | }); |
| 267 | 268 | }); |
| 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("<b>x</b>/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 | }); |
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.