365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import type { DockviewApi } from "dockview-react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import { RIGHT_TOP_GROUP } from "@/lib/state/layout-manager";
|
|
import type { AppState } from "@/lib/state/store";
|
|
import type {
|
|
FileChangeFacet,
|
|
FileInfo,
|
|
GitStatusEntry,
|
|
} from "@/lib/state/slices/session-runtime/types";
|
|
import { djb2Hash } from "@/lib/utils/hash";
|
|
|
|
type DockviewPanel = NonNullable<ReturnType<DockviewApi["getPanel"]>>;
|
|
type ChangesMarkerState = Pick<AppState, "gitStatus" | "sessionCommits">;
|
|
|
|
export type ChangesMarker = {
|
|
count: number;
|
|
fingerprint: string;
|
|
};
|
|
|
|
type GitStatusFingerprintCacheEntry = {
|
|
repoName: string;
|
|
fileCount: number;
|
|
fingerprint: string;
|
|
};
|
|
|
|
export type ActivateChangesPanelResult =
|
|
| "activated"
|
|
| "blocked-agent-group"
|
|
| "blocked-layout"
|
|
| "no-api"
|
|
| "no-panel";
|
|
|
|
function groupContainsAgentSessionPanel(panel: DockviewPanel): boolean {
|
|
return panel.group.panels.some((p) => p.id === "chat" || p.id.startsWith("session:"));
|
|
}
|
|
|
|
function isDefaultChangesGroup(panel: DockviewPanel): boolean {
|
|
const panelIds = panel.group.panels.map((groupPanel) => groupPanel.id);
|
|
return panel.group.id === RIGHT_TOP_GROUP && panelIds.length === 2 && panelIds.includes("files");
|
|
}
|
|
|
|
function isDefaultLayoutProfile(): boolean {
|
|
const { activeLayoutProfile } = useDockviewStore.getState();
|
|
return activeLayoutProfile.kind === "built-in" && activeLayoutProfile.id === "default";
|
|
}
|
|
|
|
/** Activate the Changes panel only in the Default Files and Changes group. */
|
|
export function activateChangesPanel(
|
|
api: DockviewApi | null | undefined,
|
|
): ActivateChangesPanelResult {
|
|
if (!api) return "no-api";
|
|
|
|
const panel = api.getPanel("changes");
|
|
if (!panel) return "no-panel";
|
|
if (groupContainsAgentSessionPanel(panel)) return "blocked-agent-group";
|
|
if (!isDefaultLayoutProfile()) return "blocked-layout";
|
|
if (!isDefaultChangesGroup(panel)) return "blocked-layout";
|
|
|
|
panel.api.setActive();
|
|
return "activated";
|
|
}
|
|
|
|
export function autoActivateChangesPanel(): ActivateChangesPanelResult {
|
|
return activateChangesPanel(useDockviewStore.getState().api);
|
|
}
|
|
|
|
const fileFingerprintCache = new WeakMap<FileInfo, string>();
|
|
const gitStatusFingerprintCache = new WeakMap<GitStatusEntry, GitStatusFingerprintCacheEntry>();
|
|
|
|
function changeFacetFingerprint(facet: FileChangeFacet | undefined): string {
|
|
if (!facet) return "";
|
|
return [
|
|
facet.status,
|
|
facet.additions ?? 0,
|
|
facet.deletions ?? 0,
|
|
facet.old_path ?? "",
|
|
djb2Hash(facet.diff ?? ""),
|
|
facet.diff_skip_reason ?? "",
|
|
].join(":");
|
|
}
|
|
|
|
function fileFingerprint(file: FileInfo): string {
|
|
const cached = fileFingerprintCache.get(file);
|
|
if (cached !== undefined) return cached;
|
|
|
|
const fingerprint = [
|
|
file.path,
|
|
file.status,
|
|
file.staged ? "1" : "0",
|
|
file.additions ?? 0,
|
|
file.deletions ?? 0,
|
|
file.old_path ?? "",
|
|
djb2Hash(file.diff ?? ""),
|
|
file.diff_skip_reason ?? "",
|
|
changeFacetFingerprint(file.staged_change),
|
|
changeFacetFingerprint(file.unstaged_change),
|
|
file.repository_name ?? "",
|
|
].join(":");
|
|
fileFingerprintCache.set(file, fingerprint);
|
|
return fingerprint;
|
|
}
|
|
|
|
function gitStatusFingerprint(
|
|
repoName: string,
|
|
status: GitStatusEntry,
|
|
): GitStatusFingerprintCacheEntry {
|
|
const cached = gitStatusFingerprintCache.get(status);
|
|
if (cached?.repoName === repoName) return cached;
|
|
|
|
const fileKeys = Object.keys(status.files ?? {}).sort();
|
|
const files = fileKeys.map((path) => fileFingerprint(status.files[path])).join(",");
|
|
const entry = {
|
|
repoName,
|
|
fileCount: fileKeys.length,
|
|
fingerprint: [
|
|
repoName,
|
|
status.branch ?? "",
|
|
status.remote_branch ?? "",
|
|
status.ahead,
|
|
status.behind,
|
|
status.repository_name ?? "",
|
|
files,
|
|
].join("|"),
|
|
};
|
|
gitStatusFingerprintCache.set(status, entry);
|
|
return entry;
|
|
}
|
|
|
|
export function selectChangesMarkerByEnvironment(
|
|
state: ChangesMarkerState,
|
|
): Record<string, ChangesMarker> {
|
|
const envKeys = new Set([
|
|
...Object.keys(state.gitStatus.byEnvironmentRepo),
|
|
...Object.keys(state.sessionCommits.byEnvironmentId),
|
|
]);
|
|
const markers: Record<string, ChangesMarker> = {};
|
|
|
|
for (const envKey of envKeys) {
|
|
const commits = state.sessionCommits.byEnvironmentId[envKey] ?? [];
|
|
let count = commits.length;
|
|
const repoStatuses = state.gitStatus.byEnvironmentRepo[envKey] ?? {};
|
|
const repoFingerprint = Object.entries(repoStatuses)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([repoName, status]) => {
|
|
const entry = gitStatusFingerprint(repoName, status);
|
|
count += entry.fileCount;
|
|
return entry.fingerprint;
|
|
})
|
|
.join(";");
|
|
const commitFingerprint = commits.map((commit) => commit.commit_sha).join(",");
|
|
markers[envKey] = {
|
|
count,
|
|
fingerprint: `${repoFingerprint}#${commitFingerprint}`,
|
|
};
|
|
}
|
|
|
|
return markers;
|
|
}
|
|
|
|
function markerToSignal(marker: ChangesMarker): string {
|
|
// Zustand shallow comparison is stable for primitive values; count always
|
|
// lives left of the first NUL and the full fingerprint lives to the right.
|
|
return `${marker.count}\u0000${marker.fingerprint}`;
|
|
}
|
|
|
|
function signalToMarker(signal: string): ChangesMarker {
|
|
const separatorIndex = signal.indexOf("\u0000");
|
|
return {
|
|
count: Number(signal.slice(0, separatorIndex)),
|
|
fingerprint: signal.slice(separatorIndex + 1),
|
|
};
|
|
}
|
|
|
|
function signalsToMarkers(signalsByEnv: Record<string, string>): Record<string, ChangesMarker> {
|
|
return Object.fromEntries(
|
|
Object.entries(signalsByEnv).map(([envKey, signal]) => [envKey, signalToMarker(signal)]),
|
|
);
|
|
}
|
|
|
|
export function selectChangesSignalByEnvironment(
|
|
state: ChangesMarkerState,
|
|
): Record<string, string> {
|
|
const markers = selectChangesMarkerByEnvironment(state);
|
|
return Object.fromEntries(
|
|
Object.entries(markers).map(([envKey, marker]) => [envKey, markerToSignal(marker)]),
|
|
);
|
|
}
|
|
|
|
function shouldQueueInactiveFocus(args: {
|
|
envKey: string;
|
|
activeEnvKey: string | null;
|
|
previousActiveEnvKey: string | null;
|
|
previous: ChangesMarker;
|
|
next: ChangesMarker;
|
|
}): boolean {
|
|
const { envKey, activeEnvKey, previousActiveEnvKey, previous, next } = args;
|
|
const changed =
|
|
next.count > previous.count || (next.count > 0 && next.fingerprint !== previous.fingerprint);
|
|
if (!changed) return false;
|
|
return (
|
|
envKey !== activeEnvKey || (previousActiveEnvKey !== null && envKey !== previousActiveEnvKey)
|
|
);
|
|
}
|
|
|
|
export function migrateEnvironmentKeys(args: {
|
|
environmentIdBySessionId: Record<string, string>;
|
|
previousMarkers: Record<string, ChangesMarker>;
|
|
pendingEnvKeys: Set<string>;
|
|
}): void {
|
|
const { environmentIdBySessionId, previousMarkers, pendingEnvKeys } = args;
|
|
for (const [sessionId, envKey] of Object.entries(environmentIdBySessionId)) {
|
|
if (sessionId === envKey) continue;
|
|
if (pendingEnvKeys.delete(sessionId)) pendingEnvKeys.add(envKey);
|
|
if (previousMarkers[sessionId] && !previousMarkers[envKey]) {
|
|
previousMarkers[envKey] = previousMarkers[sessionId];
|
|
}
|
|
delete previousMarkers[sessionId];
|
|
}
|
|
}
|
|
|
|
export function markInactiveChangesIncreases(args: {
|
|
markersByEnv: Record<string, ChangesMarker>;
|
|
activeEnvKey: string | null;
|
|
previousActiveEnvKey: string | null;
|
|
previousMarkers: Record<string, ChangesMarker>;
|
|
pendingEnvKeys: Set<string>;
|
|
queueFirstObservedInactiveChanges?: boolean;
|
|
}): void {
|
|
const {
|
|
markersByEnv,
|
|
activeEnvKey,
|
|
previousActiveEnvKey,
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
queueFirstObservedInactiveChanges = false,
|
|
} = args;
|
|
for (const envKey of Object.keys(previousMarkers)) {
|
|
if (markersByEnv[envKey] !== undefined) continue;
|
|
delete previousMarkers[envKey];
|
|
pendingEnvKeys.delete(envKey);
|
|
}
|
|
for (const [envKey, marker] of Object.entries(markersByEnv)) {
|
|
const previous = previousMarkers[envKey];
|
|
previousMarkers[envKey] = marker;
|
|
if (marker.count === 0) {
|
|
pendingEnvKeys.delete(envKey);
|
|
continue;
|
|
}
|
|
if (previous === undefined) {
|
|
if (queueFirstObservedInactiveChanges && envKey !== activeEnvKey) {
|
|
pendingEnvKeys.add(envKey);
|
|
}
|
|
continue;
|
|
}
|
|
if (
|
|
shouldQueueInactiveFocus({
|
|
envKey,
|
|
activeEnvKey,
|
|
previousActiveEnvKey,
|
|
previous,
|
|
next: marker,
|
|
})
|
|
) {
|
|
pendingEnvKeys.add(envKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function shouldClearPendingChangesFocus(result: ActivateChangesPanelResult): boolean {
|
|
return result === "activated" || result === "no-panel";
|
|
}
|
|
|
|
export function applyChangesPanelAutoFocusState(args: {
|
|
signalsByEnv: Record<string, string>;
|
|
activeEnvKey: string | null;
|
|
previousActiveEnvKey: string | null;
|
|
environmentIdBySessionId: Record<string, string>;
|
|
previousMarkers: Record<string, ChangesMarker>;
|
|
pendingEnvKeys: Set<string>;
|
|
isRestoringLayout: boolean;
|
|
getIsRestoringLayout?: () => boolean;
|
|
isMaximized: boolean;
|
|
getIsMaximized?: () => boolean;
|
|
activate: () => ActivateChangesPanelResult;
|
|
}): string | null {
|
|
const {
|
|
signalsByEnv,
|
|
activeEnvKey,
|
|
previousActiveEnvKey,
|
|
environmentIdBySessionId,
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout,
|
|
getIsRestoringLayout,
|
|
isMaximized,
|
|
getIsMaximized,
|
|
activate,
|
|
} = args;
|
|
|
|
migrateEnvironmentKeys({
|
|
environmentIdBySessionId,
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
});
|
|
|
|
const markersByEnv = signalsToMarkers(signalsByEnv);
|
|
const hasPreviousMarkers = Object.keys(previousMarkers).length > 0;
|
|
|
|
markInactiveChangesIncreases({
|
|
markersByEnv,
|
|
activeEnvKey,
|
|
previousActiveEnvKey,
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
queueFirstObservedInactiveChanges: hasPreviousMarkers,
|
|
});
|
|
|
|
const isCurrentlyRestoringLayout = () => isRestoringLayout || getIsRestoringLayout?.() === true;
|
|
const isCurrentlyMaximized = () => isMaximized || getIsMaximized?.() === true;
|
|
|
|
if (activeEnvKey && !isCurrentlyRestoringLayout() && pendingEnvKeys.has(activeEnvKey)) {
|
|
const result = activate();
|
|
if (
|
|
shouldClearPendingChangesFocus(result) &&
|
|
!(result === "no-panel" && (isCurrentlyRestoringLayout() || isCurrentlyMaximized()))
|
|
) {
|
|
pendingEnvKeys.delete(activeEnvKey);
|
|
}
|
|
}
|
|
|
|
return activeEnvKey;
|
|
}
|
|
|
|
export function useChangesPanelAutoFocus(activeEnvKey: string | null) {
|
|
const api = useDockviewStore((s) => s.api);
|
|
const isRestoringLayout = useDockviewStore((s) => s.isRestoringLayout);
|
|
const isMaximized = useDockviewStore((s) => s.preMaximizeLayout !== null);
|
|
const signalsByEnv = useAppStore(useShallow(selectChangesSignalByEnvironment));
|
|
const environmentIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
|
|
const previousMarkersRef = useRef<Record<string, ChangesMarker>>({});
|
|
const pendingEnvKeysRef = useRef<Set<string>>(new Set());
|
|
const previousActiveEnvKeyRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
previousActiveEnvKeyRef.current = applyChangesPanelAutoFocusState({
|
|
signalsByEnv,
|
|
activeEnvKey,
|
|
previousActiveEnvKey: previousActiveEnvKeyRef.current,
|
|
environmentIdBySessionId,
|
|
previousMarkers: previousMarkersRef.current,
|
|
pendingEnvKeys: pendingEnvKeysRef.current,
|
|
isRestoringLayout,
|
|
getIsRestoringLayout: () => useDockviewStore.getState().isRestoringLayout,
|
|
isMaximized,
|
|
getIsMaximized: () => useDockviewStore.getState().preMaximizeLayout !== null,
|
|
activate: () => activateChangesPanel(api),
|
|
});
|
|
}, [signalsByEnv, activeEnvKey, api, isRestoringLayout, isMaximized, environmentIdBySessionId]);
|
|
}
|