GistFileExplorer
2 variants. Open one in isolation: use the link on each card.
NestedPaths
Open alone →export function main(): void { console.log('ok')}export const VERSION = '1.0.0'SingleFile
Open alone →service: port: 4321---import { Code } from 'astro-expressive-code/components'import type { GistFileFigure } from '../../lib/gist-files'import { buildGistFileTreeRootFromDisplayPaths } from '../../lib/gist-file-tree-model'import GistExplorerTreeList from './GistExplorerTreeList.astro'import '../../styles/gist-explorer.css'
/** * `codeSlotExpressiveHtml` is the full trusted `<Code>` HTML (Storybook static prerender only). * Production gist pages omit it and use `<Code>`. */type GistFileExplorerPanel = GistFileFigure & { codeSlotExpressiveHtml?: string}
interface Props { panels: GistFileExplorerPanel[]}
const { panels } = Astro.props
const gistFileTreeRoot = panels.length > 0 ? buildGistFileTreeRootFromDisplayPaths(panels.map(p => p.displayPath)) : null
function gistPanelLineCount(code: string): number { if (code.length === 0) return 0 return code.split('\n').length}
function gistPanelByteLength(code: string): number { return new TextEncoder().encode(code).length}
function formatGistFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B` return `${(bytes / 1024).toFixed(1)} KB`}
function gistCaptionFileStats(code: string): string { const lineCount = gistPanelLineCount(code) const lineLabel = lineCount === 1 ? 'line' : 'lines' return `${lineCount} ${lineLabel} · ${formatGistFileSize(gistPanelByteLength(code))}`}---
{ panels.length > 0 && gistFileTreeRoot ? ( <div class="not-prose gist-files-block"> <div class="gist-source-files"> <div class="gist-editor-shell gist-editor-shell--explorer"> <div class="gist-explorer-chrome"> <div class="gist-explorer-toolbar meta"> <span class="gist-explorer-toolbar-spacer" aria-hidden="true" /> <button type="button" class="gist-line-wrap-toggle" id="gist-line-wrap-toggle" aria-pressed="false" aria-controls="gist-source-code-panels" > Line wrap </button> </div> </div> <div class="gist-explorer-body"> <div class="gist-file-tree-wrap"> <div class="gist-file-tree-scroll"> <nav id="gist-source-file-tree" class="gist-file-tree" aria-label="Gist source files"> <div class="gist-file-tree-header"> <div class="gist-file-tree-header-top"> <span class="gist-file-tree-heading">Files</span> <button type="button" class="gist-file-tree-sidebar-toggle" aria-expanded="true" aria-controls="gist-file-tree-panel" aria-label="Collapse file list" > <span class="gist-file-tree-sidebar-toggle-icon" aria-hidden="true" /> </button> </div> <div class="gist-file-tree-search-wrap"> <input type="search" class="gist-file-tree-filter" placeholder="Go to file" autocomplete="off" aria-label="Filter source files" /> </div> </div> <GistExplorerTreeList entries={gistFileTreeRoot.children} pathPrefix="" isRoot /> </nav> </div> </div> <div class="gist-editor-code" id="gist-source-code-panels"> {panels.map(panel => ( <figure class="code-file not-prose" data-gist-file={panel.displayPath} data-gist-lines={String(gistPanelLineCount(panel.code))} data-gist-bytes={String(gistPanelByteLength(panel.code))} > <figcaption class="code-filename meta"> <span class="gist-filename-row"> <button type="button" class="gist-file-tree-expand-btn" aria-label="Show file list" aria-controls="gist-source-file-tree" > <span class="gist-file-tree-expand-btn-icon" aria-hidden="true" /> </button> <span class="gist-code-caption-path">{panel.displayPath}</span> <span class="gist-caption-path-actions"> <button type="button" class="gist-copy-path-btn" data-gist-copy-path={panel.displayPath} aria-label="Copy file path to clipboard" title="Copy path" > <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" > <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /> </svg> </button> <span class="gist-copy-path-feedback" aria-live="polite">Copied!</span> </span> </span> <span class="gist-caption-file-stats meta">{gistCaptionFileStats(panel.code)}</span> </figcaption> {panel.codeSlotExpressiveHtml != null && panel.codeSlotExpressiveHtml.trim().length > 0 ? ( <Fragment set:html={panel.codeSlotExpressiveHtml} /> ) : ( <Code code={panel.code} lang={panel.lang} meta='frame="none"' showLineNumbers={true} wrap={false} /> )} </figure> ))} </div> </div> </div> </div> </div> ) : null}
<script> document.addEventListener('DOMContentLoaded', () => { const filesBlock = document.querySelector<HTMLElement>('.gist-files-block') if (!filesBlock) return const codePane = filesBlock.querySelector('.gist-editor-code') if (!(codePane instanceof HTMLElement)) return
const GIST_LINE_WRAP_STORAGE_KEY = 'blog:gist-line-wrap' const GIST_FILE_TREE_PANEL_ID = 'gist-file-tree-panel'
function readStoredLineWrap(): boolean { try { return window.localStorage.getItem(GIST_LINE_WRAP_STORAGE_KEY) === '1' } catch { return false } }
function persistLineWrap(enabled: boolean) { try { window.localStorage.setItem(GIST_LINE_WRAP_STORAGE_KEY, enabled ? '1' : '0') } catch { /* ignore quota / private mode */ } }
function gistCodePres(): HTMLElement[] { return Array.from(codePane.querySelectorAll<HTMLElement>('.expressive-code pre')) }
function applyGistLineWrap(enabled: boolean) { for (const pre of gistCodePres()) { if (enabled) pre.classList.add('wrap') else pre.classList.remove('wrap') } }
const initialWrap = readStoredLineWrap() applyGistLineWrap(initialWrap)
const figures = Array.from(codePane.querySelectorAll<HTMLElement>('figure.code-file')) if (figures.length === 0) return
const multi = figures.length > 1 const fileTreeNav = filesBlock.querySelector<HTMLElement>('#gist-source-file-tree') const explorerBody = filesBlock.querySelector<HTMLElement>('.gist-explorer-body') const sidebarToggle = fileTreeNav?.querySelector<HTMLButtonElement>('.gist-file-tree-sidebar-toggle') const filterInput = fileTreeNav?.querySelector<HTMLInputElement>('.gist-file-tree-filter')
function readHashPath(): string | null { const raw = window.location.hash.slice(1) if (!raw) return null try { return decodeURIComponent(raw) } catch { return raw } }
function setHashToFile(displayPath: string) { const u = new URL(window.location.href) u.hash = encodeURIComponent(displayPath) history.replaceState(null, '', u.href) }
function expandAncestorFolders(treeNav: HTMLElement, displayPath: string) { const segments = displayPath.split('/').filter(Boolean) for (let depth = 1; depth < segments.length; depth++) { const dirPath = segments.slice(0, depth).join('/') const folder = treeNav.querySelector<HTMLButtonElement>( `.gist-tree-folder[data-gist-dir="${CSS.escape(dirPath)}"]`, ) folder?.setAttribute('aria-expanded', 'true') } }
function setGistFileTreeCollapsed(collapsed: boolean) { if (!explorerBody || !sidebarToggle) return explorerBody.classList.toggle('gist-explorer-body--tree-collapsed', collapsed) sidebarToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true') sidebarToggle.setAttribute('aria-label', collapsed ? 'Expand file list' : 'Collapse file list') const panel = fileTreeNav?.querySelector<HTMLElement>(`#${CSS.escape(GIST_FILE_TREE_PANEL_ID)}`) if (collapsed) { panel?.setAttribute('aria-hidden', 'true') } else { panel?.removeAttribute('aria-hidden') } }
function selectIndex(index: number) { const i = Math.max(0, Math.min(index, figures.length - 1)) figures.forEach((f, j) => { f.hidden = multi && j !== i }) fileTreeNav?.querySelectorAll<HTMLButtonElement>('.gist-tree-file').forEach(b => { const j = Number(b.dataset.figureIndex) if (j === i) { b.setAttribute('aria-current', 'true') } else { b.removeAttribute('aria-current') } }) const path = figures[i]?.dataset.gistFile?.trim() const explorerBodyEl = fileTreeNav?.closest('.gist-explorer-body') const treeCollapsed = explorerBodyEl?.classList.contains('gist-explorer-body--tree-collapsed') if (path && fileTreeNav && !treeCollapsed) { expandAncestorFolders(fileTreeNav, path) fileTreeNav .querySelector<HTMLButtonElement>( `.gist-tree-file[data-figure-index="${CSS.escape(String(i))}"]`, ) ?.scrollIntoView({ block: 'nearest' }) } }
function applyFromHash(options?: { scroll?: boolean }) { const wanted = readHashPath() const idx = wanted ? figures.findIndex(f => (f.dataset.gistFile ?? '').trim() === wanted) : -1 const i = idx >= 0 ? idx : 0 selectIndex(i) if (options?.scroll && idx >= 0) { figures[i]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } }
sidebarToggle?.addEventListener('click', () => { if (!explorerBody) return const collapsed = !explorerBody.classList.contains('gist-explorer-body--tree-collapsed') setGistFileTreeCollapsed(collapsed) })
filterInput?.addEventListener('input', () => { const raw = filterInput.value.trim().toLowerCase() fileTreeNav?.querySelectorAll<HTMLButtonElement>('.gist-tree-file').forEach(btn => { const li = btn.closest('li') if (!li) return const path = (btn.dataset.gistFilterPath ?? '').toLowerCase() li.style.display = !raw || path.includes(raw) ? '' : 'none' }) })
fileTreeNav?.querySelectorAll<HTMLButtonElement>('.gist-tree-folder').forEach(folderButton => { folderButton.addEventListener('click', () => { const open = folderButton.getAttribute('aria-expanded') === 'true' folderButton.setAttribute('aria-expanded', open ? 'false' : 'true') }) })
fileTreeNav?.querySelectorAll<HTMLButtonElement>('.gist-tree-file').forEach(fileButton => { fileButton.addEventListener('click', () => { const idx = Number(fileButton.dataset.figureIndex) if (Number.isNaN(idx)) return selectIndex(idx) const path = fileButton.dataset.gistFilterPath?.trim() if (path) setHashToFile(path) }) })
figures.forEach((figure, i) => { figure.setAttribute('role', 'region') figure.setAttribute('aria-label', `Source: ${figure.dataset.gistFile?.trim() ?? `file ${i + 1}`}`) if (multi && i !== 0) figure.hidden = true })
function wireGistCopyPathButtons() { codePane.querySelectorAll<HTMLButtonElement>('.gist-copy-path-btn').forEach(btn => { const text = btn.dataset.gistCopyPath?.trim() const feedback = btn.nextElementSibling let copyPathTimer: ReturnType<typeof setTimeout> | null = null btn.addEventListener('click', async () => { if (!text) return try { await navigator.clipboard.writeText(text) if ( feedback instanceof HTMLElement && feedback.classList.contains('gist-copy-path-feedback') ) { if (copyPathTimer) clearTimeout(copyPathTimer) feedback.style.opacity = '1' copyPathTimer = setTimeout(() => { feedback.style.opacity = '0' }, 1500) } } catch { /* clipboard unavailable */ } }) }) }
wireGistCopyPathButtons()
codePane.querySelectorAll<HTMLButtonElement>('.gist-file-tree-expand-btn').forEach(btn => { btn.addEventListener('click', () => { setGistFileTreeCollapsed(false) }) })
function wireGistLineWrapToggle() { const toggle = document.getElementById('gist-line-wrap-toggle') if (!toggle) return toggle.setAttribute('aria-pressed', initialWrap ? 'true' : 'false') toggle.addEventListener('click', () => { const pressed = toggle.getAttribute('aria-pressed') === 'true' const next = !pressed toggle.setAttribute('aria-pressed', next ? 'true' : 'false') persistLineWrap(next) applyGistLineWrap(next) }) }
wireGistLineWrapToggle()
const hadMatchingHash = (() => { const wanted = readHashPath() if (!wanted) return false return figures.some(f => (f.dataset.gistFile ?? '').trim() === wanted) })() applyFromHash({ scroll: hadMatchingHash })
window.addEventListener('hashchange', () => { applyFromHash({ scroll: true }) }) })</script>