feat(commits): implement commit history retrieval and display in the database view

This commit is contained in:
Sam Chau
2025-12-29 01:13:10 +10:30
parent def987d8e9
commit 3ae82153d9
12 changed files with 459 additions and 134 deletions

View File

@@ -2,7 +2,7 @@
* Git class - wraps git operations for a repository
*/
import type { GitStatus, OperationFile, CommitResult, UpdateInfo, RepoInfo } from './types.ts';
import type { GitStatus, OperationFile, CommitResult, UpdateInfo, Commit } from './types.ts';
import * as repo from './repo.ts';
import * as status from './status.ts';
import * as ops from './ops.ts';
@@ -23,6 +23,7 @@ export class Git {
status = (options?: status.GetStatusOptions): Promise<GitStatus> => status.getStatus(this.repoPath, options);
checkForUpdates = (): Promise<UpdateInfo> => status.checkForUpdates(this.repoPath);
getLastPushed = () => status.getLastPushed(this.repoPath);
getCommits = (limit?: number): Promise<Commit[]> => status.getCommits(this.repoPath, limit);
// Operation file methods
getUncommittedOps = (): Promise<OperationFile[]> => ops.getUncommittedOps(this.repoPath);

View File

@@ -4,7 +4,7 @@
import { execGit, execGitSafe } from './exec.ts';
import { fetch } from './repo.ts';
import type { GitStatus, UpdateInfo } from './types.ts';
import type { GitStatus, UpdateInfo, Commit } from './types.ts';
/**
* Get current branch name
@@ -132,3 +132,46 @@ export async function getBranches(repoPath: string): Promise<string[]> {
return branches;
}
/**
* Get commit history
*/
export async function getCommits(repoPath: string, limit: number = 50): Promise<Commit[]> {
// Format: hash|shortHash|message|author|email|date
const format = '%H|%h|%s|%an|%ae|%cI';
const output = await execGit(
['log', `--format=${format}`, `-${limit}`],
repoPath
);
if (!output.trim()) {
return [];
}
const commits: Commit[] = [];
for (const line of output.split('\n')) {
if (!line.trim()) continue;
const [hash, shortHash, message, author, authorEmail, date] = line.split('|');
// Get files changed for this commit
const statOutput = await execGitSafe(
['diff-tree', '--no-commit-id', '--name-only', '-r', hash],
repoPath
);
const files = statOutput ? statOutput.split('\n').filter(f => f.trim()) : [];
commits.push({
hash,
shortHash,
message,
author,
authorEmail,
date,
files
});
}
return commits;
}

View File

@@ -45,3 +45,13 @@ export interface RepoInfo {
ownerType: 'User' | 'Organization';
htmlUrl: string;
}
export interface Commit {
hash: string;
shortHash: string;
message: string;
author: string;
authorEmail: string;
date: string;
files: string[];
}

View File

@@ -0,0 +1,35 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { Git, getRepoInfo } from '$utils/git/index.ts';
export const GET: RequestHandler = async ({ params }) => {
const id = parseInt(params.id || '', 10);
const database = databaseInstancesQueries.getById(id);
if (!database) {
error(404, 'Database not found');
}
if (!database.personal_access_token) {
error(403, 'Changes page requires a personal access token');
}
const git = new Git(database.local_path);
const [status, uncommittedOps, lastPushed, branches, repoInfo] = await Promise.all([
git.status(),
git.getUncommittedOps(),
git.getLastPushed(),
git.getBranches(),
getRepoInfo(database.repository_url, database.personal_access_token)
]);
return json({
status,
uncommittedOps,
lastPushed,
branches,
repoInfo
});
};

View File

@@ -0,0 +1,27 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { Git } from '$utils/git/index.ts';
export const GET: RequestHandler = async ({ params, url }) => {
const id = parseInt(params.id || '', 10);
const database = databaseInstancesQueries.getById(id);
if (!database) {
error(404, 'Database not found');
}
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const git = new Git(database.local_path);
const [commits, branch] = await Promise.all([
git.getCommits(limit),
git.getBranch()
]);
return json({
commits,
branch,
repositoryUrl: database.repository_url
});
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Database, Plus, Lock, Code, Trash2, Pencil, ExternalLink } from 'lucide-svelte';
import { Database, Plus, Lock, Code, Trash2, Pencil, ExternalLink, ChevronRight } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import EmptyState from '$ui/state/EmptyState.svelte';
@@ -17,6 +17,9 @@
let selectedDatabase: DatabaseInstance | null = null;
let unlinkFormElement: HTMLFormElement;
// Track loaded images
let loadedImages: Set<number> = new Set();
// Extract GitHub username/org from repository URL
function getGitHubAvatar(repoUrl: string): string {
const match = repoUrl.match(/github\.com\/([^\/]+)\//);
@@ -26,6 +29,11 @@
return '';
}
function handleImageLoad(id: number) {
loadedImages.add(id);
loadedImages = loadedImages;
}
// Format sync strategy for display
function formatSyncStrategy(minutes: number): string {
if (minutes === 0) return 'Manual';
@@ -41,11 +49,9 @@
return new Date(date).toLocaleDateString();
}
// Handle row click - only navigate for dev databases
// Handle row click - navigate to database details
function handleRowClick(database: DatabaseInstance) {
if (database.personal_access_token) {
goto(`/databases/${database.id}`);
}
goto(`/databases/${database.id}`);
}
// Handle unlink click
@@ -89,7 +95,7 @@
</div>
<a
href="/databases/new"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
class="inline-flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600"
>
<Plus size={16} />
Link Database
@@ -99,14 +105,20 @@
<!-- Database Table -->
<Table {columns} data={data.databases} hoverable={true}>
<svelte:fragment slot="cell" let:row let:column>
<div on:click={() => handleRowClick(row)} role={row.personal_access_token ? "button" : undefined} tabindex={row.personal_access_token ? 0 : undefined} on:keydown={(e) => e.key === 'Enter' && handleRowClick(row)} class={row.personal_access_token ? "cursor-pointer" : ""}>
<div on:click={() => handleRowClick(row)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && handleRowClick(row)} class="cursor-pointer">
{#if column.key === 'name'}
<div class="flex items-center gap-3">
<img
src={getGitHubAvatar(row.repository_url)}
alt="{row.name} avatar"
class="h-8 w-8 rounded-lg"
/>
<div class="relative h-8 w-8">
{#if !loadedImages.has(row.id)}
<div class="absolute inset-0 animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"></div>
{/if}
<img
src={getGitHubAvatar(row.repository_url)}
alt="{row.name} avatar"
class="h-8 w-8 rounded-lg {loadedImages.has(row.id) ? 'opacity-100' : 'opacity-0'}"
on:load={() => handleImageLoad(row.id)}
/>
</div>
<div class="flex items-center gap-2">
<div class="font-medium text-neutral-900 dark:text-neutral-50">
{row.name}
@@ -149,7 +161,7 @@
target="_blank"
rel="noopener noreferrer"
on:click={(e) => e.stopPropagation()}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-blue-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-blue-400 dark:hover:bg-neutral-700"
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-accent-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-accent-400 dark:hover:bg-neutral-700"
title="View on GitHub"
>
<ExternalLink size={14} />
@@ -174,6 +186,16 @@
>
<Trash2 size={14} />
</button>
<!-- View Button -->
<button
type="button"
on:click={() => handleRowClick(row)}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title={row.personal_access_token ? "View changes" : "View commits"}
>
<ChevronRight size={14} />
</button>
</div>
</svelte:fragment>
</Table>

View File

@@ -3,23 +3,27 @@
import { GitBranch, History } from 'lucide-svelte';
import { page } from '$app/stores';
$: instanceId = $page.params.id;
$: database = $page.data.database;
$: currentPath = $page.url.pathname;
$: tabs = [
{
label: 'Changes',
href: `/databases/${instanceId}/changes`,
icon: GitBranch,
active: currentPath.endsWith('/changes')
},
$: tabs = database ? [
...(database.personal_access_token
? [
{
label: 'Changes',
href: `/databases/${database.id}/changes`,
icon: GitBranch,
active: currentPath.endsWith('/changes')
}
]
: []),
{
label: 'Commits',
href: `/databases/${instanceId}/commits`,
href: `/databases/${database.id}/commits`,
icon: History,
active: currentPath.includes('/commits')
}
];
] : [];
$: backButton = {
label: 'Back',

View File

@@ -1,6 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
redirect(302, `/databases/${params.id}/changes`);
export const load: PageServerLoad = async ({ params, parent }) => {
const { database } = await parent();
// Dev databases go to changes, others go to commits
if (database.personal_access_token) {
redirect(302, `/databases/${params.id}/changes`);
} else {
redirect(302, `/databases/${params.id}/commits`);
}
};

View File

@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { Git, getRepoInfo } from '$utils/git/index.ts';
import { Git } from '$utils/git/index.ts';
import { logger } from '$logger/logger.ts';
export const load: PageServerLoad = async ({ parent }) => {
@@ -11,23 +11,7 @@ export const load: PageServerLoad = async ({ parent }) => {
error(403, 'Changes page requires a personal access token');
}
const git = new Git(database.local_path);
const [status, uncommittedOps, lastPushed, branches, repoInfo] = await Promise.all([
git.status(),
git.getUncommittedOps(),
git.getLastPushed(),
git.getBranches(),
getRepoInfo(database.repository_url, database.personal_access_token)
]);
return {
status,
uncommittedOps,
lastPushed,
branches,
repoInfo
};
return {};
};
export const actions: Actions = {

View File

@@ -1,26 +1,50 @@
<script lang="ts">
import Table from '$ui/table/Table.svelte';
import type { Column } from '$ui/table/types';
import ChangesActionsBar from './components/ChangesActionsBar.svelte';
import StatusCard from './components/StatusCard.svelte';
import { Check } from 'lucide-svelte';
import { invalidateAll } from '$app/navigation';
import { afterNavigate } from '$app/navigation';
import { alertStore } from '$alerts/store';
import type { PageData } from './$types';
import type { OperationFile } from '$utils/git/types';
import type { OperationFile, GitStatus, RepoInfo } from '$utils/git/types';
export let data: PageData;
let loading = true;
let status: GitStatus | null = null;
let uncommittedOps: OperationFile[] = [];
let branches: string[] = [];
let repoInfo: RepoInfo | null = null;
let selected = new Set<string>();
let commitMessage = '';
$: allSelected = data.uncommittedOps.length > 0 && selected.size === data.uncommittedOps.length;
$: allSelected = uncommittedOps.length > 0 && selected.size === uncommittedOps.length;
async function fetchChanges() {
loading = true;
try {
const response = await fetch(`/api/databases/${data.database.id}/changes`);
if (response.ok) {
const result = await response.json();
status = result.status;
uncommittedOps = result.uncommittedOps;
branches = result.branches;
repoInfo = result.repoInfo;
}
} finally {
loading = false;
}
}
afterNavigate(() => {
fetchChanges();
});
function toggleAll() {
if (allSelected) {
selected = new Set();
} else {
selected = new Set(data.uncommittedOps.map((op) => op.filepath));
selected = new Set(uncommittedOps.map((op) => op.filepath));
}
}
@@ -50,7 +74,7 @@
if (result.type === 'success' && result.data?.success) {
selected = new Set();
alertStore.add('success', 'Changes discarded');
await invalidateAll();
await fetchChanges();
} else {
alertStore.add('error', result.data?.error || 'Failed to discard changes');
}
@@ -79,7 +103,7 @@
// Always refresh to keep UI in sync with file system
selected = new Set();
await invalidateAll();
await fetchChanges();
}
function formatOperation(op: string | null): string {
@@ -99,47 +123,6 @@
return 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200';
}
}
const columns: Column<OperationFile>[] = [
{
key: 'operation',
header: 'Operation',
width: 'w-28',
sortable: true,
cell: (row: OperationFile) => {
const op = formatOperation(row.operation);
return {
html: `<span class="inline-flex px-2 py-0.5 rounded text-xs font-mono ${getOperationClass(row.operation)}">${op}</span>`
};
}
},
{
key: 'entity',
header: 'Entity',
width: 'w-36',
sortable: true,
cell: (row: OperationFile) => row.entity || '-'
},
{
key: 'name',
header: 'Name',
sortable: true,
cell: (row: OperationFile) => ({
html:
row.previousName && row.previousName !== row.name
? `<div><span class="line-through text-neutral-400">${row.previousName}</span> → ${row.name || '-'}</div>`
: row.name || '-'
})
},
{
key: 'filename',
header: 'File',
width: 'w-48',
cell: (row: OperationFile) => ({
html: `<span class="font-mono text-xs text-neutral-500 dark:text-neutral-400">${row.filename}</span>`
})
}
];
</script>
<svelte:head>
@@ -147,33 +130,80 @@
</svelte:head>
<div class="space-y-6">
<StatusCard
status={data.status}
repoInfo={data.repoInfo}
branches={data.branches}
/>
<!-- Status Card -->
{#if loading || !status}
<div class="mt-6 animate-pulse rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="h-8 w-8 rounded-lg bg-neutral-200 dark:bg-neutral-700"></div>
<div class="flex flex-col gap-2">
<div class="h-4 w-32 rounded bg-neutral-200 dark:bg-neutral-700"></div>
<div class="h-3 w-24 rounded bg-neutral-200 dark:bg-neutral-700"></div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="h-8 w-24 rounded-md bg-neutral-200 dark:bg-neutral-700"></div>
</div>
</div>
</div>
{:else}
<StatusCard {status} {repoInfo} {branches} />
{/if}
<!-- Actions Bar -->
<ChangesActionsBar
selectedCount={selected.size}
bind:commitMessage
onDiscard={handleDiscard}
onAdd={handleAdd}
/>
{#if loading}
<div class="animate-pulse rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-800">
<div class="flex items-center gap-4">
<div class="h-9 w-48 rounded-md bg-neutral-200 dark:bg-neutral-700"></div>
<div class="h-9 w-24 rounded-md bg-neutral-200 dark:bg-neutral-700"></div>
<div class="h-9 w-24 rounded-md bg-neutral-200 dark:bg-neutral-700"></div>
</div>
</div>
{:else}
<ChangesActionsBar
selectedCount={selected.size}
bind:commitMessage
onDiscard={handleDiscard}
onAdd={handleAdd}
/>
{/if}
<!-- Table -->
{#if data.uncommittedOps.length === 0}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
{#if loading}
<div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<table class="w-full">
<thead class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800">
<tr>
<th class="w-12 px-4 py-3"></th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Operation</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Entity</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">File</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
{#each Array(5) as _}
<tr class="animate-pulse">
<td class="px-4 py-3 text-center">
<div class="mx-auto h-5 w-5 rounded border-2 border-neutral-300 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800"></div>
</td>
<td class="px-4 py-3"><div class="h-5 w-16 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
<td class="px-4 py-3"><div class="h-4 w-20 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
<td class="px-4 py-3"><div class="h-4 w-32 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
<td class="px-4 py-3"><div class="h-4 w-28 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if uncommittedOps.length === 0}
<div class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900">
<p class="text-neutral-600 dark:text-neutral-400">No uncommitted changes</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
<table class="w-full">
<thead
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
>
<thead class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800">
<tr>
<th class="w-12 px-4 py-3 text-center">
<button type="button" on:click={toggleAll} class="inline-flex">
@@ -188,32 +218,22 @@
</div>
</button>
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">
Operation
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">
Entity
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">
Name
</th>
<th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">
File
</th>
</tr>
</thead>
<tbody
class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900"
>
{#each data.uncommittedOps as op}
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
{#each uncommittedOps as op}
<tr
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
on:click={() => toggleRow(op.filepath)}
@@ -230,11 +250,7 @@
</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex rounded px-2 py-0.5 font-mono text-xs {getOperationClass(
op.operation
)}"
>
<span class="inline-flex rounded px-2 py-0.5 font-mono text-xs {getOperationClass(op.operation)}">
{formatOperation(op.operation)}
</span>
</td>
@@ -244,7 +260,7 @@
<td class="px-4 py-3 text-sm text-neutral-900 dark:text-neutral-100">
{#if op.previousName && op.previousName !== op.name}
<span class="text-neutral-400 line-through">{op.previousName}</span>
&rarr;
{op.name || '-'}
{:else}
{op.name || '-'}

View File

@@ -0,0 +1,5 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {};
};

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import type { Column } from '$ui/table/types';
import { afterNavigate } from '$app/navigation';
import { ExternalLink, FileText } from 'lucide-svelte';
import type { PageData } from './$types';
import type { Commit } from '$utils/git/types';
export let data: PageData;
let loading = true;
let commits: Commit[] = [];
let branch = '';
let repositoryUrl = '';
async function fetchCommits() {
loading = true;
try {
const response = await fetch(`/api/databases/${data.database.id}/commits`);
if (response.ok) {
const result = await response.json();
commits = result.commits;
branch = result.branch;
repositoryUrl = result.repositoryUrl;
}
} finally {
loading = false;
}
}
afterNavigate(() => {
fetchCommits();
});
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours === 0) {
const diffMins = Math.floor(diffMs / (1000 * 60));
return `${diffMins}m ago`;
}
return `${diffHours}h ago`;
}
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
return date.toLocaleDateString();
}
function getCommitUrl(hash: string): string {
return `${repositoryUrl}/commit/${hash}`;
}
const columns: Column<Commit>[] = [
{
key: 'shortHash',
header: 'Commit',
width: 'w-24'
},
{
key: 'message',
header: 'Message'
},
{
key: 'author',
header: 'Author',
width: 'w-40'
},
{
key: 'date',
header: 'Date',
width: 'w-28',
align: 'right',
sortable: true,
defaultSortDirection: 'desc'
}
];
</script>
<svelte:head>
<title>Commits - {data.database.name} - Profilarr</title>
</svelte:head>
<div class="mt-6 space-y-6">
{#if loading}
<!-- Skeleton Table -->
<div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<table class="w-full">
<thead class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800">
<tr>
<th class="w-8 px-3 py-3"></th>
<th class="w-24 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Commit</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Message</th>
<th class="w-40 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Author</th>
<th class="w-28 px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Date</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
{#each Array(10) as _}
<tr class="animate-pulse">
<td class="px-3 py-4"><div class="h-4 w-4 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
<td class="px-6 py-4"><div class="h-4 w-16 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
<td class="px-6 py-4"><div class="h-4 w-64 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
<td class="px-6 py-4"><div class="h-4 w-24 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
<td class="px-6 py-4 text-right"><div class="ml-auto h-4 w-16 rounded bg-neutral-200 dark:bg-neutral-700"></div></td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<ExpandableTable
{columns}
data={commits}
getRowId={(row) => row.hash}
emptyMessage="No commits found"
defaultSort={{ key: 'date', direction: 'desc' }}
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'shortHash'}
<a
href={getCommitUrl(row.hash)}
target="_blank"
rel="noopener noreferrer"
on:click|stopPropagation
class="inline-flex items-center gap-1.5 font-mono text-xs text-accent-600 hover:underline dark:text-accent-400"
>
{row.shortHash}
<ExternalLink size={12} />
</a>
{:else if column.key === 'message'}
<span class="line-clamp-1 text-sm text-neutral-900 dark:text-neutral-100">
{row.message}
</span>
{:else if column.key === 'author'}
<span class="text-sm text-neutral-600 dark:text-neutral-400">
{row.author}
</span>
{:else if column.key === 'date'}
<span class="font-mono text-xs text-neutral-500 dark:text-neutral-400">
{formatDate(row.date)}
</span>
{/if}
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="space-y-2">
<div class="flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<FileText size={14} />
<span>{row.files.length} file{row.files.length !== 1 ? 's' : ''} changed</span>
</div>
{#if row.files.length > 0}
<div class="grid gap-1">
{#each row.files as file}
<code class="block rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{file}
</code>
{/each}
</div>
{/if}
</div>
</svelte:fragment>
</ExpandableTable>
{/if}
</div>