This commit is contained in:
2026-05-26 18:15:37 +03:00
parent 0b6f61623e
commit ee08e2f3dd
15 changed files with 13702 additions and 7702 deletions

View File

@@ -343,6 +343,7 @@
{#if showGitManager && gitDashboardId}
<GitManager
dashboardId={gitDashboardId}
envId={environmentId}
dashboardTitle={gitDashboardTitle}
bind:show={showGitManager}
/>

View File

@@ -1,58 +1,44 @@
<!-- #region BranchSelector [C:3] [TYPE Component] [SEMANTICS git, branch, checkout, create, selector] -->
<!-- @BRIEF Component component: components/git/BranchSelector.svelte -->
<!-- @BRIEF UI for selecting and creating Git branches for a dashboard repository. -->
<!-- @LAYER UI -->
<!--
@SEMANTICS: git, branch, selection, checkout
@PURPOSE: UI для выбора и создания веток Git.
@LAYER Component
@RELATION CALLS -> [gitService.getBranches]
@RELATION CALLS -> [GitServiceBranch]
@RELATION CALLS -> [gitService.createBranch]
@RELATION BINDS_TO -> [onchange]
-->
<!-- @RELATION CALLS -> [EXT:frontend:gitService.getBranches] -->
<!-- @RELATION CALLS -> [EXT:frontend:gitService.checkoutBranch] -->
<!-- @RELATION CALLS -> [EXT:frontend:gitService.createBranch] -->
<!-- @RELATION BINDS_TO -> [onchange] -->
<!-- @PRE dashboardId is a valid dashboard slug; envId required when ref is slug. -->
<!-- @UX_STATE Loading -> Select disabled, spinner. -->
<!-- @UX_STATE Loaded -> Branch dropdown enabled. -->
<!-- @UX_STATE CreateMode -> Inline input for new branch name. -->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button, Select, Input } from '../../lib/ui';
// [/SECTION]
// [SECTION: PROPS]
let {
dashboardId,
envId = null,
currentBranch = $bindable('main'),
onchange = () => {},
} = $props();
// #region props [C:2] [TYPE Block]
// @PRE dashboardId is non-empty slug; envId resolved via GitManager fallback chain.
let {
dashboardId,
envId = null,
currentBranch = $bindable('main'),
onchange = () => {},
} = $props();
// #endregion props
// [/SECTION]
// [SECTION: STATE]
// #region state [C:1] [TYPE Block]
let branches = $state([]);
let loading = $state(false);
let showCreate = $state(false);
let newBranchName = $state('');
// [/SECTION]
// #region onMount:Function [TYPE Function]
/**
* @purpose Load branches when component is mounted.
* @pre Component is initialized.
* @post loadBranches is called.
*/
onMount(async () => {
await loadBranches();
});
// #endregion onMount:Function
// #endregion state
// #region loadBranches:Function [TYPE Function]
/**
* @purpose Загружает список веток для дашборда.
* @pre dashboardId is provided.
* @post branches обновлен.
*/
// #region loadBranches [C:2] [TYPE Function]
// @BRIEF Load branch list from backend.
// @PRE dashboardId is non-empty; envId required when ref is slug.
// @POST branches array populated with BranchSchema objects.
// @SIDE_EFFECT GETs /git/repositories/{ref}/branches.
async function loadBranches() {
console.log(`[EXT:frontend:BranchSelector][Action] Loading branches for dashboard ${dashboardId}`);
loading = true;
@@ -66,25 +52,22 @@
loading = false;
}
}
// #endregion loadBranches:Function
// #endregion loadBranches
// #region handleSelect:Function [TYPE Function]
/**
* @purpose Handles branch selection from dropdown.
* @pre event contains branch name.
* @post handleCheckout is called with selected branch.
*/
// #region handleSelect [C:1] [TYPE Function]
// @BRIEF Handle branch selection from dropdown.
// @PRE event contains target.value with branch name.
// @POST handleCheckout called with selected branch name.
function handleSelect(event) {
handleCheckout(event.target.value);
}
// #endregion handleSelect:Function
// #endregion handleSelect
// #region handleCheckout:Function [TYPE Function]
/**
* @purpose Переключает текущую ветку.
* @param {string} branchName - Имя ветки.
* @post currentBranch обновлен, родительский callback вызван.
*/
// #region handleCheckout [C:2] [TYPE Function]
// @BRIEF Switch repository to selected branch.
// @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.
async function handleCheckout(branchName) {
console.log(`[EXT:frontend:BranchSelector][Action] Checking out branch ${branchName}`);
try {
@@ -98,14 +81,13 @@
toast(e.message, 'error');
}
}
// #endregion handleCheckout:Function
// #endregion handleCheckout
// #region handleCreate:Function [TYPE Function]
/**
* @purpose Создает новую ветку.
* @pre newBranchName is not empty.
* @post Новая ветка создана и загружена; showCreate reset.
*/
// #region handleCreate [C:2] [TYPE Function]
// @BRIEF Create a new branch from current branch.
// @PRE newBranchName is non-empty; envId required when ref is slug.
// @POST New branch created; branch list reloaded; create mode closed.
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/branches.
async function handleCreate() {
if (!newBranchName) return;
console.log(`[EXT:frontend:BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`);
@@ -121,7 +103,11 @@
toast(e.message, 'error');
}
}
// #endregion handleCreate:Function
// #endregion handleCreate
onMount(async () => {
await loadBranches();
});
</script>
<!-- [SECTION: TEMPLATE] -->

View File

@@ -92,10 +92,13 @@
const configHost = $derived(extractHttpHost(repositoryConfigUrl));
const hasOriginConfigMismatch = $derived(originHost && configHost && originHost !== configHost);
// ── Resolve envId with localStorage fallback ──────────────
let resolvedEnvId = $derived(resolveCurrentEnvironmentId(envId));
// ── State proxy for composable ────────────────────────────
function getState() {
return {
dashboardId, envId, dashboardTitle, toast, t,
dashboardId, envId: resolvedEnvId, dashboardTitle, toast, t,
currentBranch, showDeployModal, loading, initialized, checkingStatus,
configs, selectedConfigId, remoteUrl, creatingRemoteRepo,
activeTab, showAdvancedPromote, promoting, promoteFromBranch, promoteToBranch, promoteMode, promoteReason,
@@ -170,7 +173,7 @@
await Promise.all([h.checkStatus(), h.loadCurrentEnvironmentStage()]);
if (initialized) {
try {
const binding = await gitService.getRepositoryBinding(dashboardId, envId);
const binding = await gitService.getRepositoryBinding(dashboardId, resolvedEnvId);
repositoryProvider = binding?.provider || '';
repositoryBindingRemoteUrl = binding?.remote_url || '';
if (binding?.config_id) selectedConfigId = String(binding.config_id);
@@ -203,7 +206,7 @@
<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>
<div class="w-full max-w-sm"><BranchSelector {dashboardId} {envId} bind:currentBranch /></div>
<div class="w-full max-w-sm"><BranchSelector {dashboardId} envId={resolvedEnvId} bind:currentBranch /></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>
@@ -211,7 +214,7 @@
<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>
{#if activeTab === 'workspace'}
<GitWorkspacePanel {hasWorkspaceChanges} {changedFilesCount} {workspaceLoading} {committing} {generatingMessage} bind:commitMessage bind:autoPushAfterCommit {loading} {pushProviderLabel} onSync={h.handleSync} onGenerateMessage={h.handleGenerateMessage} onCommit={h.handleCommit} />
<GitWorkspacePanel {hasWorkspaceChanges} {changedFilesCount} {workspaceLoading} {workspaceDiff} {committing} {generatingMessage} bind:commitMessage bind:autoPushAfterCommit {loading} {pushProviderLabel} onSync={h.handleSync} onGenerateMessage={h.handleGenerateMessage} onCommit={h.handleCommit} />
{:else if activeTab === 'release'}
<GitReleasePanel {currentEnvStage} bind:promoteFromBranch bind:promoteToBranch bind:promoteMode bind:promoteReason {preferredDeployTargetStage} bind:showAdvancedPromote {promoting} onPromote={h.handlePromote} />
{:else}
@@ -224,5 +227,5 @@
</div>
{/if}
<ConflictResolver conflicts={mergeConflicts} bind:show={showConflictResolver} onresolve={h.handleResolveConflicts} />
<DeploymentModal {dashboardId} {envId} preferredTargetStage={preferredDeployTargetStage} bind:show={showDeployModal} />
<DeploymentModal {dashboardId} envId={resolvedEnvId} preferredTargetStage={preferredDeployTargetStage} bind:show={showDeployModal} />
<!-- #endregion GitManager -->

View File

@@ -3,17 +3,22 @@
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [GitUtils] -->
<!-- @RELATION CALLS -> [EXT:frontend:gitService] -->
<!-- @UX_STATE Idle -> Commit form with diff viewer. -->
<!-- @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 Error -> Toast on failure. -->
<script>
import { t } from "$lib/i18n";
import { Button } from "$lib/ui";
import * as Diff2Html from 'diff2html';
import 'diff2html/bundles/css/diff2html.min.css';
let {
hasWorkspaceChanges,
changedFilesCount,
workspaceLoading,
workspaceDiff = '',
committing,
generatingMessage,
commitMessage = $bindable(),
@@ -24,6 +29,16 @@
onGenerateMessage,
onCommit,
} = $props();
// ── Render diff as HTML via diff2html ──
let diffHtml = $derived(workspaceDiff
? Diff2Html.html(workspaceDiff, {
outputFormat: 'side-by-side',
drawFileList: true,
matching: 'lines',
highlight: true,
})
: '');
</script>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
@@ -85,6 +100,10 @@
<div class="h-4 animate-pulse rounded bg-slate-200"></div>
{/each}
</div>
{:else if hasWorkspaceChanges && diffHtml}
<div class="diff-view">{@html diffHtml}</div>
{:else if hasWorkspaceChanges}
<pre class="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-slate-500">Загрузка diff... (повторите синхронизацию)</pre>
{:else}
<div class="flex h-full items-center justify-center text-sm text-slate-500">
Нет изменений для коммита

View File

@@ -20,8 +20,8 @@ describe('GitManager unfinished merge dialog contract', () => {
expect(utils).toContain("error_code !== 'GIT_UNFINISHED_MERGE'");
expect(handlers).toContain('showUnfinishedMergeDialog: true');
expect(handlers).toContain('this.openUnfinishedMergeDialogFromError(e)');
expect(handlers).toContain('await this.loadMergeRecoveryState();');
expect(handlers).toContain('handlers.openUnfinishedMergeDialogFromError(e)');
expect(handlers).toContain('await handlers.loadMergeRecoveryState();');
expect(manager).toContain('h.handlePull');
expect(manager).toContain('h.loadMergeRecoveryState');
});

View File

@@ -11,7 +11,14 @@ export function createGitHandlers(getState, setState) {
const s = () => getState();
const u = (patch) => setState(patch);
return {
// ── Use const reference instead of `this` to avoid context loss in callbacks ──
const handlers = {
// #region loadCurrentEnvironmentStage [C:2] [TYPE Function]
// @BRIEF Resolve current environment stage (DEV/PREPROD/PROD) and apply GitFlow defaults.
// @PRE resolveEnvId() returns a valid environment ID, or handler exits silently.
// @POST currentEnvStage and GitFlow defaults (promoteFromBranch, promoteToBranch) are set.
// @SIDE_EFFECT Fetches /environments from API.
async loadCurrentEnvironmentStage() {
const currentEnvId = s().resolveEnvId();
if (!currentEnvId) return;
@@ -27,7 +34,13 @@ export function createGitHandlers(getState, setState) {
console.error(`[GitManager][Coherence:Failed] Failed to resolve environment stage: ${e.message}`);
}
},
// #endregion loadCurrentEnvironmentStage
// #region checkStatus [C:3] [TYPE Function]
// @BRIEF Check if Git repository is initialized for the dashboard.
// @PRE dashboardId is a non-numeric slug; envId is required (resolved with localStorage fallback).
// @POST initialized=true if repository found and workspace loaded; initialized=false if not found or envId missing.
// @SIDE_EFFECT Fetches /git/repositories/{ref}/branches; may fetch /git/repositories/{ref}/status and /diff on success.
async checkStatus() {
if (isNumericDashboardRef(s().dashboardId)) {
u({ checkingStatus: false, initialized: false });
@@ -35,18 +48,25 @@ export function createGitHandlers(getState, setState) {
return;
}
u({ checkingStatus: true });
if (!s().envId) {
u({ initialized: false, checkingStatus: false });
return;
}
try {
await gitService.getBranches(s().dashboardId, s().envId);
u({ initialized: true });
await this.loadWorkspace();
await handlers.loadWorkspace();
} catch (_e) {
u({ initialized: false });
try { const configs = await gitService.getConfigs(); u({ configs }); } catch (_e2) { u({ configs: [] }); }
const defaultConfig = resolveDefaultConfig(s().configs, s().selectedConfigId);
if (defaultConfig?.id) u({ selectedConfigId: defaultConfig.id });
} finally { u({ checkingStatus: false }); }
},
// #endregion checkStatus
// #region loadWorkspace [C:2] [TYPE Function]
// @BRIEF Load Git workspace status and diff for initialized repository.
// @PRE initialized is true; envId is required when ref is slug.
// @POST workspaceStatus and workspaceDiff are updated; currentBranch synced from server.
// @SIDE_EFFECT Fetches /status and /diff for the dashboard repository.
async loadWorkspace() {
if (!s().initialized) return;
u({ workspaceLoading: true });
@@ -59,18 +79,29 @@ export function createGitHandlers(getState, setState) {
s().toast(e.message || 'Не удалось загрузить изменения', 'error');
} finally { u({ workspaceLoading: false }); }
},
// #endregion loadWorkspace
// #region handleSync [C:2] [TYPE Function]
// @BRIEF Synchronize local dashboard state with Git repository.
// @PRE dashboardId is a non-numeric slug; envId required when ref is slug.
// @POST Dashboard state synced to Git workspace; workspace reloaded.
// @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; }
u({ loading: true });
try {
const sourceEnvId = localStorage.getItem('selected_env_id');
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 this.loadWorkspace();
await handlers.loadWorkspace();
} catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); }
},
// #endregion handleSync
// #region handleGenerateMessage [C:2] [TYPE Function]
// @BRIEF Generate AI commit message from dashboard diff.
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/generate-message.
// @POST commitMessage state updated with generated text.
async handleGenerateMessage() {
u({ generatingMessage: true });
try {
@@ -79,7 +110,13 @@ export function createGitHandlers(getState, setState) {
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 }); }
},
// #endregion handleGenerateMessage
// #region handleCommit [C:2] [TYPE Function]
// @BRIEF Stage and commit workspace changes, optionally push.
// @PRE commitMessage is non-empty; hasWorkspaceChanges is true; envId required when ref is slug.
// @POST Changes committed (and pushed if autoPushAfterCommit); workspace reloaded.
// @SIDE_EFFECT POSTs to /commit and optionally /push.
async handleCommit() {
if (!s().commitMessage || !s().hasWorkspaceChanges) return;
u({ committing: true });
@@ -90,22 +127,33 @@ export function createGitHandlers(getState, setState) {
s().toast(s().t?.git?.commit_and_push_success || 'Коммит создан и отправлен в remote', 'success');
} else { s().toast(s().t?.git?.commit_success || 'Коммит успешно создан', 'success'); }
u({ commitMessage: '' });
await this.loadWorkspace();
await handlers.loadWorkspace();
} catch (e) { s().toast(e.message, 'error'); } finally { u({ committing: false }); }
},
// #endregion handleCommit
// #region openUnfinishedMergeDialogFromError [C:2] [TYPE Function]
// @BRIEF Parse error for unfinished merge context and open dialog.
// @PRE error contains GIT_UNFINISHED_MERGE error_code or related detail.
// @POST Returns true and opens dialog if merge context found; false otherwise.
openUnfinishedMergeDialogFromError(error) {
const context = extractUnfinishedMergeContext(error);
if (!context) return false;
u({ unfinishedMergeContext: context, showUnfinishedMergeDialog: true });
return true;
},
// #endregion openUnfinishedMergeDialogFromError
// #region loadMergeRecoveryState [C:2] [TYPE Function]
// @BRIEF Load merge recovery state from backend for unfinished merge.
// @PRE Repository exists; envId required when ref is slug.
// @POST unfinishedMergeContext is populated; dialog closed if no merge in progress.
// @SIDE_EFFECT GETs /merge/status from backend.
async loadMergeRecoveryState() {
u({ mergeRecoveryLoading: true });
try {
const status = await gitService.getMergeStatus(s().dashboardId, s().envId);
if (!status?.has_unfinished_merge) { this.closeUnfinishedMergeDialog(); return; }
if (!status?.has_unfinished_merge) { handlers.closeUnfinishedMergeDialog(); return; }
u({
unfinishedMergeContext: {
...(s().unfinishedMergeContext || {}),
@@ -122,11 +170,21 @@ export function createGitHandlers(getState, setState) {
});
} catch (e) { s().toast(e.message || 'Failed to load merge status', 'error'); } finally { u({ mergeRecoveryLoading: false }); }
},
// #endregion loadMergeRecoveryState
// #region closeUnfinishedMergeDialog [C:1] [TYPE Function]
// @BRIEF Close unfinished merge dialog and reset merge state.
// @POST Dialog hidden; mergeConflicts, showConflictResolver reset.
closeUnfinishedMergeDialog() {
u({ showUnfinishedMergeDialog: false, unfinishedMergeContext: null, mergeConflicts: [], showConflictResolver: false });
},
// #endregion closeUnfinishedMergeDialog
// #region handleOpenConflictResolver [C:2] [TYPE Function]
// @BRIEF Load merge conflicts and open conflict resolver UI.
// @PRE Unfinished merge is in progress; envId required when ref is slug.
// @POST mergeConflicts populated; showConflictResolver set to true if conflicts found.
// @SIDE_EFFECT GETs /merge/conflicts from backend.
async handleOpenConflictResolver() {
u({ mergeRecoveryLoading: true });
try {
@@ -135,7 +193,12 @@ export function createGitHandlers(getState, setState) {
u({ mergeConflicts: mc, showConflictResolver: true });
} catch (e) { s().toast(e.message || 'Failed to load merge conflicts', 'error'); } finally { u({ mergeRecoveryLoading: false }); }
},
// #endregion handleOpenConflictResolver
// #region handleResolveConflicts [C:2] [TYPE Function]
// @BRIEF Apply conflict resolutions and stage resolved files.
// @PRE event.detail maps file paths to resolution strategies; envId required when ref is slug.
// @POST Conflicts resolved and staged; resolver closed; workspace reloaded.
async handleResolveConflicts(event) {
const detail = event?.detail || {};
const resolutions = Object.entries(detail).map(([fp, r]) => ({ file_path: fp, resolution: r }));
@@ -145,41 +208,60 @@ export function createGitHandlers(getState, setState) {
await gitService.resolveMergeConflicts(s().dashboardId, resolutions, s().envId);
s().toast(s().t?.git?.unfinished_merge?.resolve_success || 'Conflicts were resolved and staged', 'success');
u({ showConflictResolver: false });
await this.loadMergeRecoveryState();
await this.loadWorkspace();
await handlers.loadMergeRecoveryState();
await handlers.loadWorkspace();
} catch (e) { s().toast(e.message || 'Failed to resolve conflicts', 'error'); } finally { u({ mergeResolveInProgress: false }); }
},
// #endregion handleResolveConflicts
// #region handleAbortUnfinishedMerge [C:2] [TYPE Function]
// @BRIEF Abort current unfinished merge.
// @PRE envId required when ref is slug.
// @POST Merge aborted; dialog closed; workspace reloaded.
async handleAbortUnfinishedMerge() {
u({ mergeAbortInProgress: true });
try {
await gitService.abortMerge(s().dashboardId, s().envId);
s().toast(s().t?.git?.unfinished_merge?.abort_success || 'Merge was aborted', 'success');
this.closeUnfinishedMergeDialog();
await this.loadWorkspace();
handlers.closeUnfinishedMergeDialog();
await handlers.loadWorkspace();
} catch (e) { s().toast(e.message || 'Failed to abort merge', 'error'); } finally { u({ mergeAbortInProgress: false }); }
},
// #endregion handleAbortUnfinishedMerge
// #region handleContinueUnfinishedMerge [C:2] [TYPE Function]
// @BRIEF Finalize unfinished merge by creating merge commit.
// @PRE All conflicts resolved; envId required when ref is slug.
// @POST Merge commit created; dialog closed; workspace reloaded.
async handleContinueUnfinishedMerge() {
u({ mergeContinueInProgress: true });
try {
await gitService.continueMerge(s().dashboardId, '', s().envId);
s().toast(s().t?.git?.unfinished_merge?.continue_success || 'Merge commit created successfully', 'success');
this.closeUnfinishedMergeDialog();
await this.loadWorkspace();
handlers.closeUnfinishedMergeDialog();
await handlers.loadWorkspace();
} catch (e) {
s().toast(e.message || 'Failed to continue merge', 'error');
await this.loadMergeRecoveryState();
await handlers.loadMergeRecoveryState();
} finally { u({ mergeContinueInProgress: false }); }
},
// #endregion handleContinueUnfinishedMerge
// #region getUnfinishedMergeCommandsText [C:1] [TYPE Function]
// @BRIEF Get shell commands for manual merge recovery.
// @POST Returns concatenated command string or empty string.
getUnfinishedMergeCommandsText() {
if (!s().unfinishedMergeContext?.commands?.length) return '';
return s().unfinishedMergeContext.commands.join('\n');
},
// #endregion getUnfinishedMergeCommandsText
// #region handleCopyUnfinishedMergeCommands [C:1] [TYPE Function]
// @BRIEF Copy merge recovery commands to clipboard.
// @PRE commands exist in unfinishedMergeContext.
// @POST Commands copied to clipboard; toast shown.
async handleCopyUnfinishedMergeCommands() {
const text = this.getUnfinishedMergeCommandsText();
const text = handlers.getUnfinishedMergeCommandsText();
if (!text) { s().toast(s().t?.git?.unfinished_merge?.copy_empty || 'Команды для копирования отсутствуют', 'warning'); return; }
u({ copyingUnfinishedMergeCommands: true });
try {
@@ -187,29 +269,46 @@ export function createGitHandlers(getState, setState) {
s().toast(s().t?.git?.unfinished_merge?.copy_success || 'Команды скопированы в буфер обмена', 'success');
} catch (_e) { s().toast(s().t?.git?.unfinished_merge?.copy_failed || 'Не удалось скопировать команды', 'error'); } finally { u({ copyingUnfinishedMergeCommands: false }); }
},
// #endregion handleCopyUnfinishedMergeCommands
// #region handlePull [C:2] [TYPE Function]
// @BRIEF Pull changes from remote repository.
// @PRE Remote 'origin' configured; envId required when ref is slug.
// @POST Local repository updated with remote changes; workspace reloaded.
// @SIDE_EFFECT POSTs to /pull; may open merge dialog on GIT_UNFINISHED_MERGE.
async handlePull() {
u({ isPulling: true });
try {
await gitService.pull(s().dashboardId, s().envId);
s().toast(s().t?.git?.pull_success || 'Изменения получены из Git', 'success');
await this.loadWorkspace();
await handlers.loadWorkspace();
} catch (e) {
const handled = this.openUnfinishedMergeDialogFromError(e);
if (handled) await this.loadMergeRecoveryState();
const handled = handlers.openUnfinishedMergeDialogFromError(e);
if (handled) await handlers.loadMergeRecoveryState();
else s().toast(e.message, 'error');
} finally { u({ isPulling: false }); }
},
// #endregion handlePull
// #region handlePush [C:2] [TYPE Function]
// @BRIEF Push local commits to remote repository.
// @PRE Remote 'origin' configured; envId required when ref is slug.
// @POST Remote updated with local commits; workspace reloaded.
async handlePush() {
u({ isPushing: true });
try {
await gitService.push(s().dashboardId, s().envId);
s().toast(s().t?.git?.push_success || 'Изменения отправлены в Git', 'success');
await this.loadWorkspace();
await handlers.loadWorkspace();
} catch (e) { s().toast(e.message, 'error'); } finally { u({ isPushing: false }); }
},
// #endregion handlePush
// #region handlePromote [C:3] [TYPE Function]
// @BRIEF Promote changes between branches via MR or direct merge.
// @PRE promoteFromBranch and promoteToBranch differ; direct mode requires reason; envId required when ref is slug.
// @POST MR created on Git server (MR mode) or direct merge executed (direct mode).
// @SIDE_EFFECT Opens MR URL in new tab (MR mode); writes policy violation to logs (direct mode).
async handlePromote() {
const st = s();
if (!st.promoteFromBranch || !st.promoteToBranch || st.promoteFromBranch === st.promoteToBranch) { st.toast('Выберите разные исходную и целевую ветки', 'error'); return; }
@@ -226,7 +325,12 @@ export function createGitHandlers(getState, setState) {
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 }); }
},
// #endregion handlePromote
// #region openDeployModal [C:2] [TYPE Function]
// @BRIEF Open deployment target modal with PROD confirmation.
// @PRE dashboardId is a valid slug.
// @POST showDeployModal set to true if PROD confirmation passes.
openDeployModal() {
const st = s();
if (st.currentEnvStage === 'PROD') {
@@ -236,7 +340,13 @@ export function createGitHandlers(getState, setState) {
}
u({ showDeployModal: true });
},
// #endregion openDeployModal
// #region handleCreateRemoteRepo [C:2] [TYPE Function]
// @BRIEF Create a remote repository on the selected Git provider.
// @PRE A Git config is selected; user provides a repo name via prompt.
// @POST remoteUrl set to created (or existing) repository clone URL.
// @SIDE_EFFECT POSTs to /git/config/{id}/repositories; prompts user for repo name.
async handleCreateRemoteRepo() {
const config = resolveDefaultConfig(s().configs, s().selectedConfigId);
if (!config) { s().toast(s().t?.git?.init_validation_error || 'Сначала выберите Git сервер', 'error'); return; }
@@ -256,20 +366,37 @@ export function createGitHandlers(getState, setState) {
if (!url) throw new Error('Remote repository created, but URL is empty');
u({ remoteUrl: url });
s().toast(`Repository created on ${config.provider}`, 'success');
} catch (e) { s().toast(e.message, 'error'); } finally { u({ creatingRemoteRepo: false }); }
} 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'); }
} finally { u({ creatingRemoteRepo: false }); }
},
// #endregion handleCreateRemoteRepo
// #region handleInit [C:3] [TYPE Function]
// @BRIEF Initialize local Git repository for the dashboard: clone remote.
// @PRE selectedConfigId is set; remoteUrl is set; envId required when ref is slug.
// @POST Repository initialized; initialized=true; workspace loaded.
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/init; loads workspace on success.
async handleInit() {
if (!s().selectedConfigId || !s().remoteUrl) { s().toast(s().t?.git?.init_validation_error || 'Заполните все поля', 'error'); return; }
// ── Guard: slug-based init requires envId ──
if (!s().envId && !isNumericDashboardRef(s().dashboardId)) {
s().toast('Environment must be selected to initialize Git for this dashboard.', 'error');
return;
}
u({ loading: true });
try {
await gitService.initRepository(s().dashboardId, s().selectedConfigId, s().remoteUrl, s().envId);
s().toast(s().t?.git?.init_success || 'Репозиторий инициализирован', 'success');
const provider = resolveDefaultConfig(s().configs, s().selectedConfigId)?.provider || '';
u({ initialized: true, repositoryProvider: provider });
await this.loadWorkspace();
await handlers.loadWorkspace();
} catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); }
},
// #endregion handleInit
};
return handlers;
}
// #endregion UseGitManager