// frontend/src/lib/models/GitManagerModel.svelte.ts // #region Git.ManagerModel [C:4] [TYPE Model] [SEMANTICS git,workspace,screen-model,state,repository] // @ingroup Git // @BRIEF State model for the Git workspace panel — declares atoms, invariants, and actions. // @INVARIANT Loading states are mutually exclusive per operation: // checkingStatus, loading, workspaceLoading, committing, isPulling, isPushing, // generatingMessage, promoting, mergeResolveInProgress, mergeAbortInProgress, // mergeContinueInProgress, mergeRecoveryLoading, creatingRemoteRepo. // At most one of these loading flags is true at any time. // @INVARIANT Git error is always cleared before any new operation (clearError before try). // @INVARIANT Repository binding (configId, provider, remoteUrl) is loaded only after initialized=true. // @STATE uninitialized — No repository bound to dashboard. // @STATE initialized — Repository exists, tab workspace/release/operations available. // @STATE checking — Initial status check in progress. // @STATE error — Persistent error banner visible with message and dismiss action. // @ACTION checkStatus() — Checks if repository is initialized; loads workspace on success. // @ACTION loadWorkspace() — Loads workspace status and diff. // @ACTION handleSync() — Synchronizes dashboard state with Git. // @ACTION handleGenerateMessage() — Generates AI commit message from diff. // @ACTION handleCommit() — Stages and commits workspace changes. // @ACTION handlePromote(...) — Promotes changes between branches. // @ACTION handlePull() — Pulls from remote. // @ACTION handlePush() — Pushes to remote. // @ACTION openDeployModal() — Opens deployment target modal. // @ACTION handleCreateRemoteRepo() — Creates remote repository on provider. // @ACTION handleInit() — Initializes local repository from remote URL. // @ACTION loadMergeRecoveryState() — Loads merge recovery state. // @ACTION closeUnfinishedMergeDialog() — Closes merge dialog and resets state. // @ACTION handleOpenConflictResolver() — Loads conflicts and opens resolver. // @ACTION handleResolveConflicts(event) — Applies conflict resolutions. // @ACTION handleAbortUnfinishedMerge() — Aborts in-progress merge. // @ACTION handleContinueUnfinishedMerge() — Finalizes merge commit. // @ACTION handleCopyUnfinishedMergeCommands() — Copies commands to clipboard. // @ACTION clearGitError() — Clears the persistent error banner. // @ACTION initialize(dashboardId, envId, dashboardTitle) — Bootstraps state on mount. // @RELATION DEPENDS_ON -> [EXT:frontend:gitService] // @RELATION DEPENDS_ON -> [EXT:frontend:api] // @RELATION DEPENDS_ON -> [GitUtils] // @RELATION DEPENDS_ON -> [ToastsModule] // @REJECTED getState/setState proxy pattern rejected — fragile, untyped, hard to test. // Real $state atoms with class methods enable L1 testing without DOM render. import { gitService } from '../../services/gitService.js'; import { api } from '$lib/api.js'; import { addToast } from '$lib/toasts.svelte.js'; import { isNumericDashboardRef, resolveDefaultConfig, extractUnfinishedMergeContext, buildSuggestedRepoName, resolveCurrentEnvironmentId, normalizeEnvStage, applyGitflowStageDefaults, resolvePushProviderLabel, extractHttpHost, } from '../../services/git-utils.js'; import { getT } from '$lib/i18n/index.svelte.js'; // ── Types ───────────────────────────────────────────────────── interface GitConfig { id: string | number; provider?: string; url?: string; [key: string]: unknown; } interface WorkspaceStatus { current_branch?: string; staged_files?: string[]; modified_files?: string[]; untracked_files?: string[]; is_dirty?: boolean; [key: string]: unknown; } interface GitErrorPayload { message: string; status: number; error_code: string | null; files: unknown[]; next_steps: string[]; detail: Record | null; errorType?: string; } interface UnfinishedMergeContext { message?: string; repositoryPath?: string; gitDir?: string; currentBranch?: string; mergeHead?: string; mergeMessagePreview?: string; nextSteps?: string[]; commands?: string[]; conflictsCount?: number; [key: string]: unknown; } interface MergeConflict { file_path: string; [key: string]: unknown; } interface RepositoryBinding { provider?: string; remote_url?: string; config_id?: string | number; [key: string]: unknown; } interface PromotePayload { from_branch: string; to_branch: string; mode: string; title: string; description?: string; reason?: string; } interface PromoteResponse { url?: string; [key: string]: unknown; } interface MergeStatus { has_unfinished_merge?: boolean; repository_path?: string; git_dir?: string; current_branch?: string; merge_head?: string; merge_message_preview?: string; conflicts_count?: number; [key: string]: unknown; } interface CreateRepoPayload { name: string; private: boolean; description: string; auto_init: boolean; default_branch: string; } interface CreateRepoResult { clone_url?: string; html_url?: string; [key: string]: unknown; } interface EnvironmentItem { id: string; [key: string]: unknown; } interface NumericDashboardRefCheck { isNumericDashboardRef: typeof isNumericDashboardRef; resolveEnvId: () => string | null; normalizeEnvStageFn: typeof normalizeEnvStage; applyGitflowStageDefaultsFn: typeof applyGitflowStageDefaults; } interface GitflowStageDefaults { promoteFromBranch?: string; promoteToBranch?: string; preferredDeployTargetStage?: string; } // #region buildGitErrorFromException [C:2] [TYPE Function] // @ingroup Git // @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: unknown): GitErrorPayload { if (!e) return { message: 'Неизвестная ошибка Git', status: 500, error_code: null, files: [], next_steps: [], detail: null }; const err = e as Record; const detail = err?.detail || (typeof err?.detail === 'string' ? { message: err.detail } : null) || null; const detailObj = typeof detail === 'object' ? detail as Record : null; return { message: (detailObj?.message as string) || (detailObj?.message_en as string) || (err.message as string) || 'Произошла ошибка Git', status: (err?.status as number) || 500, error_code: (err?.error_code as string) || (detailObj?.error_code as string) || null, files: (detailObj?.files as unknown[]) || [], next_steps: (detailObj?.next_steps as string[]) || (detailObj?.next_steps_en as string[]) || [], detail: detailObj, }; } // #endregion buildGitErrorFromException /** short alias for getT() */ function tt(): Record { return getT(); } export class GitManagerModel { // ── Props (synced from component) ──────────────────────────── dashboardId: string = $state(''); /** Raw envId from props (null = not set). resolvedEnvId adds localStorage fallback. */ envId: string | null = $state(null); dashboardTitle: string = $state(''); // ── Branch & Status ───────────────────────────────────────── initialized: boolean = $state(false); loading: boolean = $state(false); checkingStatus: boolean = $state(true); currentBranch: string = $state('main'); activeTab: string = $state('workspace'); // ── Configs & Remote ──────────────────────────────────────── configs: GitConfig[] = $state([]); selectedConfigId: string = $state(''); remoteUrl: string = $state(''); creatingRemoteRepo: boolean = $state(false); repositoryProvider: string = $state(''); repositoryBindingRemoteUrl: string = $state(''); repositoryConfigUrl: string = $state(''); // ── Commit ────────────────────────────────────────────────── commitMessage: string = $state(''); committing: boolean = $state(false); generatingMessage: boolean = $state(false); autoPushAfterCommit: boolean = $state(true); // ── Workspace ─────────────────────────────────────────────── workspaceStatus: WorkspaceStatus | null = $state(null); workspaceDiff: string = $state(''); workspaceLoading: boolean = $state(false); // ── Pull / Push ───────────────────────────────────────────── isPulling: boolean = $state(false); isPushing: boolean = $state(false); // ── Promote ───────────────────────────────────────────────── showAdvancedPromote: boolean = $state(false); promoting: boolean = $state(false); promoteFromBranch: string = $state('dev'); promoteToBranch: string = $state('preprod'); promoteMode: string = $state('mr'); promoteReason: string = $state(''); // ── Environment ───────────────────────────────────────────── currentEnvStage: string = $state(''); preferredDeployTargetStage: string = $state(''); // ── Deploy ────────────────────────────────────────────────── showDeployModal: boolean = $state(false); // ── Merge Recovery ────────────────────────────────────────── showUnfinishedMergeDialog: boolean = $state(false); unfinishedMergeContext: UnfinishedMergeContext | null = $state(null); copyingUnfinishedMergeCommands: boolean = $state(false); mergeRecoveryLoading: boolean = $state(false); mergeConflicts: MergeConflict[] = $state([]); showConflictResolver: boolean = $state(false); mergeResolveInProgress: boolean = $state(false); mergeAbortInProgress: boolean = $state(false); mergeContinueInProgress: boolean = $state(false); // ── Error Banner ──────────────────────────────────────────── gitError: GitErrorPayload | null = $state(null); gitErrorType: string = $state('error'); // ── Derived ───────────────────────────────────────────────── /** Resolved envId with localStorage fallback. */ resolvedEnvId: string | null = $derived(resolveCurrentEnvironmentId(this.envId)); /** True when there are any workspace changes (staged, modified, or untracked). */ hasWorkspaceChanges: boolean = $derived.by(() => { if (!this.workspaceStatus) return false; const w = this.workspaceStatus; return [...(w.staged_files || []), ...(w.modified_files || []), ...(w.untracked_files || [])].length > 0; }); /** Total count of changed files (staged + modified + untracked). */ changedFilesCount: number = $derived.by(() => { if (!this.workspaceStatus) return 0; const w = this.workspaceStatus; return [...(w.staged_files || []), ...(w.modified_files || []), ...(w.untracked_files || [])].length; }); /** Lower-case provider label for auto-push checkbox text. */ pushProviderLabel: string = $derived(resolvePushProviderLabel(this.configs, this.selectedConfigId, this.repositoryProvider)); /** Origin host from repository binding URL. */ originHost: string = $derived(extractHttpHost(this.repositoryBindingRemoteUrl)); /** Config host from default config URL. Synced from component via model.repositoryConfigUrl. */ configHost: string = $derived(extractHttpHost(this.repositoryConfigUrl)); /** True when origin and config hosts differ (server mismatch warning). */ hasOriginConfigMismatch: boolean = $derived(!!(this.originHost && this.configHost && this.originHost !== this.configHost)); constructor({ dashboardId = '', envId = null, dashboardTitle = '' }: { dashboardId?: string; envId?: string | null; dashboardTitle?: string } = {}) { this.dashboardId = dashboardId; this.envId = envId; this.dashboardTitle = dashboardTitle; } // ── Helpers ────────────────────────────────────────────────── /** Clear the persistent error banner. */ clearGitError(): void { this.gitError = null; this.gitErrorType = 'error'; } /** @private Set the persistent error banner + show persistent toast. */ _setGitError(e: unknown, errorType?: string): void { const err = buildGitErrorFromException(e); err.errorType = errorType || (err.status >= 500 ? 'error' : err.status === 409 ? 'warning' : 'error'); this.gitError = err; this.gitErrorType = err.errorType; addToast(err.message, err.errorType === 'warning' ? 'warning' : 'error', 0); } /** @private Convenient access to i18n store value for translations. */ get _t(): Record { return tt(); } /** @private Collect current scalar + helper snapshot for handler context. */ _snapshot(): NumericDashboardRefCheck { return { isNumericDashboardRef, resolveEnvId: () => resolveCurrentEnvironmentId(this.envId), normalizeEnvStageFn: normalizeEnvStage, applyGitflowStageDefaultsFn: applyGitflowStageDefaults, }; } // ── Initialization ─────────────────────────────────────────── /** * Bootstrap state on mount: load configs, check status, resolve env stage, * and load repository binding if initialized. */ async initialize(): Promise { this.loading = true; try { this.configs = await gitService.getConfigs(); const dc = resolveDefaultConfig(this.configs, this.selectedConfigId); if (dc?.id) this.selectedConfigId = String(dc.id); } catch { this.configs = []; } this.loading = false; await Promise.all([this.checkStatus(), this.loadCurrentEnvironmentStage()]); if (this.initialized) { try { const binding: RepositoryBinding = await gitService.getRepositoryBinding(this.dashboardId, this.resolvedEnvId); this.repositoryProvider = binding?.provider || ''; this.repositoryBindingRemoteUrl = binding?.remote_url || ''; if (binding?.config_id) this.selectedConfigId = String(binding.config_id); } catch { this.repositoryProvider = ''; this.repositoryBindingRemoteUrl = ''; } } } // ── Environment Stage ──────────────────────────────────────── /** * Resolve current environment stage (DEV/PREPROD/PROD) and apply GitFlow defaults. * @SIDE_EFFECT Fetches /environments from API. */ async loadCurrentEnvironmentStage(): Promise { const currentEnvId = resolveCurrentEnvironmentId(this.envId); if (!currentEnvId) return; try { const environments: EnvironmentItem[] = await api.getEnvironmentsList(); const currentEnv = (environments || []).find((item: EnvironmentItem) => item.id === currentEnvId); if (!currentEnv) return; const stage = normalizeEnvStage(currentEnv); this.currentEnvStage = stage; const defaults: GitflowStageDefaults = applyGitflowStageDefaults(stage); if (defaults.promoteFromBranch !== undefined) this.promoteFromBranch = defaults.promoteFromBranch; if (defaults.promoteToBranch !== undefined) this.promoteToBranch = defaults.promoteToBranch; if (defaults.preferredDeployTargetStage !== undefined) this.preferredDeployTargetStage = defaults.preferredDeployTargetStage; } catch (e: unknown) { console.error(`[GitManagerModel][loadCurrentEnvironmentStage] Failed: ${e instanceof Error ? e.message : String(e)}`); } } // ── Check Status ───────────────────────────────────────────── /** * Check if Git repository is initialized for the dashboard. * @POST initialized=true if repository found and workspace loaded. */ async checkStatus(): Promise { if (isNumericDashboardRef(this.dashboardId)) { this.checkingStatus = false; this.initialized = false; addToast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); return; } this.checkingStatus = true; if (!this.resolvedEnvId) { this.initialized = false; this.checkingStatus = false; return; } try { await gitService.getBranches(this.dashboardId, this.resolvedEnvId); this.initialized = true; await this.loadWorkspace(); } catch { this.initialized = false; } finally { this.checkingStatus = false; } } // ── Workspace ──────────────────────────────────────────────── /** * Load Git workspace status and diff for initialized repository. * @SIDE_EFFECT Fetches /status and /diff for the dashboard repository. */ async loadWorkspace(): Promise { if (!this.initialized) return; this.clearGitError(); this.workspaceLoading = true; try { const ws: WorkspaceStatus = await gitService.getStatus(this.dashboardId, this.resolvedEnvId); const sd: string = await gitService.getDiff(this.dashboardId, null, true, this.resolvedEnvId); const ud: string = await gitService.getDiff(this.dashboardId, null, false, this.resolvedEnvId); this.workspaceStatus = ws; this.workspaceDiff = [sd, ud].filter(Boolean).join('\n\n'); this.currentBranch = ws?.current_branch || this.currentBranch; } catch (e: unknown) { this._setGitError(e); } finally { this.workspaceLoading = false; } } // ── Sync ───────────────────────────────────────────────────── /** * Synchronize local dashboard state with Git repository. * @SIDE_EFFECT POSTs to /git/repositories/{ref}/sync. */ async handleSync(): Promise { if (isNumericDashboardRef(this.dashboardId)) { addToast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); return; } this.clearGitError(); this.loading = true; try { const sourceEnvId = this.resolvedEnvId || localStorage.getItem('selected_env_id'); await gitService.sync(this.dashboardId, sourceEnvId, this.resolvedEnvId); addToast((this._t?.git as Record)?.sync_success as string || 'Состояние дашборда синхронизировано с Git', 'success'); await this.loadWorkspace(); } catch (e: unknown) { this._setGitError(e); } finally { this.loading = false; } } // ── Generate Message ───────────────────────────────────────── /** * Generate AI commit message from dashboard diff. * @SIDE_EFFECT POSTs to /git/repositories/{ref}/generate-message. */ async handleGenerateMessage(): Promise { this.clearGitError(); this.generatingMessage = true; try { const data: { message?: string } = await api.postApi( `/git/repositories/${encodeURIComponent(String(this.dashboardId))}/generate-message${ this.resolvedEnvId ? `?env_id=${encodeURIComponent(String(this.resolvedEnvId))}` : '' }`, undefined, { suppressToast: true }, ); this.commitMessage = data?.message || ''; addToast((this._t?.git as Record)?.commit_message_generated as string || 'Сообщение для коммита сгенерировано', 'success'); } catch (e: unknown) { this._setGitError(e); } finally { this.generatingMessage = false; } } // ── Commit ─────────────────────────────────────────────────── /** * Stage and commit workspace changes, optionally push. * @PRE commitMessage is non-empty; hasWorkspaceChanges is true. */ async handleCommit(): Promise { if (!this.commitMessage || !this.hasWorkspaceChanges) return; this.clearGitError(); this.committing = true; try { await gitService.commit(this.dashboardId, this.commitMessage, [], this.resolvedEnvId); if (this.autoPushAfterCommit) { await gitService.push(this.dashboardId, this.resolvedEnvId); addToast((this._t?.git as Record)?.commit_and_push_success as string || 'Коммит создан и отправлен в remote', 'success'); } else { addToast((this._t?.git as Record)?.commit_success as string || 'Коммит успешно создан', 'success'); } this.commitMessage = ''; await this.loadWorkspace(); } catch (e: unknown) { this._setGitError(e); } finally { this.committing = false; } } // ── Promote ────────────────────────────────────────────────── /** * Promote changes between branches via MR or direct merge. * @SIDE_EFFECT Opens MR URL in new tab (MR mode). */ async handlePromote(): Promise { if (!this.promoteFromBranch || !this.promoteToBranch || this.promoteFromBranch === this.promoteToBranch) { addToast('Выберите разные исходную и целевую ветки', 'error'); return; } if (this.promoteMode === 'direct' && !String(this.promoteReason || '').trim()) { addToast('Для небезопасного прямого переноса укажите причину', 'error'); return; } this.clearGitError(); this.promoting = true; try { const response: PromoteResponse = await gitService.promote( this.dashboardId, { from_branch: this.promoteFromBranch, to_branch: this.promoteToBranch, mode: this.promoteMode, title: `Promote ${this.promoteFromBranch} -> ${this.promoteToBranch}: ${this.dashboardTitle || this.dashboardId}`, description: this.promoteMode === 'direct' ? `Unsafe direct promote.\nReason: ${this.promoteReason}` : undefined, reason: this.promoteMode === 'direct' ? this.promoteReason : undefined, } satisfies PromotePayload, this.resolvedEnvId, ); if (this.promoteMode === 'direct') { addToast('Прямой перенос выполнен. Нарушение политики записано в логи.', 'warning'); } else { if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer'); addToast('Merge Request создан на Git сервере', 'success'); } } catch (e: unknown) { this._setGitError(e); } finally { this.promoting = false; } } // ── Pull ───────────────────────────────────────────────────── /** * Pull changes from remote repository. * @SIDE_EFFECT POSTs to /pull; opens merge dialog on GIT_UNFINISHED_MERGE. */ async handlePull(): Promise { this.clearGitError(); this.isPulling = true; try { await gitService.pull(this.dashboardId, this.resolvedEnvId); addToast((this._t?.git as Record)?.pull_success as string || 'Изменения получены из Git', 'success'); await this.loadWorkspace(); } catch (e: unknown) { const handled = this.openUnfinishedMergeDialogFromError(e); if (handled) { await this.loadMergeRecoveryState(); } else { this._setGitError(e); } } finally { this.isPulling = false; } } // ── Push ───────────────────────────────────────────────────── /** * Push local commits to remote repository. * @SIDE_EFFECT POSTs to /push. */ async handlePush(): Promise { this.clearGitError(); this.isPushing = true; try { await gitService.push(this.dashboardId, this.resolvedEnvId); addToast((this._t?.git as Record)?.push_success as string || 'Изменения отправлены в Git', 'success'); await this.loadWorkspace(); } catch (e: unknown) { this._setGitError(e); } finally { this.isPushing = false; } } // ── Deploy Modal ───────────────────────────────────────────── /** * Open deployment target modal with PROD confirmation prompt. */ openDeployModal(): void { if (this.currentEnvStage === 'PROD') { const expected = String(this.dashboardId); const confirmation = prompt(`Подтвердите деплой в PROD. Введите slug дашборда: ${expected}`); if (String(confirmation || '').trim() !== expected) { addToast('Подтверждение PROD не пройдено. Деплой отменен.', 'error'); return; } } this.showDeployModal = true; } // ── Remote Repository ──────────────────────────────────────── /** * Create a remote repository on the selected Git provider. * @SIDE_EFFECT POSTs to /git/config/{id}/repositories; prompts user for repo name. */ async handleCreateRemoteRepo(): Promise { const config = resolveDefaultConfig(this.configs, this.selectedConfigId); if (!config) { addToast((this._t?.git as Record)?.init_validation_error as string || 'Сначала выберите Git сервер', 'error'); return; } if (!this.selectedConfigId && config.id) this.selectedConfigId = String(config.id); const suggestedName = buildSuggestedRepoName(this.dashboardTitle, this.dashboardId); const inputName = prompt(`Repository name for ${config.provider}:`, suggestedName); const repoName = String(inputName || '').trim(); if (!repoName) return; this.clearGitError(); this.creatingRemoteRepo = true; try { const repo: CreateRepoResult = await gitService.createRemoteRepository(config.id, { name: repoName, private: true, description: `Superset dashboard ${this.dashboardId}: ${this.dashboardTitle || repoName}`, auto_init: true, default_branch: 'main', } satisfies CreateRepoPayload); const url = repo?.clone_url || repo?.html_url || ''; if (!url) throw new Error('Remote repository created, but URL is empty'); this.remoteUrl = url; addToast(`Repository created on ${config.provider}`, 'success'); } catch (e: unknown) { if ((e as Record)?.status === 409 && /already exists/i.test(String((e as Error)?.message || ''))) { addToast((this._t?.git as Record)?.repo_already_exists as string || 'Repository already exists. Enter its URL below and click Init.', 'warning', 0); } else { this._setGitError(e, 'warning'); } } finally { this.creatingRemoteRepo = false; } } // ── Init Repository ────────────────────────────────────────── /** * Initialize local Git repository for the dashboard: clone remote. * @SIDE_EFFECT POSTs to /git/repositories/{ref}/init. */ async handleInit(): Promise { if (!this.selectedConfigId || !this.remoteUrl) { addToast((this._t?.git as Record)?.init_validation_error as string || 'Заполните все поля', 'error'); return; } if (!this.resolvedEnvId && !isNumericDashboardRef(this.dashboardId)) { addToast('Environment must be selected to initialize Git for this dashboard.', 'error'); return; } this.clearGitError(); this.loading = true; try { await gitService.initRepository(this.dashboardId, this.selectedConfigId, this.remoteUrl, this.resolvedEnvId); addToast((this._t?.git as Record)?.init_success as string || 'Репозиторий инициализирован', 'success'); const provider = resolveDefaultConfig(this.configs, this.selectedConfigId)?.provider || ''; this.initialized = true; this.repositoryProvider = provider; await this.loadWorkspace(); } catch (e: unknown) { this._setGitError(e); } finally { this.loading = false; } } // ── Merge Recovery ─────────────────────────────────────────── /** * Parse error for unfinished merge context and open dialog. * @POST Returns true and opens dialog if merge context found. */ openUnfinishedMergeDialogFromError(error: unknown): boolean { const context = extractUnfinishedMergeContext(error); if (!context) return false; this.unfinishedMergeContext = context; this.showUnfinishedMergeDialog = true; return true; } /** * Load merge recovery state from backend for unfinished merge. * @SIDE_EFFECT GETs /merge/status from backend. */ async loadMergeRecoveryState(): Promise { this.clearGitError(); this.mergeRecoveryLoading = true; try { const status: MergeStatus = await gitService.getMergeStatus(this.dashboardId, this.resolvedEnvId); if (!status?.has_unfinished_merge) { this.closeUnfinishedMergeDialog(); return; } this.unfinishedMergeContext = { ...(this.unfinishedMergeContext || {}), message: this.unfinishedMergeContext?.message || ((this._t?.git as Record)?.unfinished_merge as Record)?.default_message as string || '', repositoryPath: String(status.repository_path || this.unfinishedMergeContext?.repositoryPath || ''), gitDir: String(status.git_dir || this.unfinishedMergeContext?.gitDir || ''), currentBranch: String(status.current_branch || this.unfinishedMergeContext?.currentBranch || ''), mergeHead: String(status.merge_head || this.unfinishedMergeContext?.mergeHead || ''), mergeMessagePreview: String(status.merge_message_preview || ''), nextSteps: Array.isArray(this.unfinishedMergeContext?.nextSteps) ? this.unfinishedMergeContext.nextSteps : [], commands: Array.isArray(this.unfinishedMergeContext?.commands) ? this.unfinishedMergeContext.commands : [], conflictsCount: Number(status.conflicts_count || 0), }; } catch (e: unknown) { this._setGitError(e); } finally { this.mergeRecoveryLoading = false; } } /** * Close unfinished merge dialog and reset merge state. */ closeUnfinishedMergeDialog(): void { this.showUnfinishedMergeDialog = false; this.unfinishedMergeContext = null; this.mergeConflicts = []; this.showConflictResolver = false; } /** * Load merge conflicts and open conflict resolver UI. * @SIDE_EFFECT GETs /merge/conflicts from backend. */ async handleOpenConflictResolver(): Promise { this.clearGitError(); this.mergeRecoveryLoading = true; try { const mc: MergeConflict[] = await gitService.getMergeConflicts(this.dashboardId, this.resolvedEnvId); if (!Array.isArray(mc) || mc.length === 0) { addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.no_conflicts as string || 'No unresolved conflicts were found', 'info'); return; } this.mergeConflicts = mc; this.showConflictResolver = true; } catch (e: unknown) { this._setGitError(e); } finally { this.mergeRecoveryLoading = false; } } /** * Apply conflict resolutions and stage resolved files. * @SIDE_EFFECT POSTs resolutions to /merge/resolve. */ async handleResolveConflicts(event: CustomEvent): Promise { const detail = event?.detail || {}; const resolutions: Array<{ file_path: string; resolution: unknown }> = Object.entries(detail).map(([fp, r]) => ({ file_path: fp, resolution: r })); if (!resolutions.length) { addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.resolve_empty as string || 'No conflict resolutions selected', 'warning'); return; } this.clearGitError(); this.mergeResolveInProgress = true; try { await gitService.resolveMergeConflicts(this.dashboardId, resolutions, this.resolvedEnvId); addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.resolve_success as string || 'Conflicts were resolved and staged', 'success'); this.showConflictResolver = false; await this.loadMergeRecoveryState(); await this.loadWorkspace(); } catch (e: unknown) { this._setGitError(e); } finally { this.mergeResolveInProgress = false; } } /** * Abort current unfinished merge. * @SIDE_EFFECT POSTs to /merge/abort. */ async handleAbortUnfinishedMerge(): Promise { this.clearGitError(); this.mergeAbortInProgress = true; try { await gitService.abortMerge(this.dashboardId, this.resolvedEnvId); addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.abort_success as string || 'Merge was aborted', 'success'); this.closeUnfinishedMergeDialog(); await this.loadWorkspace(); } catch (e: unknown) { this._setGitError(e); } finally { this.mergeAbortInProgress = false; } } /** * Finalize unfinished merge by creating merge commit. * @SIDE_EFFECT POSTs to /merge/continue. */ async handleContinueUnfinishedMerge(): Promise { this.clearGitError(); this.mergeContinueInProgress = true; try { await gitService.continueMerge(this.dashboardId, '', this.resolvedEnvId); addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.continue_success as string || 'Merge commit created successfully', 'success'); this.closeUnfinishedMergeDialog(); await this.loadWorkspace(); } catch (e: unknown) { this._setGitError(e); await this.loadMergeRecoveryState(); } finally { this.mergeContinueInProgress = false; } } /** * Get shell commands text for manual merge recovery. */ getUnfinishedMergeCommandsText(): string { if (!this.unfinishedMergeContext?.commands?.length) return ''; return this.unfinishedMergeContext.commands.join('\n'); } /** * Copy merge recovery commands to clipboard. */ async handleCopyUnfinishedMergeCommands(): Promise { const text = this.getUnfinishedMergeCommandsText(); if (!text) { addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_empty as string || 'Команды для копирования отсутствуют', 'warning'); return; } this.copyingUnfinishedMergeCommands = true; try { await navigator.clipboard.writeText(text); addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_success as string || 'Команды скопированы в буфер обмена', 'success'); } catch { addToast(((this._t?.git as Record)?.unfinished_merge as Record)?.copy_failed as string || 'Не удалось скопировать команды', 'error'); } finally { this.copyingUnfinishedMergeCommands = false; } } } // #endregion Git.ManagerModel