feat: enhance Git operations and add changes management for databases

- Updated Git class to include options for fetching status and retrieving branches.
- Introduced getRepoInfo function to fetch repository details from GitHub API.
- Implemented changes page for database instances, allowing users to view and manage uncommitted changes.
- Added actions for discarding and adding changes with appropriate logging.
- Created UI components for displaying changes and managing actions.
- Implemented server-side redirects based on database access token presence.
- Enhanced delay profile management with improved delete functionality and logging.
This commit is contained in:
Sam Chau
2025-12-29 00:15:00 +10:30
parent 9ddb426f13
commit 1eb2e983a5
21 changed files with 976 additions and 87 deletions

View File

@@ -3,12 +3,25 @@
import { X, User, GitBranch } from 'lucide-svelte';
export let open = false;
export let mode: 'save' | 'delete' = 'save';
const dispatch = createEventDispatcher<{
select: 'user' | 'base';
cancel: void;
}>();
$: title = mode === 'save' ? 'Where to save?' : 'Where to delete from?';
$: userLabel = mode === 'save' ? 'Personal Override' : 'Remove Personal Override';
$: userDescription =
mode === 'save'
? "Save locally only. Changes won't sync upstream and stay on this machine."
: 'Remove your local override. The base database version will apply.';
$: baseLabel = mode === 'save' ? 'Contribute to Database' : 'Delete from Database';
$: baseDescription =
mode === 'save'
? "Add to base operations. You'll need to commit and push manually."
: "Create a delete operation. You'll need to commit and push manually.";
function handleSelect(layer: 'user' | 'base') {
dispatch('select', layer);
}
@@ -54,7 +67,7 @@
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Where to save?</h2>
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{title}</h2>
<button
type="button"
on:click={handleCancel}
@@ -75,9 +88,9 @@
<User size={20} />
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Personal Override</div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">{userLabel}</div>
<div class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Save locally only. Changes won't sync upstream and stay on this machine.
{userDescription}
</div>
</div>
</button>
@@ -91,9 +104,9 @@
<GitBranch size={20} />
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Contribute to Database</div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">{baseLabel}</div>
<div class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Add to base operations. You'll need to commit and push manually.
{baseDescription}
</div>
</div>
</button>

View File

@@ -147,6 +147,74 @@ function generateMetadataHeader(metadata: OperationMetadata): string {
return lines.join('\n') + '\n\n';
}
/**
* Parse metadata from an operation file
*/
async function parseOperationMetadata(filepath: string): Promise<OperationMetadata | null> {
try {
const content = await Deno.readTextFile(filepath);
const operationMatch = content.match(/^-- @operation:\s*(\w+)/m);
const entityMatch = content.match(/^-- @entity:\s*(\w+)/m);
const nameMatch = content.match(/^-- @name:\s*(.+)$/m);
const previousNameMatch = content.match(/^-- @previous_name:\s*(.+)$/m);
if (!operationMatch || !entityMatch || !nameMatch) {
return null;
}
return {
operation: operationMatch[1] as OperationType,
entity: entityMatch[1],
name: nameMatch[1].trim(),
previousName: previousNameMatch?.[1]?.trim()
};
} catch {
return null;
}
}
/**
* Find and remove a matching create operation for a delete
* Returns true if a create was found and removed (no delete needed)
*/
async function cancelOutCreate(
targetDir: string,
metadata: OperationMetadata
): Promise<boolean> {
if (metadata.operation !== 'delete') {
return false;
}
try {
for await (const entry of Deno.readDir(targetDir)) {
if (!entry.isFile || !entry.name.endsWith('.sql')) continue;
const filepath = `${targetDir}/${entry.name}`;
const fileMeta = await parseOperationMetadata(filepath);
// Check if this is a create operation for the same entity/name
if (
fileMeta &&
fileMeta.operation === 'create' &&
fileMeta.entity === metadata.entity &&
fileMeta.name === metadata.name
) {
// Remove the create file - it cancels out with the delete
await Deno.remove(filepath);
await logger.info('Cancelled out create operation with delete', {
source: 'PCDWriter',
meta: { filepath, entity: metadata.entity, name: metadata.name }
});
return true;
}
}
} catch {
// Directory doesn't exist or other error - proceed with normal write
}
return false;
}
/**
* Write operations to a PCD layer
*
@@ -176,6 +244,13 @@ export async function writeOperation(options: WriteOptions): Promise<WriteResult
// Ensure directory exists
await ensureDir(targetDir);
// Optimization: if this is a delete and there's an uncommitted create, just remove the create
if (metadata && await cancelOutCreate(targetDir, metadata)) {
// Recompile the cache after removing the create file
await compile(instance.local_path, instance.id);
return { success: true };
}
// Get next operation number
const opNumber = await getNextOperationNumber(targetDir);

View File

@@ -2,7 +2,7 @@
* Git class - wraps git operations for a repository
*/
import type { GitStatus, OperationFile, CommitResult, UpdateInfo } from './types.ts';
import type { GitStatus, OperationFile, CommitResult, UpdateInfo, RepoInfo } from './types.ts';
import * as repo from './repo.ts';
import * as status from './status.ts';
import * as ops from './ops.ts';
@@ -19,7 +19,8 @@ export class Git {
// Status queries
getBranch = () => status.getBranch(this.repoPath);
status = (): Promise<GitStatus> => status.getStatus(this.repoPath);
getBranches = () => status.getBranches(this.repoPath);
status = (options?: status.GetStatusOptions): Promise<GitStatus> => status.getStatus(this.repoPath, options);
checkForUpdates = (): Promise<UpdateInfo> => status.checkForUpdates(this.repoPath);
getLastPushed = () => status.getLastPushed(this.repoPath);

View File

@@ -4,6 +4,7 @@
export { Git } from './Git.ts';
export * from './types.ts';
export type { GetStatusOptions } from './status.ts';
// Direct function exports
export { clone } from './repo.ts';
export { clone, getRepoInfo } from './repo.ts';

View File

@@ -69,24 +69,23 @@ export async function getUncommittedOps(repoPath: string): Promise<OperationFile
}
/**
* Get the highest operation number in ops/
* Get the highest operation number from COMMITTED files in ops/
*/
export async function getMaxOpNumber(repoPath: string): Promise<number> {
let maxNum = 0;
const opsPath = `${repoPath}/ops`;
try {
for await (const entry of Deno.readDir(opsPath)) {
if (entry.isFile && entry.name.endsWith('.sql')) {
const match = entry.name.match(/^(\d+)\./);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNum) maxNum = num;
}
}
// Use git ls-tree to only count committed files
const output = await execGitSafe(['ls-tree', '--name-only', 'HEAD', 'ops/'], repoPath);
if (!output) return maxNum;
for (const filename of output.split('\n')) {
if (!filename.trim() || !filename.endsWith('.sql')) continue;
const basename = filename.replace('ops/', '');
const match = basename.match(/^(\d+)\./);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNum) maxNum = num;
}
} catch {
// Directory might not exist
}
return maxNum;
@@ -112,6 +111,11 @@ export async function discardOps(repoPath: string, filepaths: string[]): Promise
/**
* Add operation files: pull, renumber if needed, commit, push
*
* TODO: This functionality needs to be redesigned. The current approach of
* pull -> renumber -> commit -> push has race conditions and edge cases that
* can leave files in inconsistent states if any step fails mid-way. We also want to better
* choose when / how renumbering happens (e.g., only when there are conflicts).
*/
export async function addOps(
repoPath: string,

View File

@@ -3,6 +3,7 @@
*/
import { execGit, execGitSafe } from './exec.ts';
import type { RepoInfo } from './types.ts';
/**
* Validate that a repository URL is accessible and detect if it's private
@@ -147,7 +148,11 @@ export async function resetToRemote(repoPath: string): Promise<void> {
*/
export async function stage(repoPath: string, filepaths: string[]): Promise<void> {
for (const filepath of filepaths) {
await execGit(['add', filepath], repoPath);
// Convert to relative path if it starts with the repo path
const relativePath = filepath.startsWith(repoPath + '/')
? filepath.slice(repoPath.length + 1)
: filepath;
await execGit(['add', relativePath], repoPath);
}
}
@@ -157,3 +162,56 @@ export async function stage(repoPath: string, filepaths: string[]): Promise<void
export async function commit(repoPath: string, message: string): Promise<void> {
await execGit(['commit', '-m', message], repoPath);
}
/**
* Get repository info from GitHub API
*/
export async function getRepoInfo(
repositoryUrl: string,
personalAccessToken?: string | null
): Promise<RepoInfo | null> {
const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/;
const normalizedUrl = repositoryUrl.replace(/\.git$/, '');
const match = normalizedUrl.match(githubPattern);
if (!match) {
return null;
}
const [, owner, repo] = match;
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Profilarr'
};
if (personalAccessToken) {
headers['Authorization'] = `Bearer ${personalAccessToken}`;
}
try {
const response = await globalThis.fetch(apiUrl, { headers });
if (!response.ok) {
return null;
}
const data = await response.json();
return {
owner: data.owner.login,
repo: data.name,
description: data.description,
stars: data.stargazers_count,
forks: data.forks_count,
openIssues: data.open_issues_count,
ownerAvatarUrl: data.owner.avatar_url,
ownerType: data.owner.type,
htmlUrl: data.html_url
};
} catch {
return null;
}
}

View File

@@ -13,14 +13,21 @@ export async function getBranch(repoPath: string): Promise<string> {
return await execGit(['branch', '--show-current'], repoPath);
}
export interface GetStatusOptions {
/** Whether to fetch from remote first (slower but accurate ahead/behind) */
fetch?: boolean;
}
/**
* Get full repository status
*/
export async function getStatus(repoPath: string): Promise<GitStatus> {
export async function getStatus(repoPath: string, options: GetStatusOptions = {}): Promise<GitStatus> {
const branch = await getBranch(repoPath);
// Fetch to get accurate ahead/behind
await fetch(repoPath);
// Optionally fetch to get accurate ahead/behind
if (options.fetch) {
await fetch(repoPath);
}
// Get ahead/behind
let ahead = 0;
@@ -109,3 +116,19 @@ export async function getLastPushed(repoPath: string): Promise<string | null> {
const output = await execGitSafe(['log', '-1', '--format=%cI', `origin/${branch}`], repoPath);
return output || null;
}
/**
* Get all local and remote branches
*/
export async function getBranches(repoPath: string): Promise<string[]> {
// Get all branches (local and remote tracking)
const output = await execGit(['branch', '-a', '--format=%(refname:short)'], repoPath);
const branches = output
.split('\n')
.map((b) => b.trim())
.filter((b) => b && !b.includes('HEAD'))
.map((b) => b.replace(/^origin\//, ''))
.filter((b, i, arr) => arr.indexOf(b) === i); // dedupe
return branches;
}

View File

@@ -33,3 +33,15 @@ export interface UpdateInfo {
latestRemoteCommit: string;
currentLocalCommit: string;
}
export interface RepoInfo {
owner: string;
repo: string;
description: string | null;
stars: number;
forks: number;
openIssues: number;
ownerAvatarUrl: string;
ownerType: 'User' | 'Organization';
htmlUrl: string;
}

View File

@@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
export const load: LayoutServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
error(400, 'Invalid database ID');
}
const database = databaseInstancesQueries.getById(id);
if (!database) {
error(404, 'Database not found');
}
return {
database
};
};

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { RefreshCw, GitBranch } from 'lucide-svelte';
import { page } from '$app/stores';
$: instanceId = $page.params.id;
$: currentPath = $page.url.pathname;
$: hasToken = !!$page.data.database?.personal_access_token;
$: tabs = [
{
label: 'Sync',
href: `/databases/${instanceId}/sync`,
icon: RefreshCw,
active: currentPath.includes('/sync')
},
...(hasToken
? [
{
label: 'Changes',
href: `/databases/${instanceId}/changes`,
icon: GitBranch,
active: currentPath.includes('/changes')
}
]
: [])
];
$: backButton = {
label: 'Back',
href: '/databases'
};
</script>
<div class="p-8">
<Tabs {tabs} {backButton} />
<slot />
</div>

View File

@@ -1,21 +1,13 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
export const load: PageServerLoad = async ({ params, parent }) => {
const { database } = await parent();
if (isNaN(id)) {
error(400, 'Invalid database ID');
// Dev databases (with PAT) go to changes, others go to sync
if (database.personal_access_token) {
redirect(302, `/databases/${params.id}/changes`);
} else {
redirect(302, `/databases/${params.id}/sync`);
}
const database = databaseInstancesQueries.getById(id);
if (!database) {
error(404, 'Database not found');
}
return {
database
};
};

View File

@@ -1,19 +1 @@
<script lang="ts">
import type { PageData } from './$types';
import { Pencil } from 'lucide-svelte';
export let data: PageData;
</script>
<svelte:head>
<title>{data.database.name} - Profilarr</title>
</svelte:head>
<!-- Floating Edit Button -->
<a
href="/databases/{data.database.id}/edit"
class="group fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-md transition-all hover:scale-110 hover:border-neutral-300 hover:shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:border-neutral-600"
aria-label="Edit database"
>
<Pencil size={18} class="transition-transform duration-300 group-hover:rotate-12" />
</a>
<!-- Server-side redirect to /sync -->

View File

@@ -0,0 +1,108 @@
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 { logger } from '$logger/logger.ts';
export const load: PageServerLoad = async ({ parent }) => {
const { database } = await parent();
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 {
status,
uncommittedOps,
lastPushed,
branches,
repoInfo
};
};
export const actions: Actions = {
discard: async ({ request, params }) => {
const id = parseInt(params.id || '', 10);
const database = databaseInstancesQueries.getById(id);
if (!database) {
return { success: false, error: 'Database not found' };
}
const formData = await request.formData();
const files = formData.getAll('files') as string[];
if (files.length === 0) {
return { success: false, error: 'No files selected' };
}
const git = new Git(database.local_path);
await git.discardOps(files);
return { success: true };
},
add: async ({ request, params }) => {
const id = parseInt(params.id || '', 10);
const database = databaseInstancesQueries.getById(id);
if (!database) {
return { success: false, error: 'Database not found' };
}
const formData = await request.formData();
const files = formData.getAll('files') as string[];
const message = formData.get('message') as string;
const git = new Git(database.local_path);
const result = await git.addOps(files, message);
if (result.success) {
await logger.info('Changes committed and pushed', {
source: 'changes',
meta: { databaseId: id, files: files.length, message }
});
} else {
await logger.error('Failed to add changes', {
source: 'changes',
meta: { databaseId: id, error: result.error, files }
});
}
return result;
},
checkout: async ({ request, params }) => {
const id = parseInt(params.id || '', 10);
const database = databaseInstancesQueries.getById(id);
if (!database) {
return { success: false, error: 'Database not found' };
}
const formData = await request.formData();
const branch = formData.get('branch') as string;
if (!branch) {
return { success: false, error: 'No branch specified' };
}
try {
const git = new Git(database.local_path);
await git.checkout(branch);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Failed to switch branch' };
}
}
};

View File

@@ -0,0 +1,265 @@
<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 { alertStore } from '$alerts/store';
import type { PageData } from './$types';
import type { OperationFile } from '$utils/git/types';
export let data: PageData;
let selected = new Set<string>();
let commitMessage = '';
$: allSelected = data.uncommittedOps.length > 0 && selected.size === data.uncommittedOps.length;
function toggleAll() {
if (allSelected) {
selected = new Set();
} else {
selected = new Set(data.uncommittedOps.map((op) => op.filepath));
}
}
function toggleRow(filepath: string) {
const newSelected = new Set(selected);
if (newSelected.has(filepath)) {
newSelected.delete(filepath);
} else {
newSelected.add(filepath);
}
selected = newSelected;
}
async function handleDiscard() {
const formData = new FormData();
for (const filepath of selected) {
formData.append('files', filepath);
}
const response = await fetch('?/discard', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.type === 'success' && result.data?.success) {
selected = new Set();
alertStore.add('success', 'Changes discarded');
await invalidateAll();
} else {
alertStore.add('error', result.data?.error || 'Failed to discard changes');
}
}
async function handleAdd() {
const formData = new FormData();
for (const filepath of selected) {
formData.append('files', filepath);
}
formData.append('message', commitMessage);
const response = await fetch('?/add', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.type === 'success' && result.data?.success) {
commitMessage = '';
alertStore.add('success', 'Changes committed and pushed');
} else {
alertStore.add('error', result.data?.error || 'Failed to add changes');
}
// Always refresh to keep UI in sync with file system
selected = new Set();
await invalidateAll();
}
function formatOperation(op: string | null): string {
if (!op) return '-';
return op.charAt(0).toUpperCase() + op.slice(1);
}
function getOperationClass(op: string | null): string {
switch (op) {
case 'create':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'update':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
case 'delete':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default:
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>
<title>Changes - {data.database.name} - Profilarr</title>
</svelte:head>
<div class="space-y-6">
<StatusCard
status={data.status}
repoInfo={data.repoInfo}
branches={data.branches}
databaseId={data.database.id}
/>
<!-- Actions Bar -->
<ChangesActionsBar
selectedCount={selected.size}
bind:commitMessage
onDiscard={handleDiscard}
onAdd={handleAdd}
/>
<!-- 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"
>
<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"
>
<tr>
<th class="w-12 px-4 py-3 text-center">
<button type="button" on:click={toggleAll} class="inline-flex">
<div
class="flex h-5 w-5 items-center justify-center rounded border-2 transition-all {allSelected
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
: 'border-neutral-300 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'}"
>
{#if allSelected}
<Check size={14} class="text-white" />
{/if}
</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"
>
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 data.uncommittedOps as op}
<tr
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
on:click={() => toggleRow(op.filepath)}
>
<td class="px-4 py-3 text-center">
<div
class="mx-auto flex h-5 w-5 items-center justify-center rounded border-2 transition-all {selected.has(op.filepath)
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
: 'border-neutral-300 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'}"
>
{#if selected.has(op.filepath)}
<Check size={14} class="text-white" />
{/if}
</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
)}"
>
{formatOperation(op.operation)}
</span>
</td>
<td class="px-4 py-3 text-sm text-neutral-900 dark:text-neutral-100">
{op.entity || '-'}
</td>
<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 || '-'}
{/if}
</td>
<td class="px-4 py-3">
<span class="font-mono text-xs text-neutral-500 dark:text-neutral-400">
{op.filename}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { Trash2, Upload } from 'lucide-svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
export let selectedCount: number;
export let commitMessage: string;
export let onDiscard: () => void;
export let onAdd: () => void;
$: canDiscard = selectedCount > 0;
$: canAdd = selectedCount > 0 && commitMessage.trim().length > 0;
</script>
<ActionsBar className="w-full">
<div class="relative flex flex-1">
<div
class="flex h-10 w-full items-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"
>
<input
type="text"
bind:value={commitMessage}
placeholder="Commit message..."
class="h-full w-full bg-transparent px-3 font-mono text-sm text-neutral-700 placeholder-neutral-400 outline-none dark:text-neutral-300 dark:placeholder-neutral-500"
/>
</div>
</div>
<ActionButton
icon={Upload}
hasDropdown={true}
dropdownPosition="right"
on:click={canAdd ? onAdd : undefined}
>
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="12rem">
<div class="px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400">
{#if !selectedCount}
Select changes to add
{:else if !commitMessage.trim()}
Enter a commit message
{:else}
Add {selectedCount} change{selectedCount === 1 ? '' : 's'}
{/if}
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
<ActionButton
icon={Trash2}
hasDropdown={true}
dropdownPosition="right"
on:click={canDiscard ? onDiscard : undefined}
>
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="10rem">
<div class="px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400">
{#if canDiscard}
Discard {selectedCount} change{selectedCount === 1 ? '' : 's'}
{:else}
Select changes to discard
{/if}
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
</ActionsBar>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import {
GitBranch,
ArrowUp,
ArrowDown,
ExternalLink,
Star,
GitFork,
CircleDot,
History,
ChevronDown,
Check
} from 'lucide-svelte';
import { invalidateAll } from '$app/navigation';
import type { GitStatus, RepoInfo } from '$utils/git/types';
export let status: GitStatus;
export let repoInfo: RepoInfo | null;
export let branches: string[];
export let databaseId: number;
let branchDropdownOpen = false;
let switching = false;
async function handleBranchSwitch(branch: string) {
if (branch === status.branch || switching) return;
switching = true;
branchDropdownOpen = false;
const formData = new FormData();
formData.append('branch', branch);
try {
const response = await fetch('?/checkout', {
method: 'POST',
body: formData
});
if (response.ok) {
await invalidateAll();
}
} finally {
switching = false;
}
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.branch-dropdown')) {
branchDropdownOpen = false;
}
}
</script>
<svelte:window on:click={handleClickOutside} />
<div
class="mt-6 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">
<!-- Left: Repo info -->
<div class="flex items-center gap-4">
{#if repoInfo}
<img
src={repoInfo.ownerAvatarUrl}
alt={repoInfo.owner}
class="h-8 w-8 rounded-lg"
/>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<code class="font-mono text-sm text-neutral-700 dark:text-neutral-300">
{repoInfo.owner}/{repoInfo.repo}
</code>
<a
href={repoInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
class="text-neutral-400 transition-colors hover:text-neutral-600 dark:hover:text-neutral-300"
>
<ExternalLink size={14} />
</a>
</div>
<div class="flex items-center gap-3 text-xs text-neutral-500 dark:text-neutral-400">
<span class="flex items-center gap-1">
<Star size={12} />
<code class="font-mono">{repoInfo.stars.toLocaleString()}</code>
</span>
<span class="flex items-center gap-1">
<GitFork size={12} />
<code class="font-mono">{repoInfo.forks.toLocaleString()}</code>
</span>
<span class="flex items-center gap-1">
<CircleDot size={12} />
<code class="font-mono">{repoInfo.openIssues.toLocaleString()}</code>
</span>
</div>
</div>
{/if}
</div>
<!-- Right: Branch, status, commits -->
<div class="flex items-center gap-4">
<!-- Ahead/Behind indicators -->
{#if status.ahead > 0}
<div class="flex items-center gap-1 text-sm text-yellow-600 dark:text-yellow-400">
<ArrowUp size={14} />
<code class="font-mono">{status.ahead}</code>
</div>
{/if}
{#if status.behind > 0}
<div class="flex items-center gap-1 text-sm text-orange-600 dark:text-orange-400">
<ArrowDown size={14} />
<code class="font-mono">{status.behind}</code>
</div>
{/if}
<!-- Branch dropdown -->
<div class="branch-dropdown relative">
<button
type="button"
on:click|stopPropagation={() => (branchDropdownOpen = !branchDropdownOpen)}
disabled={switching}
class="flex items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm transition-colors hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<GitBranch size={14} class="text-neutral-500 dark:text-neutral-400" />
<code class="font-mono text-neutral-700 dark:text-neutral-300">{status.branch}</code>
<ChevronDown
size={14}
class="text-neutral-400 transition-transform {branchDropdownOpen
? 'rotate-180'
: ''}"
/>
</button>
{#if branchDropdownOpen}
<div
class="absolute right-0 top-full z-50 mt-1 max-h-60 w-48 overflow-auto rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
{#each branches as branch}
<button
type="button"
on:click={() => handleBranchSwitch(branch)}
class="flex w-full items-center justify-between px-3 py-1.5 text-left text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
<code class="font-mono {branch === status.branch
? 'text-blue-600 dark:text-blue-400'
: 'text-neutral-700 dark:text-neutral-300'}">{branch}</code>
{#if branch === status.branch}
<Check size={14} class="text-blue-600 dark:text-blue-400" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Commits button -->
<a
href="/databases/{databaseId}/changes/commits"
class="flex items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<History size={14} class="text-neutral-500 dark:text-neutral-400" />
<code class="font-mono text-neutral-700 dark:text-neutral-300">Commits</code>
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
<script lang="ts">
// Reset to root layout - edit page doesn't need the tabs
</script>
<slot />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
// Sync page - configure how/when to sync to arr instances
</script>
<div class="text-neutral-500 dark:text-neutral-400">
Sync configuration coming soon
</div>

View File

@@ -5,6 +5,7 @@ import { canWriteToBase } from '$pcd/writer.ts';
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
import type { OperationLayer } from '$pcd/writer.ts';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles/index.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId, id } = params;
@@ -127,7 +128,7 @@ export const actions: Actions = {
throw redirect(303, `/delay-profiles/${databaseId}`);
},
delete: async ({ params }) => {
delete: async ({ request, params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
@@ -152,11 +153,29 @@ export const actions: Actions = {
return fail(404, { error: 'Delay profile not found' });
}
// Delete always goes to user layer (can't remove base ops)
const formData = await request.formData();
const layerFromForm = formData.get('layer');
const layer = (layerFromForm as OperationLayer) || 'user';
await logger.debug('Delete action received', {
source: 'DelayProfileDelete',
meta: {
profileId,
profileName: current.name,
layerFromForm,
layerUsed: layer
}
});
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
const result = await delayProfileQueries.remove({
databaseId: currentDatabaseId,
cache,
layer: 'user',
layer,
current
});

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import TagInput from '$ui/form/TagInput.svelte';
import Modal from '$ui/modal/Modal.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import { alertStore } from '$alerts/store';
import { Check, Save, Trash2, Loader2 } from 'lucide-svelte';
@@ -36,10 +36,13 @@
// Modal states
let showSaveTargetModal = false;
let showDeleteModal = false;
let showDeleteTargetModal = false;
let mainFormElement: HTMLFormElement;
let deleteFormElement: HTMLFormElement;
// Delete layer selection
let deleteLayer: 'user' | 'base' = 'user';
// Display text based on mode
$: title = mode === 'create' ? 'New Delay Profile' : 'Edit Delay Profile';
$: description =
@@ -61,22 +64,41 @@
$: isValid = name.trim() !== '' && tags.length > 0;
function handleSaveClick() {
async function handleSaveClick() {
if (canWriteToBase) {
// Show modal to ask where to save
showSaveTargetModal = true;
} else {
// No choice, just submit with 'user' layer
selectedLayer = 'user';
await tick();
mainFormElement?.requestSubmit();
}
}
function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
await tick();
mainFormElement?.requestSubmit();
}
async function handleDeleteClick() {
if (canWriteToBase) {
showDeleteTargetModal = true;
} else {
deleteLayer = 'user';
await tick();
deleteFormElement?.requestSubmit();
}
}
async function handleDeleteLayerSelect(event: CustomEvent<'user' | 'base'>) {
deleteLayer = event.detail;
showDeleteTargetModal = false;
await tick();
deleteFormElement?.requestSubmit();
}
</script>
<div class="space-y-8 p-8">
@@ -292,7 +314,7 @@
{#if mode === 'edit'}
<button
type="button"
on:click={() => (showDeleteModal = true)}
on:click={handleDeleteClick}
class="flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900"
>
<Trash2 size={14} />
@@ -348,32 +370,25 @@
};
}}
>
<input type="hidden" name="layer" value={deleteLayer} />
</form>
{/if}
</div>
<!-- Delete Confirmation Modal -->
{#if mode === 'edit'}
<Modal
open={showDeleteModal}
header="Delete Delay Profile"
bodyMessage={`Are you sure you want to delete "${name}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
deleteFormElement?.requestSubmit();
}}
on:cancel={() => (showDeleteModal = false)}
/>
{/if}
<!-- Save Target Modal -->
{#if canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
mode="save"
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
<!-- Delete Target Modal -->
<SaveTargetModal
open={showDeleteTargetModal}
mode="delete"
on:select={handleDeleteLayerSelect}
on:cancel={() => (showDeleteTargetModal = false)}
/>
{/if}

View File

@@ -5,6 +5,7 @@ import { canWriteToBase } from '$pcd/writer.ts';
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
import type { OperationLayer } from '$pcd/writer.ts';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles/index.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = ({ params }) => {
const { databaseId } = params;
@@ -58,7 +59,17 @@ export const actions: Actions = {
const bypassIfHighestQuality = formData.get('bypassIfHighestQuality') === 'true';
const bypassIfAboveCfScore = formData.get('bypassIfAboveCfScore') === 'true';
const minimumCfScore = parseInt(formData.get('minimumCfScore') as string, 10) || 0;
const layer = (formData.get('layer') as OperationLayer) || 'user';
const layerFromForm = formData.get('layer');
const layer = (layerFromForm as OperationLayer) || 'user';
await logger.debug('Create action received', {
source: 'DelayProfileCreate',
meta: {
profileName: name,
layerFromForm,
layerUsed: layer
}
});
// Validate
if (!name?.trim()) {