feat(tracking): enhance event tracking for repository actions

This update significantly improves the event tracking system within the
application. Various user interactions, such as toggling favorite
repositories, managing branches, and handling file operations, are now
logged with detailed event tracking, providing better insights into user
behavior and application usage.

- Added tracking for repository management actions and file operations
- Enhanced tracking for branch and tag management events
- Implemented tracking for explorer interactions and conflict resolutions
This commit is contained in:
Christoph Brandau
2026-07-08 14:17:51 +02:00
parent 7589a38564
commit de6f497e35
+190 -4
View File
@@ -1043,10 +1043,14 @@
function toggleFavoriteRepo(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
favoriteRepoPaths = isFavoriteRepo(path)
const wasFavorite = isFavoriteRepo(path);
favoriteRepoPaths = wasFavorite
? favoriteRepoPaths.filter((favoritePath) => !sameRepoPath(favoritePath, path))
: uniqueRepoPaths([path, ...favoriteRepoPaths]);
persistRepoLists();
trackEvent(wasFavorite ? "repository_favorite_removed" : "repository_favorite_added", {
favorite_repositories: favoriteRepoPaths.length,
});
}
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
@@ -1311,6 +1315,7 @@
});
if (typeof selected !== "string") return;
repoPath = selected;
trackEvent("repository_folder_selected");
await openRepo(selected);
} catch (error) {
errorMessage = errorToMessage(error);
@@ -1373,6 +1378,10 @@
credDialogKey = null;
}
lastRepoSwitchAt = Date.now();
trackEvent("repository_cloned", {
changed_files: bundle.status.files.length,
has_upstream: bundle.status.upstream ? 1 : 0,
});
} catch (error) {
const rawMessage = errorToMessage(error);
const message = stripAuthPrefix(rawMessage);
@@ -1401,17 +1410,26 @@
if (isBusy) return;
setCloneDialogError("");
cloneDialogOpen = true;
trackEvent("clone_dialog_opened");
}
function openRepoManagement() {
if (isBusy) return;
activeView = "management";
trackEvent("repository_management_opened", {
open_repositories: repoTabs.length,
recent_repositories: recentRepoPaths.length,
favorite_repositories: favoriteRepoPaths.length,
});
void backgroundRepoStatusTick(false);
}
async function selectRepoTab(path: string) {
if (isBusy) return;
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
trackEvent("repository_tab_selected", {
open_repositories: repoTabs.length,
});
await openRepo(path);
}
@@ -1422,10 +1440,15 @@
const index = repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
const remaining = repoTabs.filter((tab) => !sameRepoPath(tab.path, path));
const next = remaining[index] ?? remaining[index - 1] ?? null;
const wasActive = sameRepoPath(activeRepoPath, path);
repoTabs = remaining;
persistRepoLists();
trackEvent("repository_tab_closed", {
open_repositories: repoTabs.length,
was_active: wasActive ? 1 : 0,
});
if (!sameRepoPath(activeRepoPath, path)) return;
if (!wasActive) return;
if (next) {
await openRepo(next.path);
} else {
@@ -1437,15 +1460,23 @@
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
const wasOpen = repoTabs.some((tab) => sameRepoPath(tab.path, path));
const wasFavorite = isFavoriteRepo(path);
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
favoriteRepoPaths = favoriteRepoPaths.filter((favoritePath) => !sameRepoPath(favoritePath, path));
persistRepoLists();
trackEvent("repository_removed_from_management", {
was_open: wasOpen ? 1 : 0,
was_favorite: wasFavorite ? 1 : 0,
recent_repositories: recentRepoPaths.length,
favorite_repositories: favoriteRepoPaths.length,
});
if (repoStatusCache[repoKey(path)]) {
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
repoStatusCache = rest;
persistRepoStatusCache();
}
if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) {
if (wasOpen) {
await closeRepoTab(path);
}
}
@@ -1454,6 +1485,7 @@
if (!activeRepoPath || isBusy) return;
try {
await openRepoInExplorer(activeRepoPath);
trackEvent("repository_opened_in_explorer");
} catch (error) {
errorMessage = errorToMessage(error);
}
@@ -1468,6 +1500,9 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("repository_refreshed", {
changed_files: status?.files.length ?? 0,
});
});
}
@@ -1479,6 +1514,9 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("branch_checked_out", {
remote: branch.remote ? 1 : 0,
});
});
}
@@ -1491,12 +1529,14 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("branch_created");
});
}
function renameLocalBranch(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return;
renameBranchTarget = branch;
trackEvent("branch_rename_dialog_opened");
}
async function submitRenameBranch(branchName: string) {
@@ -1511,6 +1551,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("branch_renamed");
});
}
@@ -1523,6 +1564,7 @@
deleteBranchTarget = branch;
deleteBranchForce = false;
trackEvent("branch_delete_dialog_opened");
}
async function confirmDeleteBranch() {
@@ -1532,6 +1574,7 @@
operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`;
errorMessage = "";
try {
const forceDelete = deleteBranchForce;
applyStatus(await deleteBranch(activeRepoPath, branch.name, deleteBranchForce));
deleteBranchTarget = null;
deleteBranchForce = false;
@@ -1539,6 +1582,9 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("branch_deleted", {
force: forceDelete ? 1 : 0,
});
} catch (error) {
const message = errorToMessage(error);
if (deleteBranchForce || !isBranchNotFullyMergedError(message)) {
@@ -1561,6 +1607,7 @@
function openNewBranchDialog(commit: GitCommit) {
if (!activeRepoPath || isBusy) return;
newBranchCommit = commit;
trackEvent("branch_from_commit_dialog_opened");
}
async function createBranchFromCommit(branchName: string) {
@@ -1574,6 +1621,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("branch_created_from_commit");
});
}
@@ -1585,6 +1633,9 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("branch_merged", {
remote: branch.remote ? 1 : 0,
});
});
}
@@ -1596,6 +1647,9 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("branch_rebased", {
remote: branch.remote ? 1 : 0,
});
});
}
@@ -1607,6 +1661,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("rebase_continued");
});
}
@@ -1625,6 +1680,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("rebase_aborted");
});
}
@@ -1633,6 +1689,9 @@
if (!activeRepoPath || !trimmed) return;
await runOperation(`Creating tag ${trimmed}`, async () => {
await refreshTags(activeRepoPath, await createTag(activeRepoPath, trimmed, undefined, message));
trackEvent("tag_created", {
annotated: message.trim() ? 1 : 0,
});
});
}
@@ -1643,6 +1702,7 @@
await runOperation(`Deleting tag ${tag.name}`, async () => {
await refreshTags(activeRepoPath, await deleteTag(activeRepoPath, tag.name));
trackEvent("tag_deleted");
});
}
@@ -1663,6 +1723,7 @@
: stripAuthPrefix(message),
);
}
trackEvent("tag_pushed");
});
}
@@ -1674,6 +1735,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("commit_cherry_picked");
});
}
@@ -1685,6 +1747,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("cherry_pick_continued");
});
}
@@ -1703,6 +1766,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("cherry_pick_aborted");
});
}
@@ -1732,6 +1796,9 @@
credDialogAction = action;
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
credDialogOpen = true;
trackEvent("credential_dialog_opened", {
action,
});
}
// Post-process a pull/push result: surface errors, and on rejected/expired
@@ -1776,6 +1843,10 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("repository_pulled", {
from_stored_credential: fromStore ? 1 : 0,
changed_files: status?.files.length ?? 0,
});
});
handleRemoteResult("pull", key, fromStore);
}
@@ -1789,6 +1860,11 @@
errorMessage = "";
await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password));
trackEvent("repository_fetched", {
from_stored_credential: fromStore ? 1 : 0,
ahead: status?.ahead ?? 0,
behind: status?.behind ?? 0,
});
});
handleRemoteResult("fetch", key, fromStore);
}
@@ -1805,6 +1881,10 @@
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("repository_pushed", {
from_stored_credential: fromStore ? 1 : 0,
changed_files: status?.files.length ?? 0,
});
});
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
@@ -1847,6 +1927,10 @@
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("repository_pushed_after_pull", {
from_stored_credential: fromStore ? 1 : 0,
changed_files: status?.files.length ?? 0,
});
});
}
@@ -1887,6 +1971,9 @@
async function startRemoteAction(action: "push" | "pull" | "fetch") {
if (!activeRepoPath) return;
trackEvent("remote_action_started", {
action,
});
const key = await currentCredKey();
const stored = await loadStoredCredential(key);
@@ -1916,11 +2003,16 @@
async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return;
const stashedFiles = changedFiles.length;
await runOperation("Stashing changes", async () => {
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("stash_saved", {
include_untracked: includeUntracked ? 1 : 0,
changed_files: stashedFiles,
});
});
}
@@ -1931,6 +2023,7 @@
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("stash_applied");
});
}
@@ -1941,6 +2034,7 @@
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("stash_popped");
});
}
@@ -1952,6 +2046,7 @@
await runOperation(`Dropping ${stash.selector}`, async () => {
applyStatus(await stashDrop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
trackEvent("stash_dropped");
});
}
@@ -1961,6 +2056,9 @@
await runOperation(`Staging ${file.path}`, async () => {
applyStatus(await stageFiles(activeRepoPath, [file.path]));
await refreshExplorerFiles(activeRepoPath);
trackEvent("file_staged", {
status: file.unstaged ?? file.staged ?? "unknown",
});
});
}
@@ -1968,12 +2066,19 @@
await runOperation(`Unstaging ${file.path}`, async () => {
applyStatus(await unstageFiles(activeRepoPath, [file.path]));
await refreshExplorerFiles(activeRepoPath);
trackEvent("file_unstaged", {
status: file.staged ?? file.unstaged ?? "unknown",
});
});
}
function discardFile(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath || isBusy) return;
pendingDiscard = { kind: "file", file, staged };
trackEvent("discard_confirm_opened", {
kind: "file",
staged: staged ? 1 : 0,
});
}
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
@@ -1981,6 +2086,9 @@
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("file_discarded", {
staged: staged ? 1 : 0,
});
});
}
@@ -1992,6 +2100,9 @@
linePatchText = "";
linePatchError = "";
linePatchLoading = true;
trackEvent("line_patch_opened", {
staged: staged ? 1 : 0,
});
try {
linePatchText = await getFilePatch(activeRepoPath, file.path, staged);
@@ -2083,6 +2194,9 @@
linePatchFile = null;
linePatchText = "";
}
trackEvent("line_patch_applied", {
action,
});
} catch (error) {
linePatchError = errorToMessage(error);
errorMessage = linePatchError;
@@ -2098,6 +2212,10 @@
if (isDiscardPatchAction(action)) {
pendingDiscard = { kind: "hunk", file, staged, action, patch };
trackEvent("discard_confirm_opened", {
kind: "hunk",
staged: staged ? 1 : 0,
});
return;
}
@@ -2128,6 +2246,9 @@
await runOperation("Staging all", async () => {
applyStatus(await stageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath);
trackEvent("all_files_staged", {
files: paths.length,
});
});
}
@@ -2137,6 +2258,9 @@
await runOperation("Unstaging all", async () => {
applyStatus(await unstageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath);
trackEvent("all_files_unstaged", {
files: paths.length,
});
});
}
@@ -2227,6 +2351,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("commit_undone");
});
}
@@ -2242,6 +2367,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("commit_restored");
});
}
@@ -2253,6 +2379,7 @@
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("commit_file_restored");
});
return !errorMessage;
}
@@ -2268,6 +2395,10 @@
selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path;
pendingRestoreFile = { commit: target, file };
compareDialogOpen = true;
trackEvent("diff_opened", {
source: "commit_file",
files: result.files.length,
});
});
}
@@ -2276,16 +2407,25 @@
function toggleExplorerFolder(node: ExplorerNode) {
if (node.kind !== "folder") return;
const next = new Set(expandedExplorerPaths);
if (next.has(node.path)) next.delete(node.path); else next.add(node.path);
const wasExpanded = next.has(node.path);
if (wasExpanded) next.delete(node.path); else next.add(node.path);
expandedExplorerPaths = next;
trackEvent("explorer_folder_toggled", {
expanded: wasExpanded ? 0 : 1,
expanded_folders: expandedExplorerPaths.size,
});
}
function expandAllExplorerFolders() {
expandedExplorerPaths = allExplorerFolderPaths(repoFiles);
trackEvent("explorer_folders_expanded", {
folders: expandedExplorerPaths.size,
});
}
function collapseAllExplorerFolders() {
expandedExplorerPaths = new Set();
trackEvent("explorer_folders_collapsed");
}
function explorerParentFolders(path: string): string[] {
@@ -2311,6 +2451,10 @@
selectedExplorerPath = node.path;
selectedExplorerKind = node.kind;
void loadSelectedFileHistory(node.path);
trackEvent("explorer_node_selected", {
kind: node.kind,
tracked: node.tracked ? 1 : 0,
});
}
async function selectFileFromSearch(file: GitRepositoryFile) {
@@ -2320,6 +2464,10 @@
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
void loadSelectedFileHistory(file.path);
trackEvent("explorer_file_selected", {
source: "search",
tracked: file.tracked ? 1 : 0,
});
}
function selectFileFromStatus(file: GitFileStatus) {
@@ -2329,6 +2477,10 @@
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
void loadSelectedFileHistory(file.path);
trackEvent("explorer_file_selected", {
source: "status",
status: file.unstaged ?? file.staged ?? "unknown",
});
}
async function openFileFromExplorer(node: ExplorerNode) {
@@ -2339,6 +2491,9 @@
try {
await openRepositoryFile(activeRepoPath, node.path);
trackEvent("explorer_file_opened", {
tracked: node.tracked ? 1 : 0,
});
} catch (error) {
errorMessage = errorToMessage(error);
}
@@ -2353,6 +2508,9 @@
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("selected_file_restored_from_commit", {
kind,
});
});
}
@@ -2379,6 +2537,9 @@
pendingRestoreFile = null;
compareSelectOpen = false;
compareDialogOpen = true;
trackEvent("compare_completed", {
files: result.files.length,
});
});
}
@@ -2391,6 +2552,10 @@
diffHighlightQuery = "";
pendingRestoreFile = null;
compareDialogOpen = true;
trackEvent("diff_opened", {
source: "file_history",
files: result.files.length,
});
});
}
@@ -2403,12 +2568,17 @@
diffHighlightQuery = lastSearchQuery;
pendingRestoreFile = null;
compareDialogOpen = true;
trackEvent("diff_opened", {
source: "search_hit",
files: result.files.length,
});
});
}
function closeCompareDialog() {
compareDialogOpen = false;
pendingRestoreFile = null;
trackEvent("compare_closed");
}
async function restorePreviewedCommitFile() {
@@ -2419,6 +2589,10 @@
function selectDiffFile(file: GitDiffFile) {
selectedDiffPath = file.path;
trackEvent("diff_file_selected", {
additions: file.additions,
deletions: file.deletions,
});
}
async function runGlobalSearch(query: string, caseSensitive: boolean, limit: number) {
@@ -2479,6 +2653,9 @@
preparedResolutions = {};
resolveDialogOpen = true;
await loadConflict(first);
trackEvent("resolve_dialog_opened", {
conflicts: conflictedFiles.length,
});
});
}
@@ -2486,11 +2663,16 @@
if (path === conflictTarget || isBusy) return;
await runOperation(`Loading ${path}`, async () => {
await loadConflict(path);
trackEvent("conflict_file_selected");
});
}
async function handleMarkResolved(path: string, resolution: PreparedResolution) {
preparedResolutions = { ...preparedResolutions, [path]: resolution };
trackEvent("conflict_resolution_prepared", {
kind: resolution.kind,
prepared_files: Object.keys(preparedResolutions).length,
});
const next = conflictedFiles.find((f) => f.path !== path && preparedResolutions[f.path] == null);
if (next) {
@@ -2525,6 +2707,10 @@
} else {
await loadConflict(remaining[0].path);
}
trackEvent("conflicts_resolved", {
files: entries.length,
remaining: remaining.length,
});
});
}