feat: manual incoming changes handling

- Enhanced Git class to include method for fetching incoming changes from remote repository.
- Implemented logic to retrieve and display incoming commits in the changes page.
- Updated API routes to handle incoming changes and pull requests.
- Modified UI components to show incoming changes and allow users to pull updates.
- Improved actions bar to disable commit actions when there are incoming changes.
- Added sync button to refresh repository status and check for updates.
This commit is contained in:
Sam Chau
2026-01-17 15:25:24 +10:30
parent b13ec91e32
commit 47ba9dd7e9
10 changed files with 832 additions and 224 deletions

View File

@@ -0,0 +1,302 @@
# Manual Pull Handling for Databases
**Status: Complete**
## Summary
When `auto_pull = 0`, users receive notifications that updates are available but
have no way to review or pull them. This document plans extending the existing
`/databases/[id]/changes` page to support incoming changes (pull) alongside
outgoing changes (push).
---
## Current State
### The `/databases/[id]/changes` Page
Currently this page:
- Only accessible to developers (requires `personal_access_token`)
- Shows **outgoing changes** (uncommitted local ops)
- Allows: select files, write commit message, push to remote
- Allows: discard local changes
- Allows: switch branches
### The Gap
When `auto_pull = 0` and updates are found:
1. User receives notification "Updates available for X"
2. User has no way to see what those updates contain
3. User has no way to pull them from the UI
---
## Proposed Solution
Extend the changes page to show both directions:
| Section | Who Sees It | Description |
| ----------------- | ----------- | ---------------------------------- |
| Incoming Changes | Everyone | Commits available to pull |
| Outgoing Changes | Developers | Uncommitted local ops to push |
### User Flow
**For regular users (no PAT):**
1. Navigate to `/databases/[id]/changes`
2. See "Incoming Changes" section with commits behind
3. Review the changes (files modified in each commit)
4. Click "Pull Updates" to sync
**For developers (with PAT):**
1. Same as above, plus...
2. See "Outgoing Changes" section with uncommitted ops
3. Full commit/push/discard functionality
---
## Implementation Plan
### 1. Remove PAT Requirement for Page Access
**File:** `src/routes/databases/[id]/changes/+page.server.ts`
```typescript
export const load: PageServerLoad = async ({ parent }) => {
const { database } = await parent();
// Remove the PAT check - page is now accessible to everyone
// PAT only needed for push actions
return {
isDeveloper: !!database.personal_access_token
};
};
```
### 2. Update API to Return Incoming Changes
**File:** `src/routes/api/databases/[id]/changes/+server.ts`
Remove PAT requirement for GET. Add incoming changes data:
```typescript
export const GET: RequestHandler = async ({ params }) => {
const database = databaseInstancesQueries.getById(id);
const git = new Git(database.local_path);
// Fetch for everyone
const [status, incomingChanges, branches, repoInfo] = await Promise.all([
git.status(),
git.getIncomingChanges(),
git.getBranches(),
getRepoInfo(database.repository_url, database.personal_access_token)
]);
// Only fetch outgoing changes for developers
let uncommittedOps = null;
if (database.personal_access_token) {
uncommittedOps = await git.getUncommittedOps();
}
return json({
status,
incomingChanges,
branches,
repoInfo,
uncommittedOps
});
};
```
### 3. Add Git Function for Incoming Changes
**File:** `src/lib/server/utils/git/status.ts`
```typescript
export interface IncomingCommit {
hash: string;
shortHash: string;
message: string;
author: string;
date: string;
files: string[];
}
export interface IncomingChanges {
hasUpdates: boolean;
commitsBehind: number;
commits: IncomingCommit[];
}
export async function getIncomingChanges(repoPath: string): Promise<IncomingChanges> {
// Fetch latest from remote
await execGitSafe(['fetch'], repoPath);
const branch = await getBranch(repoPath);
const remoteBranch = `origin/${branch}`;
// Count commits behind
const countOutput = await execGitSafe(
['rev-list', '--count', `HEAD..${remoteBranch}`],
repoPath
);
const commitsBehind = parseInt(countOutput || '0') || 0;
if (commitsBehind === 0) {
return { hasUpdates: false, commitsBehind: 0, commits: [] };
}
// Get commit details for incoming commits
const logOutput = await execGitSafe(
['log', '--format=%H|%h|%s|%an|%aI', `HEAD..${remoteBranch}`],
repoPath
);
const commits: IncomingCommit[] = [];
for (const line of logOutput.split('\n').filter(Boolean)) {
const [hash, shortHash, message, author, date] = line.split('|');
// Get files changed in this commit
const filesOutput = await execGitSafe(
['diff-tree', '--no-commit-id', '--name-only', '-r', hash],
repoPath
);
const files = filesOutput.split('\n').filter(Boolean);
commits.push({ hash, shortHash, message, author, date, files });
}
return { hasUpdates: true, commitsBehind, commits };
}
```
### 4. Add Pull Action
**File:** `src/routes/databases/[id]/changes/+page.server.ts`
```typescript
export const actions: Actions = {
// ... existing actions ...
pull: async ({ params }) => {
const id = parseInt(params.id || '', 10);
const database = databaseInstancesQueries.getById(id);
if (!database) {
return { success: false, error: 'Database not found' };
}
try {
const result = await pcdManager.sync(id);
return result;
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to pull'
};
}
}
};
```
### 5. Update Page UI
**File:** `src/routes/databases/[id]/changes/+page.svelte`
```svelte
<script lang="ts">
export let data: PageData;
let incomingChanges: IncomingChanges | null = null;
// ... existing state ...
$: isDeveloper = data.isDeveloper;
</script>
<!-- Incoming Changes Section (visible to everyone) -->
<section>
<h2>Incoming Changes</h2>
{#if incomingChanges?.hasUpdates}
<p>{incomingChanges.commitsBehind} commits available</p>
<!-- Expandable table showing commits -->
<ExpandableTable data={incomingChanges.commits} ...>
<!-- Show commit message, author, date -->
<!-- Expanded: show files changed -->
</ExpandableTable>
<Button on:click={handlePull}>Pull Updates</Button>
{:else}
<p>Up to date</p>
{/if}
</section>
<!-- Outgoing Changes Section (developers only) -->
{#if isDeveloper}
<section>
<h2>Outgoing Changes</h2>
<!-- Existing uncommitted ops table -->
<!-- Existing commit message + push UI -->
</section>
{/if}
```
---
## Sync Job Integration
When the sync job runs with `auto_pull = 0`:
1. `checkForUpdates()` already fetches and counts commits behind
2. This data is already available via `git.status()` (the `behind` field)
3. The `/api/databases/[id]/changes` endpoint will show this when user visits
No additional "store" needed - the git state IS the store. Each time the user
visits the changes page, we fetch fresh data from git.
---
## Files to Create/Modify
### Modified Files
- `src/routes/databases/[id]/changes/+page.server.ts` - Remove PAT requirement
for load, add pull action
- `src/routes/databases/[id]/changes/+page.svelte` - Add incoming changes UI
- `src/routes/api/databases/[id]/changes/+server.ts` - Remove PAT requirement
for GET, add incoming changes data
- `src/lib/server/utils/git/status.ts` - Add `getIncomingChanges()` function
- `src/lib/server/utils/git/types.ts` - Add `IncomingChanges` types
- `src/lib/server/utils/git/Git.ts` - Expose `getIncomingChanges()`
### New Components (optional)
- `src/routes/databases/[id]/changes/components/IncomingChangesTable.svelte`
- `src/routes/databases/[id]/changes/components/OutgoingChangesTable.svelte`
Could extract existing table into `OutgoingChangesTable` and create matching
`IncomingChangesTable` for consistency.
---
## Edge Cases
1. **No incoming changes**: Show "Up to date" message
2. **Pull fails**: Show error, allow retry
3. **Conflicts**: Shouldn't happen since user_ops are gitignored, but handle
gracefully if it does
4. **Large number of commits**: Paginate or limit to recent N commits
---
## UI Considerations
- StatusCard (repo info, branch switcher) visible for everyone
- Use consistent table styling between incoming/outgoing
- Incoming table is read-only (no checkboxes)
- Clear visual separation between sections
- Consider showing incoming changes count in the tab/nav if updates available

View File

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

View File

@@ -4,7 +4,7 @@
import { execGit, execGitSafe } from './exec.ts';
import { fetch } from './repo.ts';
import type { GitStatus, UpdateInfo, Commit } from './types.ts';
import type { GitStatus, UpdateInfo, Commit, IncomingChanges } from './types.ts';
/**
* Get current branch name
@@ -241,3 +241,58 @@ export async function getCommits(repoPath: string, limit: number = 50): Promise<
return commits;
}
/**
* Get incoming changes (commits available to pull from remote)
*/
export async function getIncomingChanges(repoPath: string): Promise<IncomingChanges> {
await fetch(repoPath);
const branch = await getBranch(repoPath);
const remoteBranch = `origin/${branch}`;
// Count commits behind
const countOutput = await execGitSafe(
['rev-list', '--count', `HEAD..${remoteBranch}`],
repoPath
);
const commitsBehind = parseInt(countOutput || '0') || 0;
if (commitsBehind === 0) {
return { hasUpdates: false, commitsBehind: 0, commits: [] };
}
// Get commit details for incoming commits
const format = '%H|%h|%s|%an|%ae|%cI';
const output = await execGit(
['log', `--format=${format}`, `HEAD..${remoteBranch}`],
repoPath
);
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 filesOutput = await execGitSafe(
['diff-tree', '--no-commit-id', '--name-only', '-r', hash],
repoPath
);
const files = filesOutput ? filesOutput.split('\n').filter((f) => f.trim()) : [];
commits.push({
hash,
shortHash,
message,
author,
authorEmail,
date,
files
});
}
return { hasUpdates: true, commitsBehind, commits };
}

View File

@@ -55,3 +55,9 @@ export interface Commit {
date: string;
files: string[];
}
export interface IncomingChanges {
hasUpdates: boolean;
commitsBehind: number;
commits: Commit[];
}

View File

@@ -11,25 +11,27 @@ export const GET: RequestHandler = async ({ params }) => {
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([
// Fetch data for everyone
const [status, incomingChanges, branches, repoInfo] = await Promise.all([
git.status(),
git.getUncommittedOps(),
git.getLastPushed(),
git.getIncomingChanges(),
git.getBranches(),
getRepoInfo(database.repository_url, database.personal_access_token)
]);
// Only fetch outgoing changes for developers
let uncommittedOps = null;
if (database.personal_access_token) {
uncommittedOps = await git.getUncommittedOps();
}
return json({
status,
uncommittedOps,
lastPushed,
incomingChanges,
branches,
repoInfo
repoInfo,
uncommittedOps
});
};

View File

@@ -7,16 +7,12 @@
$: currentPath = $page.url.pathname;
$: tabs = database ? [
...(database.personal_access_token
? [
{
label: 'Changes',
href: `/databases/${database.id}/changes`,
icon: GitBranch,
active: currentPath.endsWith('/changes')
}
]
: []),
{
label: 'Changes',
href: `/databases/${database.id}/changes`,
icon: GitBranch,
active: currentPath.endsWith('/changes')
},
{
label: 'Commits',
href: `/databases/${database.id}/commits`,

View File

@@ -1,18 +1,16 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { Git } from '$utils/git/index.ts';
import { logger } from '$logger/logger.ts';
import { compile, startWatch } from '$lib/server/pcd/cache.ts';
import { pcdManager } from '$pcd/pcd.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');
}
return {};
return {
isDeveloper: !!database.personal_access_token
};
};
export const actions: Actions = {
@@ -102,5 +100,36 @@ export const actions: Actions = {
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Failed to switch branch' };
}
},
pull: async ({ params }) => {
const id = parseInt(params.id || '', 10);
const database = databaseInstancesQueries.getById(id);
if (!database) {
return { success: false, error: 'Database not found' };
}
try {
const result = await pcdManager.sync(id);
if (result.success) {
await logger.info('Database synced', {
source: 'changes',
meta: { databaseId: id, commitsPulled: result.commitsBehind }
});
}
return result;
} catch (err) {
await logger.error('Failed to pull changes', {
source: 'changes',
meta: { databaseId: id, error: String(err) }
});
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to pull'
};
}
}
};

View File

@@ -1,16 +1,22 @@
<script lang="ts">
import ChangesActionsBar from './components/ChangesActionsBar.svelte';
import StatusCard from './components/StatusCard.svelte';
import { Check } from 'lucide-svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import { Check, ArrowDown, ExternalLink, FileText } from 'lucide-svelte';
import { afterNavigate } from '$app/navigation';
import { alertStore } from '$alerts/store';
import type { PageData } from './$types';
import type { OperationFile, GitStatus, RepoInfo } from '$utils/git/types';
import type { OperationFile, GitStatus, RepoInfo, IncomingChanges, Commit } from '$utils/git/types';
import type { Column } from '$ui/table/types';
export let data: PageData;
let loading = true;
let pulling = false;
let adding = false;
let discarding = false;
let status: GitStatus | null = null;
let incomingChanges: IncomingChanges | null = null;
let uncommittedOps: OperationFile[] = [];
let branches: string[] = [];
let repoInfo: RepoInfo | null = null;
@@ -19,6 +25,8 @@
let selected = new Set<string>();
let commitMessage = '';
$: isDeveloper = data.isDeveloper;
$: hasIncomingChanges = incomingChanges?.hasUpdates ?? false;
$: allSelected = uncommittedOps.length > 0 && selected.size === uncommittedOps.length;
$: selectedFiles = Array.from(selected);
@@ -29,7 +37,8 @@
if (response.ok) {
const result = await response.json();
status = result.status;
uncommittedOps = result.uncommittedOps;
incomingChanges = result.incomingChanges;
uncommittedOps = result.uncommittedOps || [];
branches = result.branches;
repoInfo = result.repoInfo;
}
@@ -73,66 +82,94 @@
selected = newSelected;
}
async function handlePull() {
pulling = true;
try {
const response = await fetch('?/pull', {
method: 'POST',
body: new FormData(),
headers: { Accept: 'application/json' }
});
const result = await response.json();
const isSuccess = result.type === 'success' || result.type === 'redirect' || result.data?.success;
const errorMsg = result.data?.error || result.error;
if (isSuccess && !errorMsg) {
const commits = result.data?.commitsBehind || incomingChanges?.commitsBehind || 0;
alertStore.add('success', `Pulled ${commits} commit${commits === 1 ? '' : 's'}`);
} else {
alertStore.add('error', errorMsg || 'Failed to pull changes');
}
await fetchChanges();
} finally {
pulling = false;
}
}
async function handleDiscard() {
const formData = new FormData();
for (const filepath of selected) {
formData.append('files', filepath);
discarding = true;
try {
const formData = new FormData();
for (const filepath of selected) {
formData.append('files', filepath);
}
const response = await fetch('?/discard', {
method: 'POST',
body: formData,
headers: { Accept: 'application/json' }
});
const result = await response.json();
const isSuccess = result.type === 'success' || result.type === 'redirect' || result.data?.success;
const errorMsg = result.data?.error || result.error;
if (isSuccess && !errorMsg) {
alertStore.add('success', 'Changes discarded');
} else {
alertStore.add('error', errorMsg || 'Failed to discard changes');
}
selected = new Set();
await fetchChanges();
} finally {
discarding = false;
}
const response = await fetch('?/discard', {
method: 'POST',
body: formData,
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
// SvelteKit form action response types: 'success', 'redirect', 'failure', 'error'
// Redirect is also considered success for our purposes
const isSuccess = result.type === 'success' || result.type === 'redirect' || result.data?.success;
const errorMsg = result.data?.error || result.error;
if (isSuccess && !errorMsg) {
alertStore.add('success', 'Changes discarded');
} else {
alertStore.add('error', errorMsg || 'Failed to discard changes');
}
selected = new Set();
await fetchChanges();
}
async function handleAdd() {
const formData = new FormData();
for (const filepath of selected) {
formData.append('files', filepath);
adding = true;
try {
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,
headers: { Accept: 'application/json' }
});
const result = await response.json();
const isSuccess = result.type === 'success' || result.type === 'redirect' || result.data?.success;
const errorMsg = result.data?.error || result.error;
if (isSuccess && !errorMsg) {
alertStore.add('success', 'Changes committed and pushed');
} else {
alertStore.add('error', errorMsg || 'Failed to add changes');
}
commitMessage = '';
selected = new Set();
await fetchChanges();
} finally {
adding = false;
}
formData.append('message', commitMessage);
const response = await fetch('?/add', {
method: 'POST',
body: formData,
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
// SvelteKit form action response types: 'success', 'redirect', 'failure', 'error'
const isSuccess = result.type === 'success' || result.type === 'redirect' || result.data?.success;
const errorMsg = result.data?.error || result.error;
if (isSuccess && !errorMsg) {
alertStore.add('success', 'Changes committed and pushed');
} else {
alertStore.add('error', errorMsg || 'Failed to add changes');
}
// Always clear and refresh
commitMessage = '';
// Always refresh to keep UI in sync with file system
selected = new Set();
await fetchChanges();
}
function formatOperation(op: string | null): string {
@@ -152,6 +189,37 @@
return 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200';
}
}
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`;
return date.toLocaleDateString();
}
function getCommitUrl(hash: string): string {
return `${data.database.repository_url}/commit/${hash}`;
}
const incomingColumns: 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' }
];
</script>
<svelte:head>
@@ -176,137 +244,247 @@
</div>
</div>
{:else}
<StatusCard {status} {repoInfo} {branches} database={data.database} />
<StatusCard {status} {repoInfo} {branches} database={data.database} onSync={fetchChanges} />
{/if}
<!-- Actions Bar -->
{#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>
<!-- Incoming Changes Section -->
<section>
<div class="mb-3 flex items-center justify-between">
<h2 class="flex items-center gap-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
<ArrowDown size={20} />
Incoming Changes
</h2>
{#if incomingChanges?.hasUpdates}
<button
type="button"
on:click={handlePull}
disabled={pulling}
class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if pulling}
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
Pulling...
{:else}
Pull {incomingChanges.commitsBehind} commit{incomingChanges.commitsBehind === 1 ? '' : 's'}
{/if}
</button>
{/if}
</div>
{#if loading}
<div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<div class="animate-pulse p-8">
<div class="h-4 w-48 rounded bg-neutral-200 dark:bg-neutral-700"></div>
</div>
</div>
</div>
{:else}
<ChangesActionsBar
databaseId={data.database.id}
selectedCount={selected.size}
{selectedFiles}
bind:commitMessage
{aiEnabled}
onDiscard={handleDiscard}
onAdd={handleAdd}
/>
{/if}
<!-- Table -->
{#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">
<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 uncommittedOps as op}
<tr
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
on:click={() => toggleRow(op.filepath)}
{:else if !incomingChanges?.hasUpdates}
<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">
{#if data.database.auto_pull}
Up to date — updates are pulled automatically
{:else}
Up to date
{/if}
</p>
</div>
{:else}
<ExpandableTable
columns={incomingColumns}
data={incomingChanges.commits}
getRowId={(row) => row.hash}
emptyMessage="No incoming changes"
>
<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"
>
<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'}"
{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}
</section>
<!-- Outgoing Changes Section (Developers Only) -->
{#if isDeveloper}
<section>
<h2 class="mb-3 flex items-center gap-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg>
Outgoing Changes
</h2>
<!-- Actions Bar -->
{#if loading}
<div class="mb-4 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}
<div class="mb-4">
<ChangesActionsBar
databaseId={data.database.id}
selectedCount={selected.size}
{selectedFiles}
bind:commitMessage
{aiEnabled}
{hasIncomingChanges}
{adding}
{discarding}
onDiscard={handleDiscard}
onAdd={handleAdd}
/>
</div>
{/if}
<!-- Outgoing Table -->
{#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">
<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 uncommittedOps as op}
<tr
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
on:click={() => toggleRow(op.filepath)}
>
{#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>
&rarr;
{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>
<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>
&rarr;
{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}
</section>
{/if}
</div>

View File

@@ -10,15 +10,18 @@
export let selectedFiles: string[] = [];
export let commitMessage: string;
export let aiEnabled: boolean = false;
export let hasIncomingChanges: boolean = false;
export let adding: boolean = false;
export let discarding: boolean = false;
export let onDiscard: () => void;
export let onAdd: () => void;
let generating = false;
$: canDiscard = selectedCount > 0;
$: canAdd = selectedCount > 0 && commitMessage.trim().length > 0;
$: canGenerate = aiEnabled && selectedCount > 0 && !generating;
$: canDiscard = selectedCount > 0 && !discarding;
$: canAdd = selectedCount > 0 && commitMessage.trim().length > 0 && !hasIncomingChanges && !adding;
$: canGenerate = aiEnabled && selectedCount > 0 && !generating && !hasIncomingChanges;
async function handleGenerate() {
if (!canGenerate) return;
@@ -49,13 +52,14 @@
<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"
class="flex h-10 w-full items-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800 {hasIncomingChanges ? 'opacity-50' : ''}"
>
<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"
disabled={hasIncomingChanges}
placeholder={hasIncomingChanges ? "Pull incoming changes first..." : "Commit message..."}
class="h-full w-full bg-transparent px-3 font-mono text-sm text-neutral-700 placeholder-neutral-400 outline-none disabled:cursor-not-allowed dark:text-neutral-300 dark:placeholder-neutral-500"
/>
</div>
</div>
@@ -71,7 +75,9 @@
<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 generating}
{#if hasIncomingChanges}
Pull incoming changes first
{:else if generating}
Generating...
{:else if !selectedCount}
Select changes first
@@ -85,7 +91,8 @@
{/if}
<ActionButton
icon={Upload}
icon={adding ? Loader2 : Upload}
iconClass={adding ? 'animate-spin' : ''}
hasDropdown={true}
dropdownPosition="right"
on:click={canAdd ? onAdd : undefined}
@@ -93,7 +100,11 @@
<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}
{#if adding}
Pushing...
{:else if hasIncomingChanges}
Pull incoming changes first
{:else if !selectedCount}
Select changes to add
{:else if !commitMessage.trim()}
Enter a commit message
@@ -106,7 +117,8 @@
</ActionButton>
<ActionButton
icon={Trash2}
icon={discarding ? Loader2 : Trash2}
iconClass={discarding ? 'animate-spin' : ''}
hasDropdown={true}
dropdownPosition="right"
on:click={canDiscard ? onDiscard : undefined}
@@ -114,7 +126,9 @@
<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}
{#if discarding}
Discarding...
{:else if selectedCount > 0}
Discard {selectedCount} change{selectedCount === 1 ? '' : 's'}
{:else}
Select changes to discard

View File

@@ -9,7 +9,8 @@
CircleDot,
ChevronDown,
Check,
Database
Database,
RefreshCw
} from 'lucide-svelte';
import { invalidateAll } from '$app/navigation';
import type { GitStatus, RepoInfo } from '$utils/git/types';
@@ -19,9 +20,11 @@
export let repoInfo: RepoInfo | null;
export let branches: string[];
export let database: DatabaseInstance;
export let onSync: (() => Promise<void>) | undefined = undefined;
let branchDropdownOpen = false;
let switching = false;
let syncing = false;
async function handleBranchSwitch(branch: string) {
if (branch === status.branch || switching) return;
@@ -52,6 +55,18 @@
branchDropdownOpen = false;
}
}
async function handleSync() {
if (syncing) return;
syncing = true;
try {
// Just refresh the data (fetches from remote to check for updates)
if (onSync) await onSync();
} finally {
syncing = false;
}
}
</script>
<svelte:window on:click={handleClickOutside} />
@@ -179,6 +194,16 @@
{/if}
</div>
<!-- Sync button -->
<button
type="button"
on:click={handleSync}
disabled={syncing}
title="Sync now"
class="flex items-center justify-center rounded-md border border-neutral-200 bg-white p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
>
<RefreshCw size={16} class={syncing ? 'animate-spin' : ''} />
</button>
</div>
</div>
</div>