diff --git a/src/App.svelte b/src/App.svelte index c8e8c49..7eab195 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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, + }); }); }