git
This commit is contained in:
@@ -323,72 +323,122 @@
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="p-4 border rounded-lg bg-white shadow-md">
|
||||
{#if $selectedTask}
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h2 class="text-xl font-semibold">{$t.tasks?.task_label}: {$selectedTask.plugin_id}</h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
{#if connectionStatus === 'connecting'}
|
||||
<span class="flex h-3 w-3 relative">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-yellow-500"></span>
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">{$t.tasks?.connecting}</span>
|
||||
{:else if connectionStatus === 'connected'}
|
||||
<span class="h-3 w-3 rounded-full bg-green-500"></span>
|
||||
<span class="text-xs text-gray-500">{$t.tasks?.live}</span>
|
||||
{:else if connectionStatus === 'completed'}
|
||||
<span class="h-3 w-3 rounded-full bg-blue-500"></span>
|
||||
<span class="text-xs text-gray-500">{$t.tasks?.completed}</span>
|
||||
{:else if connectionStatus === 'awaiting_mapping'}
|
||||
<span class="h-3 w-3 rounded-full bg-orange-500 animate-pulse"></span>
|
||||
<span class="text-xs text-gray-500">{$t.tasks?.awaiting_mapping}</span>
|
||||
{:else if connectionStatus === 'awaiting_input'}
|
||||
<span class="h-3 w-3 rounded-full bg-orange-500 animate-pulse"></span>
|
||||
<span class="text-xs text-gray-500">{$t.tasks?.awaiting_input}</span>
|
||||
{:else}
|
||||
<span class="h-3 w-3 rounded-full bg-red-500"></span>
|
||||
<span class="text-xs text-gray-500">{$t.tasks?.disconnected}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Info Section -->
|
||||
<div class="mb-4 bg-gray-50 p-3 rounded text-sm border border-gray-200">
|
||||
<details open>
|
||||
<summary class="cursor-pointer font-medium text-gray-700 focus:outline-none hover:text-indigo-600">{$t.tasks?.details_parameters}</summary>
|
||||
<div class="mt-2 pl-2 border-l-2 border-indigo-200">
|
||||
<div class="grid grid-cols-2 gap-2 mb-2">
|
||||
<div><span class="font-semibold">{$t.common?.id}:</span> <span class="text-gray-600">{$selectedTask.id}</span></div>
|
||||
<div><span class="font-semibold">{$t.dashboard?.status}:</span> <span class="text-gray-600">{$selectedTask.status}</span></div>
|
||||
<div><span class="font-semibold">{$t.tasks?.started_label}:</span> <span class="text-gray-600">{new Date($selectedTask.started_at || $selectedTask.created_at || Date.now()).toLocaleString()}</span></div>
|
||||
<div><span class="font-semibold">{$t.tasks?.plugin}:</span> <span class="text-gray-600">{$selectedTask.plugin_id}</span></div>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<span class="font-semibold">{$t.tasks?.parameters}:</span>
|
||||
<pre class="text-xs bg-gray-100 p-2 rounded mt-1 overflow-x-auto border border-gray-200">{JSON.stringify($selectedTask.params, null, 2)}</pre>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div class="px-6 py-4 bg-gradient-to-r from-gray-50 to-slate-50 border-b border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-gray-100 text-gray-600">
|
||||
<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">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="h-[500px]">
|
||||
<TaskLogPanel
|
||||
taskId={$selectedTask.id}
|
||||
logs={$taskLogs}
|
||||
autoScroll={true}
|
||||
onfilterchange={handleFilterChange}
|
||||
/>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">{$t.tasks?.task_label}</h2>
|
||||
<p class="text-sm text-gray-500">Мониторинг выполнения задач</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if waitingForData && connectionStatus === 'connected'}
|
||||
<div class="text-gray-500 italic mt-2 animate-pulse text-xs">
|
||||
{$t.tasks?.waiting_logs}
|
||||
{#if $selectedTask}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if connectionStatus === 'connecting'}
|
||||
<span class="flex h-3 w-3 relative">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-yellow-500"></span>
|
||||
</span>
|
||||
<span class="text-xs font-medium text-yellow-600 bg-yellow-50 px-2.5 py-1 rounded-full">{$t.tasks?.connecting}</span>
|
||||
{:else if connectionStatus === 'connected'}
|
||||
<span class="h-3 w-3 rounded-full bg-green-500"></span>
|
||||
<span class="text-xs font-medium text-green-600 bg-green-50 px-2.5 py-1 rounded-full">{$t.tasks?.live}</span>
|
||||
{:else if connectionStatus === 'completed'}
|
||||
<span class="h-3 w-3 rounded-full bg-blue-500"></span>
|
||||
<span class="text-xs font-medium text-blue-600 bg-blue-50 px-2.5 py-1 rounded-full">{$t.tasks?.completed}</span>
|
||||
{:else if connectionStatus === 'awaiting_mapping'}
|
||||
<span class="h-3 w-3 rounded-full bg-orange-500 animate-pulse"></span>
|
||||
<span class="text-xs font-medium text-orange-600 bg-orange-50 px-2.5 py-1 rounded-full">{$t.tasks?.awaiting_mapping}</span>
|
||||
{:else if connectionStatus === 'awaiting_input'}
|
||||
<span class="h-3 w-3 rounded-full bg-orange-500 animate-pulse"></span>
|
||||
<span class="text-xs font-medium text-orange-600 bg-orange-50 px-2.5 py-1 rounded-full">{$t.tasks?.awaiting_input}</span>
|
||||
{:else}
|
||||
<span class="h-3 w-3 rounded-full bg-red-500"></span>
|
||||
<span class="text-xs font-medium text-red-600 bg-red-50 px-2.5 py-1 rounded-full">{$t.tasks?.disconnected}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p>{$t.tasks?.select_task}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Card Body -->
|
||||
<div class="p-6">
|
||||
{#if $selectedTask}
|
||||
<!-- Task Info Section -->
|
||||
<div class="mb-4 bg-gray-50 rounded-lg text-sm border border-gray-200 overflow-hidden">
|
||||
<details open>
|
||||
<summary class="cursor-pointer font-medium text-gray-700 focus:outline-none hover:text-indigo-600 px-4 py-3 hover:bg-gray-100 transition-colors">{$t.tasks?.details_parameters}</summary>
|
||||
<div class="px-4 pb-4 pt-2 border-t border-gray-200">
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-600">{$t.common?.id}:</span>
|
||||
<span class="text-gray-900 font-mono text-xs bg-white px-2 py-0.5 rounded border border-gray-200">{$selectedTask.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-600">{$t.dashboard?.status}:</span>
|
||||
<span class="text-gray-900">{$selectedTask.status}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-600">{$t.tasks?.started_label}:</span>
|
||||
<span class="text-gray-900">{new Date($selectedTask.started_at || $selectedTask.created_at || Date.now()).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-600">{$t.tasks?.plugin}:</span>
|
||||
<span class="text-gray-900">{$selectedTask.plugin_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="font-semibold text-gray-600">{$t.tasks?.parameters}:</span>
|
||||
<pre class="text-xs bg-white p-3 rounded-lg mt-2 overflow-x-auto border border-gray-200 shadow-inner font-mono">{JSON.stringify($selectedTask.params, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Log Panel -->
|
||||
<div class="h-[500px]">
|
||||
<TaskLogPanel
|
||||
taskId={$selectedTask.id}
|
||||
logs={$taskLogs}
|
||||
autoScroll={true}
|
||||
onfilterchange={handleFilterChange}
|
||||
/>
|
||||
|
||||
{#if waitingForData && connectionStatus === 'connected'}
|
||||
<div class="flex items-center gap-2 text-gray-500 italic mt-3 animate-pulse text-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
{$t.tasks?.waiting_logs}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty State -->
|
||||
<div class="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-gray-400">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
<polyline points="10 9 9 9 8 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{$t.tasks?.select_task}</h3>
|
||||
<p class="text-sm text-gray-500 max-w-xs">Запустите задачу маппинга, чтобы увидеть логи и детали выполнения</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MissingMappingModal
|
||||
|
||||
@@ -1,28 +1,75 @@
|
||||
<!-- #region Toast [C:3] [TYPE Component] [SEMANTICS toast, notification, feedback, message] -->
|
||||
<!-- @BRIEF Component component: components/Toast.svelte -->
|
||||
<!-- @BRIEF Toast notifications: transient (auto-dismiss) and persistent (close button only).
|
||||
Persistent toasts render above modals (z-[100]) with warning style and close button. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!--
|
||||
@SEMANTICS: toast, notification, feedback, ui
|
||||
@PURPOSE: Displays transient notifications (toasts) in the bottom-right corner.
|
||||
@LAYER UI
|
||||
@RELATION BINDS_TO -> [EXT:frontend:toasts]
|
||||
|
||||
@UX_STATE: Empty -> Toast viewport stays mounted without visible notifications.
|
||||
@UX_STATE: Visible -> Active toast items render with type-specific emphasis.
|
||||
@UX_STATE: Visible -> Active toast items render with type-specific emphasis and optional close button.
|
||||
@UX_STATE: Persistent -> Warning-style toast with close button, stays until manually dismissed.
|
||||
@UX_FEEDBACK Close button on persistent toasts; auto-dismiss on transient toasts.
|
||||
-->
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { toasts } from '../lib/toasts.js';
|
||||
// [/SECTION]
|
||||
import { toasts, removeToast } from '../lib/toasts.js';
|
||||
|
||||
function handleDismiss(id) {
|
||||
removeToast(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="fixed bottom-0 right-0 p-4 space-y-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div class="p-4 rounded-md shadow-lg text-white
|
||||
{toast.type === 'info' && 'bg-primary'}
|
||||
{toast.type === 'success' && 'bg-green-500'}
|
||||
{toast.type === 'error' && 'bg-red-500'}
|
||||
<!-- Persistent toasts: ABOVE the modal (z-[100]), top-center, with close button -->
|
||||
<div class="pointer-events-none fixed inset-0 z-[100] flex items-start justify-center pt-4">
|
||||
{#each $toasts.filter(t => t.persistent) as toast (toast.id)}
|
||||
<div
|
||||
class="pointer-events-auto mx-4 flex w-full max-w-lg items-start gap-3 rounded-lg border p-4 shadow-2xl
|
||||
{toast.type === 'warning' ? 'border-amber-300 bg-amber-50 text-amber-900' : ''}
|
||||
{toast.type === 'error' ? 'border-red-300 bg-red-50 text-red-900' : ''}
|
||||
{toast.type === 'info' ? 'border-blue-200 bg-blue-50 text-blue-900' : ''}
|
||||
{toast.type === 'success' ? 'border-green-200 bg-green-50 text-green-900' : ''}
|
||||
"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
{#if toast.type === 'warning'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
{:else if toast.type === 'error'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 9l-6 6m0-6l6 6"/></svg>
|
||||
{:else if toast.type === 'success'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 16v-4m0-4h.01"/></svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 text-sm">
|
||||
{toast.message}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDismiss(toast.id)}
|
||||
class="flex-shrink-0 rounded p-1 transition-colors
|
||||
{toast.type === 'warning' ? 'hover:bg-amber-100 text-amber-400' : ''}
|
||||
{toast.type === 'error' ? 'hover:bg-red-100 text-red-400' : ''}
|
||||
{toast.type === 'info' ? 'hover:bg-blue-100 text-blue-400' : ''}
|
||||
{toast.type === 'success' ? 'hover:bg-green-100 text-green-400' : ''}
|
||||
"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Transient toasts: above modal (z-[60] > modal z-50), bottom-right, auto-dismiss -->
|
||||
<div class="pointer-events-none fixed bottom-0 right-0 z-[60] p-4">
|
||||
{#each $toasts.filter(t => !t.persistent) as toast (toast.id)}
|
||||
<div class="pointer-events-auto mb-2 rounded-lg px-4 py-3 text-sm text-white shadow-lg
|
||||
{toast.type === 'info' && 'bg-blue-600'}
|
||||
{toast.type === 'success' && 'bg-green-600'}
|
||||
{toast.type === 'error' && 'bg-red-600'}
|
||||
{toast.type === 'warning' && 'bg-amber-600'}
|
||||
">
|
||||
{toast.message}
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,15 @@
|
||||
requestApi(filesUrl)
|
||||
]);
|
||||
environments = envsData;
|
||||
// Pre-fill with active environment from global context
|
||||
if (!selectedEnvId) {
|
||||
const { get } = await import('svelte/store');
|
||||
const envCtx = await import('$lib/stores/environmentContext.js');
|
||||
const state = get(envCtx.environmentContextStore);
|
||||
if (state.selectedEnvId && environments.some((env) => env.id === state.selectedEnvId)) {
|
||||
selectedEnvId = state.selectedEnvId;
|
||||
}
|
||||
}
|
||||
|
||||
backups = (storageData || []).map((file: any) => ({
|
||||
id: file.path,
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
let loading = $state(false);
|
||||
let showCreate = $state(false);
|
||||
let newBranchName = $state('');
|
||||
let branchError = $state(null);
|
||||
// #endregion state
|
||||
|
||||
// #region loadBranches [C:2] [TYPE Function]
|
||||
@@ -68,7 +69,9 @@
|
||||
// @PRE branchName is non-empty; envId required when ref is slug.
|
||||
// @POST currentBranch updated; onchange callback fired.
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/checkout.
|
||||
// @UX_STATE Error -> Inline error message below the dropdown; persistent toast above modal with full details.
|
||||
async function handleCheckout(branchName) {
|
||||
branchError = null;
|
||||
console.log(`[EXT:frontend:BranchSelector][Action] Checking out branch ${branchName}`);
|
||||
try {
|
||||
await gitService.checkoutBranch(dashboardId, branchName, envId);
|
||||
@@ -78,7 +81,15 @@
|
||||
console.log(`[EXT:frontend:BranchSelector][Coherence:OK] Checked out ${branchName}`);
|
||||
} catch (e) {
|
||||
console.error(`[EXT:frontend:BranchSelector][Coherence:Failed] ${e.message}`);
|
||||
toast(e.message, 'error');
|
||||
const detail = typeof e?.detail === 'object' ? e.detail : {};
|
||||
const nextSteps = detail?.next_steps || [];
|
||||
const shortMsg = detail?.message || detail?.message_en || 'Ошибка переключения ветки';
|
||||
branchError = {
|
||||
message: shortMsg,
|
||||
next_steps: nextSteps,
|
||||
};
|
||||
// Persistent toast above modal with the full technical error
|
||||
toast(shortMsg, 'warning', 0);
|
||||
}
|
||||
}
|
||||
// #endregion handleCheckout
|
||||
@@ -90,6 +101,7 @@
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/branches.
|
||||
async function handleCreate() {
|
||||
if (!newBranchName) return;
|
||||
branchError = null;
|
||||
console.log(`[EXT:frontend:BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`);
|
||||
try {
|
||||
await gitService.createBranch(dashboardId, newBranchName, currentBranch, envId);
|
||||
@@ -100,6 +112,7 @@
|
||||
console.log(`[EXT:frontend:BranchSelector][Coherence:OK] Branch created`);
|
||||
} catch (e) {
|
||||
console.error(`[EXT:frontend:BranchSelector][Coherence:Failed] ${e.message}`);
|
||||
branchError = { message: e.message || 'Ошибка создания ветки', next_steps: [] };
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -133,6 +146,27 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if branchError}
|
||||
<div class="max-h-32 overflow-y-auto rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800" role="alert">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-0.5 h-4 w-4 flex-shrink-0 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 9l-6 6m0-6l6 6"/></svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="truncate">{branchError.message}</p>
|
||||
{#if branchError.next_steps?.length}
|
||||
<ul class="mt-1 list-disc pl-4 text-xs text-red-700">
|
||||
{#each branchError.next_steps as step}
|
||||
<li>{step}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" onclick={() => branchError = null} class="flex-shrink-0 rounded p-0.5 hover:bg-red-100" aria-label="Закрыть">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showCreate}
|
||||
<div class="flex items-end gap-2 bg-gray-50 p-3 rounded-lg border border-dashed border-gray-200">
|
||||
<div class="flex-grow">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- @UX_STATE Loading -> Buttons disabled, spinner visible. -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
import { Button, Card, Select, Input } from "$lib/ui";
|
||||
import { Button, Card, Select, Input, HelpTooltip } from "$lib/ui";
|
||||
|
||||
let {
|
||||
configs,
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
<!-- @UX_STATE Uninitialized -> GitInitPanel. -->
|
||||
<!-- @UX_STATE Initialized -> Tabbed workspace/release/operations. -->
|
||||
<!-- @UX_STATE MergeDialog -> Recovery overlay. -->
|
||||
<!-- @UX_STATE Error -> Red banner with error details and dismiss action. -->
|
||||
<!-- @UX_FEEDBACK Error banner shown in modal body, persistent until dismissed or next operation. -->
|
||||
<!-- @UX_FEEDBACK Toast is still shown for quick notification alongside the persistent banner. -->
|
||||
<!-- @UX_RECOVERY Dismiss error, Retry operation after fixing the issue. -->
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { gitService } from '../../services/gitService';
|
||||
import { addToast as toast } from '../../lib/toasts.js';
|
||||
import { t } from '$lib/i18n';
|
||||
import { PageHeader } from '$lib/ui';
|
||||
import { PageHeader, HelpTooltip } from '$lib/ui';
|
||||
import BranchSelector from './BranchSelector.svelte';
|
||||
import DeploymentModal from './DeploymentModal.svelte';
|
||||
import ConflictResolver from './ConflictResolver.svelte';
|
||||
@@ -77,6 +81,9 @@
|
||||
let mergeResolveInProgress = $state(false);
|
||||
let mergeAbortInProgress = $state(false);
|
||||
let mergeContinueInProgress = $state(false);
|
||||
// ── Git Error state (persistent error banner in modal) ─────
|
||||
let gitError = $state(null);
|
||||
let gitErrorType = $state('error');
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────
|
||||
const hasWorkspaceChanges = $derived.by(() => {
|
||||
@@ -107,10 +114,12 @@
|
||||
isPulling, isPushing, autoPushAfterCommit, repositoryProvider, repositoryBindingRemoteUrl, repositoryConfigUrl,
|
||||
showUnfinishedMergeDialog, unfinishedMergeContext, copyingUnfinishedMergeCommands,
|
||||
mergeRecoveryLoading, mergeConflicts, showConflictResolver, mergeResolveInProgress, mergeAbortInProgress, mergeContinueInProgress,
|
||||
gitError, gitErrorType,
|
||||
hasWorkspaceChanges,
|
||||
normalizeEnvStageFn: normalizeEnvStage,
|
||||
applyGitflowStageDefaultsFn: applyGitflowStageDefaults,
|
||||
resolveEnvId: () => resolveCurrentEnvironmentId(envId),
|
||||
clearGitError: () => { gitError = null; gitErrorType = 'error'; },
|
||||
};
|
||||
}
|
||||
function setState(patch) {
|
||||
@@ -154,12 +163,14 @@
|
||||
else if (k === 'mergeResolveInProgress') mergeResolveInProgress = v;
|
||||
else if (k === 'mergeAbortInProgress') mergeAbortInProgress = v;
|
||||
else if (k === 'mergeContinueInProgress') mergeContinueInProgress = v;
|
||||
else if (k === 'gitError') gitError = v;
|
||||
else if (k === 'gitErrorType') gitErrorType = v;
|
||||
}
|
||||
}
|
||||
|
||||
const h = createGitHandlers(getState, setState);
|
||||
|
||||
function closeModal() { show = false; }
|
||||
function closeModal() { show = false; gitError = null; gitErrorType = 'error'; }
|
||||
function handleBackdropClick(e) { if (e.target === e.currentTarget) closeModal(); }
|
||||
|
||||
$effect(() => { repositoryConfigUrl = resolveDefaultConfig(configs, selectedConfigId)?.url || ''; });
|
||||
@@ -183,35 +194,121 @@
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50" onclick={handleBackdropClick} onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }} role="button" tabindex="0" aria-label={$t.common?.close || 'Close'}>
|
||||
<div class="relative max-h-[90vh] w-full max-w-6xl overflow-y-auto rounded-lg bg-white p-6 shadow-2xl" role="dialog" aria-modal="true" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
|
||||
<button type="button" onclick={closeModal} class="absolute right-4 top-4 z-10 rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-700" aria-label={$t.common?.close || 'Close'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
<PageHeader title={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
|
||||
<div slot="subtitle" class="text-sm text-gray-500">slug: {dashboardId}</div>
|
||||
</PageHeader>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 pt-6 backdrop-blur-sm" onclick={handleBackdropClick} onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }} role="dialog" tabindex="-1" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
|
||||
<div class="relative w-[95vw] max-w-[1600px] overflow-hidden rounded-xl bg-white shadow-2xl" role="document" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-600 text-white shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">{$t.git?.management || 'Управление Git'}</h2>
|
||||
<p class="text-sm text-slate-500">{dashboardTitle} <span class="text-slate-300">·</span> slug: {dashboardId}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick={closeModal} class="flex h-9 w-9 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700" aria-label={$t.common?.close || 'Close'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Git Error Banner -->
|
||||
{#if gitError}
|
||||
<div
|
||||
class="mx-6 mt-4 flex items-start gap-3 rounded-lg border p-4 text-sm shadow-sm {gitErrorType === 'warning' ? 'border-amber-300 bg-amber-50 text-amber-900' : 'border-red-300 bg-red-50 text-red-900'}"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
{#if gitErrorType === 'warning'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 9l-6 6m0-6l6 6"/></svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<p class="font-medium">{gitError.message || gitError}</p>
|
||||
{#if gitError.files?.length}
|
||||
<details class="text-xs text-red-700/80">
|
||||
<summary class="cursor-pointer font-medium">Файлы, которые будут перезаписаны ({gitError.files.length})</summary>
|
||||
<ul class="mt-1 list-disc space-y-0.5 pl-5">
|
||||
{#each gitError.files as file}
|
||||
<li><code class="rounded bg-red-100/50 px-1">{file}</code></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{#if gitError.next_steps?.length}
|
||||
<div class="text-xs">
|
||||
<span class="font-medium">Рекомендации:</span>
|
||||
<ol class="mt-1 list-decimal space-y-0.5 pl-5">
|
||||
{#each gitError.next_steps as step}
|
||||
<li>{step}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { gitError = null; gitErrorType = 'error'; }}
|
||||
class="flex-shrink-0 rounded p-1 transition-colors {gitErrorType === 'warning' ? 'hover:bg-amber-100' : 'hover:bg-red-100'}"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Body: height fills space, scroll is inside GitWorkspacePanel diff panel -->
|
||||
<div class="flex flex-col p-6" style="height: calc(92vh - 80px);">
|
||||
|
||||
{#if checkingStatus}
|
||||
<div class="flex justify-center py-12"><div class="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div></div>
|
||||
{:else if !initialized}
|
||||
<GitInitPanel {configs} bind:selectedConfigId bind:remoteUrl {creatingRemoteRepo} {loading} onCreateRemoteRepo={h.handleCreateRemoteRepo} onInit={h.handleInit} />
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{#if hasOriginConfigMismatch}
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"><div class="font-semibold">Git server mismatch detected</div><div class="mt-1">Configured: <code>{configHost}</code>, origin: <code>{originHost}</code>.</div></div>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold ${stageBadgeClass(currentEnvStage)}`}>{currentEnvStage || 'DEV'}</span>
|
||||
<span class="text-xs text-slate-600">Текущая ветка: <strong>{currentBranch}</strong></span>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider ${stageBadgeClass(currentEnvStage)}`}>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current opacity-60"></span>
|
||||
{currentEnvStage || 'DEV'}
|
||||
</span>
|
||||
<span class="hidden text-xs text-slate-500 sm:inline">·</span>
|
||||
<span class="flex items-center gap-1.5 text-sm text-slate-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
|
||||
<span class="hidden sm:inline">Ветка:</span> <strong class="font-mono text-slate-800">{currentBranch}</strong>
|
||||
</span>
|
||||
{#if changedFilesCount > 0}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{changedFilesCount} изменений
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full max-w-sm"><BranchSelector {dashboardId} envId={resolvedEnvId} bind:currentBranch /></div>
|
||||
<div class="flex w-full items-center gap-1 sm:w-64"><BranchSelector {dashboardId} envId={resolvedEnvId} bind:currentBranch /><HelpTooltip text={$t.git?.hint_branch_selector || ''} /></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-200 pb-2">
|
||||
<button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'workspace' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'workspace')}>📝 Фиксация изменений</button>
|
||||
<button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'release' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'release')}>🚀 Релиз</button>
|
||||
<button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'operations' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'operations')}>⚙️ Серверные операции</button>
|
||||
<div class="flex flex-wrap items-center gap-1 border-b border-slate-200 pb-0">
|
||||
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${activeTab === 'workspace' ? 'border border-b-white bg-white text-blue-700 shadow-sm' : 'border border-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-700'}`} onclick={() => (activeTab = 'workspace')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Фиксация изменений
|
||||
<HelpTooltip text={$t.git?.hint_workspace || ''} />
|
||||
</button>
|
||||
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${activeTab === 'release' ? 'border border-b-white bg-white text-blue-700 shadow-sm' : 'border border-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-700'}`} onclick={() => (activeTab = 'release')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
Релиз
|
||||
<HelpTooltip text={$t.git?.hint_release || ''} />
|
||||
</button>
|
||||
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${activeTab === 'operations' ? 'border border-b-white bg-white text-blue-700 shadow-sm' : 'border border-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-700'}`} onclick={() => (activeTab = 'operations')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Серверные операции
|
||||
<HelpTooltip text={$t.git?.hint_operations || ''} />
|
||||
</button>
|
||||
</div>
|
||||
{#if activeTab === 'workspace'}
|
||||
<GitWorkspacePanel {hasWorkspaceChanges} {changedFilesCount} {workspaceLoading} {workspaceDiff} {committing} {generatingMessage} bind:commitMessage bind:autoPushAfterCommit {loading} {pushProviderLabel} onSync={h.handleSync} onGenerateMessage={h.handleGenerateMessage} onCommit={h.handleCommit} />
|
||||
@@ -223,8 +320,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<GitMergeDialog bind:show={showUnfinishedMergeDialog} {unfinishedMergeContext} {mergeRecoveryLoading} {mergeResolveInProgress} {mergeAbortInProgress} {mergeContinueInProgress} {copyingUnfinishedMergeCommands} onRefresh={h.loadMergeRecoveryState} onCopyCommands={h.handleCopyUnfinishedMergeCommands} onOpenConflictResolver={h.handleOpenConflictResolver} onAbortMerge={h.handleAbortUnfinishedMerge} onContinueMerge={h.handleContinueUnfinishedMerge} onClose={h.closeUnfinishedMergeDialog} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<ConflictResolver conflicts={mergeConflicts} bind:show={showConflictResolver} onresolve={h.handleResolveConflicts} />
|
||||
<DeploymentModal {dashboardId} envId={resolvedEnvId} preferredTargetStage={preferredDeployTargetStage} bind:show={showDeployModal} />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<!-- #region GitWorkspacePanel [C:3] [TYPE Component] [SEMANTICS git, workspace, commit, diff, sync] -->
|
||||
<!-- @BRIEF Git workspace panel: sync, commit message input, diff viewer, commit action. -->
|
||||
<!-- #region GitWorkspacePanel [C:4] [TYPE Component] [SEMANTICS git, workspace, commit, diff, sync] -->
|
||||
<!-- @BRIEF Git workspace panel: sync, commit, lazy-chunked diff viewer (IntersectionObserver). -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [GitUtils] -->
|
||||
<!-- @RELATION CALLS -> [EXT:frontend:gitService] -->
|
||||
<!-- @PRE hasWorkspaceChanges and workspaceDiff are propagated from GitManager. -->
|
||||
<!-- @UX_STATE Idle -> "Нет изменений" placeholder when !hasWorkspaceChanges. -->
|
||||
<!-- @UX_STATE Changes -> workspaceDiff rendered when hasWorkspaceChanges && workspaceDiff. -->
|
||||
<!-- @UX_STATE Loading -> GeneratingMessage spinner, workspace loading. -->
|
||||
<!-- @UX_STATE Changes -> workspaceDiff rendered in chunks as user scrolls. -->
|
||||
<!-- @UX_STATE Loading -> GeneratingMessage spinner, diff-chunks loading indicator. -->
|
||||
<!-- @UX_STATE Error -> Toast on failure. -->
|
||||
<!-- @UX_RECOVERY Scroll down to load more diff chunks; sentinel triggers lazy render. -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
import { Button } from "$lib/ui";
|
||||
@@ -30,86 +31,265 @@
|
||||
onCommit,
|
||||
} = $props();
|
||||
|
||||
// ── Render diff as HTML via diff2html ──
|
||||
let diffHtml = $derived(workspaceDiff
|
||||
? Diff2Html.html(workspaceDiff, {
|
||||
// ── Lazy chunked diff rendering ──
|
||||
// Split diff into per-file sections on "diff --git " header
|
||||
let diffChunks = $derived.by(() => {
|
||||
if (!workspaceDiff) return [];
|
||||
const header = 'diff --git ';
|
||||
const parts = [];
|
||||
let start = 0;
|
||||
while (true) {
|
||||
const idx = workspaceDiff.indexOf(header, start);
|
||||
if (idx < 0) break;
|
||||
// Include the "diff --git " prefix in the chunk
|
||||
const chunkStart = idx;
|
||||
const nextIdx = workspaceDiff.indexOf(header, idx + 1);
|
||||
const chunkEnd = nextIdx >= 0 ? nextIdx : workspaceDiff.length;
|
||||
parts.push(workspaceDiff.slice(chunkStart, chunkEnd));
|
||||
start = nextIdx >= 0 ? nextIdx : workspaceDiff.length;
|
||||
if (nextIdx < 0) break;
|
||||
}
|
||||
// Fallback: if no "diff --git" headers, treat entire diff as one chunk
|
||||
if (parts.length === 0 && workspaceDiff.trim()) {
|
||||
parts.push(workspaceDiff);
|
||||
}
|
||||
return parts;
|
||||
});
|
||||
|
||||
const CHUNKS_PER_PAGE = 3; // render 3 file diffs at a time
|
||||
let renderedChunkCount = $state(0);
|
||||
let sentinelEl = $state(null);
|
||||
let chunkLoading = $state(false);
|
||||
|
||||
// When diff changes, reset to first chunk page
|
||||
$effect(() => {
|
||||
if (workspaceDiff) {
|
||||
renderedChunkCount = Math.min(CHUNKS_PER_PAGE, diffChunks.length);
|
||||
}
|
||||
});
|
||||
|
||||
// IntersectionObserver on sentinel to load more chunks
|
||||
$effect(() => {
|
||||
const el = sentinelEl;
|
||||
if (!el) return;
|
||||
if (renderedChunkCount >= diffChunks.length) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && renderedChunkCount < diffChunks.length) {
|
||||
chunkLoading = true;
|
||||
// Yield to let browser paint the loading indicator
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
renderedChunkCount = Math.min(
|
||||
renderedChunkCount + CHUNKS_PER_PAGE,
|
||||
diffChunks.length
|
||||
);
|
||||
chunkLoading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px 0px' } // trigger 200px before sentinel enters viewport
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Render only visible chunks via Diff2Html individually
|
||||
let renderedHtml = $derived.by(() => {
|
||||
const count = renderedChunkCount;
|
||||
if (count === 0 || diffChunks.length === 0) return '';
|
||||
const visible = diffChunks.slice(0, count).join('\n');
|
||||
try {
|
||||
return Diff2Html.html(visible, {
|
||||
outputFormat: 'side-by-side',
|
||||
drawFileList: true,
|
||||
matching: 'lines',
|
||||
highlight: true,
|
||||
})
|
||||
: '');
|
||||
});
|
||||
} catch (_e) {
|
||||
return '<div class="p-4 text-sm text-red-500">Failed to render diff</div>';
|
||||
}
|
||||
});
|
||||
|
||||
const totalChunks = $derived(diffChunks.length);
|
||||
const hasMoreChunks = $derived(renderedChunkCount < totalChunks);
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={onSync}
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
class="w-full"
|
||||
>
|
||||
🔄 Синхронизировать из Superset
|
||||
</Button>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
|
||||
<!-- Left sidebar: commit controls (sticky, no scroll) -->
|
||||
<div class="w-full shrink-0 space-y-4 lg:w-80 xl:w-96">
|
||||
<!-- Sync button -->
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={onSync}
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
class="w-full"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-1.5 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Синхронизировать из Superset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<!-- Commit message -->
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-700">Сообщение коммита</h3>
|
||||
<button
|
||||
class="inline-flex items-center text-xs text-primary hover:text-blue-800 disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:opacity-50"
|
||||
onclick={onGenerateMessage}
|
||||
disabled={generatingMessage || workspaceLoading}
|
||||
>
|
||||
✨ Сгенерировать LLM
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"/></svg>
|
||||
LLM
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={commitMessage}
|
||||
class={`h-36 w-full resize-none rounded border p-2 outline-none focus-visible:ring-2 focus-visible:ring-primary-ring ${generatingMessage ? 'animate-pulse bg-slate-100' : ''}`}
|
||||
class={`h-32 w-full resize-none rounded-lg border border-slate-200 p-3 text-sm outline-none transition-colors focus:border-blue-400 focus:ring-2 focus:ring-blue-100 ${generatingMessage ? 'animate-pulse bg-slate-50' : 'bg-white'}`}
|
||||
placeholder="Опишите изменения..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
Файлов с изменениями: {changedFilesCount}
|
||||
</div>
|
||||
<!-- Commit action -->
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div class="mb-3 flex items-center gap-2 rounded-lg bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Файлов с изменениями: <strong class="text-slate-800">{changedFilesCount}</strong>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onclick={onCommit}
|
||||
disabled={committing || workspaceLoading || !commitMessage || !hasWorkspaceChanges}
|
||||
isLoading={committing}
|
||||
class="w-full"
|
||||
>
|
||||
Зафиксировать (Commit)
|
||||
</Button>
|
||||
<label class="flex items-center gap-2 text-xs text-slate-600">
|
||||
<input type="checkbox" bind:checked={autoPushAfterCommit} />
|
||||
{$t.git?.auto_push_after_commit || 'Сделать push после commit в'} {pushProviderLabel}
|
||||
</label>
|
||||
<Button
|
||||
onclick={onCommit}
|
||||
disabled={committing || workspaceLoading || !commitMessage || !hasWorkspaceChanges}
|
||||
isLoading={committing}
|
||||
class="w-full"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-1.5 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
Зафиксировать (Commit)
|
||||
</Button>
|
||||
|
||||
<label class="mt-3 flex items-center gap-2.5 rounded-md border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-600 transition-colors hover:bg-slate-100">
|
||||
<input type="checkbox" bind:checked={autoPushAfterCommit} class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span>{$t.git?.auto_push_after_commit || 'Сделать push после commit в'} <strong class="font-medium text-slate-700">{pushProviderLabel}</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[420px] flex-col overflow-hidden rounded-lg border border-slate-200 bg-slate-50">
|
||||
<div class="border-b bg-slate-100 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-600">
|
||||
Diff (изменения)
|
||||
<!-- Right area: diff preview — lazy chunked rendering -->
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>
|
||||
<span class="text-sm font-semibold text-slate-700">Diff (изменения)</span>
|
||||
</div>
|
||||
{#if hasWorkspaceChanges}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-200">
|
||||
{changedFilesCount} файлов
|
||||
{#if totalChunks > 0}
|
||||
<span class="text-blue-400">· {renderedChunkCount}/{totalChunks}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
<div class="flex-1 overflow-auto bg-white p-4" id="diff-scroll-container">
|
||||
{#if workspaceLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-4 animate-pulse rounded bg-slate-200"></div>
|
||||
<div class="space-y-3">
|
||||
{#each Array(8) as _}
|
||||
<div class="h-4 animate-pulse rounded bg-slate-100"></div>
|
||||
{/each}
|
||||
<div class="mt-6 grid grid-cols-2 gap-3">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-6 animate-pulse rounded bg-slate-50"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasWorkspaceChanges && diffHtml}
|
||||
<div class="diff-view">{@html diffHtml}</div>
|
||||
{:else if hasWorkspaceChanges && renderedHtml}
|
||||
<div class="diff-view">{@html renderedHtml}</div>
|
||||
<!-- Sentinel for IntersectionObserver — triggers next chunk load -->
|
||||
{#if hasMoreChunks}
|
||||
<div
|
||||
bind:this={sentinelEl}
|
||||
class="flex items-center justify-center py-6 text-sm text-slate-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if chunkLoading}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-500"></div>
|
||||
<span>{$t.git?.diff_loading || 'Загрузка изменений...'}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="cursor-pointer text-blue-500 hover:text-blue-700" onclick={() => {
|
||||
renderedChunkCount = Math.min(renderedChunkCount + CHUNKS_PER_PAGE, totalChunks);
|
||||
}}>
|
||||
{$t.git?.diff_show_more?.replace('{count}', totalChunks - renderedChunkCount) || `Показать ещё (${totalChunks - renderedChunkCount} файлов)`}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if totalChunks > 0}
|
||||
<div class="flex items-center justify-center py-4 text-xs text-slate-400">
|
||||
{$t.git?.diff_all_shown?.replace('{count}', totalChunks) || `Показаны все изменения (${totalChunks} файлов)`}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if hasWorkspaceChanges}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-slate-500">Загрузка diff... (повторите синхронизацию)</pre>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-slate-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-slate-200" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>
|
||||
<span>Загрузка diff... (нажмите «Синхронизировать»)</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-sm text-slate-500">
|
||||
Нет изменений для коммита
|
||||
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-slate-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-slate-200" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"/></svg>
|
||||
<span>Нет изменений для коммита</span>
|
||||
<span class="text-xs text-slate-300">Синхронизируйте дашборд, чтобы увидеть изменения</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom styles for diff2html -->
|
||||
<style>
|
||||
:global(.diff-view .d2h-wrapper) {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
:global(.diff-view .d2h-file-header) {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
:global(.diff-view .d2h-code-line) {
|
||||
padding: 0 12px;
|
||||
}
|
||||
:global(.diff-view .d2h-ins) {
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
:global(.diff-view .d2h-del) {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
:global(.diff-view .d2h-code-side-linenumber) {
|
||||
width: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
:global(.diff-view table.d2h-diff-table) {
|
||||
width: 100%;
|
||||
}
|
||||
:global(.diff-view .d2h-side-sides) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Fix: absolute-positioned line numbers must scroll with their parent container */
|
||||
:global(.diff-view .d2h-file-side-diff) {
|
||||
position: relative;
|
||||
}
|
||||
:global(.diff-view .d2h-code-wrapper) {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
<!-- #endregion GitWorkspacePanel -->
|
||||
|
||||
@@ -7,10 +7,46 @@ import { gitService } from '../../services/gitService';
|
||||
import { api } from '../../lib/api.js';
|
||||
import { isNumericDashboardRef, resolveDefaultConfig, extractUnfinishedMergeContext, buildSuggestedRepoName } from '../../services/git-utils.js';
|
||||
|
||||
// #region buildGitErrorFromException [C:2] [TYPE Function]
|
||||
// @BRIEF Extract structured gitError payload from a caught exception for modal error banner.
|
||||
// @PRE e is an Error object with optional .status, .detail, .error_code properties.
|
||||
// @POST Returns { message, status, error_code, files, next_steps, detail } for state.
|
||||
function buildGitErrorFromException(e) {
|
||||
if (!e) return { message: 'Неизвестная ошибка Git', status: 500, error_code: null, files: [], next_steps: [], detail: null };
|
||||
const detail = e?.detail || (e?.detail === 'string' ? { message: e.detail } : null) || null;
|
||||
const detailObj = typeof detail === 'object' ? detail : null;
|
||||
return {
|
||||
message: detailObj?.message || detailObj?.message_en || e.message || 'Произошла ошибка Git',
|
||||
status: e?.status || 500,
|
||||
error_code: e?.error_code || detailObj?.error_code || null,
|
||||
files: detailObj?.files || [],
|
||||
next_steps: detailObj?.next_steps || detailObj?.next_steps_en || [],
|
||||
detail: detailObj,
|
||||
};
|
||||
}
|
||||
// #endregion buildGitErrorFromException
|
||||
|
||||
export function createGitHandlers(getState, setState) {
|
||||
const s = () => getState();
|
||||
const u = (patch) => setState(patch);
|
||||
|
||||
// ── Helper: clear modal error banner ──
|
||||
function clearError() {
|
||||
u({ gitError: null, gitErrorType: 'error' });
|
||||
}
|
||||
|
||||
// ── Helper: set modal error banner + persistent toast ──
|
||||
function setGitError(e, errorType) {
|
||||
const err = buildGitErrorFromException(e);
|
||||
err.errorType = errorType || (err.status >= 500 ? 'error' : err.status === 409 ? 'warning' : 'error');
|
||||
u({ gitError: err, gitErrorType: err.errorType });
|
||||
// Show persistent toast above the modal (duration=0 = no auto-dismiss)
|
||||
if (s().toast) {
|
||||
const type = err.errorType === 'warning' ? 'warning' : 'error';
|
||||
s().toast(err.message, type, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Use const reference instead of `this` to avoid context loss in callbacks ──
|
||||
const handlers = {
|
||||
|
||||
@@ -69,6 +105,7 @@ export function createGitHandlers(getState, setState) {
|
||||
// @SIDE_EFFECT Fetches /status and /diff for the dashboard repository.
|
||||
async loadWorkspace() {
|
||||
if (!s().initialized) return;
|
||||
clearError();
|
||||
u({ workspaceLoading: true });
|
||||
try {
|
||||
const ws = await gitService.getStatus(s().dashboardId, s().envId);
|
||||
@@ -76,7 +113,7 @@ export function createGitHandlers(getState, setState) {
|
||||
const ud = await gitService.getDiff(s().dashboardId, null, false, s().envId);
|
||||
u({ workspaceStatus: ws, workspaceDiff: [sd, ud].filter(Boolean).join('\n\n'), currentBranch: ws?.current_branch || s().currentBranch });
|
||||
} catch (e) {
|
||||
s().toast(e.message || 'Не удалось загрузить изменения', 'error');
|
||||
setGitError(e);
|
||||
} finally { u({ workspaceLoading: false }); }
|
||||
},
|
||||
// #endregion loadWorkspace
|
||||
@@ -88,13 +125,16 @@ export function createGitHandlers(getState, setState) {
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/sync; reloads workspace on success.
|
||||
async handleSync() {
|
||||
if (isNumericDashboardRef(s().dashboardId)) { s().toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); return; }
|
||||
clearError();
|
||||
u({ loading: true });
|
||||
try {
|
||||
const sourceEnvId = s().envId || localStorage.getItem('selected_env_id');
|
||||
await gitService.sync(s().dashboardId, sourceEnvId, s().envId);
|
||||
s().toast(s().t?.git?.sync_success || 'Состояние дашборда синхронизировано с Git', 'success');
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ loading: false }); }
|
||||
},
|
||||
// #endregion handleSync
|
||||
|
||||
@@ -103,12 +143,15 @@ export function createGitHandlers(getState, setState) {
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/generate-message.
|
||||
// @POST commitMessage state updated with generated text.
|
||||
async handleGenerateMessage() {
|
||||
clearError();
|
||||
u({ generatingMessage: true });
|
||||
try {
|
||||
const data = await api.postApi(`/git/repositories/${encodeURIComponent(String(s().dashboardId))}/generate-message${s().envId ? `?env_id=${encodeURIComponent(String(s().envId))}` : ''}`, undefined, { suppressToast: true });
|
||||
u({ commitMessage: data?.message || '' });
|
||||
s().toast(s().t?.git?.commit_message_generated || 'Сообщение для коммита сгенерировано', 'success');
|
||||
} catch (e) { s().toast(e.message || s().t?.git?.commit_message_failed || 'Не удалось сгенерировать сообщение', 'error'); } finally { u({ generatingMessage: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ generatingMessage: false }); }
|
||||
},
|
||||
// #endregion handleGenerateMessage
|
||||
|
||||
@@ -119,6 +162,7 @@ export function createGitHandlers(getState, setState) {
|
||||
// @SIDE_EFFECT POSTs to /commit and optionally /push.
|
||||
async handleCommit() {
|
||||
if (!s().commitMessage || !s().hasWorkspaceChanges) return;
|
||||
clearError();
|
||||
u({ committing: true });
|
||||
try {
|
||||
await gitService.commit(s().dashboardId, s().commitMessage, [], s().envId);
|
||||
@@ -128,7 +172,9 @@ export function createGitHandlers(getState, setState) {
|
||||
} else { s().toast(s().t?.git?.commit_success || 'Коммит успешно создан', 'success'); }
|
||||
u({ commitMessage: '' });
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ committing: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ committing: false }); }
|
||||
},
|
||||
// #endregion handleCommit
|
||||
|
||||
@@ -150,6 +196,7 @@ export function createGitHandlers(getState, setState) {
|
||||
// @POST unfinishedMergeContext is populated; dialog closed if no merge in progress.
|
||||
// @SIDE_EFFECT GETs /merge/status from backend.
|
||||
async loadMergeRecoveryState() {
|
||||
clearError();
|
||||
u({ mergeRecoveryLoading: true });
|
||||
try {
|
||||
const status = await gitService.getMergeStatus(s().dashboardId, s().envId);
|
||||
@@ -168,7 +215,9 @@ export function createGitHandlers(getState, setState) {
|
||||
conflictsCount: Number(status.conflicts_count || 0),
|
||||
}
|
||||
});
|
||||
} catch (e) { s().toast(e.message || 'Failed to load merge status', 'error'); } finally { u({ mergeRecoveryLoading: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ mergeRecoveryLoading: false }); }
|
||||
},
|
||||
// #endregion loadMergeRecoveryState
|
||||
|
||||
@@ -186,12 +235,15 @@ export function createGitHandlers(getState, setState) {
|
||||
// @POST mergeConflicts populated; showConflictResolver set to true if conflicts found.
|
||||
// @SIDE_EFFECT GETs /merge/conflicts from backend.
|
||||
async handleOpenConflictResolver() {
|
||||
clearError();
|
||||
u({ mergeRecoveryLoading: true });
|
||||
try {
|
||||
const mc = await gitService.getMergeConflicts(s().dashboardId, s().envId);
|
||||
if (!Array.isArray(mc) || mc.length === 0) { s().toast(s().t?.git?.unfinished_merge?.no_conflicts || 'No unresolved conflicts were found', 'info'); return; }
|
||||
u({ mergeConflicts: mc, showConflictResolver: true });
|
||||
} catch (e) { s().toast(e.message || 'Failed to load merge conflicts', 'error'); } finally { u({ mergeRecoveryLoading: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ mergeRecoveryLoading: false }); }
|
||||
},
|
||||
// #endregion handleOpenConflictResolver
|
||||
|
||||
@@ -203,6 +255,7 @@ export function createGitHandlers(getState, setState) {
|
||||
const detail = event?.detail || {};
|
||||
const resolutions = Object.entries(detail).map(([fp, r]) => ({ file_path: fp, resolution: r }));
|
||||
if (!resolutions.length) { s().toast(s().t?.git?.unfinished_merge?.resolve_empty || 'No conflict resolutions selected', 'warning'); return; }
|
||||
clearError();
|
||||
u({ mergeResolveInProgress: true });
|
||||
try {
|
||||
await gitService.resolveMergeConflicts(s().dashboardId, resolutions, s().envId);
|
||||
@@ -210,7 +263,9 @@ export function createGitHandlers(getState, setState) {
|
||||
u({ showConflictResolver: false });
|
||||
await handlers.loadMergeRecoveryState();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message || 'Failed to resolve conflicts', 'error'); } finally { u({ mergeResolveInProgress: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ mergeResolveInProgress: false }); }
|
||||
},
|
||||
// #endregion handleResolveConflicts
|
||||
|
||||
@@ -219,13 +274,16 @@ export function createGitHandlers(getState, setState) {
|
||||
// @PRE envId required when ref is slug.
|
||||
// @POST Merge aborted; dialog closed; workspace reloaded.
|
||||
async handleAbortUnfinishedMerge() {
|
||||
clearError();
|
||||
u({ mergeAbortInProgress: true });
|
||||
try {
|
||||
await gitService.abortMerge(s().dashboardId, s().envId);
|
||||
s().toast(s().t?.git?.unfinished_merge?.abort_success || 'Merge was aborted', 'success');
|
||||
handlers.closeUnfinishedMergeDialog();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message || 'Failed to abort merge', 'error'); } finally { u({ mergeAbortInProgress: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ mergeAbortInProgress: false }); }
|
||||
},
|
||||
// #endregion handleAbortUnfinishedMerge
|
||||
|
||||
@@ -234,6 +292,7 @@ export function createGitHandlers(getState, setState) {
|
||||
// @PRE All conflicts resolved; envId required when ref is slug.
|
||||
// @POST Merge commit created; dialog closed; workspace reloaded.
|
||||
async handleContinueUnfinishedMerge() {
|
||||
clearError();
|
||||
u({ mergeContinueInProgress: true });
|
||||
try {
|
||||
await gitService.continueMerge(s().dashboardId, '', s().envId);
|
||||
@@ -241,7 +300,7 @@ export function createGitHandlers(getState, setState) {
|
||||
handlers.closeUnfinishedMergeDialog();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) {
|
||||
s().toast(e.message || 'Failed to continue merge', 'error');
|
||||
setGitError(e);
|
||||
await handlers.loadMergeRecoveryState();
|
||||
} finally { u({ mergeContinueInProgress: false }); }
|
||||
},
|
||||
@@ -277,6 +336,7 @@ export function createGitHandlers(getState, setState) {
|
||||
// @POST Local repository updated with remote changes; workspace reloaded.
|
||||
// @SIDE_EFFECT POSTs to /pull; may open merge dialog on GIT_UNFINISHED_MERGE.
|
||||
async handlePull() {
|
||||
clearError();
|
||||
u({ isPulling: true });
|
||||
try {
|
||||
await gitService.pull(s().dashboardId, s().envId);
|
||||
@@ -284,8 +344,11 @@ export function createGitHandlers(getState, setState) {
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) {
|
||||
const handled = handlers.openUnfinishedMergeDialogFromError(e);
|
||||
if (handled) await handlers.loadMergeRecoveryState();
|
||||
else s().toast(e.message, 'error');
|
||||
if (handled) {
|
||||
await handlers.loadMergeRecoveryState();
|
||||
} else {
|
||||
setGitError(e);
|
||||
}
|
||||
} finally { u({ isPulling: false }); }
|
||||
},
|
||||
// #endregion handlePull
|
||||
@@ -295,12 +358,15 @@ export function createGitHandlers(getState, setState) {
|
||||
// @PRE Remote 'origin' configured; envId required when ref is slug.
|
||||
// @POST Remote updated with local commits; workspace reloaded.
|
||||
async handlePush() {
|
||||
clearError();
|
||||
u({ isPushing: true });
|
||||
try {
|
||||
await gitService.push(s().dashboardId, s().envId);
|
||||
s().toast(s().t?.git?.push_success || 'Изменения отправлены в Git', 'success');
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ isPushing: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ isPushing: false }); }
|
||||
},
|
||||
// #endregion handlePush
|
||||
|
||||
@@ -313,6 +379,7 @@ export function createGitHandlers(getState, setState) {
|
||||
const st = s();
|
||||
if (!st.promoteFromBranch || !st.promoteToBranch || st.promoteFromBranch === st.promoteToBranch) { st.toast('Выберите разные исходную и целевую ветки', 'error'); return; }
|
||||
if (st.promoteMode === 'direct' && !String(st.promoteReason || '').trim()) { st.toast('Для небезопасного прямого переноса укажите причину', 'error'); return; }
|
||||
clearError();
|
||||
u({ promoting: true });
|
||||
try {
|
||||
const response = await gitService.promote(st.dashboardId, {
|
||||
@@ -323,7 +390,9 @@ export function createGitHandlers(getState, setState) {
|
||||
}, st.envId);
|
||||
if (st.promoteMode === 'direct') { st.toast('Прямой перенос выполнен. Нарушение политики записано в логи.', 'warning'); }
|
||||
else { if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer'); st.toast('Merge Request создан на Git сервере', 'success'); }
|
||||
} catch (e) { st.toast(e.message, 'error'); } finally { u({ promoting: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ promoting: false }); }
|
||||
},
|
||||
// #endregion handlePromote
|
||||
|
||||
@@ -355,6 +424,7 @@ export function createGitHandlers(getState, setState) {
|
||||
const inputName = prompt(`Repository name for ${config.provider}:`, suggestedName);
|
||||
const repoName = String(inputName || '').trim();
|
||||
if (!repoName) return;
|
||||
clearError();
|
||||
u({ creatingRemoteRepo: true });
|
||||
try {
|
||||
const repo = await gitService.createRemoteRepository(config.id, {
|
||||
@@ -368,8 +438,10 @@ export function createGitHandlers(getState, setState) {
|
||||
s().toast(`Repository created on ${config.provider}`, 'success');
|
||||
} catch (e) {
|
||||
if (e?.status === 409 && /already exists/i.test(String(e?.message || ''))) {
|
||||
s().toast(s().t?.git?.repo_already_exists || 'Repository already exists. Enter its URL below and click Init.', 'warning');
|
||||
} else { s().toast(e.message, 'error'); }
|
||||
s().toast(s().t?.git?.repo_already_exists || 'Repository already exists. Enter its URL below and click Init.', 'warning', 0);
|
||||
} else {
|
||||
setGitError(e, 'warning');
|
||||
}
|
||||
} finally { u({ creatingRemoteRepo: false }); }
|
||||
},
|
||||
// #endregion handleCreateRemoteRepo
|
||||
@@ -386,6 +458,7 @@ export function createGitHandlers(getState, setState) {
|
||||
s().toast('Environment must be selected to initialize Git for this dashboard.', 'error');
|
||||
return;
|
||||
}
|
||||
clearError();
|
||||
u({ loading: true });
|
||||
try {
|
||||
await gitService.initRepository(s().dashboardId, s().selectedConfigId, s().remoteUrl, s().envId);
|
||||
@@ -393,7 +466,9 @@ export function createGitHandlers(getState, setState) {
|
||||
const provider = resolveDefaultConfig(s().configs, s().selectedConfigId)?.provider || '';
|
||||
u({ initialized: true, repositoryProvider: provider });
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); }
|
||||
} catch (e) {
|
||||
setGitError(e);
|
||||
} finally { u({ loading: false }); }
|
||||
},
|
||||
// #endregion handleInit
|
||||
};
|
||||
|
||||
@@ -10,13 +10,17 @@
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from 'svelte';
|
||||
import { fromStore } from 'svelte/store';
|
||||
import { api } from '../../lib/api.js';
|
||||
import { runTask, getTaskStatus } from '../../services/toolsService.js';
|
||||
import { selectedTask } from '../../lib/stores.js';
|
||||
import { addToast } from '../../lib/toasts.js';
|
||||
import { t } from '../../lib/i18n';
|
||||
import { environmentContextStore } from '$lib/stores/environmentContext.js';
|
||||
// [/SECTION]
|
||||
|
||||
const envContextState = fromStore(environmentContextStore);
|
||||
|
||||
let envs = [];
|
||||
let action = 'test-db-api';
|
||||
let selectedEnv = '';
|
||||
@@ -37,6 +41,12 @@
|
||||
async function fetchEnvironments() {
|
||||
try {
|
||||
envs = await api.getEnvironmentsList();
|
||||
// Pre-fill source and single env with active environment, leave target empty
|
||||
const activeEnvId = envContextState.current?.selectedEnvId;
|
||||
if (activeEnvId && envs.some((e) => e.id === activeEnvId)) {
|
||||
sourceEnv = activeEnvId;
|
||||
selectedEnv = activeEnvId;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast($t.debug?.fetch_env_failed, 'error');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<!-- #region MapperTool [C:3] [TYPE Component] [SEMANTICS mapper, dataset, column, mapping, sqllab] -->
|
||||
<!-- @BRIEF Component component: components/tools/MapperTool.svelte -->
|
||||
<!-- @BRIEF UI component for mapping dataset column verbose names using Superset SQL Lab or Excel files. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @UX_STATE Idle -> Form fields visible, action buttons enabled. -->
|
||||
<!-- @UX_STATE Loading -> Action buttons disabled, spinner active on clicked button. -->
|
||||
<!-- @UX_STATE Error -> Validation errors shown inline, toast notifications displayed. -->
|
||||
<!-- @UX_FEEDBACK Toast notifications on success/error. -->
|
||||
<!-- @UX_RECOVERY Fix validation errors, retry operation. -->
|
||||
<!--
|
||||
@SEMANTICS: mapper, tool, dataset, sqllab, excel
|
||||
@PURPOSE: UI component for mapping dataset column verbose names using Superset SQL Lab or Excel files.
|
||||
@@ -15,7 +20,7 @@
|
||||
import { selectedTask } from '../../lib/stores.js';
|
||||
import { addToast } from '../../lib/toasts.js';
|
||||
import { t } from '../../lib/i18n';
|
||||
import { Button, Card, Select, Input } from '../../lib/ui';
|
||||
import { Button, Card, Select, Input, Icon } from '../../lib/ui';
|
||||
import DocPreview from '../llm/DocPreview.svelte';
|
||||
// [/SECTION]
|
||||
|
||||
@@ -37,6 +42,13 @@
|
||||
async function fetchData() {
|
||||
try {
|
||||
envs = await api.fetchApi('/environments');
|
||||
// Pre-fill with active environment from global context
|
||||
const { get } = await import('svelte/store');
|
||||
const envCtx = await import('$lib/stores/environmentContext.js');
|
||||
const state = get(envCtx.environmentContextStore);
|
||||
if (state.selectedEnvId && envs.some((e) => e.id === state.selectedEnvId)) {
|
||||
selectedEnv = state.selectedEnvId;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast($t.mapper.errors.fetch_failed, 'error');
|
||||
}
|
||||
@@ -137,12 +149,32 @@
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="space-y-6">
|
||||
<Card title={$t.mapper.title}>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Main Configuration Card -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div class="px-6 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-100">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-100 text-blue-600">
|
||||
<Icon name="database" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">{$t.mapper.title}</h2>
|
||||
<p class="text-sm text-gray-500">Настройте параметры маппинга колонок</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Body -->
|
||||
<div class="p-6">
|
||||
<!-- Environment & Dataset ID Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Icon name="settings" size={16} className="text-gray-400" />
|
||||
{$t.mapper.environment}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
label={$t.mapper.environment}
|
||||
bind:value={selectedEnv}
|
||||
options={[
|
||||
{ value: '', label: $t.mapper.select_env },
|
||||
@@ -150,82 +182,166 @@
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Icon name="clipboard" size={16} className="text-gray-400" />
|
||||
{$t.mapper.dataset_id}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
label={$t.mapper.dataset_id}
|
||||
type="number"
|
||||
bind:value={datasetId}
|
||||
placeholder="Введите ID датасета..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="block text-sm font-medium text-gray-700 mb-2">{$t.mapper.source}</legend>
|
||||
<div class="flex space-x-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="radio" bind:group={source} value="sqllab" class="focus-visible:ring-primary-ring h-4 w-4 text-primary border-gray-300" />
|
||||
<span class="ml-2 text-sm text-gray-700">{$t.mapper.source_sqllab}</span>
|
||||
<!-- Source Selection -->
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 mb-3">
|
||||
<Icon name="layers" size={16} className="text-gray-400" />
|
||||
{$t.mapper.source}
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex-1 relative cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
bind:group={source}
|
||||
value="sqllab"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<div class="flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 bg-gray-50
|
||||
hover:border-blue-300 hover:bg-blue-50
|
||||
peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:ring-2 peer-checked:ring-blue-500/20
|
||||
transition-all duration-200">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-100 text-blue-600 peer-checked:bg-blue-600 peer-checked:text-white">
|
||||
<Icon name="database" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{$t.mapper.source_sqllab}</div>
|
||||
<div class="text-xs text-gray-500">Подключение через SQL Lab</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input type="radio" bind:group={source} value="excel" class="focus-visible:ring-primary-ring h-4 w-4 text-primary border-gray-300" />
|
||||
<span class="ml-2 text-sm text-gray-700">{$t.mapper.source_excel}</span>
|
||||
<label class="flex-1 relative cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
bind:group={source}
|
||||
value="excel"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<div class="flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 bg-gray-50
|
||||
hover:border-green-300 hover:bg-green-50
|
||||
peer-checked:border-green-500 peer-checked:bg-green-50 peer-checked:ring-2 peer-checked:ring-green-500/20
|
||||
transition-all duration-200">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-green-100 text-green-600 peer-checked:bg-green-600 peer-checked:text-white">
|
||||
<Icon name="clipboard" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{$t.mapper.source_excel}</div>
|
||||
<div class="text-xs text-gray-500">Импорт из Excel файла</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{#if source === 'sqllab'}
|
||||
<div class="space-y-4 p-4 bg-gray-50 rounded-md border border-gray-100">
|
||||
<div>
|
||||
<Input
|
||||
label={$t.mapper.database_id}
|
||||
type="number"
|
||||
bind:value={databaseId}
|
||||
placeholder={$t.mapper.database_id_placeholder}
|
||||
/>
|
||||
<!-- Source-specific Configuration -->
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50 p-5 mb-6">
|
||||
{#if source === 'sqllab'}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Icon name="database" size={18} className="text-blue-600" />
|
||||
<h3 class="text-sm font-semibold text-gray-900">Настройки SQL Lab</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">
|
||||
{$t.mapper.database_id}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={databaseId}
|
||||
placeholder={$t.mapper.database_id_placeholder}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">
|
||||
{$t.mapper.sql_query_label}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={sqlQuery}
|
||||
placeholder={$t.mapper.sql_query_placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label={$t.mapper.sql_query_label}
|
||||
type="text"
|
||||
bind:value={sqlQuery}
|
||||
placeholder={$t.mapper.sql_query_placeholder}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Icon name="clipboard" size={18} className="text-green-600" />
|
||||
<h3 class="text-sm font-semibold text-gray-900">Настройки Excel</h3>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">
|
||||
{$t.mapper.excel_path}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={excelPath}
|
||||
placeholder={$t.mapper?.excel_placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 bg-gray-50 rounded-md border border-gray-100">
|
||||
<Input
|
||||
label={$t.mapper.excel_path}
|
||||
type="text"
|
||||
bind:value={excelPath}
|
||||
placeholder={$t.mapper?.excel_placeholder}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2 space-x-3">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={handleGenerateDocs}
|
||||
disabled={isGeneratingDocs || isRunning}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{#if isGeneratingDocs}
|
||||
<span class="animate-spin mr-1">↻</span> {$t.mapper?.generating}
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{$t.mapper?.generating}
|
||||
{:else}
|
||||
<span class="mr-1">✨</span> {$t.datasets?.generate_docs}
|
||||
<Icon name="layers" size={16} />
|
||||
{$t.datasets?.generate_docs}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleRunMapper}
|
||||
disabled={isRunning}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{isRunning ? $t.mapper.starting : $t.mapper.run}
|
||||
{#if isRunning}
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{$t.mapper.starting}
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
{$t.mapper.run}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Documentation Preview -->
|
||||
<DocPreview
|
||||
documentation={generatedDoc}
|
||||
onCancel={() => generatedDoc = null}
|
||||
@@ -233,4 +349,4 @@
|
||||
/>
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
<!-- #endregion MapperTool -->
|
||||
<!-- #endregion MapperTool -->
|
||||
|
||||
Reference in New Issue
Block a user