add repo pinning and name sorting
Msrc/db/index.ts
| @@ -33,6 +33,7 @@ interface RepositoryTable { | |||
|---|---|---|---|
| 33 | 33 | name: string; | |
| 34 | 34 | description: string | null; | |
| 35 | 35 | is_private: number; | |
| 36 | + | is_pinned: Generated<number>; | |
| 36 | 37 | default_branch: string; | |
| 37 | 38 | created_at: string; | |
| 38 | 39 | issue_seq: Generated<number>; | |
Msrc/db/schema.sql
| @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS repositories ( | |||
|---|---|---|---|
| 29 | 29 | name TEXT UNIQUE NOT NULL, | |
| 30 | 30 | description TEXT, | |
| 31 | 31 | is_private INTEGER NOT NULL DEFAULT 0, | |
| 32 | + | is_pinned INTEGER NOT NULL DEFAULT 0, | |
| 32 | 33 | default_branch TEXT NOT NULL DEFAULT 'main', | |
| 33 | 34 | created_at TEXT NOT NULL, | |
| 34 | 35 | issue_seq INTEGER NOT NULL DEFAULT 0, | |
Msrc/routes/repos.tsx
| @@ -18,6 +18,7 @@ import { | |||
|---|---|---|---|
| 18 | 18 | } from "../services/highlightWorker.ts"; | |
| 19 | 19 | import { renderMarkdown } from "../services/markdown.ts"; | |
| 20 | 20 | import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts"; | |
| 21 | + | import { redirect } from "../lib/redirect.ts"; | |
| 21 | 22 | import { html } from "../views/render.tsx"; | |
| 22 | 23 | import { CommitDetail } from "../views/repos/CommitDetail.tsx"; | |
| 23 | 24 | import { CommitLog } from "../views/repos/CommitLog.tsx"; | |
| @@ -65,14 +66,30 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 65 | 66 | }); | |
| 66 | 67 | }) | |
| 67 | 68 | .guard({ | |
| 68 | - | cookie: t.Cookie({ session: t.Optional(t.String()) }), | |
| 69 | + | cookie: t.Cookie({ | |
| 70 | + | session: t.Optional(t.String()), | |
| 71 | + | repo_sort: t.Optional(t.String()), | |
| 72 | + | }), | |
| 69 | 73 | }) | |
| 74 | + | .post( | |
| 75 | + | "/sort", | |
| 76 | + | ({ body }) => { | |
| 77 | + | const sort = body.sort === "name" ? "name" : "created"; | |
| 78 | + | return redirect( | |
| 79 | + | "/", | |
| 80 | + | `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`, | |
| 81 | + | ); | |
| 82 | + | }, | |
| 83 | + | { body: t.Object({ sort: t.String() }) }, | |
| 84 | + | ) | |
| 70 | 85 | .get( | |
| 71 | 86 | "/", | |
| 72 | 87 | async ({ cookie, query }) => { | |
| 73 | 88 | const user = await resolveSession(cookie.session.value); | |
| 74 | 89 | const search = query.q?.trim() || undefined; | |
| 75 | 90 | const page = Math.max(1, query.page ?? 1); | |
| 91 | + | const sort = | |
| 92 | + | cookie.repo_sort.value === "name" ? "name" : "created"; | |
| 76 | 93 | ||
| 77 | 94 | const isAdmin = user?.isAdmin ?? false; | |
| 78 | 95 | ||
| @@ -123,7 +140,11 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 123 | 140 | ]), | |
| 124 | 141 | ), | |
| 125 | 142 | ) | |
| 126 | - | .orderBy("created_at", "desc") | |
| 143 | + | .orderBy("is_pinned", "desc") | |
| 144 | + | .$if(sort === "name", (qb) => qb.orderBy("name", "asc")) | |
| 145 | + | .$if(sort === "created", (qb) => | |
| 146 | + | qb.orderBy("created_at", "desc"), | |
| 147 | + | ) | |
| 127 | 148 | .limit(REPOS_PER_PAGE) | |
| 128 | 149 | .offset((safePage - 1) * REPOS_PER_PAGE) | |
| 129 | 150 | .execute(); | |
| @@ -142,6 +163,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 142 | 163 | user={user} | |
| 143 | 164 | repos={repos} | |
| 144 | 165 | search={search} | |
| 166 | + | sort={sort} | |
| 145 | 167 | pagination={pagination} | |
| 146 | 168 | />, | |
| 147 | 169 | ); | |
| @@ -578,6 +600,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 578 | 600 | const { | |
| 579 | 601 | description, | |
| 580 | 602 | is_private, | |
| 603 | + | is_pinned, | |
| 581 | 604 | default_branch, | |
| 582 | 605 | issue_template, | |
| 583 | 606 | patch_template, | |
| @@ -603,6 +626,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 603 | 626 | .set({ | |
| 604 | 627 | description: description?.trim() || null, | |
| 605 | 628 | is_private: is_private === "1" ? 1 : 0, | |
| 629 | + | is_pinned: is_pinned === "1" ? 1 : 0, | |
| 606 | 630 | default_branch: newBranch, | |
| 607 | 631 | issue_template: issue_template?.trim() || null, | |
| 608 | 632 | patch_template: patch_template?.trim() || null, | |
| @@ -633,6 +657,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 633 | 657 | body: t.Object({ | |
| 634 | 658 | description: t.Optional(t.String()), | |
| 635 | 659 | is_private: t.Optional(t.String()), | |
| 660 | + | is_pinned: t.Optional(t.String()), | |
| 636 | 661 | default_branch: t.Optional(t.String()), | |
| 637 | 662 | issue_template: t.Optional(t.String()), | |
| 638 | 663 | patch_template: t.Optional(t.String()), | |
Msrc/styles/main.css
| @@ -672,7 +672,6 @@ | |||
|---|---|---|---|
| 672 | 672 | display: flex; | |
| 673 | 673 | align-items: center; | |
| 674 | 674 | gap: var(--space-2); | |
| 675 | - | flex-wrap: wrap; | |
| 676 | 675 | margin-bottom: var(--space-1); | |
| 677 | 676 | } | |
| 678 | 677 | .repo-name { | |
| @@ -680,6 +679,7 @@ | |||
|---|---|---|---|
| 680 | 679 | font-weight: 600; | |
| 681 | 680 | color: var(--color-link); | |
| 682 | 681 | text-decoration: none; | |
| 682 | + | word-break: break-all; | |
| 683 | 683 | } | |
| 684 | 684 | .repo-name:hover { | |
| 685 | 685 | text-decoration: underline; | |
| @@ -713,6 +713,11 @@ | |||
|---|---|---|---|
| 713 | 713 | border-color: var(--color-warning); | |
| 714 | 714 | background: var(--color-warning-bg); | |
| 715 | 715 | } | |
| 716 | + | .badge-pinned { | |
| 717 | + | color: var(--color-accent); | |
| 718 | + | border-color: var(--color-accent); | |
| 719 | + | background: var(--color-accent-bg); | |
| 720 | + | } | |
| 716 | 721 | .issue-badge, | |
| 717 | 722 | .patch-badge { | |
| 718 | 723 | display: inline-flex; | |
| @@ -2081,14 +2086,37 @@ | |||
|---|---|---|---|
| 2081 | 2086 | padding: var(--space-4); | |
| 2082 | 2087 | } | |
| 2083 | 2088 | ||
| 2084 | - | /* --- Search form --- */ | |
| 2085 | - | .search-form { | |
| 2089 | + | /* --- Search row (search form + sort select) --- */ | |
| 2090 | + | .search-row { | |
| 2091 | + | display: flex; | |
| 2092 | + | align-items: center; | |
| 2093 | + | gap: var(--space-3); | |
| 2086 | 2094 | margin-bottom: var(--space-5); | |
| 2087 | 2095 | } | |
| 2096 | + | .search-form { | |
| 2097 | + | flex: 1; | |
| 2098 | + | } | |
| 2099 | + | .repo-sort-select { | |
| 2100 | + | padding: var(--space-1) var(--space-2); | |
| 2101 | + | padding-right: var(--space-5); | |
| 2102 | + | border: 1px solid var(--color-border); | |
| 2103 | + | border-radius: var(--radius-md); | |
| 2104 | + | background: var(--color-bg-subtle); | |
| 2105 | + | font-size: var(--text-sm); | |
| 2106 | + | color: var(--color-text); | |
| 2107 | + | cursor: pointer; | |
| 2108 | + | appearance: auto; | |
| 2109 | + | } | |
| 2110 | + | .repo-sort-select:hover { | |
| 2111 | + | border-color: var(--color-text-muted); | |
| 2112 | + | } | |
| 2113 | + | .repo-sort-select:focus { | |
| 2114 | + | outline: 2px solid var(--color-accent); | |
| 2115 | + | outline-offset: -1px; | |
| 2116 | + | } | |
| 2088 | 2117 | .search-input-wrap { | |
| 2089 | 2118 | display: flex; | |
| 2090 | 2119 | gap: var(--space-2); | |
| 2091 | - | max-width: 480px; | |
| 2092 | 2120 | } | |
| 2093 | 2121 | .search-input { | |
| 2094 | 2122 | flex: 1; | |
Msrc/views/repos/RepoList.tsx
| @@ -8,10 +8,17 @@ interface RepoListProps { | |||
|---|---|---|---|
| 8 | 8 | user: SessionUser | null; | |
| 9 | 9 | repos: RepositoryRow[]; | |
| 10 | 10 | search?: string; | |
| 11 | + | sort: string; | |
| 11 | 12 | pagination: PaginationInfo; | |
| 12 | 13 | } | |
| 13 | 14 | ||
| 14 | - | export function RepoList({ user, repos, search, pagination }: RepoListProps) { | |
| 15 | + | export function RepoList({ | |
| 16 | + | user, | |
| 17 | + | repos, | |
| 18 | + | search, | |
| 19 | + | sort, | |
| 20 | + | pagination, | |
| 21 | + | }: RepoListProps) { | |
| 15 | 22 | return ( | |
| 16 | 23 | <Layout user={user} title="Repositories"> | |
| 17 | 24 | <div class="container"> | |
| @@ -23,21 +30,48 @@ export function RepoList({ user, repos, search, pagination }: RepoListProps) { | |||
|---|---|---|---|
| 23 | 30 | </a> | |
| 24 | 31 | )} | |
| 25 | 32 | </div> | |
| 26 | - | <form method="GET" action="/" class="search-form"> | |
| 27 | - | <div class="search-input-wrap"> | |
| 28 | - | <input | |
| 29 | - | type="search" | |
| 30 | - | name="q" | |
| 31 | - | value={search ?? ""} | |
| 32 | - | placeholder="Search repositories…" | |
| 33 | - | class="search-input" | |
| 34 | - | autocomplete="off" | |
| 35 | - | /> | |
| 36 | - | <button type="submit" class="btn btn-ghost btn-sm"> | |
| 37 | - | Search | |
| 38 | - | </button> | |
| 39 | - | </div> | |
| 40 | - | </form> | |
| 33 | + | <div class="search-row"> | |
| 34 | + | <form method="GET" action="/" class="search-form"> | |
| 35 | + | <div class="search-input-wrap"> | |
| 36 | + | <input | |
| 37 | + | type="search" | |
| 38 | + | name="q" | |
| 39 | + | value={search ?? ""} | |
| 40 | + | placeholder="Search repositories…" | |
| 41 | + | class="search-input" | |
| 42 | + | autocomplete="off" | |
| 43 | + | /> | |
| 44 | + | <button type="submit" class="btn btn-ghost btn-sm"> | |
| 45 | + | Search | |
| 46 | + | </button> | |
| 47 | + | </div> | |
| 48 | + | </form> | |
| 49 | + | <form method="POST" action="/sort"> | |
| 50 | + | <select | |
| 51 | + | name="sort" | |
| 52 | + | class="repo-sort-select" | |
| 53 | + | onchange="this.form.submit()" | |
| 54 | + | > | |
| 55 | + | <option | |
| 56 | + | value="created" | |
| 57 | + | selected={sort === "created" || undefined} | |
| 58 | + | > | |
| 59 | + | Newest first | |
| 60 | + | </option> | |
| 61 | + | <option | |
| 62 | + | value="name" | |
| 63 | + | selected={sort === "name" || undefined} | |
| 64 | + | > | |
| 65 | + | Name (A–Z) | |
| 66 | + | </option> | |
| 67 | + | </select> | |
| 68 | + | <noscript> | |
| 69 | + | <button type="submit" class="btn btn-sm btn-ghost"> | |
| 70 | + | Go | |
| 71 | + | </button> | |
| 72 | + | </noscript> | |
| 73 | + | </form> | |
| 74 | + | </div> | |
| 41 | 75 | {repos.length === 0 ? ( | |
| 42 | 76 | <div class="empty-state"> | |
| 43 | 77 | {search ? ( | |
| @@ -60,6 +94,11 @@ export function RepoList({ user, repos, search, pagination }: RepoListProps) { | |||
|---|---|---|---|
| 60 | 94 | > | |
| 61 | 95 | {repo.name} | |
| 62 | 96 | </a> | |
| 97 | + | {repo.is_pinned ? ( | |
| 98 | + | <span class="badge badge-pinned"> | |
| 99 | + | Pinned | |
| 100 | + | </span> | |
| 101 | + | ) : null} | |
| 63 | 102 | {repo.is_private ? ( | |
| 64 | 103 | <span class="badge badge-private"> | |
| 65 | 104 | Private | |
Msrc/views/repos/RepoSettings.tsx
| @@ -80,6 +80,18 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 80 | 80 | Private repository (only visible to you) | |
| 81 | 81 | </label> | |
| 82 | 82 | </div> | |
| 83 | + | <div class="form-group"> | |
| 84 | + | <label class="checkbox-label"> | |
| 85 | + | <input | |
| 86 | + | type="checkbox" | |
| 87 | + | name="is_pinned" | |
| 88 | + | value="1" | |
| 89 | + | checked={repo.is_pinned === 1} | |
| 90 | + | /> | |
| 91 | + | Pin this repository (always shown at the top of the | |
| 92 | + | list) | |
| 93 | + | </label> | |
| 94 | + | </div> | |
| 83 | 95 | <div class="form-group"> | |
| 84 | 96 | <label for="issue_template"> | |
| 85 | 97 | Issue template{" "} | |
Mtests/e2e.test.ts
| @@ -2444,3 +2444,149 @@ describe('commit signing', () => { | |||
|---|---|---|---|
| 2444 | 2444 | } finally { await page.close(); } | |
| 2445 | 2445 | }); | |
| 2446 | 2446 | }); | |
| 2447 | + | ||
| 2448 | + | // ─── Repo sorting and pinning ───────────────────────────────────────────────── | |
| 2449 | + | ||
| 2450 | + | describe('repo sorting and pinning', () => { | |
| 2451 | + | let adminCtx: BrowserContext; | |
| 2452 | + | ||
| 2453 | + | beforeAll(async () => { | |
| 2454 | + | adminCtx = await loggedInContext(); | |
| 2455 | + | // Create two repos with predictable names: sort-aaa (created first/older), | |
| 2456 | + | // sort-zzz (created second/newer). This lets us verify both name order and | |
| 2457 | + | // creation-time order independently. | |
| 2458 | + | const page = await adminCtx.newPage(); | |
| 2459 | + | try { | |
| 2460 | + | await page.goto(`${BASE}/new`); | |
| 2461 | + | await page.fill('[name=name]', 'sort-aaa'); | |
| 2462 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 2463 | + | await page.waitForURL(`${BASE}/sort-aaa`); | |
| 2464 | + | ||
| 2465 | + | await page.goto(`${BASE}/new`); | |
| 2466 | + | await page.fill('[name=name]', 'sort-zzz'); | |
| 2467 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 2468 | + | await page.waitForURL(`${BASE}/sort-zzz`); | |
| 2469 | + | } finally { await page.close(); } | |
| 2470 | + | }); | |
| 2471 | + | ||
| 2472 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 2473 | + | ||
| 2474 | + | test('sort dropdown is visible on repo list page', async () => { | |
| 2475 | + | const page = await adminCtx.newPage(); | |
| 2476 | + | try { | |
| 2477 | + | await page.goto(BASE); | |
| 2478 | + | const options = await page.locator('.repo-sort-select option').allTextContents(); | |
| 2479 | + | expect(options.some(t => t.includes('Newest'))).toBe(true); | |
| 2480 | + | expect(options.some(t => t.includes('Name'))).toBe(true); | |
| 2481 | + | } finally { await page.close(); } | |
| 2482 | + | }); | |
| 2483 | + | ||
| 2484 | + | test('newest option is selected by default', async () => { | |
| 2485 | + | // Use a fresh context to ensure no repo_sort cookie is set. | |
| 2486 | + | const ctx = await browser.newContext(); | |
| 2487 | + | const page = await ctx.newPage(); | |
| 2488 | + | try { | |
| 2489 | + | await login(page, 'admin', ADMIN_PASS); | |
| 2490 | + | await page.goto(BASE); | |
| 2491 | + | expect(await page.locator('.repo-sort-select').inputValue()).toBe('created'); | |
| 2492 | + | } finally { await ctx.close(); } | |
| 2493 | + | }); | |
| 2494 | + | ||
| 2495 | + | test('Go button is hidden with JS and works without JS', async () => { | |
| 2496 | + | const ctx = await browser.newContext({ javaScriptEnabled: false }); | |
| 2497 | + | const page = await ctx.newPage(); | |
| 2498 | + | try { | |
| 2499 | + | await login(page, 'admin', ADMIN_PASS); | |
| 2500 | + | await page.goto(BASE); | |
| 2501 | + | // Go button visible without JS | |
| 2502 | + | expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true); | |
| 2503 | + | // Select name sort and submit via Go button | |
| 2504 | + | await page.locator('.repo-sort-select').selectOption('name'); | |
| 2505 | + | await page.locator('form[action="/sort"] button[type=submit]').click(); | |
| 2506 | + | await page.waitForURL(BASE + '/'); | |
| 2507 | + | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 2508 | + | } finally { await ctx.close(); } | |
| 2509 | + | }); | |
| 2510 | + | ||
| 2511 | + | test('selecting name sort sets cookie and persists on next visit', async () => { | |
| 2512 | + | const page = await adminCtx.newPage(); | |
| 2513 | + | try { | |
| 2514 | + | await page.goto(BASE); | |
| 2515 | + | await Promise.all([ | |
| 2516 | + | page.waitForURL(BASE + '/'), | |
| 2517 | + | page.locator('.repo-sort-select').selectOption('name'), | |
| 2518 | + | ]); | |
| 2519 | + | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 2520 | + | // Navigate away and back to confirm cookie persists | |
| 2521 | + | await page.goto(`${BASE}/my-repo`); | |
| 2522 | + | await page.goto(BASE); | |
| 2523 | + | expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); | |
| 2524 | + | } finally { | |
| 2525 | + | // Reset cookie so subsequent tests start from the default sort. | |
| 2526 | + | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 2527 | + | await page.close(); | |
| 2528 | + | } | |
| 2529 | + | }); | |
| 2530 | + | ||
| 2531 | + | test('default sort shows newer repo before older repo', async () => { | |
| 2532 | + | const page = await adminCtx.newPage(); | |
| 2533 | + | try { | |
| 2534 | + | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 2535 | + | await page.goto(`${BASE}/?q=sort-`); | |
| 2536 | + | const names = await page.locator('.repo-name').allTextContents(); | |
| 2537 | + | expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa')); | |
| 2538 | + | } finally { await page.close(); } | |
| 2539 | + | }); | |
| 2540 | + | ||
| 2541 | + | test('name sort shows repos in alphabetical order', async () => { | |
| 2542 | + | const page = await adminCtx.newPage(); | |
| 2543 | + | try { | |
| 2544 | + | await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]); | |
| 2545 | + | await page.goto(`${BASE}/?q=sort-`); | |
| 2546 | + | const names = await page.locator('.repo-name').allTextContents(); | |
| 2547 | + | expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz')); | |
| 2548 | + | } finally { | |
| 2549 | + | await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); | |
| 2550 | + | await page.close(); | |
| 2551 | + | } | |
| 2552 | + | }); | |
| 2553 | + | ||
| 2554 | + | test('pinning a repo shows pinned badge on list page', async () => { | |
| 2555 | + | const page = await adminCtx.newPage(); | |
| 2556 | + | try { | |
| 2557 | + | await page.goto(`${BASE}/sort-aaa/settings`); | |
| 2558 | + | await page.check('[name=is_pinned]'); | |
| 2559 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2560 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2561 | + | ||
| 2562 | + | await page.goto(BASE); | |
| 2563 | + | const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' }); | |
| 2564 | + | expect(await card.locator('.badge-pinned').isVisible()).toBe(true); | |
| 2565 | + | } finally { await page.close(); } | |
| 2566 | + | }); | |
| 2567 | + | ||
| 2568 | + | test('pinned repo appears before unpinned repos regardless of creation order', async () => { | |
| 2569 | + | const page = await adminCtx.newPage(); | |
| 2570 | + | try { | |
| 2571 | + | // sort-aaa is pinned; default (newest) sort would normally show sort-zzz | |
| 2572 | + | // first since it's newer — but pinned repos float to the top. | |
| 2573 | + | await page.goto(`${BASE}/?q=sort-`); | |
| 2574 | + | const names = await page.locator('.repo-name').allTextContents(); | |
| 2575 | + | expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz')); | |
| 2576 | + | } finally { await page.close(); } | |
| 2577 | + | }); | |
| 2578 | + | ||
| 2579 | + | test('unpinning a repo removes the pinned badge', async () => { | |
| 2580 | + | const page = await adminCtx.newPage(); | |
| 2581 | + | try { | |
| 2582 | + | await page.goto(`${BASE}/sort-aaa/settings`); | |
| 2583 | + | await page.uncheck('[name=is_pinned]'); | |
| 2584 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2585 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2586 | + | ||
| 2587 | + | await page.goto(BASE); | |
| 2588 | + | const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' }); | |
| 2589 | + | expect(await card.locator('.badge-pinned').count()).toBe(0); | |
| 2590 | + | } finally { await page.close(); } | |
| 2591 | + | }); | |
| 2592 | + | }); | |