add repo pinning and name sorting

AuthorKonata <konata@posteo.jp>
Date
Commit6a24612da2193969ee074e4d25950994e406686b
Parent7353a64
7 files changed, 274 insertions(+), 22 deletions(-)
Msrc/db/index.ts
@@ -33,6 +33,7 @@ interface RepositoryTable {
3333 name: string;
3434 description: string | null;
3535 is_private: number;
36+ is_pinned: Generated<number>;
3637 default_branch: string;
3738 created_at: string;
3839 issue_seq: Generated<number>;
Msrc/db/schema.sql
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS repositories (
2929 name TEXT UNIQUE NOT NULL,
3030 description TEXT,
3131 is_private INTEGER NOT NULL DEFAULT 0,
32+ is_pinned INTEGER NOT NULL DEFAULT 0,
3233 default_branch TEXT NOT NULL DEFAULT 'main',
3334 created_at TEXT NOT NULL,
3435 issue_seq INTEGER NOT NULL DEFAULT 0,
Msrc/routes/repos.tsx
@@ -18,6 +18,7 @@ import {
1818 } from "../services/highlightWorker.ts";
1919 import { renderMarkdown } from "../services/markdown.ts";
2020 import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
21+import { redirect } from "../lib/redirect.ts";
2122 import { html } from "../views/render.tsx";
2223 import { CommitDetail } from "../views/repos/CommitDetail.tsx";
2324 import { CommitLog } from "../views/repos/CommitLog.tsx";
@@ -65,14 +66,30 @@ export const repoRoutes = new Elysia()
6566 });
6667 })
6768 .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+ }),
6973 })
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+ )
7085 .get(
7186 "/",
7287 async ({ cookie, query }) => {
7388 const user = await resolveSession(cookie.session.value);
7489 const search = query.q?.trim() || undefined;
7590 const page = Math.max(1, query.page ?? 1);
91+ const sort =
92+ cookie.repo_sort.value === "name" ? "name" : "created";
7693
7794 const isAdmin = user?.isAdmin ?? false;
7895
@@ -123,7 +140,11 @@ export const repoRoutes = new Elysia()
123140 ]),
124141 ),
125142 )
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+ )
127148 .limit(REPOS_PER_PAGE)
128149 .offset((safePage - 1) * REPOS_PER_PAGE)
129150 .execute();
@@ -142,6 +163,7 @@ export const repoRoutes = new Elysia()
142163 user={user}
143164 repos={repos}
144165 search={search}
166+ sort={sort}
145167 pagination={pagination}
146168 />,
147169 );
@@ -578,6 +600,7 @@ export const repoRoutes = new Elysia()
578600 const {
579601 description,
580602 is_private,
603+ is_pinned,
581604 default_branch,
582605 issue_template,
583606 patch_template,
@@ -603,6 +626,7 @@ export const repoRoutes = new Elysia()
603626 .set({
604627 description: description?.trim() || null,
605628 is_private: is_private === "1" ? 1 : 0,
629+ is_pinned: is_pinned === "1" ? 1 : 0,
606630 default_branch: newBranch,
607631 issue_template: issue_template?.trim() || null,
608632 patch_template: patch_template?.trim() || null,
@@ -633,6 +657,7 @@ export const repoRoutes = new Elysia()
633657 body: t.Object({
634658 description: t.Optional(t.String()),
635659 is_private: t.Optional(t.String()),
660+ is_pinned: t.Optional(t.String()),
636661 default_branch: t.Optional(t.String()),
637662 issue_template: t.Optional(t.String()),
638663 patch_template: t.Optional(t.String()),
Msrc/styles/main.css
@@ -672,7 +672,6 @@
672672 display: flex;
673673 align-items: center;
674674 gap: var(--space-2);
675- flex-wrap: wrap;
676675 margin-bottom: var(--space-1);
677676 }
678677 .repo-name {
@@ -680,6 +679,7 @@
680679 font-weight: 600;
681680 color: var(--color-link);
682681 text-decoration: none;
682+ word-break: break-all;
683683 }
684684 .repo-name:hover {
685685 text-decoration: underline;
@@ -713,6 +713,11 @@
713713 border-color: var(--color-warning);
714714 background: var(--color-warning-bg);
715715 }
716+ .badge-pinned {
717+ color: var(--color-accent);
718+ border-color: var(--color-accent);
719+ background: var(--color-accent-bg);
720+ }
716721 .issue-badge,
717722 .patch-badge {
718723 display: inline-flex;
@@ -2081,14 +2086,37 @@
20812086 padding: var(--space-4);
20822087 }
20832088
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);
20862094 margin-bottom: var(--space-5);
20872095 }
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+ }
20882117 .search-input-wrap {
20892118 display: flex;
20902119 gap: var(--space-2);
2091- max-width: 480px;
20922120 }
20932121 .search-input {
20942122 flex: 1;
Msrc/views/repos/RepoList.tsx
@@ -8,10 +8,17 @@ interface RepoListProps {
88 user: SessionUser | null;
99 repos: RepositoryRow[];
1010 search?: string;
11+ sort: string;
1112 pagination: PaginationInfo;
1213 }
1314
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) {
1522 return (
1623 <Layout user={user} title="Repositories">
1724 <div class="container">
@@ -23,21 +30,48 @@ export function RepoList({ user, repos, search, pagination }: RepoListProps) {
2330 </a>
2431 )}
2532 </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>
4175 {repos.length === 0 ? (
4276 <div class="empty-state">
4377 {search ? (
@@ -60,6 +94,11 @@ export function RepoList({ user, repos, search, pagination }: RepoListProps) {
6094 >
6195 {repo.name}
6296 </a>
97+ {repo.is_pinned ? (
98+ <span class="badge badge-pinned">
99+ Pinned
100+ </span>
101+ ) : null}
63102 {repo.is_private ? (
64103 <span class="badge badge-private">
65104 Private
Msrc/views/repos/RepoSettings.tsx
@@ -80,6 +80,18 @@ export function RepoSettings({
8080 Private repository (only visible to you)
8181 </label>
8282 </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>
8395 <div class="form-group">
8496 <label for="issue_template">
8597 Issue template{" "}
Mtests/e2e.test.ts
@@ -2444,3 +2444,149 @@ describe('commit signing', () => {
24442444 } finally { await page.close(); }
24452445 });
24462446 });
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+});