// 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 { 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 { 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)?.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 { 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)?.switched_to || 'Switched to {branch}').replace('{branch}', branchName), 'success', ); } catch (e: unknown) { const err = e as Record; const detail = typeof err?.detail === 'object' ? err.detail as Record : {}; 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 { 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)?.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 { 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 { 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)?.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