mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat(commits): implement commit history retrieval and display in the database view
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
35
src/routes/api/databases/[id]/changes/+server.ts
Normal file
35
src/routes/api/databases/[id]/changes/+server.ts
Normal 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
|
||||
});
|
||||
};
|
||||
27
src/routes/api/databases/[id]/commits/+server.ts
Normal file
27
src/routes/api/databases/[id]/commits/+server.ts
Normal 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
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
→
|
||||
→
|
||||
{op.name || '-'}
|
||||
{:else}
|
||||
{op.name || '-'}
|
||||
|
||||
5
src/routes/databases/[id]/commits/+page.server.ts
Normal file
5
src/routes/databases/[id]/commits/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
171
src/routes/databases/[id]/commits/+page.svelte
Normal file
171
src/routes/databases/[id]/commits/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user