Files
ss-tools/frontend/src/lib/models/CommitModel.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

167 lines
7.3 KiB
TypeScript

// frontend/src/lib/models/CommitModel.svelte.ts
// #region Git.CommitModel [C:4] [TYPE Model] [SEMANTICS git,commit,message,diff,screen-model]
// @ingroup Git
// @BRIEF State model for commit modal — message generation, status loading, commit with optional push.
// @INVARIANT commit and generateMessage are mutually exclusive — only one runs at a time.
// @STATE idle — No operation in progress, form ready.
// @STATE loading — Fetching git status and diff.
// @STATE generating — AI commit message generation in progress.
// @STATE committing — Commit (and optional push) in progress.
// @ACTION loadStatus() — Fetch git status + staged and unstaged diff.
// @ACTION handleGenerateMessage() — Generate AI commit message via API.
// @ACTION handleCommit() — Commit changes and optionally push.
// @RELATION DEPENDS_ON -> [EXT:frontend:gitService]
// @RELATION DEPENDS_ON -> [EXT:frontend:api]
// @RELATION DEPENDS_ON -> [ToastsModule]
// @REJECTED Inline $state in CommitModal.svelte rejected — model extraction enables L1 tests without DOM render,
// follows pattern established by GitManagerModel and GitStatusModel.
import { gitService } from '../../services/gitService.js';
import { api } from '$lib/api.js';
import { addToast } from '$lib/toasts.svelte.js';
import { getT } from '$lib/i18n/index.svelte.js';
// ── Types ─────────────────────────────────────────────────────
interface CommitStatusPayload {
is_dirty?: boolean;
current_branch?: string;
staged_files?: string[];
modified_files?: string[];
untracked_files?: string[];
[key: string]: unknown;
}
/** @returns Current i18n translations. */
function tt(): Record<string, unknown> {
return getT();
}
export class CommitModel {
// ── Identity ───────────────────────────────────────────────
/** Dashboard slug or reference for git API calls. */
dashboardId: string = $state('');
/** Environment ID for scoped git operations. */
envId: string | null = $state(null);
/** Modal visibility controlled externally. */
show: boolean = $state(false);
// ── Commit Message ─────────────────────────────────────────
/** Current commit message text. */
message: string = $state('');
/** True while a commit is in progress. */
committing: boolean = $state(false);
/** True while AI message generation is in progress. */
generatingMessage: boolean = $state(false);
/** True to auto-push after commit. */
autoPushAfterCommit: boolean = $state(true);
// ── Git Status ─────────────────────────────────────────────
/** Git status payload (is_dirty, staged_files, etc.). */
status: CommitStatusPayload | null = $state(null);
/** Combined staged + unstaged diff string. */
diff: string = $state('');
/** True while loading status and diff. */
loading: boolean = $state(false);
// ── Derived ────────────────────────────────────────────────
/** Commit button is disabled when there's nothing to commit. */
canCommit: boolean = $derived.by(() => {
if (this.committing || this.loading) return false;
if (!this.message) return false;
if (!this.status) return false;
const hasChanges =
this.status.is_dirty ||
(Array.isArray(this.status.staged_files) && this.status.staged_files.length > 0);
return hasChanges;
});
constructor({ dashboardId = '', envId = null, show = false }: { dashboardId?: string; envId?: string | null; show?: boolean } = {}) {
this.dashboardId = dashboardId;
this.envId = envId;
this.show = show;
}
// ── Status Loading ─────────────────────────────────────────
/**
* Fetch current git status and diff for the dashboard.
* @SIDE_EFFECT Sets status, diff, loading lifecycle.
*/
async loadStatus(): Promise<void> {
if (!this.dashboardId || !this.show) return;
this.loading = true;
try {
this.status = await gitService.getStatus(this.dashboardId, this.envId);
const unstagedDiff: string = await gitService.getDiff(this.dashboardId, null, false, this.envId);
const stagedDiff: string = await gitService.getDiff(this.dashboardId, null, true, this.envId);
let combined = '';
if (stagedDiff) combined += '--- STAGED CHANGES ---\n' + stagedDiff + '\n\n';
if (unstagedDiff) combined += '--- UNSTAGED CHANGES ---\n' + unstagedDiff;
this.diff = combined || '';
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : 'Failed to load status';
addToast((tt().git as Record<string, string>)?.load_changes_failed || errMsg, 'error');
this.status = null;
this.diff = '';
} finally {
this.loading = false;
}
}
// ── Generate Message ───────────────────────────────────────
/**
* Generate an AI commit message from the dashboard diff.
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/generate-message.
*/
async handleGenerateMessage(): Promise<void> {
this.generatingMessage = true;
try {
const data: { message?: string } = await api.postApi(
`/git/repositories/${encodeURIComponent(String(this.dashboardId))}/generate-message${
this.envId ? `?env_id=${encodeURIComponent(String(this.envId))}` : ''
}`,
undefined,
{ suppressToast: true },
);
this.message = data?.message || '';
addToast((tt().git as Record<string, string>)?.commit_message_generated || 'Commit message generated', 'success');
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : (tt().git as Record<string, string>)?.commit_message_failed || 'Failed to generate message', 'error');
} finally {
this.generatingMessage = false;
}
}
// ── Commit ─────────────────────────────────────────────────
/**
* Commit changes and optionally push to remote.
* @PRE message is non-empty; status has changes.
* @POST On success: message cleared, toast shown, modal closed.
*/
async handleCommit(): Promise<void> {
if (!this.message || !this.canCommit) return;
this.committing = true;
try {
await gitService.commit(this.dashboardId, this.message, [], this.envId);
if (this.autoPushAfterCommit) {
await gitService.push(this.dashboardId, this.envId);
addToast((tt().git as Record<string, string>)?.commit_and_push_success || 'Committed and pushed', 'success');
} else {
addToast((tt().git as Record<string, string>)?.commit_success || 'Committed successfully', 'success');
}
this.show = false;
this.message = '';
} catch (e: unknown) {
addToast(e instanceof Error ? e.message : 'Commit failed', 'error');
} finally {
this.committing = false;
}
}
}
// #endregion Git.CommitModel