mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
302
docs/todo/2.manual-pull-handling.md
Normal file
302
docs/todo/2.manual-pull-handling.md
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -55,3 +55,9 @@ export interface Commit {
|
||||
date: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface IncomingChanges {
|
||||
hasUpdates: boolean;
|
||||
commitsBehind: number;
|
||||
commits: Commit[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
→
|
||||
{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>
|
||||
→
|
||||
{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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user