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 {
name: string;
description: string | null;
is_private: number;
is_pinned: Generated<number>;
default_branch: string;
created_at: string;
issue_seq: Generated<number>;
▾Msrc/db/schema.sql
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS repositories (
name TEXT UNIQUE NOT NULL,
description TEXT,
is_private INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
default_branch TEXT NOT NULL DEFAULT 'main',
created_at TEXT NOT NULL,
issue_seq INTEGER NOT NULL DEFAULT 0,
▾Msrc/routes/repos.tsx
@@ -18,6 +18,7 @@ import {
} from "../services/highlightWorker.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
import { redirect } from "../lib/redirect.ts";
import { html } from "../views/render.tsx";
import { CommitDetail } from "../views/repos/CommitDetail.tsx";
import { CommitLog } from "../views/repos/CommitLog.tsx";
@@ -65,14 +66,30 @@ export const repoRoutes = new Elysia()
});
})
.guard({
cookie: t.Cookie({ session: t.Optional(t.String()) }),
cookie: t.Cookie({
session: t.Optional(t.String()),
repo_sort: t.Optional(t.String()),
}),
})
.post(
"/sort",
({ body }) => {
const sort = body.sort === "name" ? "name" : "created";
return redirect(
"/",
`repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`,
);
},
{ body: t.Object({ sort: t.String() }) },
)
.get(
"/",
async ({ cookie, query }) => {
const user = await resolveSession(cookie.session.value);
const search = query.q?.trim() || undefined;
const page = Math.max(1, query.page ?? 1);
const sort =
cookie.repo_sort.value === "name" ? "name" : "created";
const isAdmin = user?.isAdmin ?? false;
@@ -123,7 +140,11 @@ export const repoRoutes = new Elysia()
]),
),
)
.orderBy("created_at", "desc")
.orderBy("is_pinned", "desc")
.$if(sort === "name", (qb) => qb.orderBy("name", "asc"))
.$if(sort === "created", (qb) =>
qb.orderBy("created_at", "desc"),
)
.limit(REPOS_PER_PAGE)
.offset((safePage - 1) * REPOS_PER_PAGE)
.execute();
@@ -142,6 +163,7 @@ export const repoRoutes = new Elysia()
user={user}
repos={repos}
search={search}
sort={sort}
pagination={pagination}
/>,
);
@@ -578,6 +600,7 @@ export const repoRoutes = new Elysia()
const {
description,
is_private,
is_pinned,
default_branch,
issue_template,
patch_template,
@@ -603,6 +626,7 @@ export const repoRoutes = new Elysia()
.set({
description: description?.trim() || null,
is_private: is_private === "1" ? 1 : 0,
is_pinned: is_pinned === "1" ? 1 : 0,
default_branch: newBranch,
issue_template: issue_template?.trim() || null,
patch_template: patch_template?.trim() || null,
@@ -633,6 +657,7 @@ export const repoRoutes = new Elysia()
body: t.Object({
description: t.Optional(t.String()),
is_private: t.Optional(t.String()),
is_pinned: t.Optional(t.String()),
default_branch: t.Optional(t.String()),
issue_template: t.Optional(t.String()),
patch_template: t.Optional(t.String()),
▾Msrc/styles/main.css
@@ -672,7 +672,6 @@
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-1);
}
.repo-name {
@@ -680,6 +679,7 @@
font-weight: 600;
color: var(--color-link);
text-decoration: none;
word-break: break-all;
}
.repo-name:hover {
text-decoration: underline;
@@ -713,6 +713,11 @@
border-color: var(--color-warning);
background: var(--color-warning-bg);
}
.badge-pinned {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-bg);
}
.issue-badge,
.patch-badge {
display: inline-flex;
@@ -2081,14 +2086,37 @@
padding: var(--space-4);
}
/* --- Search form --- */
.search-form {
/* --- Search row (search form + sort select) --- */
.search-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.search-form {
flex: 1;
}
.repo-sort-select {
padding: var(--space-1) var(--space-2);
padding-right: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
appearance: auto;
}
.repo-sort-select:hover {
border-color: var(--color-text-muted);
}
.repo-sort-select:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
}
.search-input-wrap {
display: flex;
gap: var(--space-2);
max-width: 480px;
}
.search-input {
flex: 1;
▾Msrc/views/repos/RepoList.tsx
@@ -8,10 +8,17 @@ interface RepoListProps {
user: SessionUser | null;
repos: RepositoryRow[];
search?: string;
sort: string;
pagination: PaginationInfo;
}
export function RepoList({ user, repos, search, pagination }: RepoListProps) {
export function RepoList({
user,
repos,
search,
sort,
pagination,
}: RepoListProps) {
return (
<Layout user={user} title="Repositories">
<div class="container">
@@ -23,21 +30,48 @@ export function RepoList({ user, repos, search, pagination }: RepoListProps) {
</a>
)}
</div>
<form method="GET" action="/" class="search-form">
<div class="search-input-wrap">
<input
type="search"
name="q"
value={search ?? ""}
placeholder="Search repositories…"
class="search-input"
autocomplete="off"
/>
<button type="submit" class="btn btn-ghost btn-sm">
Search
</button>
</div>
</form>
<div class="search-row">
<form method="GET" action="/" class="search-form">
<div class="search-input-wrap">
<input
type="search"
name="q"
value={search ?? ""}
placeholder="Search repositories…"
class="search-input"
autocomplete="off"
/>
<button type="submit" class="btn btn-ghost btn-sm">
Search
</button>
</div>
</form>
<form method="POST" action="/sort">
<select
name="sort"
class="repo-sort-select"
onchange="this.form.submit()"
>
<option
value="created"
selected={sort === "created" || undefined}
>
Newest first
</option>
<option
value="name"
selected={sort === "name" || undefined}
>
Name (A–Z)
</option>
</select>
<noscript>
<button type="submit" class="btn btn-sm btn-ghost">
Go
</button>
</noscript>
</form>
</div>
{repos.length === 0 ? (
<div class="empty-state">
{search ? (
@@ -60,6 +94,11 @@ export function RepoList({ user, repos, search, pagination }: RepoListProps) {
>
{repo.name}
</a>
{repo.is_pinned ? (
<span class="badge badge-pinned">
Pinned
</span>
) : null}
{repo.is_private ? (
<span class="badge badge-private">
Private
▾Msrc/views/repos/RepoSettings.tsx
@@ -80,6 +80,18 @@ export function RepoSettings({
Private repository (only visible to you)
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
name="is_pinned"
value="1"
checked={repo.is_pinned === 1}
/>
Pin this repository (always shown at the top of the
list)
</label>
</div>
<div class="form-group">
<label for="issue_template">
Issue template{" "}
▾Mtests/e2e.test.ts
@@ -2444,3 +2444,149 @@ describe('commit signing', () => {
} finally { await page.close(); }
});
});
// ─── Repo sorting and pinning ─────────────────────────────────────────────────
describe('repo sorting and pinning', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create two repos with predictable names: sort-aaa (created first/older),
// sort-zzz (created second/newer). This lets us verify both name order and
// creation-time order independently.
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'sort-aaa');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/sort-aaa`);
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'sort-zzz');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/sort-zzz`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('sort dropdown is visible on repo list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
const options = await page.locator('.repo-sort-select option').allTextContents();
expect(options.some(t => t.includes('Newest'))).toBe(true);
expect(options.some(t => t.includes('Name'))).toBe(true);
} finally { await page.close(); }
});
test('newest option is selected by default', async () => {
// Use a fresh context to ensure no repo_sort cookie is set.
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page, 'admin', ADMIN_PASS);
await page.goto(BASE);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('created');
} finally { await ctx.close(); }
});
test('Go button is hidden with JS and works without JS', async () => {
const ctx = await browser.newContext({ javaScriptEnabled: false });
const page = await ctx.newPage();
try {
await login(page, 'admin', ADMIN_PASS);
await page.goto(BASE);
// Go button visible without JS
expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true);
// Select name sort and submit via Go button
await page.locator('.repo-sort-select').selectOption('name');
await page.locator('form[action="/sort"] button[type=submit]').click();
await page.waitForURL(BASE + '/');
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
} finally { await ctx.close(); }
});
test('selecting name sort sets cookie and persists on next visit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await Promise.all([
page.waitForURL(BASE + '/'),
page.locator('.repo-sort-select').selectOption('name'),
]);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
// Navigate away and back to confirm cookie persists
await page.goto(`${BASE}/my-repo`);
await page.goto(BASE);
expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
} finally {
// Reset cookie so subsequent tests start from the default sort.
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.close();
}
});
test('default sort shows newer repo before older repo', async () => {
const page = await adminCtx.newPage();
try {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa'));
} finally { await page.close(); }
});
test('name sort shows repos in alphabetical order', async () => {
const page = await adminCtx.newPage();
try {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]);
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
} finally {
await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
await page.close();
}
});
test('pinning a repo shows pinned badge on list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/sort-aaa/settings`);
await page.check('[name=is_pinned]');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
expect(await card.locator('.badge-pinned').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('pinned repo appears before unpinned repos regardless of creation order', async () => {
const page = await adminCtx.newPage();
try {
// sort-aaa is pinned; default (newest) sort would normally show sort-zzz
// first since it's newer — but pinned repos float to the top.
await page.goto(`${BASE}/?q=sort-`);
const names = await page.locator('.repo-name').allTextContents();
expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
} finally { await page.close(); }
});
test('unpinning a repo removes the pinned badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/sort-aaa/settings`);
await page.uncheck('[name=is_pinned]');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
expect(await card.locator('.badge-pinned').count()).toBe(0);
} finally { await page.close(); }
});
});