diff --git a/AGENTS.md b/AGENTS.md index 6f0c436..ce5578d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,8 @@ Do not start by reading every file. Start from the area you are changing. - `src/pages/repo/pulls.tsx`: pull list, new pull, pull detail, and patch/diff rendering calls. - `src/pages/repo/pulls-helpers.ts`: pull-specific pure helpers such as state filter parsing. - `src/components/common.tsx`: generic UI primitives such as `Avatar`, `LoadingState`, `StateBadge`, and form/button/card style helpers. -- `src/components/repo.tsx`: repo presentation components such as tabs, file rows, README/comment cards, `CodeView`, `DiffView`, branch pills, and repo skeletons. +- `src/components/repo.tsx`: repo presentation components such as tabs, file rows, README/comment cards, branch pills, and repo skeletons. +- `src/components/code-view.tsx`: code and diff presentation components, highlighting helper functions, `CodeView`, `DiffView`, and `PullDiffView`. - `src/lib/api.ts`: compatibility facade that re-exports domain APIs. Do not add implementation here. - `src/lib/api/constants.ts`: public service URLs and OAuth scope exports. - `src/lib/api/appview.ts`: appview JSON transport, fallback, and appview response types. @@ -108,7 +109,7 @@ changing local components: - Start from the matching upstream template in `../tangled-upstream/appview/pages/templates/...`, then check nearby Go/router files when behavior or icon names are data-driven. - For repo chrome, `templates/layouts/repobase.html` is the main reference. Confirm header grouping, action buttons, tab icons, label spacing, font sizes, and vertical padding against that file before patching `src/pages/repo/shared.tsx`. -- For code/blob views, compare `templates/repo/index.html` and `templates/repo/blob.html` before changing `src/pages/repo/code.tsx`, `src/components/repo.tsx`, or file-view CSS. +- For code/blob views, compare `templates/repo/index.html` and `templates/repo/blob.html` before changing `src/pages/repo/code.tsx`, `src/components/code-view.tsx`, or file-view CSS. - Treat screenshots as prompts to inspect upstream source, not as the only source of truth. - Small differences such as `gap-*`, `py-*`, icon choice, and short-rev styling matter. - Remove or hide upstream controls that are not implemented locally instead of shipping fake UI. @@ -132,7 +133,7 @@ If a class is not present in `public/static/tw.css`, add an explicit - Preserve the `.untangled-shell` wrapper in `RootShell`; widening pages changes the app layout. - Do not add a footer unless explicitly requested. - File/tree ordering should be folders first, then files alphabetically. This is implemented in `sortedTreeEntries` in `src/lib/repo-utils.ts`. -- File/blob rendering uses `CodeView` in `src/components/repo.tsx`. +- File/blob rendering uses `CodeView` in `src/components/code-view.tsx`. - PR diffs use `DiffView`, which parses unified patches into collapsible per-file panels and reuses `CodeView`. - Repo overview and blob pages should resolve `HEAD` to the default branch for user-facing links when possible. - Keep unimplemented upstream features visually inert rather than adding fake behavior. @@ -153,7 +154,7 @@ If a class is not present in `public/static/tw.css`, add an explicit - Keep repo-route behavior in `src/pages/repo/*.tsx`. - Keep repo-route pure helpers in nearby `src/pages/repo/*-helpers.ts` files. - Keep generic display components in `src/components/common.tsx`. -- Keep repo-specific display components in `src/components/repo.tsx`. +- Keep repo-specific display components in `src/components/repo.tsx` and `src/components/code-view.tsx`. - Keep shared pure repo helpers in `src/lib/repo-utils.ts`. - Do not add large page logic back into `src/App.tsx`. - Do not remove existing behavior while restructuring. diff --git a/src/components/code-view.tsx b/src/components/code-view.tsx new file mode 100644 index 0000000..5170088 --- /dev/null +++ b/src/components/code-view.tsx @@ -0,0 +1,850 @@ +import clsx from 'clsx'; +import hljs from 'highlight.js'; +import { For, Show, createSignal, createMemo, createEffect, onCleanup, onMount, type Component, type JSX } from 'solid-js'; +import { + ChevronRight, + Circle, + CircleCheck, + Columns2, + FileText, + FoldVertical, + Folder, + PanelLeftClose, + PanelLeftOpen, + PanelRightClose, + PanelRightOpen, + UnfoldVertical, +} from 'lucide-solid'; + +const splitCodeLines = (value: string): string[] => { + const normalized = value.endsWith('\n') ? value.slice(0, -1) : value; + return normalized.length === 0 ? [''] : normalized.split('\n'); +}; + +const escapeHtml = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const customExtensionLanguages: Record = { + cjs: 'javascript', + mjs: 'javascript', + cts: 'typescript', + mts: 'typescript', + conf: 'ini', + env: 'ini', +}; + +const filenameLanguages: Record = { + dockerfile: 'bash', + gemfile: 'ruby', + makefile: 'makefile', + rakefile: 'ruby', +}; + +const languageFromPath = (path?: string) => { + if (!path) return undefined; + + const filename = path.split('/').pop()?.toLowerCase() ?? ''; + if (filenameLanguages[filename]) return filenameLanguages[filename]; + + const extension = filename.includes('.') ? filename.split('.').pop() : filename; + if (!extension) return undefined; + + // Check if highlight.js registers this extension directly or as a built-in alias + if (hljs.getLanguage(extension)) { + return extension; + } + + return customExtensionLanguages[extension] ?? extension; +}; + +const resolveHighlightLanguage = (language?: string, path?: string) => { + const candidate = (language ?? languageFromPath(path))?.toLowerCase(); + if (!candidate || candidate === 'plaintext' || candidate === 'text') return undefined; + return hljs.getLanguage(candidate) ? candidate : undefined; +}; + +const closeOpenSpans = (count: number) => ''.repeat(count); + +const splitHighlightedHtmlLines = (html: string): string[] => { + const lineParts = html.split('\n'); + const activeTags: string[] = []; + + return lineParts.map((linePart) => { + const prefix = activeTags.join(''); + + for (const tag of linePart.match(/<\/?span\b[^>]*>/g) ?? []) { + if (tag.startsWith(' { + const normalized = text.endsWith('\n') ? text.slice(0, -1) : text; + const fallback = () => splitCodeLines(normalized).map(escapeHtml); + + if (!language) return fallback(); + + try { + const highlighted = hljs.highlight(normalized, { language, ignoreIllegals: true }).value; + return splitHighlightedHtmlLines(highlighted); + } catch { + return fallback(); + } +}; + +const diffMetadataLine = (line: string) => + line.startsWith('diff --git ') || + line.startsWith('index ') || + line.startsWith('---') || + line.startsWith('+++') || + line.startsWith('@@') || + line.startsWith('Binary files ') || + line.startsWith('new file mode ') || + line.startsWith('deleted file mode ') || + line.startsWith('old mode ') || + line.startsWith('new mode ') || + line.startsWith('similarity index ') || + line.startsWith('dissimilarity index ') || + line.startsWith('rename from ') || + line.startsWith('rename to '); + +const highlightDiffLine = (line: string, language?: string) => { + if (diffMetadataLine(line)) return escapeHtml(line); + + const marker = line[0]; + const isChangedLine = marker === '+' || marker === '-' || marker === ' '; + if (!isChangedLine) return escapeHtml(line); + + const content = line.slice(1); + const highlighted = highlightCodeLines(content, language)[0] ?? escapeHtml(content); + return `${escapeHtml(marker)}${highlighted}`; +}; + +/** Parse a URL hash like #L5 or #L3-L8 into {start, end} (1-based, inclusive). */ +const parseLineHash = (hash: string): { start: number; end: number } | null => { + const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(hash); + if (!m) return null; + const start = parseInt(m[1], 10); + const end = m[2] ? parseInt(m[2], 10) : start; + if (isNaN(start) || isNaN(end)) return null; + return { start: Math.min(start, end), end: Math.max(start, end) }; +}; + +export const CodeView: Component<{ + text: string; + id?: string; + wrap?: boolean; + maxHeight?: string; + diff?: boolean; + path?: string; + language?: string; +}> = (props) => { + const lines = createMemo(() => splitCodeLines(props.text)); + const highlightedLines = createMemo(() => { + const language = resolveHighlightLanguage(props.language, props.path); + if (props.diff) return lines().map((line) => highlightDiffLine(line, language)); + return highlightCodeLines(props.text, language); + }); + const lineClass = (line: string) => + props.diff + ? clsx( + line.startsWith('+') && !line.startsWith('+++') && 'untangled-code-line-add', + line.startsWith('-') && !line.startsWith('---') && 'untangled-code-line-del', + line.startsWith('@@') && 'untangled-code-line-hunk', + ) + : undefined; + + // Line selection state (only used in non-diff mode) + const [selection, setSelection] = createSignal<{ start: number; end: number } | null>(null); + // The line number of the last plain click (anchor for shift-click ranges) + let anchorLine: number | null = null; + + const applyHashSelection = (scroll = false) => { + const parsed = parseLineHash(window.location.hash); + setSelection(parsed); + if (scroll && parsed) { + requestAnimationFrame(() => { + const target = document.getElementById(`L${parsed.start}`); + target?.scrollIntoView({ behavior: 'instant', block: 'center' }); + }); + } + }; + + const handleLineNumberClick = (lineNum: number, event: MouseEvent) => { + event.preventDefault(); + let hash: string; + if (event.shiftKey && anchorLine !== null) { + const lo = Math.min(anchorLine, lineNum); + const hi = Math.max(anchorLine, lineNum); + hash = `#L${lo}-L${hi}`; + } else { + anchorLine = lineNum; + hash = `#L${lineNum}`; + } + history.pushState(null, '', hash); + setSelection(parseLineHash(hash)); + }; + + onMount(() => { + if (props.diff) return; + + applyHashSelection(true); + + const onHashChange = () => applyHashSelection(false); + window.addEventListener('hashchange', onHashChange); + window.addEventListener('popstate', onHashChange); + onCleanup(() => { + window.removeEventListener('hashchange', onHashChange); + window.removeEventListener('popstate', onHashChange); + }); + }); + + const isLineHighlighted = (lineNum: number) => { + const s = selection(); + if (!s) return false; + return lineNum >= s.start && lineNum <= s.end; + }; + + return ( +
+
+ + {(line, index) => { + const lineNum = index() + 1; + return ( + + ); + }} + +
+
+ ); +}; + +interface DiffFile { + path: string; + text: string; + additions: number; + deletions: number; +} + +const parseDiffFiles = (patch: string): DiffFile[] => { + const files: DiffFile[] = []; + let current: { path: string; lines: string[]; additions: number; deletions: number } | null = null; + + const pushCurrent = () => { + if (!current) return; + const hasRenderableDiff = current.lines.some( + (line) => + line.startsWith('@@') || + line.startsWith('Binary files ') || + line.startsWith('new file mode ') || + line.startsWith('deleted file mode ') || + line.startsWith('old mode ') || + line.startsWith('similarity index ') || + line.startsWith('rename from '), + ); + if (!hasRenderableDiff && current.additions === 0 && current.deletions === 0) { + current = null; + return; + } + files.push({ + path: current.path, + text: current.lines.join('\n'), + additions: current.additions, + deletions: current.deletions, + }); + }; + + for (const line of splitCodeLines(patch)) { + if (line.startsWith('diff --git ')) { + pushCurrent(); + const match = /^diff --git a\/(.*?) b\/(.*)$/.exec(line); + current = { + path: match?.[2] ?? line.replace('diff --git ', ''), + lines: [line], + additions: 0, + deletions: 0, + }; + continue; + } + + if (!current) { + current = { path: 'patch', lines: [], additions: 0, deletions: 0 }; + } + + if (line.startsWith('+') && !line.startsWith('+++')) current.additions += 1; + if (line.startsWith('-') && !line.startsWith('---')) current.deletions += 1; + current.lines.push(line); + } + + pushCurrent(); + return files; +}; + +const changedFilesLabel = (count: number) => `${count} changed file${count === 1 ? '' : 's'}`; + +const DiffToolbarStats: Component<{ additions: number; deletions: number; fileCount: number; class?: string }> = (props) => ( + <> + + {changedFilesLabel(props.fileCount)} + +); + +const DiffCollapseButton: Component<{ expanded: boolean; onToggle: () => void }> = (props) => ( + +); + +const totalDiffStats = (files: DiffFile[]) => + files.reduce( + (acc, file) => ({ + additions: acc.additions + file.additions, + deletions: acc.deletions + file.deletions, + }), + { additions: 0, deletions: 0 }, + ); + +type DiffFileTreeNode = { + name: string; + path: string; + file?: DiffFile; + children?: DiffFileTreeNode[]; +}; + +const sortDiffFileTree = (nodes: DiffFileTreeNode[]): DiffFileTreeNode[] => + nodes + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ + ...node, + children: node.children ? sortDiffFileTree(node.children) : undefined, + })); + +const diffFileTree = (files: DiffFile[]) => { + const roots: DiffFileTreeNode[] = []; + + for (const file of files) { + const parts = file.path.split('/').filter(Boolean); + const pathParts = parts.length > 0 ? parts : [file.path || 'patch']; + let siblings = roots; + let path = ''; + + for (const [index, part] of pathParts.entries()) { + path = path ? `${path}/${part}` : part; + const isFile = index === pathParts.length - 1; + + if (isFile) { + siblings.push({ name: part, path: file.path, file }); + continue; + } + + let node = siblings.find((child) => !child.file && child.name === part); + if (!node) { + node = { name: part, path, children: [] }; + siblings.push(node); + } + + siblings = node.children!; + } + } + + return sortDiffFileTree(roots); +}; + +const diffFileElementId = (path: string) => + `diff-${Array.from(path, (char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('-')}`; + +const scrollToDiffFile = (path: string, event: MouseEvent) => { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + const target = document.getElementById(diffFileElementId(path)); + if (!target) return; + + event.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + const hash = `#${target.id}`; + if (window.location.hash !== hash) { + window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${hash}`); + } +}; + +const DiffFileTreeNodes: Component<{ + nodes: DiffFileTreeNode[]; + reviewedFiles?: () => Set; +}> = (props) => ( +
+ + {(node) => } + +
+); + +const DiffFileTreeNodeView: Component<{ + node: DiffFileTreeNode; + reviewedFiles?: () => Set; +}> = (props) => ( +
+ +
+ + {props.node.name} +
+ +
+ +
+
+ + } + > + {(file) => { + const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false; + return ( + scrollToDiffFile(file().path, event)} + > + + {props.node.name} + + + ✓ + + + + ); + }} +
+
+); + +const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => ( +
+ 0}> + +{props.additions} + + 0}> + -{props.deletions} + +
+); + +const SidebarToggleButton: Component<{ + visible: boolean; + onToggle: () => void; + class?: string; + ariaLabelCollapse: string; + ariaLabelExpand: string; + panel?: 'left' | 'right'; +}> = (props) => ( + +); + +const DiffFileEntry: Component<{ + file: DiffFile; + open: boolean; + onToggle?: (open: boolean) => void; + isReviewed?: boolean; + onToggleReview?: () => void; + wrap?: boolean; + maxHeight?: string; + className?: string; + headerClassName?: string; + chevronClassName?: string; + pathClassName?: string; + showEllipsis?: boolean; +}> = (props) => ( +
props.onToggle?.(e.currentTarget.open) : undefined} + > + +
+ + + {props.file.path} +
+ + + +
+ +
...
+
+ +
+); + +export const DiffView: Component<{ + patch: string; + maxHeight?: string; + wrap?: boolean; + defaultExpanded?: boolean; + showFileTree?: boolean; + variant?: 'default' | 'commit'; +}> = (props) => { + const files = createMemo(() => parseDiffFiles(props.patch)); + const stats = createMemo(() => totalDiffStats(files())); + const [expanded, setExpanded] = createSignal(props.defaultExpanded ?? false); + const [filesVisible, setFilesVisible] = createSignal(true); + const previewLimit = 5; + const showFileTree = createMemo(() => Boolean(props.showFileTree && files().length > 0)); + const tree = createMemo(() => diffFileTree(files())); + const visibleFiles = createMemo(() => (showFileTree() ? files() : files().slice(0, previewLimit))); + const hiddenFiles = createMemo(() => (showFileTree() ? [] : files().slice(previewLimit))); + + return ( +
+
+
+
+ + setFilesVisible(!filesVisible())} + class="untangled-diff-sidebar-toggle" + ariaLabelCollapse="collapse changed files sidebar" + ariaLabelExpand="expand changed files sidebar" + /> + + +
+
+ setExpanded(!expanded())} /> +
+ + +
+
+
+
+ + + + +
+ + {(file) => ( + + )} + + 0}> +
+ + + + Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} + + + + Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'} + + +
+ + {(file) => ( + + )} + +
+
+
+ +
No differences found.
+
+
+
+
+
+ ); +}; + +export const PullDiffView: Component<{ + patch: string; + roundLabel: string; + storageKey: string; + history: JSX.Element; +}> = (props) => { + const files = createMemo(() => parseDiffFiles(props.patch)); + const stats = createMemo(() => totalDiffStats(files())); + const tree = createMemo(() => diffFileTree(files())); + const [expanded, setExpanded] = createSignal(true); + const [filesVisible, setFilesVisible] = createSignal(true); + const [historyVisible, setHistoryVisible] = createSignal(true); + + const loadReviewed = (): Set => { + try { + const data = localStorage.getItem(props.storageKey); + if (!data) return new Set(); + const entry = JSON.parse(data); + return new Set(Array.isArray(entry) ? entry : (entry.files || [])); + } catch { + return new Set(); + } + }; + + const [reviewed, setReviewed] = createSignal>(new Set()); + + createEffect(() => { + setReviewed(loadReviewed()); + }); + + const saveReviewed = (newSet: Set) => { + const currentFilePaths = new Set(files().map((f) => f.path)); + const filesToSave = Array.from(newSet).filter((path) => currentFilePaths.has(path)); + try { + localStorage.setItem( + props.storageKey, + JSON.stringify({ + files: filesToSave, + ts: Date.now(), + }), + ); + } catch (e) { + console.error('Failed to save reviewed files:', e); + } + }; + + const pruneStale = () => { + const now = Date.now(); + const REVIEWED_PREFIX = 'reviewed:'; + const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(REVIEWED_PREFIX) && key !== props.storageKey) { + try { + const entry = JSON.parse(localStorage.getItem(key) || '{}'); + if (!entry.ts || now - entry.ts > MAX_AGE_MS) { + localStorage.removeItem(key); + } + } catch { + localStorage.removeItem(key); + } + } + } + }; + + createEffect(() => { + if (Math.random() < 0.1) { + pruneStale(); + } + }); + + const [localOpen, setLocalOpen] = createSignal>({}); + const [hasSetInitial, setHasSetInitial] = createSignal(false); + + createEffect(() => { + if (files().length > 0) { + const isExpanded = expanded(); + const next: Record = {}; + for (const f of files()) { + if (!hasSetInitial() && reviewed().has(f.path)) { + next[f.path] = false; + } else { + next[f.path] = isExpanded; + } + } + setLocalOpen(next); + setHasSetInitial(true); + } + }); + + const toggleReview = (path: string) => { + const nextReviewed = new Set(reviewed()); + if (nextReviewed.has(path)) { + nextReviewed.delete(path); + } else { + nextReviewed.add(path); + setLocalOpen((prev) => ({ ...prev, [path]: false })); + } + setReviewed(nextReviewed); + saveReviewed(nextReviewed); + }; + + return ( +
+
+
+ setFilesVisible(!filesVisible())} + class="untangled-pr-sidebar-toggle" + ariaLabelCollapse="collapse changed files sidebar" + ariaLabelExpand="expand changed files sidebar" + /> + + 0 + ? 'text-green-600 dark:text-green-400' + : 'text-gray-600 dark:text-gray-400' + )} + > + {reviewed().size > 0 + ? `${reviewed().size}/${files().length} file${files().length === 1 ? '' : 's'} reviewed` + : `${files().length} changed file${files().length === 1 ? '' : 's'}` + } + +
+
+ setExpanded(!expanded())} /> +
+
+ setHistoryVisible(!historyVisible())} + class="untangled-pr-sidebar-toggle" + ariaLabelCollapse="collapse history panel" + ariaLabelExpand="expand history panel" + panel="right" + /> +
+
+ +
+ + + + +
+ + {(file) => ( + { + setLocalOpen((prev) => ({ ...prev, [file.path]: open })); + }} + isReviewed={reviewed().has(file.path)} + onToggleReview={() => toggleReview(file.path)} + className="untangled-pr-diff-file" + headerClassName="untangled-pr-diff-file-header" + chevronClassName="untangled-pr-diff-chevron size-4 shrink-0" + pathClassName="text-base text-gray-100" + showEllipsis + /> + )} + +
+ + + + +
+
+ ); +}; diff --git a/src/components/repo.tsx b/src/components/repo.tsx index 83b418a..e31c30f 100644 --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -3,23 +3,13 @@ import DOMPurify from 'dompurify'; import hljs from 'highlight.js/lib/common'; import { Check, - ChevronRight, - Circle, - CircleCheck, - Columns2, Copy, Download, ExternalLink, FileText, - FoldVertical, - Folder, - PanelLeftClose, - PanelLeftOpen, - PanelRightClose, - PanelRightOpen, + File, Square, SquareCheckBig, - UnfoldVertical, BookMarked, GitFork, Star, @@ -28,7 +18,7 @@ import { } from 'lucide-solid'; import { marked } from 'marked'; import { A, useNavigate } from '@solidjs/router'; -import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; +import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; import type { RepoContext, TreeEntry } from '../lib/api/repos'; import type { Did } from '@atcute/lexicons/syntax'; import { createQuery } from '@tanstack/solid-query'; @@ -36,9 +26,9 @@ import { getRepo, getRepoLanguages, getRepoForkCount, getRepoByDid } from '../li import { getRepoStarCount } from '../lib/api/stars'; import { getRepoIssueCount } from '../lib/api/issues'; import { getRepoPullCount } from '../lib/api/pulls'; -import { formatRelativeTime, languageColor, encodePath } from '../lib/repo-utils'; +import { formatRelativeTime, languageColor, encodePath, formatLanguagePercent, blobHref, treeHref } from '../lib/repo-utils'; import { getTangledAppviewService } from '../lib/settings'; -import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common'; +import { Avatar, SkeletonBlock, StateBadge, PlaceholderAvatar, buttonStyles, cardStyles } from './common'; marked.setOptions({ gfm: true, @@ -1020,818 +1010,7 @@ export const MarkdownBlock: Component<{ return
; }; -const splitCodeLines = (value: string): string[] => { - const normalized = value.endsWith('\n') ? value.slice(0, -1) : value; - return normalized.length === 0 ? [''] : normalized.split('\n'); -}; - -const escapeHtml = (value: string) => - value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - -const languageAliases: Record = { - cjs: 'javascript', - conf: 'ini', - cts: 'typescript', - cxx: 'cpp', - dockerfile: 'bash', - env: 'ini', - h: 'c', - hpp: 'cpp', - htm: 'xml', - html: 'xml', - js: 'javascript', - jsonc: 'json', - jsx: 'javascript', - ksh: 'bash', - m: 'objectivec', - markdown: 'markdown', - md: 'markdown', - mjs: 'javascript', - mm: 'objectivec', - mts: 'typescript', - patch: 'diff', - pl: 'perl', - pm: 'perl', - pyw: 'python', - rake: 'ruby', - rs: 'rust', - sh: 'bash', - svg: 'xml', - toml: 'ini', - ts: 'typescript', - tsx: 'typescript', - txt: 'plaintext', - yml: 'yaml', - zsh: 'bash', -}; - -const filenameLanguages: Record = { - dockerfile: 'bash', - gemfile: 'ruby', - makefile: 'makefile', - rakefile: 'ruby', -}; - -const languageFromPath = (path?: string) => { - if (!path) return undefined; - - const filename = path.split('/').pop()?.toLowerCase() ?? ''; - if (filenameLanguages[filename]) return filenameLanguages[filename]; - - const extension = filename.includes('.') ? filename.split('.').pop() : filename; - if (!extension) return undefined; - - return languageAliases[extension] ?? extension; -}; - -const resolveHighlightLanguage = (language?: string, path?: string) => { - const candidate = (language ?? languageFromPath(path))?.toLowerCase(); - if (!candidate || candidate === 'plaintext' || candidate === 'text') return undefined; - return hljs.getLanguage(candidate) ? candidate : undefined; -}; - -const closeOpenSpans = (count: number) => ''.repeat(count); - -const splitHighlightedHtmlLines = (html: string): string[] => { - const lineParts = html.split('\n'); - const activeTags: string[] = []; - - return lineParts.map((linePart) => { - const prefix = activeTags.join(''); - - for (const tag of linePart.match(/<\/?span\b[^>]*>/g) ?? []) { - if (tag.startsWith(' { - const normalized = text.endsWith('\n') ? text.slice(0, -1) : text; - const fallback = () => splitCodeLines(normalized).map(escapeHtml); - - if (!language) return fallback(); - - try { - const highlighted = hljs.highlight(normalized, { language, ignoreIllegals: true }).value; - return splitHighlightedHtmlLines(highlighted); - } catch { - return fallback(); - } -}; - -const diffMetadataLine = (line: string) => - line.startsWith('diff --git ') || - line.startsWith('index ') || - line.startsWith('---') || - line.startsWith('+++') || - line.startsWith('@@') || - line.startsWith('Binary files ') || - line.startsWith('new file mode ') || - line.startsWith('deleted file mode ') || - line.startsWith('old mode ') || - line.startsWith('new mode ') || - line.startsWith('similarity index ') || - line.startsWith('dissimilarity index ') || - line.startsWith('rename from ') || - line.startsWith('rename to '); - -const highlightDiffLine = (line: string, language?: string) => { - if (diffMetadataLine(line)) return escapeHtml(line); - - const marker = line[0]; - const isChangedLine = marker === '+' || marker === '-' || marker === ' '; - if (!isChangedLine) return escapeHtml(line); - - const content = line.slice(1); - const highlighted = highlightCodeLines(content, language)[0] ?? escapeHtml(content); - return `${escapeHtml(marker)}${highlighted}`; -}; - -/** Parse a URL hash like #L5 or #L3-L8 into {start, end} (1-based, inclusive). */ -const parseLineHash = (hash: string): { start: number; end: number } | null => { - const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(hash); - if (!m) return null; - const start = parseInt(m[1], 10); - const end = m[2] ? parseInt(m[2], 10) : start; - if (isNaN(start) || isNaN(end)) return null; - return { start: Math.min(start, end), end: Math.max(start, end) }; -}; - -export const CodeView: Component<{ - text: string; - id?: string; - wrap?: boolean; - maxHeight?: string; - diff?: boolean; - path?: string; - language?: string; -}> = (props) => { - const lines = createMemo(() => splitCodeLines(props.text)); - const highlightedLines = createMemo(() => { - const language = resolveHighlightLanguage(props.language, props.path); - if (props.diff) return lines().map((line) => highlightDiffLine(line, language)); - return highlightCodeLines(props.text, language); - }); - const lineClass = (line: string) => - props.diff - ? clsx( - line.startsWith('+') && !line.startsWith('+++') && 'untangled-code-line-add', - line.startsWith('-') && !line.startsWith('---') && 'untangled-code-line-del', - line.startsWith('@@') && 'untangled-code-line-hunk', - ) - : undefined; - - // Line selection state (only used in non-diff mode) - const [selection, setSelection] = createSignal<{ start: number; end: number } | null>(null); - // The line number of the last plain click (anchor for shift-click ranges) - let anchorLine: number | null = null; - - const applyHashSelection = (scroll = false) => { - const parsed = parseLineHash(window.location.hash); - setSelection(parsed); - if (scroll && parsed) { - requestAnimationFrame(() => { - const target = document.getElementById(`L${parsed.start}`); - target?.scrollIntoView({ behavior: 'instant', block: 'center' }); - }); - } - }; - - const handleLineNumberClick = (lineNum: number, event: MouseEvent) => { - event.preventDefault(); - let hash: string; - if (event.shiftKey && anchorLine !== null) { - const lo = Math.min(anchorLine, lineNum); - const hi = Math.max(anchorLine, lineNum); - hash = `#L${lo}-L${hi}`; - } else { - anchorLine = lineNum; - hash = `#L${lineNum}`; - } - history.pushState(null, '', hash); - setSelection(parseLineHash(hash)); - }; - - onMount(() => { - if (props.diff) return; - - applyHashSelection(true); - - const onHashChange = () => applyHashSelection(false); - window.addEventListener('hashchange', onHashChange); - window.addEventListener('popstate', onHashChange); - onCleanup(() => { - window.removeEventListener('hashchange', onHashChange); - window.removeEventListener('popstate', onHashChange); - }); - }); - - const isLineHighlighted = (lineNum: number) => { - const s = selection(); - if (!s) return false; - return lineNum >= s.start && lineNum <= s.end; - }; - - return ( -
-
- - {(line, index) => { - const lineNum = index() + 1; - return ( - - ); - }} - -
-
- ); -}; - -interface DiffFile { - path: string; - text: string; - additions: number; - deletions: number; -} - -const parseDiffFiles = (patch: string): DiffFile[] => { - const files: DiffFile[] = []; - let current: { path: string; lines: string[]; additions: number; deletions: number } | null = null; - - const pushCurrent = () => { - if (!current) return; - const hasRenderableDiff = current.lines.some( - (line) => - line.startsWith('@@') || - line.startsWith('Binary files ') || - line.startsWith('new file mode ') || - line.startsWith('deleted file mode ') || - line.startsWith('old mode ') || - line.startsWith('similarity index ') || - line.startsWith('rename from '), - ); - if (!hasRenderableDiff && current.additions === 0 && current.deletions === 0) { - current = null; - return; - } - files.push({ - path: current.path, - text: current.lines.join('\n'), - additions: current.additions, - deletions: current.deletions, - }); - }; - - for (const line of splitCodeLines(patch)) { - if (line.startsWith('diff --git ')) { - pushCurrent(); - const match = /^diff --git a\/(.*?) b\/(.*)$/.exec(line); - current = { - path: match?.[2] ?? line.replace('diff --git ', ''), - lines: [line], - additions: 0, - deletions: 0, - }; - continue; - } - - if (!current) { - current = { path: 'patch', lines: [], additions: 0, deletions: 0 }; - } - - if (line.startsWith('+') && !line.startsWith('+++')) current.additions += 1; - if (line.startsWith('-') && !line.startsWith('---')) current.deletions += 1; - current.lines.push(line); - } - - pushCurrent(); - return files; -}; - -const changedFilesLabel = (count: number) => `${count} changed file${count === 1 ? '' : 's'}`; - -const DiffToolbarStats: Component<{ additions: number; deletions: number; fileCount: number; class?: string }> = (props) => ( - <> - - {changedFilesLabel(props.fileCount)} - -); - -const DiffCollapseButton: Component<{ expanded: boolean; onToggle: () => void }> = (props) => ( - -); - -export const DiffView: Component<{ - patch: string; - maxHeight?: string; - wrap?: boolean; - defaultExpanded?: boolean; - showFileTree?: boolean; - variant?: 'default' | 'commit'; -}> = (props) => { - const files = createMemo(() => parseDiffFiles(props.patch)); - const stats = createMemo(() => totalDiffStats(files())); - const [expanded, setExpanded] = createSignal(props.defaultExpanded ?? false); - const [filesVisible, setFilesVisible] = createSignal(true); - const previewLimit = 5; - const showFileTree = createMemo(() => Boolean(props.showFileTree && files().length > 0)); - const tree = createMemo(() => diffFileTree(files())); - const visibleFiles = createMemo(() => (showFileTree() ? files() : files().slice(0, previewLimit))); - const hiddenFiles = createMemo(() => (showFileTree() ? [] : files().slice(previewLimit))); - - return ( -
-
-
-
- - - - -
-
- setExpanded(!expanded())} /> -
- - -
-
-
-
- - - - -
- - {(file) => ( -
- -
- - - {file.path} -
-
- -
- )} -
- 0}> -
- - - - Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} - - - - Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'} - - -
- - {(file) => ( -
- -
- - - {file.path} -
-
- -
- )} -
-
-
-
- -
No differences found.
-
-
-
-
-
- ); -}; - -const totalDiffStats = (files: DiffFile[]) => - files.reduce( - (acc, file) => ({ - additions: acc.additions + file.additions, - deletions: acc.deletions + file.deletions, - }), - { additions: 0, deletions: 0 }, - ); - -type DiffFileTreeNode = { - name: string; - path: string; - file?: DiffFile; - children?: DiffFileTreeNode[]; -}; - -const sortDiffFileTree = (nodes: DiffFileTreeNode[]): DiffFileTreeNode[] => - nodes - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ - ...node, - children: node.children ? sortDiffFileTree(node.children) : undefined, - })); - -const diffFileTree = (files: DiffFile[]) => { - const roots: DiffFileTreeNode[] = []; - - for (const file of files) { - const parts = file.path.split('/').filter(Boolean); - const pathParts = parts.length > 0 ? parts : [file.path || 'patch']; - let siblings = roots; - let path = ''; - - for (const [index, part] of pathParts.entries()) { - path = path ? `${path}/${part}` : part; - const isFile = index === pathParts.length - 1; - - if (isFile) { - siblings.push({ name: part, path: file.path, file }); - continue; - } - - let node = siblings.find((child) => !child.file && child.name === part); - if (!node) { - node = { name: part, path, children: [] }; - siblings.push(node); - } - - siblings = node.children!; - } - } - - return sortDiffFileTree(roots); -}; - -const diffFileElementId = (path: string) => - `diff-${Array.from(path, (char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('-')}`; - -const scrollToDiffFile = (path: string, event: MouseEvent) => { - if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - - const target = document.getElementById(diffFileElementId(path)); - if (!target) return; - - event.preventDefault(); - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - - const hash = `#${target.id}`; - if (window.location.hash !== hash) { - window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${hash}`); - } -}; - -const DiffFileTreeNodes: Component<{ - nodes: DiffFileTreeNode[]; - reviewedFiles?: () => Set; -}> = (props) => ( -
- - {(node) => } - -
-); - -const DiffFileTreeNodeView: Component<{ - node: DiffFileTreeNode; - reviewedFiles?: () => Set; -}> = (props) => ( -
- -
- - {props.node.name} -
- -
- -
-
- - } - > - {(file) => { - const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false; - return ( - scrollToDiffFile(file().path, event)} - > - - {props.node.name} - - - ✓ - - - - ); - }} -
-
-); - -const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => ( -
- 0}> - +{props.additions} - - 0}> - -{props.deletions} - -
-); - -export const PullDiffView: Component<{ - patch: string; - roundLabel: string; - storageKey: string; - history: JSX.Element; -}> = (props) => { - const files = createMemo(() => parseDiffFiles(props.patch)); - const stats = createMemo(() => totalDiffStats(files())); - const tree = createMemo(() => diffFileTree(files())); - const [expanded, setExpanded] = createSignal(true); - const [filesVisible, setFilesVisible] = createSignal(true); - const [historyVisible, setHistoryVisible] = createSignal(true); - - const loadReviewed = (): Set => { - try { - const data = localStorage.getItem(props.storageKey); - if (!data) return new Set(); - const entry = JSON.parse(data); - return new Set(Array.isArray(entry) ? entry : (entry.files || [])); - } catch { - return new Set(); - } - }; - - const [reviewed, setReviewed] = createSignal>(new Set()); - - createEffect(() => { - setReviewed(loadReviewed()); - }); - - const saveReviewed = (newSet: Set) => { - const currentFilePaths = new Set(files().map((f) => f.path)); - const filesToSave = Array.from(newSet).filter((path) => currentFilePaths.has(path)); - try { - localStorage.setItem( - props.storageKey, - JSON.stringify({ - files: filesToSave, - ts: Date.now(), - }), - ); - } catch (e) { - console.error('Failed to save reviewed files:', e); - } - }; - - const pruneStale = () => { - const now = Date.now(); - const REVIEWED_PREFIX = 'reviewed:'; - const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith(REVIEWED_PREFIX) && key !== props.storageKey) { - try { - const entry = JSON.parse(localStorage.getItem(key) || '{}'); - if (!entry.ts || now - entry.ts > MAX_AGE_MS) { - localStorage.removeItem(key); - } - } catch { - localStorage.removeItem(key); - } - } - } - }; - - createEffect(() => { - if (Math.random() < 0.1) { - pruneStale(); - } - }); - - const [localOpen, setLocalOpen] = createSignal>({}); - const [hasSetInitial, setHasSetInitial] = createSignal(false); - - createEffect(() => { - if (files().length > 0) { - const isExpanded = expanded(); - const next: Record = {}; - for (const f of files()) { - if (!hasSetInitial() && reviewed().has(f.path)) { - next[f.path] = false; - } else { - next[f.path] = isExpanded; - } - } - setLocalOpen(next); - setHasSetInitial(true); - } - }); - - const toggleReview = (path: string) => { - const nextReviewed = new Set(reviewed()); - if (nextReviewed.has(path)) { - nextReviewed.delete(path); - } else { - nextReviewed.add(path); - setLocalOpen((prev) => ({ ...prev, [path]: false })); - } - setReviewed(nextReviewed); - saveReviewed(nextReviewed); - }; - - return ( -
-
-
- - - 0 - ? 'text-green-600 dark:text-green-400' - : 'text-gray-600 dark:text-gray-400' - )} - > - {reviewed().size > 0 - ? `${reviewed().size}/${files().length} file${files().length === 1 ? '' : 's'} reviewed` - : `${files().length} changed file${files().length === 1 ? '' : 's'}` - } - -
-
- setExpanded(!expanded())} /> -
-
- -
-
-
- - - - -
- - {(file) => ( -
{ - const target = e.currentTarget; - setLocalOpen((prev) => ({ ...prev, [file.path]: target.open })); - }} - > - -
- - - {file.path} -
- -
-
...
- -
- )} -
-
- - - - -
-
- ); -}; export const AsideCard: Component<{ title: string; children: JSX.Element }> = (props) => (
@@ -2121,4 +1300,251 @@ export const RepoCardSkeleton: Component<{ compact?: boolean; noBorder?: boolean ); }; +export const IssueOrPullRow: Component<{ + title: string; + number: number | string; + state: 'open' | 'closed' | 'merged'; + kind: 'issue' | 'pull'; + author: { did: string; handle: string }; + createdAt: string; + href: string; + meta?: JSX.Element; + children?: JSX.Element; +}> = (props) => { + return ( + +
+
+ {props.title}{' '} + #{props.number} +
+
+ + + {props.author.handle} + · + {formatRelativeTime(props.createdAt)} + {props.meta} +
+ {props.children} +
+
+ ); +}; + +export const ThreadCommentView: Component<{ + id?: string; + author: { did: string; handle: string }; + createdAt: string; + markdown: string; + isAuthor?: boolean; + isReply?: boolean; + class?: string; +}> = (props) => ( +
+
+ +
+
+
+ + {props.author.handle} + + + (author) + + · + {formatRelativeTime(props.createdAt)}}> + + {formatRelativeTime(props.createdAt)} + + +
+
+ +
+
+
+); + +const commitSummary = (message: string) => message.trim().split('\n')[0] || 'no commit message'; + +export const PathBreadcrumbs: Component<{ + repo: Parameters[0]; + refName: string; + path: string; + withDivider?: boolean; +}> = (props) => { + const segments = createMemo(() => props.path.split('/').filter(Boolean)); + + return ( +
+
+ + {props.repo.slug} + + + {(segment, index) => ( + <> + / + {segment}} + > + + {segment} + + + + )} + +
+
+ ); +}; + +export const TreeLatestCommitPanel: Component<{ + repo: Parameters[0]; + commit: { + hash: string; + message: string; + when: string; + author?: { + email: string; + name: string; + }; + }; +}> = (props) => ( +
+
+ } + > + {(did) => } + + {props.commit.author?.name || 'unknown author'} + · + + {commitSummary(props.commit.message)} + + · + {formatRelativeTime(props.commit.when)} +
+ + {props.commit.hash.slice(0, 8)} + +
+); + +export const RepoLanguageBar: Component<{ + languages: Array<{ name: string; percentage: number; color?: string }>; + loading?: boolean; +}> = (props) => ( + 0} fallback={}> +
+ + + {(language) => { + const name = language.name || 'Other'; + const color = language.color || languageColor(name); + return ( +
+ ); + }} + +
+
+ + {(language) => { + const name = language.name || 'Other'; + const color = language.color || languageColor(name); + return ( +
+
+
+ {name}{' '} + + {formatLanguagePercent(language.percentage)} + +
+
+ ); + }} + +
+
+
+); + +export const LoadingFilePath: Component<{ + repo: Parameters[0]; + refName: string; + path: string; +}> = (props) => { + const segments = createMemo(() => props.path.split('/').filter(Boolean)); + const filename = createMemo(() => segments().at(-1) ?? props.path); + + return ( +
+
+
+ +
+ + at {props.refName} + +
+
+
+
+ } + class="untangled-file-row-loading" + /> +
+
+ ); +}; + +export { CodeView, DiffView, PullDiffView } from './code-view'; + + + diff --git a/src/index.css b/src/index.css index e0d2b8d..6935a7a 100644 --- a/src/index.css +++ b/src/index.css @@ -1934,6 +1934,7 @@ details[open] > .untangled-language-bar { .untangled-safe-header { padding-top: env(safe-area-inset-top); + z-index: 20; } .untangled-unread-badge { @@ -2004,6 +2005,133 @@ details[open] > .untangled-language-bar { } } +.untangled-toggle-group { + display: flex; + align-items: stretch; + border-radius: 0.25rem; + border: 1px solid rgb(209 213 219); + overflow: hidden; +} +@media (prefers-color-scheme: dark) { + .untangled-toggle-group { + border-color: rgb(55 65 81); + } +} + +.untangled-list-row { + display: block; + border-radius: 0.25rem; + background-color: rgb(255 255 255); + padding: 1rem 1.5rem; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + text-decoration-line: none; +} +.untangled-list-row:hover { + text-decoration-line: underline; +} +@media (prefers-color-scheme: dark) { + .untangled-list-row { + background-color: rgb(31 41 55); + border: 1px solid rgb(55 65 81); + } +} + +.untangled-thread-group { + overflow: hidden; + border-radius: 0.25rem; + border: 1px solid rgb(229 231 235); + background-color: rgb(249 250 251); + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); +} +@media (prefers-color-scheme: dark) { + .untangled-thread-group { + border-color: rgb(55 65 81); + background-color: rgb(31 41 55 / 0.5); + } +} + +.untangled-thread-comment { + display: flex; + gap: 0.5rem; + border-radius: 0.25rem; + background-color: rgb(255 255 255); + padding: 1rem 1.5rem; +} +@media (prefers-color-scheme: dark) { + .untangled-thread-comment { + background-color: rgb(31 41 55); + } +} + +.untangled-thread-replies-list { + position: relative; + margin-left: 2.5rem; + border-left: 1px solid rgb(229 231 235); +} +@media (prefers-color-scheme: dark) { + .untangled-thread-replies-list { + border-left-color: rgb(55 65 81); + } +} + +.untangled-thread-reply { + margin-left: -1rem; + display: flex; + gap: 0.5rem; + padding: 1rem 1rem 1rem 0; +} + +.untangled-thread-reply-trigger { + display: flex; + width: 100%; + align-items: center; + gap: 0.5rem; + border-top: 1px solid rgb(209 213 219); + background: transparent; + padding: 0.5rem 1.5rem; + text-align: left; + font-size: 0.875rem; + color: rgb(107 114 128); +} +@media (prefers-color-scheme: dark) { + .untangled-thread-reply-trigger { + border-top-color: rgb(55 65 81); + color: rgb(156 163 175); + } +} + +.untangled-thread-reply-form { + display: flex; + width: 100%; + flex-direction: column; + gap: 0.5rem; + border-top: 1px solid rgb(229 231 235); + padding: 0.5rem; +} +@media (prefers-color-scheme: dark) { + .untangled-thread-reply-form { + border-top-color: rgb(55 65 81); + } +} + +.untangled-comment-composer { + position: relative; + display: flex; + min-width: 0; + width: 100%; + flex-direction: column; + gap: 0.75rem; + border-radius: 0.25rem; + background-color: rgb(255 255 255); + padding: 1rem; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); +} +@media (prefers-color-scheme: dark) { + .untangled-comment-composer { + background-color: rgb(31 41 55); + } +} + .untangled-button { min-height: 30px; } diff --git a/src/layout.tsx b/src/layout.tsx index ef4acd9..4c94506 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -395,7 +395,7 @@ const Topbar: Component = () => { export const RootShell: Component<{ children?: JSX.Element }> = (props) => (
-
+
diff --git a/src/pages/repo/code.tsx b/src/pages/repo/code.tsx index d913f30..916ca55 100644 --- a/src/pages/repo/code.tsx +++ b/src/pages/repo/code.tsx @@ -5,50 +5,11 @@ import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-q import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Accessor, type Component } from 'solid-js'; import { buildBlobDataUrl, decodeBlobBytes, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree, type CommitHeadline } from '../../lib/api/repos'; import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; -import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoLanguageBarSkeleton, RepoTreeSkeleton } from '../../components/repo'; +import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoTreeSkeleton, PathBreadcrumbs, TreeLatestCommitPanel, RepoLanguageBar, LoadingFilePath } from '../../components/repo'; import { RepoFrame, useRepoQuery } from './shared'; -import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, updateRecentRepoLanguage, videoLike } from '../../lib/repo-utils'; +import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, updateRecentRepoLanguage, videoLike } from '../../lib/repo-utils'; import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers'; -const PathBreadcrumbs: Component<{ - repo: Parameters[0]; - refName: string; - path: string; - withDivider?: boolean; -}> = (props) => { - const segments = createMemo(() => props.path.split('/').filter(Boolean)); - - return ( -
-
- - {props.repo.slug} - - - {(segment, index) => ( - <> - / - {segment}} - > - - {segment} - - - - )} - -
-
- ); -}; - -const commitSummary = (message: string) => message.trim().split('\n')[0] || 'no commit message'; - const readmeIsAvailable = ( readme: { filename: string; contents: string } | undefined, entries: Array<{ name: string; mode: string }>, @@ -60,145 +21,6 @@ const readmeIsAvailable = ( return filename.length > 0 && entries.some((entry) => !entry.mode.startsWith('004') && entry.name.toLowerCase() === filename); }; -const TreeLatestCommitPanel: Component<{ - repo: Parameters[0]; - commit: { - hash: string; - message: string; - when: string; - author?: { - email: string; - name: string; - }; - }; -}> = (props) => ( -
-
- } - > - {(did) => } - - {props.commit.author?.name || 'unknown author'} - · - - {commitSummary(props.commit.message)} - - · - {formatRelativeTime(props.commit.when)} -
- - {props.commit.hash.slice(0, 8)} - -
-); - -// const FallbackLanguageBar: Component = () => ( -//
-// -//
-//
-//
-// ); - -const RepoLanguageBar: Component<{ - languages: Array<{ name: string; percentage: number; color?: string }>; - loading?: boolean; -}> = (props) => ( - 0} fallback={}> -
- - - {(language) => { - const name = language.name || 'Other'; - const color = language.color || languageColor(name); - return ( -
- ); - }} - -
-
- - {(language) => { - const name = language.name || 'Other'; - const color = language.color || languageColor(name); - return ( -
-
-
- {name}{' '} - - {formatLanguagePercent(language.percentage)} - -
-
- ); - }} - -
-
-
-); - -const LoadingFilePath: Component<{ - repo: Parameters[0]; - refName: string; - path: string; -}> = (props) => { - const segments = createMemo(() => props.path.split('/').filter(Boolean)); - const filename = createMemo(() => segments().at(-1) ?? props.path); - - return ( -
-
-
- -
- - at {props.refName} - -
-
-
-
- } - class="untangled-file-row-loading" - /> -
-
- ); -}; - const RepoCodePageLegacy: Component = () => { const params = useParams(); const navigate = useNavigate(); diff --git a/src/pages/repo/issues.tsx b/src/pages/repo/issues.tsx index 62e0168..469c692 100644 --- a/src/pages/repo/issues.tsx +++ b/src/pages/repo/issues.tsx @@ -10,7 +10,7 @@ import { parseAtUri } from '../../lib/api/records'; import type { RepoContext } from '../../lib/api/repos'; import { useAuth } from '../../lib/auth'; import { Avatar, ErrorState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; -import { AtUriPanel, MarkdownBlock, RepoFormSkeleton, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; +import { AtUriPanel, MarkdownBlock, RepoFormSkeleton, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton, IssueOrPullRow, ThreadCommentView } from '../../components/repo'; import { RepoFrame, issueQueryKey, issuesQueryKey, useRepoQuery } from './shared'; import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, issueHref, parseIntegerSearchParam, uniqueCommenters, saveRecentIssueOrPull } from '../../lib/repo-utils'; import { buildIssueCommentThreads, buildIssueFilterQuery, parseIssueFilter } from './issues-helpers'; @@ -86,7 +86,7 @@ export const IssuesPage: Component = () => { }>
-
+
setStateFilter('open')} @@ -159,26 +159,17 @@ export const IssuesPage: Component = () => {
@@ -530,48 +521,31 @@ export const IssuePage: Component = () => {
-
-
+
{(thread) => ( -
- +
+ 0}> -
+
{(reply) => ( -
-
- -
- -
+ )}
@@ -583,7 +557,7 @@ export const IssuePage: Component = () => { fallback={