Files
ss-tools/frontend/src/lib/models/BranchModel.svelte.ts
busya 8c10632494 feat(semantic): curator-driven protocol hardening — decision memory + relation repair
- Add @RATIONALE/@REJECTED to 103+ C4/C5 contracts across backend core, services, API routes, and frontend models
- Fix 109 unresolved @RELATION edges (Auth.*, SupersetClient.*, AgentChat.*, ADR cross-refs)
- Add 13 @ingroup tags for DSA/HCA attention grouping
- Repair 29 stale graph edges via index rebuild
- Update .kilo agent prompts and skills for GRACE-Poly v2.6 compliance
- Git integration: merge routes, branch lifecycle, remote providers, UX components
- 0 broken anchor pairs, index rebuilt with 0 parse warnings
2026-07-02 08:53:19 +03:00

316 lines
13 KiB
TypeScript

// frontend/src/lib/models/BranchModel.svelte.ts
// #region Git.BranchModel [C:4] [TYPE Model] [SEMANTICS git,branch,checkout,create,merge,delete,screen-model]
// @ingroup Git
// @BRIEF State model for branch selector — list, checkout, create, merge, and delete Git branches.
// @INVARIANT Loading guards all operations — only one operation runs at a time.
// @STATE idle — Branch list loaded, dropdown enabled.
// @STATE loading — Fetching branch list.
// @STATE createMode — Inline create form visible.
// @STATE checking_out — Branch checkout in progress.
// @STATE creating — Branch creation in progress.
// @STATE merging — Branch merge in progress.
// @STATE deleting — Branch deletion in progress.
// @ACTION loadBranches(dashboardId, envId) — Fetch branch list from API.
// @ACTION handleCheckout(dashboardId, branchName, envId) — Checkout a branch.
// @ACTION handleCreate(dashboardId, branchName, sourceBranch, envId) — Create a new branch.
// @ACTION handleMerge(dashboardId, sourceBranch, targetBranch, message, autoDelete, envId) — Merge branches.
// @ACTION handleDelete(dashboardId, branchName, force, envId) — Delete a branch.
// @ACTION handleSelect(event) — Handle dropdown selection.
// @ACTION toggleCreateForm() — Toggle create form visibility.
// @RELATION DEPENDS_ON -> [EXT:frontend:gitService]
// @RELATION DEPENDS_ON -> [ToastsModule]
// @RATIONALE Git.BranchModel encapsulates branch management (list, checkout, create, merge, delete) into a testable model, following the pattern established by GitStatusModel. Each git operation is gated by a dedicated loading flag ($state) so the UI can disable controls during in-flight operations. Error handling captures structured BranchError (message + next_steps) from the backend, enabling richer inline error display than a simple toast.
// @REJECTED Inline $state in BranchSelector.svelte rejected — model extraction enables L1 tests without DOM render,
// follows pattern established by GitStatusModel and GitManagerModel.
import { gitService } from '../../services/gitService.js';
import { addToast } from '$lib/toasts.svelte.js';
import { getT } from '$lib/i18n/index.svelte.js';
// ── Types ─────────────────────────────────────────────────────
interface BranchItem {
name: string;
[key: string]: unknown;
}
interface BranchError {
message: string;
next_steps: string[];
}
/** @returns Current i18n translations. */
function tt(): Record<string, unknown> {
return getT();
}
export class BranchModel {
// ── Identity ───────────────────────────────────────────────
/** Dashboard slug for git API calls. */
dashboardId: string = $state('');
/** Environment ID for scoped git operations. */
envId: string | null = $state(null);
/** Current active branch name. Updated on checkout. */
currentBranch: string = $state('prod');
/** Callback invoked when branch changes. */
onChange: ((_event: { branch: string }) => void) | null = $state(null);
// ── Branch List ────────────────────────────────────────────
/** Array of branch objects from the API. */
branches: BranchItem[] = $state([]);
/** True while fetching branches. */
loading: boolean = $state(false);
// ── Create Form ────────────────────────────────────────────
/** True when the create-branch form is visible. */
showCreate: boolean = $state(false);
/** New branch name input value. */
newBranchName: string = $state('');
/** Error object { message, next_steps? } from last failed checkout/create. */
branchError: BranchError | null = $state(null);
// ── Operation Loading Flags ────────────────────────────────
/** True while a checkout is in progress. */
checkingOut: boolean = $state(false);
/** True while a branch creation is in progress. */
creating: boolean = $state(false);
/** True while a branch merge is in progress. */
merging: boolean = $state(false);
/** True while a branch deletion is in progress. */
deleting: boolean = $state(false);
// ── Merge State ─────────────────────────────────────────────
/** Merge result from the last merge operation. */
mergeResult: { status: string; conflicts?: string[]; source_deleted?: boolean } | null = $state(null);
/** Name of the branch being merged (for dialog context). */
mergeSourceBranch: string = $state('');
/** Name of the target branch for merge. */
mergeTargetBranch: string = $state('');
// ── Delete State ────────────────────────────────────────────
/** True when delete confirmation dialog is shown. */
showDeleteConfirm: boolean = $state(false);
/** Name of the branch pending deletion. */
branchToDelete: string = $state('');
// ── Dialog Flags ────────────────────────────────────────────
/** True when create dialog is shown. */
showCreateDialog: boolean = $state(false);
constructor({
dashboardId = '',
envId = null,
currentBranch = 'prod',
onChange = null,
}: {
dashboardId?: string;
envId?: string | null;
currentBranch?: string;
onChange?: ((_event: { branch: string }) => void) | null;
} = {}) {
this.dashboardId = dashboardId;
this.envId = envId;
this.currentBranch = currentBranch;
this.onChange = onChange;
}
// ── Branch List ────────────────────────────────────────────
/**
* Fetch branch list from the backend.
* @SIDE_EFFECT GETs /git/repositories/{ref}/branches.
*/
async loadBranches(dashboardId?: string, envId?: string | null): Promise<void> {
const ref = dashboardId || this.dashboardId;
const eid = envId !== undefined ? envId : this.envId;
if (!ref) return;
this.loading = true;
this.branchError = null;
try {
this.branches = await gitService.getBranches(ref, eid);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to load branches';
addToast((tt().git as Record<string, string>)?.load_branches_failed || msg, 'error');
this.branches = [];
} finally {
this.loading = false;
}
}
// ── Select (sync) ──────────────────────────────────────────
/**
* Handle dropdown selection — delegates to handleCheckout.
*/
handleSelect(event: { target?: { value?: string } }): void {
const branchName = event?.target?.value;
if (!branchName) return;
void this.handleCheckout(this.dashboardId, branchName, this.envId);
}
// ── Checkout ───────────────────────────────────────────────
/**
* Switch repository to the selected branch.
* @POST currentBranch updated; onChange callback fired on success.
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/checkout.
*/
async handleCheckout(dashboardId?: string, branchName?: string, envId?: string | null): Promise<void> {
const ref = dashboardId || this.dashboardId;
const eid = envId !== undefined ? envId : this.envId;
if (!branchName || !ref) return;
this.branchError = null;
this.checkingOut = true;
try {
await gitService.checkoutBranch(ref, branchName, eid);
this.currentBranch = branchName;
if (typeof this.onChange === 'function') {
this.onChange({ branch: branchName });
}
addToast(
((tt().git as Record<string, string>)?.switched_to || 'Switched to {branch}').replace('{branch}', branchName),
'success',
);
} catch (e: unknown) {
const err = e as Record<string, unknown>;
const detail = typeof err?.detail === 'object' ? err.detail as Record<string, unknown> : {};
const shortMsg = (detail?.message as string) || (detail?.message_en as string) || (err.message as string) || 'Branch switch failed';
this.branchError = {
message: shortMsg,
next_steps: Array.isArray(detail?.next_steps) ? detail.next_steps as string[] : [],
};
addToast(shortMsg, 'warning');
} finally {
this.checkingOut = false;
}
}
// ── Create Branch ──────────────────────────────────────────
/**
* Create a new branch from the current branch.
* @POST On success: branch list reloaded, create form closed, input cleared.
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/branches.
*/
async handleCreate(dashboardId?: string, branchName?: string, sourceBranch?: string, envId?: string | null): Promise<void> {
const ref = dashboardId || this.dashboardId;
const eid = envId !== undefined ? envId : this.envId;
const name = branchName || this.newBranchName;
const source = sourceBranch || this.currentBranch;
if (!name || !ref) return;
this.branchError = null;
this.creating = true;
try {
await gitService.createBranch(ref, name, source, eid);
addToast(
((tt().git as Record<string, string>)?.created_branch || 'Created branch {branch}').replace('{branch}', name),
'success',
);
this.showCreate = false;
this.newBranchName = '';
await this.loadBranches(ref, eid);
} catch (e: unknown) {
this.branchError = { message: e instanceof Error ? e.message : 'Branch creation failed', next_steps: [] };
addToast(e instanceof Error ? e.message : 'Branch creation failed', 'error');
} finally {
this.creating = false;
}
}
// ── Merge Branch ───────────────────────────────────────────
/**
* Merge source branch into target branch with optional auto-delete.
* @POST mergeResult populated; branches reloaded on success.
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/merge.
*/
async handleMerge(
sourceBranch: string,
targetBranch: string,
message: string | null = null,
autoDelete = false,
): Promise<void> {
if (!sourceBranch || !targetBranch || !this.dashboardId) return;
this.merging = true;
this.mergeResult = null;
this.mergeSourceBranch = sourceBranch;
this.mergeTargetBranch = targetBranch;
this.branchError = null;
try {
const res = await gitService.mergeBranch(
this.dashboardId, sourceBranch, targetBranch,
message, autoDelete, this.envId,
) as { status: string; conflicts?: string[]; source_deleted?: boolean };
this.mergeResult = res;
if (res.status === 'success') {
addToast(
'Merged ' + sourceBranch + ' → ' + targetBranch,
'success',
);
await this.loadBranches();
} else if (res.status === 'conflicts') {
const conflictFiles = (res.conflicts || []).join(', ');
addToast('Merge conflicts in: ' + conflictFiles, 'warning');
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Merge failed';
this.branchError = { message: msg, next_steps: [] };
addToast(msg, 'error');
} finally {
this.merging = false;
}
}
// ── Delete Branch ──────────────────────────────────────────
/**
* Delete a branch with confirmation state management.
* @POST Branch deleted; branches reloaded.
* @SIDE_EFFECT DELETEs /git/repositories/{ref}/branches/{name}.
*/
openDeleteConfirm(branchName: string): void {
this.branchToDelete = branchName;
this.showDeleteConfirm = true;
}
closeDeleteConfirm(): void {
this.branchToDelete = '';
this.showDeleteConfirm = false;
}
async handleDelete(branchName?: string, force = false): Promise<void> {
const name = branchName || this.branchToDelete;
if (!name || !this.dashboardId) return;
this.deleting = true;
this.branchError = null;
try {
await gitService.deleteBranch(this.dashboardId, name, force, this.envId);
addToast(
((tt().git as Record<string, string>)?.delete_branch?.success || 'Branch deleted').replace('{branch}', name),
'success',
);
this.closeDeleteConfirm();
await this.loadBranches();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Delete failed';
this.branchError = { message: msg, next_steps: [] };
addToast(msg, 'error');
} finally {
this.deleting = false;
}
}
// ── UI Actions (sync) ──────────────────────────────────────
/**
* Toggle create branch form visibility.
*/
toggleCreateForm(): void {
this.showCreate = !this.showCreate;
if (!this.showCreate) {
this.newBranchName = '';
}
}
}
// #endregion Git.BranchModel