Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/748-repositories-add-dialog.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The dashboard's Repositories card now uses the full content row; Add repository is a header button that opens a native dialog with the GitHub App and fine-grained token options. `#add-repo` remains a working deep link, and the form still renders inline when JavaScript is unavailable.
8 changes: 8 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ header, not a section — so at the top of that page nothing has passed and
`#release`, the first chip, is marked. That fallback is the accepted state, not a
defect.

The dashboard's Repositories card owns the full content row; its Add repository
button sits in the card header and opens a native `<dialog>` with the GitHub App
and fine-grained token options. `#add-repo` remains the deep link used by the
empty state, the next action, the setup checklist and the `POST /repos` error, so
the dialog is opened by the hash as well as the button. The dialog lives outside
the secondary disclosure; a `<noscript>` rule renders it inline when JavaScript is
unavailable, and the token form keeps its CSRF field and `type="password"`.

The admin backup card has two read-only sources: `readBackupStatus()` lists the
plaintext copies in `config.backup.dir` and counts the encrypted copies beside
them, and `readRestoreDrill()` reads `<dataDir>/backup-drill.json`. The card
Expand Down
9 changes: 9 additions & 0 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,15 @@ a.list-row:hover strong { color: var(--accent); }
.add-manual { margin-top: 8px; border-top: 1px dashed var(--line); padding-top: 10px; }
.add-manual summary { cursor: pointer; color: var(--muted-2); font-size: 13px; }

.repos-head { align-items: center; gap: 12px; margin-bottom: 12px; }
.repos-head h2 { margin: 0; }
.add-repo-dialog { width: min(680px, calc(100vw - 32px)); max-height: min(80vh, 720px); overflow: auto; border: 1px solid var(--line-strong); border-radius: var(--radius-md); padding: 22px; background: var(--panel); color: var(--text); box-shadow: var(--shadow); }
.add-repo-dialog::backdrop { background: rgba(0, 0, 0, 0.62); }
.add-repo-dialog .dialog-head { position: sticky; top: 0; z-index: 1; align-items: center; background: var(--panel); padding-bottom: 8px; }
.add-repo-dialog .dialog-head h2 { margin: 0; }
body:has(.add-repo-dialog[open]) { overflow: hidden; }
@media (max-width: 640px) { .add-repo-dialog { width: calc(100vw - 16px); max-height: calc(100vh - 16px); padding: 16px; } }

.info-tip { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; margin-left: 4px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: transparent; color: var(--muted-2); font: inherit; font-size: 12px; line-height: 1; cursor: help; position: relative; vertical-align: middle; }
.info-tip::after { content: attr(data-tip); position: absolute; left: 0; top: 130%; z-index: 60; width: 270px; max-width: min(270px, calc(100vw - 48px)); padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-raised); color: var(--tip-ink); font-size: 12px; font-weight: 400; line-height: 1.45; white-space: normal; overflow-wrap: anywhere; display: none; opacity: 0; pointer-events: none; transform: translateY(-3px); transition: opacity .12s ease; box-shadow: 0 8px 24px rgba(0, 0, 0, .35); }
/* Hover and focus are the no-JavaScript fallback only: with no script there is no
Expand Down
44 changes: 44 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,50 @@
})
}

// Native modal dialogs. The add-repository form moved out of a permanent
// half-width card into one; `#add-repo` stays the deep link used by the empty
// state, the next action, the setup checklist and the POST /repos error.
const openDialog = (dialog) => {
if (!(dialog instanceof HTMLDialogElement) || typeof dialog.showModal !== 'function') return false
if (!dialog.open) dialog.showModal()
return true
}
const clearDialogHash = (dialog) => {
if (location.hash === `#${dialog.id}`) history.replaceState(null, '', `${location.pathname}${location.search}`)
}
document.querySelectorAll('dialog').forEach((dialog) => {
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close()
})
dialog.addEventListener('close', () => clearDialogHash(dialog))
})
document.querySelectorAll('[data-open-dialog]').forEach((button) => {
button.addEventListener('click', () => {
const dialog = document.getElementById(button.getAttribute('data-open-dialog') ?? '')
if (dialog === null || !openDialog(dialog)) return
if (location.hash !== `#${dialog.id}`) history.replaceState(null, '', `${location.pathname}${location.search}#${dialog.id}`)
})
})
document.querySelectorAll('[data-close-dialog]').forEach((button) => {
button.addEventListener('click', () => button.closest('dialog')?.close())
})
const openHashDialog = () => {
if (location.hash.length <= 1) return
const dialog = document.getElementById(location.hash.slice(1))
if (dialog instanceof HTMLDialogElement) openDialog(dialog)
}
document.addEventListener('click', (event) => {
const link = event.target instanceof Element ? event.target.closest('a[href="#add-repo"]') : null
if (link === null) return
const dialog = document.getElementById('add-repo')
if (dialog === null) return
event.preventDefault()
openDialog(dialog)
if (location.hash !== '#add-repo') history.replaceState(null, '', `${location.pathname}${location.search}#add-repo`)
})
openHashDialog()
window.addEventListener('hashchange', openHashDialog)

document.querySelectorAll('table.table-priority').forEach((table) => {
if (table.querySelector('[data-priority="low"]') === null) return
const scroll = table.closest('.table-scroll')
Expand Down
40 changes: 29 additions & 11 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,7 +1009,7 @@ export function dashboardPage(input: {
// other two ways in: both target `#jobs`, so a page loaded on page two or with
// an active filter is a page whose table must be visible.
const jobTableDeepLinked = jobsPage > 1 || jobFilterIsActive(jobFilter)
const secondaryOpen = jobTableDeepLinked || ['#add-repo', '#inbox', '#attention'].includes(nextAction.href)
const secondaryOpen = jobTableDeepLinked || reposPage > 1 || ['#repositories', '#add-repo', '#inbox', '#attention'].includes(nextAction.href)
const repoRows = repos.length === 0
? stateCell(5, { kind: 'empty', title: t('还没有仓库。', 'No repositories yet.'), body: t('添加第一个仓库并选择你的角色,任务才会开始。', 'Add your first repository and choose your role before any job can start.'), action: { href: '#add-repo', label: t('添加仓库', 'Add repository') } })
: repos
Expand Down Expand Up @@ -1040,6 +1040,27 @@ export function dashboardPage(input: {
<p class="fine">${escapeHtml(t('添加后请选择你的角色;未选择前不会启动任务。', 'Choose your role after adding; jobs stay paused until you do.'))}</p>
<button class="btn" type="submit">${escapeHtml(t('添加仓库', 'Add repository'))}</button>
</form>`
const addRepoOptions = config.githubApp.id === ''
? `<p class="fine">${escapeHtml(t('GitHub App 未配置,请使用下面的 fine-grained token。', 'The GitHub App is not configured; use the fine-grained token below.'))}</p>${manualForm}`
: `<div class="add-choice"><h3>${escapeHtml(t('方式一(推荐):GitHub App', 'Option 1 (recommended): GitHub App'))}</h3><p class="muted">${escapeHtml(t('安装 App 并选择仓库,不需要创建 PAT;Fledge 使用短时 installation token。', 'Install the App and choose repositories; no PAT is needed. Fledge uses short-lived installation tokens.'))}</p><p><a class="btn" href="/auth/github/app/install">${escapeHtml(t('通过 GitHub App 接入', 'Connect with GitHub App'))}</a> <a class="link" href="/repos/new">${escapeHtml(t('选择已安装的仓库', 'Choose installed repositories'))}</a></p></div><details class="add-manual"><summary>${escapeHtml(t('方式二:手动提供 fine-grained token', 'Option 2: provide a fine-grained token manually'))}</summary>${manualForm}</details>`
// The add flow is a modal rather than a permanent half-width card, so the
// repository table owns the row. `#add-repo` stays on the dialog: the empty
// state, next action, setup checklist and POST /repos error all link to it.
const addRepoDialog = `<dialog id="add-repo" class="add-repo-dialog" aria-labelledby="add-repo-title">
<div class="row between dialog-head">
<h2 id="add-repo-title">${escapeHtml(t('添加仓库', 'Add repository'))}</h2>
<button class="link" type="button" data-close-dialog>${escapeHtml(t('关闭', 'Close'))}</button>
</div>
${error === undefined || error === '' ? '' : `<div class="banner bad" role="alert">${escapeHtml(error)}</div>`}
${addRepoOptions}
<p class="fine">${escapeHtml(t('两种方式选一种即可;GitHub App 不需要 PAT。', 'Choose one of the two options; the GitHub App does not need a PAT.'))}</p>
</dialog>
<noscript><style>
#add-repo { display:block; position:static; width:auto; max-width:none; max-height:none; margin:16px 0; padding:0; border:0; background:none; box-shadow:none; }
#add-repo::backdrop { display:none; }
#add-repo .dialog-head .link { display:none; }
[data-open-dialog="add-repo"] { display:none; }
</style></noscript>`
const body = `<section class="workspace-hero">
<div class="workspace-intro">
<div class="eyebrow">${escapeHtml(t('仓库', 'REPOSITORIES'))}</div>
Expand Down Expand Up @@ -1097,17 +1118,13 @@ export function dashboardPage(input: {
</div>
</section>

<section class="cols">
<div class="card">
<section class="card" id="repositories">
<div class="row between repos-head">
<h2>${escapeHtml(t('仓库', 'Repositories'))}</h2>
<div class="table-scroll"><table class="table-cards"><thead><tr><th>${escapeHtml(t('仓库', 'Repository'))}</th><th>${escapeHtml(t('模式', 'Mode'))}</th><th>${escapeHtml(t('状态', 'Status'))}</th><th>${escapeHtml(t('上次运行', 'Last run'))}</th><th></th></tr></thead><tbody>${repoRows}</tbody></table></div>
${pager('/dashboard', 'reposPage', reposPage, reposTotal, 10, t, { ...dashboardStateParams(jobFilter, windowDays), jobsPage }, '#add-repo')}
</div>
<div class="card" id="add-repo">
<h2>${escapeHtml(t('添加仓库', 'Add repository'))}</h2>
${config.githubApp.id === '' ? `<p class="fine">${escapeHtml(t('GitHub App 未配置,请使用下面的 fine-grained token。', 'The GitHub App is not configured; use the fine-grained token below.'))}</p>${manualForm}` : `<div class="add-choice"><h3>${escapeHtml(t('方式一(推荐):GitHub App', 'Option 1 (recommended): GitHub App'))}</h3><p class="muted">${escapeHtml(t('安装 App 并选择仓库,不需要创建 PAT;Fledge 使用短时 installation token。', 'Install the App and choose repositories; no PAT is needed. Fledge uses short-lived installation tokens.'))}</p><p><a class="btn" href="/auth/github/app/install">${escapeHtml(t('通过 GitHub App 接入', 'Connect with GitHub App'))}</a> <a class="link" href="/repos/new">${escapeHtml(t('选择已安装的仓库', 'Choose installed repositories'))}</a></p></div><details class="add-manual"><summary>${escapeHtml(t('方式二:手动提供 fine-grained token', 'Option 2: provide a fine-grained token manually'))}</summary>${manualForm}</details>`}
<p class="fine">${escapeHtml(t('两种方式选一种即可;GitHub App 不需要 PAT。', 'Choose one of the two options; the GitHub App does not need a PAT.'))}</p>
<button class="btn" type="button" data-open-dialog="add-repo" aria-haspopup="dialog" aria-controls="add-repo">${escapeHtml(t('添加仓库', 'Add repository'))}</button>
</div>
<div class="table-scroll"><table class="table-cards"><thead><tr><th>${escapeHtml(t('仓库', 'Repository'))}</th><th>${escapeHtml(t('模式', 'Mode'))}</th><th>${escapeHtml(t('状态', 'Status'))}</th><th>${escapeHtml(t('上次运行', 'Last run'))}</th><th></th></tr></thead><tbody>${repoRows}</tbody></table></div>
${pager('/dashboard', 'reposPage', reposPage, reposTotal, 10, t, { ...dashboardStateParams(jobFilter, windowDays), jobsPage }, '#repositories')}
</section>

<section class="card" id="jobs">
Expand All @@ -1127,7 +1144,8 @@ export function dashboardPage(input: {
${pager('/dashboard', 'jobsPage', jobsPage, jobsTotal, 10, t, { reposPage, ...(windowDays === 30 ? { window: 30 } : {}), ...jobFilterParams(jobFilter) }, '#jobs')}
</section>
</div>
</details>`
</details>
${addRepoDialog}`
return layout({ title: t('仓库', 'Repositories'), viewer, body, config, locale, path: '/dashboard', active: 'dashboard', notice, error })
}

Expand Down
2 changes: 1 addition & 1 deletion test/jobFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ test('every dashboard control keeps the state it is not changing', async () => {
assert.equal(link.params.get('status'), 'failed', 'paging keeps the status filter')
assert.equal(link.params.get('kind'), 'iterate', 'paging keeps the kind filter')
}
assert.equal(pagers.filter((link) => link.hash === '#add-repo').length, 1, 'the repository table has its pager')
assert.equal(pagers.filter((link) => link.hash === '#repositories').length, 1, 'the repository table has its pager')
assert.equal(pagers.filter((link) => link.hash === '#jobs').length, 1, 'the job table has its pager')

// The "Clear filters" link on a filtered list must still drop the filter and
Expand Down
4 changes: 3 additions & 1 deletion test/nextAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,11 @@ test('secondary sections collapse into one disclosure and lose nothing', () => {
assert.notEqual(disclosure, '', 'the secondary sections have one disclosure')
assert.doesNotMatch(disclosure, /\bopen\b/, 'it starts collapsed so the first screen keeps one action')
assert.ok(html.indexOf('data-secondary') > html.indexOf('data-next-action'), 'it sits below the next action')
for (const marker of ['id="quota"', 'id="delivery"', 'id="jobs"', 'id="add-repo"']) {
for (const marker of ['id="quota"', 'id="delivery"', 'id="jobs"']) {
assert.ok(html.indexOf(marker) > html.indexOf('data-secondary'), `${marker} stays inside the disclosure`)
}
assert.match(html, /<dialog[^>]*id="add-repo"/)
assert.ok(html.indexOf('id="add-repo"') > html.indexOf('data-secondary'), 'the add dialog sits below the secondary content')
// The table now carries `class="table-cards"` (#130), so the marker is the
// element, not the bare tag that carried no attribute.
assert.match(html, /<table\b/, 'the repository table is still rendered, only collapsed')
Expand Down
8 changes: 8 additions & 0 deletions test/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,14 @@ test('dashboard paginates repositories and jobs at ten per page', async () => {
assert.match(first.body, /方式二:手动提供 fine-grained token/)
assert.match(first.body, /add-manual/)
assert.match(first.body, /info-tip/)
assert.match(first.body, /<section class="card" id="repositories">/)
assert.match(first.body, /data-open-dialog="add-repo"/)
assert.match(first.body, /aria-haspopup="dialog"/)
assert.match(first.body, /aria-controls="add-repo"/)
assert.match(first.body, /<dialog[^>]*id="add-repo"[^>]*aria-labelledby="add-repo-title"/)
assert.match(first.body, /data-close-dialog/)
assert.doesNotMatch(first.body, /<div class="card" id="add-repo">/)
assert.match(first.body, /<noscript><style>/)
assert.match(first.body, /PR \/ Issue/)
assert.doesNotMatch(first.body, /onclick=|onsubmit=/)
assert.doesNotMatch(first.body, /间隔是两次自动迭代之间的最小间隔/)
Expand Down
Loading