Add tabs to separate apps and tool repos

This commit is contained in:
Chris Wanstrath 2026-03-09 00:14:58 -07:00
parent 946cdb1794
commit 267e4e59f7
3 changed files with 135 additions and 59 deletions

View File

@ -15,26 +15,6 @@ const TOES_URL = process.env.TOES_URL!
const REPOS_DIR = resolve(DATA_ROOT, 'repos') const REPOS_DIR = resolve(DATA_ROOT, 'repos')
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json') const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
const TOGGLE_SCRIPT = `
function toggleVisibility(btn) {
var repo = btn.dataset.repo;
var current = btn.dataset.visibility;
var next = current === 'public' ? 'private' : 'public';
btn.dataset.visibility = next;
btn.textContent = next;
btn.classList.toggle('public', next === 'public');
fetch('/api/visibility/' + encodeURIComponent(repo), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility: next })
}).catch(function() {
btn.dataset.visibility = current;
btn.textContent = current;
btn.classList.toggle('public', current === 'public');
});
}
`
const app = new Hype({ prettyHTML: false, layout: false }) const app = new Hype({ prettyHTML: false, layout: false })
const deployLocks = new Map<string, Promise<void>>() const deployLocks = new Map<string, Promise<void>>()
@ -113,6 +93,31 @@ const RepoName = define('RepoName', {
color: theme('colors-text'), color: theme('colors-text'),
}) })
const Tab = define('Tab', {
base: 'button',
padding: '6px 0',
background: 'none',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
fontSize: '14px',
color: theme('colors-textMuted'),
states: {
':hover': { color: theme('colors-text') },
'.active': {
color: theme('colors-text'),
borderBottomColor: theme('colors-primary'),
fontWeight: '500',
},
},
})
const TabBar = define('TabBar', {
display: 'flex',
gap: '24px',
marginBottom: '20px',
})
const Toggle = define('Toggle', { const Toggle = define('Toggle', {
base: 'button', base: 'button',
display: 'inline-flex', display: 'inline-flex',
@ -156,7 +161,7 @@ interface LayoutProps {
interface RepoListPageProps { interface RepoListPageProps {
baseUrl: string baseUrl: string
external: boolean external: boolean
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility }> repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
tunnelUrl?: string tunnelUrl?: string
} }
@ -544,7 +549,56 @@ function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
) )
} }
function RepoListItems({ baseUrl, external, repos, tunnelUrl }: {
baseUrl: string
external: boolean
repos: RepoListPageProps['repos']
tunnelUrl?: string
}) {
if (repos.length === 0) {
return <HelpText>No repositories yet.</HelpText>
}
return (
<RepoList>
{repos.map(({ name, commits, branch, visibility }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{name}
</HelpText>
{!external && tunnelUrl && visibility === 'public' && (
<HelpText style="margin: 2px 0 0; font-size: 12px">
git clone {tunnelUrl}/{name}
</HelpText>
)}
</div>
<div style="display: flex; gap: 8px; align-items: center">
{!external && (
<Toggle
class={visibility === 'public' ? 'public' : ''}
data-repo={name}
data-visibility={visibility}
onclick="toggleVisibility(this)"
>
{visibility === 'public' ? 'public' : 'private'}
</Toggle>
)}
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
)
}
function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) { function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) {
const appRepos = repos.filter(r => !r.tool)
const toolRepos = repos.filter(r => r.tool)
return ( return (
<Layout title="Git"> <Layout title="Git">
{!external && ( {!external && (
@ -569,40 +623,20 @@ function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps
{repos.length > 0 && ( {repos.length > 0 && (
<> <>
<Heading>Repositories</Heading> <Heading>Repositories</Heading>
<RepoList> <TabBar>
{repos.map(({ name, commits, branch, visibility }) => ( <Tab class="active" data-tab="tab-apps" onclick="switchTab(this)">Apps</Tab>
<RepoItem> <Tab data-tab="tab-tools" onclick="switchTab(this)">Tools</Tab>
<div> </TabBar>
<RepoName>{name}</RepoName> <div>
<HelpText style="margin: 4px 0 0; font-size: 12px"> <div id="tab-apps">
git clone {baseUrl}/{name} <RepoListItems baseUrl={baseUrl} external={external} repos={appRepos} tunnelUrl={tunnelUrl} />
</HelpText> </div>
{!external && tunnelUrl && visibility === 'public' && ( <div id="tab-tools" style="display: none">
<HelpText style="margin: 2px 0 0; font-size: 12px"> <RepoListItems baseUrl={baseUrl} external={external} repos={toolRepos} tunnelUrl={tunnelUrl} />
git clone {tunnelUrl}/{name} </div>
</HelpText> </div>
)} {!external && <script src="/client/toggle.js" />}
</div> <script src="/client/tabs.js" />
<div style="display: flex; gap: 8px; align-items: center">
{!external && (
<Toggle
class={visibility === 'public' ? 'public' : ''}
data-repo={name}
data-visibility={visibility}
onclick="toggleVisibility(this)"
>
{visibility === 'public' ? 'public' : 'private'}
</Toggle>
)}
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
{!external && <script src="/toggle.js" />}
</> </>
)} )}
@ -630,9 +664,6 @@ app.get('/styles.css', c =>
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }), c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
) )
app.get('/toggle.js', c =>
c.text(TOGGLE_SCRIPT, 200, { 'Content-Type': 'application/javascript; charset=utf-8' }),
)
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack // GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => { app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
@ -780,6 +811,19 @@ app.get('/', async c => {
// No app selected — show all repos // No app selected — show all repos
const repos = await listRepos() const repos = await listRepos()
// Fetch all apps to determine which repos are tools
let toolSet = new Set<string>()
try {
const res = await fetch(`${TOES_URL}/api/apps`)
if (res.ok) {
const apps = await res.json() as Array<{ name: string; tool?: boolean | string }>
for (const a of apps) {
if (a.tool) toolSet.add(a.name)
}
}
} catch {}
const repoData = await Promise.all(repos.map(async name => { const repoData = await Promise.all(repos.map(async name => {
const bare = repoPath(name) const bare = repoPath(name)
const [commits, branch, visibility] = await Promise.all([ const [commits, branch, visibility] = await Promise.all([
@ -787,7 +831,7 @@ app.get('/', async c => {
getDefaultBranch(bare), getDefaultBranch(bare),
getVisibility(name), getVisibility(name),
]) ])
return { name, commits, branch, visibility } return { name, commits, branch, visibility, tool: toolSet.has(name) }
})) }))
// Hide private repos from external (sneaker) requests // Hide private repos from external (sneaker) requests

View File

@ -0,0 +1,13 @@
function switchTab(btn: HTMLButtonElement) {
const tabs = btn.parentElement!.querySelectorAll('button')
for (const tab of tabs) tab.classList.remove('active')
btn.classList.add('active')
const panels = btn.parentElement!.nextElementSibling!.children
for (const panel of panels) (panel as HTMLElement).style.display = 'none'
const target = document.getElementById(btn.dataset.tab!)
if (target) target.style.display = 'block'
}
Object.assign(window, { switchTab })

View File

@ -0,0 +1,19 @@
function toggleVisibility(btn: HTMLButtonElement) {
const repo = btn.dataset.repo!
const current = btn.dataset.visibility!
const next = current === 'public' ? 'private' : 'public'
btn.dataset.visibility = next
btn.textContent = next
btn.classList.toggle('public', next === 'public')
fetch('/api/visibility/' + encodeURIComponent(repo), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility: next }),
}).catch(() => {
btn.dataset.visibility = current
btn.textContent = current
btn.classList.toggle('public', current === 'public')
})
}
Object.assign(window, { toggleVisibility })