git list refactor

This commit is contained in:
2026-03-01 12:13:19 +03:00
parent a3a127b590
commit 0a09fb5e6d
26 changed files with 10313 additions and 2179 deletions

View File

@@ -0,0 +1,613 @@
<!-- [DEF:DashboardGrid:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: dashboard, grid, selection, pagination
@PURPOSE: Displays a grid of dashboards with selection and pagination.
@LAYER: Component
@RELATION: USED_BY -> frontend/src/routes/migration/+page.svelte
@INVARIANT: Selected IDs must be a subset of available dashboards.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { createEventDispatcher, untrack } from "svelte";
import type { DashboardMetadata } from "../types/dashboard";
import { t } from "../lib/i18n";
import { Button, Input } from "../lib/ui";
import GitManager from "./git/GitManager.svelte";
import { gitService } from "../services/gitService";
import { addToast } from "../lib/toasts.js";
// [/SECTION]
// [SECTION: PROPS]
let { dashboards = [], selectedIds = [], statusMode = "dashboard" } = $props();
// [/SECTION]
// [SECTION: STATE]
let filterText = $state("");
let currentPage = $state(0);
let pageSize = $state(20);
let sortColumn: keyof DashboardMetadata = $state("title");
let sortDirection: "asc" | "desc" = $state("asc");
// [/SECTION]
// [SECTION: UI STATE]
let showGitManager = $state(false);
let gitDashboardId: number | null = $state(null);
let gitDashboardTitle = $state("");
let repositoryStatusByDashboardId = $state<Record<number, string>>({});
let repositoryStatusRequestId = $state(0);
let bulkActionRunning = $state(false);
// [/SECTION]
// [SECTION: DERIVED]
let filteredDashboards = $derived(
dashboards.filter((d) =>
d.title.toLowerCase().includes(filterText.toLowerCase()),
),
);
let sortedDashboards = $derived(
[...filteredDashboards].sort((a, b) => {
let aVal =
sortColumn === "status"
? getSortStatusValue(a)
: a[sortColumn];
let bVal =
sortColumn === "status"
? getSortStatusValue(b)
: b[sortColumn];
if (sortColumn === "id") {
aVal = Number(aVal);
bVal = Number(bVal);
}
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
return 0;
}),
);
let paginatedDashboards = $derived(
sortedDashboards.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize,
),
);
let totalPages = $derived(Math.ceil(sortedDashboards.length / pageSize));
let allSelected = $derived(
paginatedDashboards.length > 0 &&
paginatedDashboards.every((d) => selectedIds.includes(d.id)),
);
let someSelected = $derived(
paginatedDashboards.some((d) => selectedIds.includes(d.id)),
);
// [/SECTION]
// [SECTION: EVENTS]
const dispatch = createEventDispatcher<{ selectionChanged: number[] }>();
// [/SECTION]
// [DEF:handleSort:Function]
// @PURPOSE: Toggles sort direction or changes sort column.
// @PRE: column name is provided.
// @POST: sortColumn and sortDirection state updated.
function handleSort(column: keyof DashboardMetadata) {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortColumn = column;
sortDirection = "asc";
}
}
// [/DEF:handleSort:Function]
// [DEF:handleSelectionChange:Function]
// @PURPOSE: Handles individual checkbox changes.
// @PRE: dashboard ID and checked status provided.
// @POST: selectedIds array updated and selectionChanged event dispatched.
function handleSelectionChange(id: number, checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
if (!newSelected.includes(id)) newSelected.push(id);
} else {
newSelected = newSelected.filter((sid) => sid !== id);
}
selectedIds = newSelected;
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectionChange:Function]
// [DEF:handleSelectAll:Function]
// @PURPOSE: Handles select all checkbox.
// @PRE: checked status provided.
// @POST: selectedIds array updated for all paginated items and event dispatched.
function handleSelectAll(checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
paginatedDashboards.forEach((d) => {
if (!newSelected.includes(d.id)) newSelected.push(d.id);
});
} else {
paginatedDashboards.forEach((d) => {
newSelected = newSelected.filter((sid) => sid !== d.id);
});
}
selectedIds = newSelected;
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectAll:Function]
// [DEF:goToPage:Function]
// @PURPOSE: Changes current page.
// @PRE: page index is provided.
// @POST: currentPage state updated if within valid range.
function goToPage(page: number) {
if (page >= 0 && page < totalPages) {
currentPage = page;
}
}
// [/DEF:goToPage:Function]
// [DEF:getRepositoryStatusToken:Function]
/**
* @purpose Returns normalized repository status token for a dashboard.
* @pre Dashboard exists.
* @post Returns one of loading|no_repo|synced|changes|behind_remote|ahead_remote|diverged|error.
*/
function getRepositoryStatusToken(dashboardId: number): string {
return repositoryStatusByDashboardId[dashboardId] || "loading";
}
// [/DEF:getRepositoryStatusToken:Function]
// [DEF:isRepositoryReady:Function]
// @PURPOSE: Determines whether git actions can run for a dashboard.
function isRepositoryReady(dashboardId: number): boolean {
const token = getRepositoryStatusToken(dashboardId);
return token !== "loading" && token !== "no_repo" && token !== "error";
}
// [/DEF:isRepositoryReady:Function]
// [DEF:invalidateRepositoryStatuses:Function]
// @PURPOSE: Marks dashboard statuses as loading so they are refetched.
function invalidateRepositoryStatuses(dashboardIds: number[]): void {
if (dashboardIds.length === 0) return;
const nextStatuses = { ...repositoryStatusByDashboardId };
dashboardIds.forEach((dashboardId) => {
nextStatuses[dashboardId] = "loading";
});
repositoryStatusByDashboardId = nextStatuses;
}
// [/DEF:invalidateRepositoryStatuses:Function]
// [DEF:resolveRepositoryStatusToken:Function]
/**
* @purpose Converts git status payload into a stable UI status token.
*/
function resolveRepositoryStatusToken(status: any): string {
const syncState = String(status?.sync_state || "").toUpperCase();
if (syncState === "DIVERGED") return "diverged";
if (syncState === "BEHIND_REMOTE") return "behind_remote";
if (syncState === "AHEAD_REMOTE") return "ahead_remote";
if (syncState === "CHANGES") return "changes";
if (syncState === "SYNCED") return "synced";
const syncStatus = String(status?.sync_status || "").toUpperCase();
if (syncStatus === "NO_REPO") return "no_repo";
if (syncStatus === "ERROR") return "error";
if (syncStatus === "DIFF") return "changes";
if (syncStatus === "OK") return "synced";
const aheadCount = Number(status?.ahead_count || 0);
const behindCount = Number(status?.behind_count || 0);
if (aheadCount > 0 && behindCount > 0) return "diverged";
if (behindCount > 0) return "behind_remote";
if (aheadCount > 0) return "ahead_remote";
const hasChanges =
Boolean(status?.is_dirty) ||
(status?.untracked_files?.length || 0) > 0 ||
(status?.modified_files?.length || 0) > 0 ||
(status?.staged_files?.length || 0) > 0;
return hasChanges ? "changes" : "synced";
}
// [/DEF:resolveRepositoryStatusToken:Function]
// [DEF:loadRepositoryStatuses:Function]
/**
* @purpose Hydrates repository status map for dashboards in repository mode.
*/
async function loadRepositoryStatuses() {
if (statusMode !== "repository") {
repositoryStatusByDashboardId = {};
return;
}
const requestId = repositoryStatusRequestId + 1;
repositoryStatusRequestId = requestId;
const visibleDashboards = paginatedDashboards;
const visibleIds = visibleDashboards.map((dashboard) => dashboard.id);
if (visibleDashboards.length === 0) {
repositoryStatusByDashboardId = {};
return;
}
const missingIds = visibleIds.filter((dashboardId) => {
const token = repositoryStatusByDashboardId[dashboardId];
return token === undefined || token === "loading";
});
if (missingIds.length === 0) {
return;
}
const loadingStatuses = { ...repositoryStatusByDashboardId };
missingIds.forEach((dashboardId) => {
loadingStatuses[dashboardId] = "loading";
});
repositoryStatusByDashboardId = loadingStatuses;
let entries: Array<readonly [number, string]> = [];
try {
const batchResult = await gitService.getStatusesBatch(
missingIds,
);
const statusesByDashboardId = batchResult?.statuses || {};
entries = missingIds.map((dashboardId) => {
const status =
statusesByDashboardId[dashboardId] ??
statusesByDashboardId[String(dashboardId)];
if (!status) return [dashboardId, "error"] as const;
return [dashboardId, resolveRepositoryStatusToken(status)] as const;
});
} catch (error) {
entries = missingIds.map((dashboardId) => [dashboardId, "error"] as const);
}
if (requestId !== repositoryStatusRequestId) return;
repositoryStatusByDashboardId = {
...repositoryStatusByDashboardId,
...Object.fromEntries(entries),
};
}
// [/DEF:loadRepositoryStatuses:Function]
// [DEF:runBulkGitAction:Function]
// @PURPOSE: Executes git action for selected dashboards with limited parallelism.
async function runBulkGitAction(
actionToken: string,
action: (dashboardId: number) => Promise<void>,
): Promise<void> {
if (bulkActionRunning) return;
const selectedDashboardIds = selectedIds.filter((dashboardId) =>
isRepositoryReady(dashboardId),
);
if (selectedDashboardIds.length === 0) {
addToast($t.git?.no_repositories_selected, "error");
return;
}
bulkActionRunning = true;
const concurrency = 3;
const idsQueue = [...selectedDashboardIds];
let successCount = 0;
let failedCount = 0;
const worker = async () => {
while (idsQueue.length > 0) {
const dashboardId = idsQueue.shift();
if (dashboardId === undefined) break;
try {
await action(dashboardId);
successCount += 1;
} catch (_error) {
failedCount += 1;
}
}
};
try {
await Promise.all(
Array.from({ length: Math.min(concurrency, selectedDashboardIds.length) }, () => worker()),
);
invalidateRepositoryStatuses(selectedDashboardIds);
const actionLabel = $t.git?.[`bulk_action_${actionToken}`];
addToast(
$t.git?.bulk_result
.replace("{action}", actionLabel)
.replace("{success}", String(successCount))
.replace("{failed}", String(failedCount)),
failedCount > 0 ? "warning" : "success",
);
} finally {
bulkActionRunning = false;
}
}
// [/DEF:runBulkGitAction:Function]
// [DEF:handleBulkSync:Function]
async function handleBulkSync(): Promise<void> {
await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId));
}
// [/DEF:handleBulkSync:Function]
// [DEF:handleBulkCommit:Function]
async function handleBulkCommit(): Promise<void> {
const message = prompt($t.git?.commit_message);
if (!message?.trim()) return;
await runBulkGitAction("commit", (dashboardId) =>
gitService.commit(dashboardId, message.trim(), []),
);
}
// [/DEF:handleBulkCommit:Function]
// [DEF:handleBulkPull:Function]
async function handleBulkPull(): Promise<void> {
await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId));
}
// [/DEF:handleBulkPull:Function]
// [DEF:handleBulkPush:Function]
async function handleBulkPush(): Promise<void> {
await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId));
}
// [/DEF:handleBulkPush:Function]
// [DEF:handleManageSelected:Function]
// @PURPOSE: Opens Git manager for exactly one selected dashboard.
async function handleManageSelected(): Promise<void> {
if (selectedIds.length !== 1) {
addToast($t.git?.select_single_for_manage, "warning");
return;
}
const selectedDashboardId = selectedIds[0];
const selectedDashboard = dashboards.find(
(dashboard) => dashboard.id === selectedDashboardId,
);
gitDashboardId = selectedDashboardId;
gitDashboardTitle = selectedDashboard?.title || "";
showGitManager = true;
}
// [/DEF:handleManageSelected:Function]
// [DEF:getSortStatusValue:Function]
/**
* @purpose Returns sort value for status column based on mode.
*/
function getSortStatusValue(dashboard: DashboardMetadata): string {
if (statusMode === "repository") {
return getRepositoryStatusToken(dashboard.id);
}
return String(dashboard.status || "").toLowerCase();
}
// [/DEF:getSortStatusValue:Function]
// [DEF:getStatusLabel:Function]
/**
* @purpose Returns localized label for status column.
*/
function getStatusLabel(dashboard: DashboardMetadata): string {
if (statusMode !== "repository") {
return String(dashboard.status || "");
}
const token = getRepositoryStatusToken(dashboard.id);
return $t.git?.repo_status?.[token] || token;
}
// [/DEF:getStatusLabel:Function]
// [DEF:getStatusBadgeClass:Function]
/**
* @purpose Returns badge style for status column.
*/
function getStatusBadgeClass(dashboard: DashboardMetadata): string {
if (statusMode !== "repository") {
return dashboard.status === "published"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800";
}
const token = getRepositoryStatusToken(dashboard.id);
if (token === "loading") return "bg-slate-100 text-slate-500";
if (token === "no_repo") return "bg-gray-100 text-gray-800";
if (token === "synced") return "bg-green-100 text-green-800";
if (token === "changes") return "bg-amber-100 text-amber-800";
if (token === "behind_remote") return "bg-blue-100 text-blue-800";
if (token === "ahead_remote") return "bg-indigo-100 text-indigo-800";
if (token === "diverged") return "bg-purple-100 text-purple-800";
return "bg-rose-100 text-rose-800";
}
// [/DEF:getStatusBadgeClass:Function]
$effect(() => {
dashboards;
statusMode;
currentPage;
pageSize;
filterText;
sortColumn;
sortDirection;
untrack(() => {
void loadRepositoryStatuses();
});
});
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="dashboard-grid">
<!-- Filter Input -->
<div class="mb-6">
<Input bind:value={filterText} placeholder={$t.dashboard.search} />
</div>
{#if selectedIds.length > 0}
<div class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-blue-100 bg-blue-50/60 px-3 py-2">
<Button
size="sm"
variant="secondary"
onclick={handleManageSelected}
disabled={bulkActionRunning || selectedIds.length !== 1}
class="border-blue-200 bg-white text-blue-700 hover:bg-blue-50 disabled:opacity-40"
>
{$t.git?.manage_selected}
</Button>
<Button size="sm" variant="secondary" onclick={handleBulkSync} disabled={bulkActionRunning} class="border-blue-200 bg-white text-blue-700 hover:bg-blue-50">
{$t.git?.bulk_sync}
</Button>
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning} class="border-amber-200 bg-white text-amber-700 hover:bg-amber-50">
{$t.git?.bulk_commit}
</Button>
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning} class="border-cyan-200 bg-white text-cyan-700 hover:bg-cyan-50">
{$t.git?.bulk_pull}
</Button>
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning} class="border-indigo-200 bg-white text-indigo-700 hover:bg-indigo-50">
{$t.git?.bulk_push}
</Button>
<span class="ml-1 text-xs font-medium text-slate-600">
{$t.git?.selected_count.replace(
"{count}",
String(selectedIds.length),
)}
</span>
</div>
{/if}
<!-- Grid/Table -->
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left">
<input
type="checkbox"
checked={allSelected}
indeterminate={someSelected && !allSelected}
onchange={(e) =>
handleSelectAll((e.target as HTMLInputElement).checked)}
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
onclick={() => handleSort("title")}
>
{$t.dashboard.title}
{sortColumn === "title"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
onclick={() => handleSort("last_modified")}
>
{$t.dashboard.last_modified}
{sortColumn === "last_modified"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
onclick={() => handleSort("status")}
>
{$t.dashboard.status}
{sortColumn === "status"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each paginatedDashboards as dashboard (dashboard.id)}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedIds.includes(dashboard.id)}
onchange={(e) =>
handleSelectionChange(
dashboard.id,
(e.target as HTMLInputElement).checked,
)}
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"
>{dashboard.title}</td
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
>{new Date(dashboard.last_modified).toLocaleDateString()}</td
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span
class="px-2 py-1 text-xs font-medium rounded-full {getStatusBadgeClass(dashboard)}"
>
{getStatusLabel(dashboard)}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<div class="flex items-center justify-between mt-6">
<div class="text-sm text-gray-500">
{($t.dashboard?.showing )
.replace("{start}", (currentPage * pageSize + 1).toString())
.replace(
"{end}",
Math.min(
(currentPage + 1) * pageSize,
sortedDashboards.length,
).toString(),
)
.replace("{total}", sortedDashboards.length.toString())}
</div>
<div class="flex gap-2">
<Button
variant="secondary"
size="sm"
disabled={currentPage === 0}
onclick={() => goToPage(currentPage - 1)}
>
{$t.dashboard.previous}
</Button>
<Button
variant="secondary"
size="sm"
disabled={currentPage >= totalPages - 1}
onclick={() => goToPage(currentPage + 1)}
>
{$t.dashboard.next}
</Button>
</div>
</div>
</div>
{#if showGitManager && gitDashboardId}
<GitManager
dashboardId={gitDashboardId}
dashboardTitle={gitDashboardTitle}
bind:show={showGitManager}
/>
{/if}
<!-- [/SECTION] -->
<!-- [/DEF:DashboardGrid:Component] -->

View File

@@ -157,17 +157,56 @@
}
// [/DEF:handlePull:Function]
// [DEF:closeModal:Function]
/**
* @purpose Закрывает модальное окно управления Git.
* @post show=false.
*/
function closeModal() {
show = false;
}
// [/DEF:closeModal:Function]
// [DEF:handleBackdropClick:Function]
/**
* @purpose Закрывает модалку по клику на подложку.
* @pre Событие пришло с оверлея.
* @post show=false.
*/
function handleBackdropClick(event) {
if (event.target === event.currentTarget) {
closeModal();
}
}
// [/DEF:handleBackdropClick:Function]
onMount(checkStatus);
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-6 rounded-lg shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={handleBackdropClick}
>
<div
class="relative bg-white p-6 rounded-lg shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onclick={(event) => event.stopPropagation()}
>
<button
type="button"
onclick={closeModal}
class="absolute right-4 top-4 z-10 rounded p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
aria-label={$t.common?.close || "Close"}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<PageHeader title={`${$t.git?.management}: ${dashboardTitle}`}>
<div slot="subtitle" class="text-sm text-gray-500">{$t.common?.id}: {dashboardId}</div>
<div slot="actions">
<button onclick={() => show = false} class="text-gray-400 hover:text-gray-600 transition-colors">
<button type="button" onclick={closeModal} class="text-gray-400 hover:text-gray-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>