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.
167 lines
7.3 KiB
TypeScript
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
|