2026-04-16 23:38:30 +02:00
|
|
|
import { useCallback, useRef, useState, type MutableRefObject } from 'react'
|
2026-04-12 19:38:25 +02:00
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
2026-04-16 23:38:30 +02:00
|
|
|
import type { GitPushResult, GitRemoteStatus, ModifiedFile } from '../types'
|
feat: implement PostHog event tracking plan
Add trackEvent() calls for all user actions in the tracking plan:
- Vault: vault_opened (with has_git, note_count), vault_switched
- Notes: note_created (with has_type, creation_path), note_deleted,
note_trashed, note_archived, note_favorited, note_unfavorited
- Git: commit_made, sync_triggered
- Search: search_used
- Editor: raw_mode_toggled, wikilink_inserted
- Types & Views: type_created, view_created
- Telemetry: telemetry_opted_in, telemetry_opted_out
All events gated by PostHog initialization (only fires when opted in).
No PII in any event properties — counts, booleans, and enums only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:42:43 +02:00
|
|
|
import { trackEvent } from '../lib/telemetry'
|
2026-04-12 19:38:25 +02:00
|
|
|
import { isTauri, mockInvoke } from '../mock-tauri'
|
2026-04-16 23:38:30 +02:00
|
|
|
import { generateAutomaticCommitMessage } from '../utils/automaticCommitMessage'
|
2026-04-12 19:38:25 +02:00
|
|
|
|
|
|
|
|
export type CommitMode = 'push' | 'local'
|
|
|
|
|
|
|
|
|
|
interface LocalCommitResult {
|
|
|
|
|
status: 'local_only'
|
|
|
|
|
message: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CommitResult = GitPushResult | LocalCommitResult
|
2026-04-16 23:38:30 +02:00
|
|
|
type CheckpointAction = 'commit' | 'push_only'
|
|
|
|
|
|
|
|
|
|
interface AutomaticCheckpointOptions {
|
|
|
|
|
savePendingBeforeCommit?: boolean
|
|
|
|
|
}
|
2026-02-23 20:34:12 +01:00
|
|
|
|
|
|
|
|
interface CommitFlowConfig {
|
2026-02-24 12:19:09 +01:00
|
|
|
savePending: () => Promise<void | boolean>
|
2026-02-23 20:34:12 +01:00
|
|
|
loadModifiedFiles: () => Promise<void>
|
2026-04-12 19:38:25 +02:00
|
|
|
resolveRemoteStatus: () => Promise<GitRemoteStatus | null>
|
2026-02-23 20:34:12 +01:00
|
|
|
setToastMessage: (msg: string | null) => void
|
2026-03-19 09:51:06 +01:00
|
|
|
onPushRejected?: () => void
|
2026-04-12 19:38:25 +02:00
|
|
|
vaultPath: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
interface VaultPathArgs {
|
|
|
|
|
vaultPath: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CommitArgs extends VaultPathArgs {
|
|
|
|
|
message: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CommitExecutionArgs extends CommitArgs {
|
|
|
|
|
commitMode: CommitMode
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AutomaticCheckpointContext extends VaultPathArgs {
|
|
|
|
|
remoteStatus: GitRemoteStatus | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AutomaticCheckpointCommand extends AutomaticCheckpointContext {
|
|
|
|
|
action: CheckpointAction
|
|
|
|
|
message?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ExecutedCheckpoint {
|
|
|
|
|
action: CheckpointAction
|
|
|
|
|
result: CommitResult
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 19:38:25 +02:00
|
|
|
function commitModeFromRemoteStatus(remoteStatus: GitRemoteStatus | null): CommitMode {
|
|
|
|
|
return remoteStatus?.hasRemote === false ? 'local' : 'push'
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
async function commitLocally({ vaultPath, message }: CommitArgs): Promise<void> {
|
2026-04-12 19:38:25 +02:00
|
|
|
if (!isTauri()) {
|
|
|
|
|
await mockInvoke<string>('git_commit', { vaultPath, message })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await invoke<string>('git_commit', { vaultPath, message })
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
async function pushCommittedChanges({ vaultPath }: VaultPathArgs): Promise<GitPushResult> {
|
2026-04-12 19:38:25 +02:00
|
|
|
if (!isTauri()) {
|
|
|
|
|
return mockInvoke<GitPushResult>('git_push', { vaultPath })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return invoke<GitPushResult>('git_push', { vaultPath })
|
2026-02-23 20:34:12 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
async function readModifiedFiles({ vaultPath }: VaultPathArgs): Promise<ModifiedFile[]> {
|
|
|
|
|
if (!isTauri()) {
|
|
|
|
|
return mockInvoke<ModifiedFile[]>('get_modified_files', { vaultPath })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return invoke<ModifiedFile[]>('get_modified_files', { vaultPath })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function executeCommitAction({
|
|
|
|
|
vaultPath,
|
|
|
|
|
message,
|
|
|
|
|
commitMode,
|
|
|
|
|
}: CommitExecutionArgs): Promise<CommitResult> {
|
|
|
|
|
await commitLocally({ vaultPath, message })
|
2026-04-12 19:38:25 +02:00
|
|
|
if (commitMode === 'local') {
|
|
|
|
|
return { status: 'local_only', message: 'Committed locally (no remote configured)' }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
return pushCommittedChanges({ vaultPath })
|
2026-04-12 19:38:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commitToastMessage(result: CommitResult): string {
|
|
|
|
|
if (result.status === 'ok') return 'Committed and pushed'
|
|
|
|
|
if (result.status === 'local_only') return result.message
|
|
|
|
|
if (result.status === 'rejected') return 'Committed, but push rejected — remote has new commits. Pull first.'
|
|
|
|
|
return result.message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isPushRejected(result: CommitResult): boolean {
|
|
|
|
|
return result.status === 'rejected'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatCommitError(error: unknown): string {
|
|
|
|
|
return error instanceof Error ? error.message : String(error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
function shouldRetryPush(remoteStatus: GitRemoteStatus | null): boolean {
|
|
|
|
|
return remoteStatus?.hasRemote === true && remoteStatus.ahead > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nothingToCommitToast(remoteStatus: GitRemoteStatus | null): string {
|
|
|
|
|
return remoteStatus?.hasRemote === false ? 'Nothing to commit' : 'Nothing to commit or push'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function checkpointToastMessage(result: CommitResult, action: CheckpointAction): string {
|
|
|
|
|
if (action === 'push_only') {
|
|
|
|
|
if (result.status === 'ok') return 'Pushed committed changes'
|
|
|
|
|
if (result.status === 'rejected') return 'Push rejected — remote has new commits. Pull first.'
|
|
|
|
|
return result.message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return commitToastMessage(result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createAutomaticCheckpointCommand({
|
|
|
|
|
remoteStatus,
|
|
|
|
|
vaultPath,
|
|
|
|
|
message,
|
|
|
|
|
}: AutomaticCheckpointContext & { message: string }): AutomaticCheckpointCommand | null {
|
|
|
|
|
if (message.length > 0) {
|
|
|
|
|
return { action: 'commit', remoteStatus, vaultPath, message }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shouldRetryPush(remoteStatus)) {
|
|
|
|
|
return { action: 'push_only', remoteStatus, vaultPath }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function executeAutomaticCheckpoint(
|
|
|
|
|
command: AutomaticCheckpointCommand,
|
|
|
|
|
): Promise<ExecutedCheckpoint> {
|
|
|
|
|
if (command.action === 'push_only') {
|
|
|
|
|
return {
|
|
|
|
|
action: 'push_only',
|
|
|
|
|
result: await pushCommittedChanges({ vaultPath: command.vaultPath }),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await executeCommitAction({
|
|
|
|
|
vaultPath: command.vaultPath,
|
|
|
|
|
message: command.message ?? '',
|
|
|
|
|
commitMode: commitModeFromRemoteStatus(command.remoteStatus),
|
|
|
|
|
})
|
|
|
|
|
trackEvent('commit_made')
|
|
|
|
|
return { action: 'commit', result }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runCheckpointRefresh({
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
}: Pick<CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatus'>): Promise<void> {
|
|
|
|
|
await loadModifiedFiles()
|
|
|
|
|
await resolveRemoteStatus()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function finalizeCheckpoint({
|
|
|
|
|
result,
|
|
|
|
|
toastMessage,
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected,
|
|
|
|
|
}: Pick<CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatus' | 'setToastMessage' | 'onPushRejected'> & {
|
|
|
|
|
result: CommitResult
|
|
|
|
|
toastMessage: string
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
setToastMessage(toastMessage)
|
|
|
|
|
if (isPushRejected(result)) {
|
|
|
|
|
onPushRejected?.()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await runCheckpointRefresh({ loadModifiedFiles, resolveRemoteStatus })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useAutomaticCheckpointAction({
|
|
|
|
|
checkpointInFlightRef,
|
|
|
|
|
savePending,
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected,
|
|
|
|
|
vaultPath,
|
|
|
|
|
}: CommitFlowConfig & {
|
|
|
|
|
checkpointInFlightRef: MutableRefObject<boolean>
|
|
|
|
|
}) {
|
|
|
|
|
return useCallback(async ({
|
|
|
|
|
savePendingBeforeCommit = false,
|
|
|
|
|
}: AutomaticCheckpointOptions = {}): Promise<boolean> => {
|
|
|
|
|
if (checkpointInFlightRef.current) return false
|
|
|
|
|
checkpointInFlightRef.current = true
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (savePendingBeforeCommit) {
|
|
|
|
|
await savePending()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const remoteStatus = await resolveRemoteStatus()
|
|
|
|
|
const modifiedFiles = await readModifiedFiles({ vaultPath })
|
|
|
|
|
const message = generateAutomaticCommitMessage(modifiedFiles)
|
|
|
|
|
const command = createAutomaticCheckpointCommand({ remoteStatus, vaultPath, message })
|
|
|
|
|
if (!command) {
|
|
|
|
|
setToastMessage(nothingToCommitToast(remoteStatus))
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { action, result } = await executeAutomaticCheckpoint(command)
|
|
|
|
|
await finalizeCheckpoint({
|
|
|
|
|
result,
|
|
|
|
|
toastMessage: checkpointToastMessage(result, action),
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected,
|
|
|
|
|
})
|
|
|
|
|
return true
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Commit failed:', err)
|
|
|
|
|
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
|
|
|
|
return false
|
|
|
|
|
} finally {
|
|
|
|
|
checkpointInFlightRef.current = false
|
|
|
|
|
}
|
|
|
|
|
}, [checkpointInFlightRef, loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useManualCommitPushAction({
|
|
|
|
|
checkpointInFlightRef,
|
|
|
|
|
savePending,
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected,
|
|
|
|
|
vaultPath,
|
|
|
|
|
setShowCommitDialog,
|
|
|
|
|
}: CommitFlowConfig & {
|
|
|
|
|
checkpointInFlightRef: MutableRefObject<boolean>
|
|
|
|
|
setShowCommitDialog: (open: boolean) => void
|
|
|
|
|
}) {
|
|
|
|
|
return useCallback(async (message: string) => {
|
|
|
|
|
setShowCommitDialog(false)
|
|
|
|
|
if (checkpointInFlightRef.current) return
|
|
|
|
|
checkpointInFlightRef.current = true
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await savePending()
|
|
|
|
|
const remoteStatus = await resolveRemoteStatus()
|
|
|
|
|
const result = await executeCommitAction({
|
|
|
|
|
vaultPath,
|
|
|
|
|
message,
|
|
|
|
|
commitMode: commitModeFromRemoteStatus(remoteStatus),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
trackEvent('commit_made')
|
|
|
|
|
await finalizeCheckpoint({
|
|
|
|
|
result,
|
|
|
|
|
toastMessage: commitToastMessage(result),
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected,
|
|
|
|
|
})
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Commit failed:', err)
|
|
|
|
|
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
|
|
|
|
} finally {
|
|
|
|
|
checkpointInFlightRef.current = false
|
|
|
|
|
}
|
|
|
|
|
}, [checkpointInFlightRef, loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setShowCommitDialog, setToastMessage, vaultPath])
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 19:38:25 +02:00
|
|
|
/** Manages the commit dialog state and the save→commit→push/local flow. */
|
|
|
|
|
export function useCommitFlow({
|
|
|
|
|
savePending,
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected,
|
|
|
|
|
vaultPath,
|
|
|
|
|
}: CommitFlowConfig) {
|
2026-02-23 20:34:12 +01:00
|
|
|
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
2026-04-12 19:38:25 +02:00
|
|
|
const [commitMode, setCommitMode] = useState<CommitMode>('push')
|
2026-04-16 23:38:30 +02:00
|
|
|
const checkpointInFlightRef = useRef(false)
|
2026-02-23 20:34:12 +01:00
|
|
|
|
|
|
|
|
const openCommitDialog = useCallback(async () => {
|
|
|
|
|
await savePending()
|
|
|
|
|
await loadModifiedFiles()
|
2026-04-12 19:38:25 +02:00
|
|
|
const remoteStatus = await resolveRemoteStatus()
|
|
|
|
|
setCommitMode(commitModeFromRemoteStatus(remoteStatus))
|
2026-02-23 20:34:12 +01:00
|
|
|
setShowCommitDialog(true)
|
2026-04-12 19:38:25 +02:00
|
|
|
}, [loadModifiedFiles, resolveRemoteStatus, savePending])
|
2026-02-23 20:34:12 +01:00
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
const runAutomaticCheckpoint = useAutomaticCheckpointAction({
|
|
|
|
|
checkpointInFlightRef,
|
|
|
|
|
savePending,
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected,
|
|
|
|
|
vaultPath,
|
|
|
|
|
})
|
2026-04-12 19:38:25 +02:00
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
const handleCommitPush = useManualCommitPushAction({
|
|
|
|
|
checkpointInFlightRef,
|
|
|
|
|
savePending,
|
|
|
|
|
loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected,
|
|
|
|
|
vaultPath,
|
|
|
|
|
setShowCommitDialog,
|
|
|
|
|
})
|
2026-02-23 20:34:12 +01:00
|
|
|
|
|
|
|
|
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog, runAutomaticCheckpoint }
|
2026-02-23 20:34:12 +01:00
|
|
|
}
|