Files
ss-tools/frontend/src/lib/models/GitManagerModel.svelte.ts
busya 149c05bf1c fix(frontend): full ADR compliance — Model-View concept, model decomposition, button migration
P0 — Model-first ADR compliance:
  - Decompose DashboardHubModel (590→496 lines) into Dashboards.FiltersModel,
    Dashboards.SelectionModel, Dashboards.GitActionsModel (DG split per plan)
  - Decompose AgentChatModel (630→356 lines) into ConnectionManager,
    StreamProcessor, LocalStorage, shared types
  - Decompose MigrationModel (457→389 lines) into WizardModel, ExecutorModel

P0 — /ui atom compliance:
  - Replace all raw <button> with <Button> from /ui in dashboards/+page.svelte
    (~20 replacements) and 16 additional routes/ files (~70 replacements total)

P0 — Hierarchical region IDs (ATTN_2):
  - Rename all 22 model #region/#endregion IDs from flat to Domain.Name format
  - Update @ingroup from generic 'Models' to domain-specific (Dashboards, Git, etc.)

P1 — UX contract compliance:
  - Add @UX_STATE declarations to agent/+page.svelte
  - Extract Gradio Client.connect from page into AgentChatModel.retryConnection()

All new model files have proper GRACE anchors (#region/#endregion, @ingroup,
@BRIEF, @INVARIANT, @STATE, @ACTION, @RELATION).

Build: npm run build passes.
Tests: DashboardHubModel 112/112, MigrationModel 74/74 pass.
2026-06-17 14:02:17 +03:00

868 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string, unknown> | 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<string, unknown>;
const detail = err?.detail || (typeof err?.detail === 'string' ? { message: err.detail } : null) || null;
const detailObj = typeof detail === 'object' ? detail as Record<string, unknown> : 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<string, unknown> {
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<string, unknown> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown>)?.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<void> {
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<string, unknown>)?.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<void> {
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<string, unknown>)?.commit_and_push_success as string || 'Коммит создан и отправлен в remote', 'success');
} else {
addToast((this._t?.git as Record<string, unknown>)?.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<void> {
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<void> {
this.clearGitError();
this.isPulling = true;
try {
await gitService.pull(this.dashboardId, this.resolvedEnvId);
addToast((this._t?.git as Record<string, unknown>)?.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<void> {
this.clearGitError();
this.isPushing = true;
try {
await gitService.push(this.dashboardId, this.resolvedEnvId);
addToast((this._t?.git as Record<string, unknown>)?.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<void> {
const config = resolveDefaultConfig(this.configs, this.selectedConfigId);
if (!config) {
addToast((this._t?.git as Record<string, unknown>)?.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<string, unknown>)?.status === 409 && /already exists/i.test(String((e as Error)?.message || ''))) {
addToast((this._t?.git as Record<string, unknown>)?.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<void> {
if (!this.selectedConfigId || !this.remoteUrl) {
addToast((this._t?.git as Record<string, unknown>)?.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<string, unknown>)?.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<void> {
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<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.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<void> {
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<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.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<void> {
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<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.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<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.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<void> {
this.clearGitError();
this.mergeAbortInProgress = true;
try {
await gitService.abortMerge(this.dashboardId, this.resolvedEnvId);
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.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<void> {
this.clearGitError();
this.mergeContinueInProgress = true;
try {
await gitService.continueMerge(this.dashboardId, '', this.resolvedEnvId);
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.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<void> {
const text = this.getUnfinishedMergeCommandsText();
if (!text) {
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.copy_empty as string || 'Команды для копирования отсутствуют', 'warning');
return;
}
this.copyingUnfinishedMergeCommands = true;
try {
await navigator.clipboard.writeText(text);
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.copy_success as string || 'Команды скопированы в буфер обмена', 'success');
} catch {
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.copy_failed as string || 'Не удалось скопировать команды', 'error');
} finally {
this.copyingUnfinishedMergeCommands = false;
}
}
}
// #endregion Git.ManagerModel