diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index eb58ad1b..c05cff34 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -177,6 +177,8 @@ The status-bar workspace manager edits installation-local identity and mount sta Git-facing renderer code must pass an explicit repository path instead of assuming a single active vault. Changes and Pulse/history display one selected repository at a time, manual commit selects one target repository, and AutoGit checkpoints iterate every active repository. Diff, file history, note saves, and discarded changes resolve the repository from the note's workspace provenance or from the selected Git surface. +`useGitFileWorkflows` is the renderer abstraction for note-scoped Git file actions. It translates active tabs, visible entries, and modified-file surfaces into the correct repository path for diff/history commands, deleted-note previews, queued editor diff requests, and discard refresh behavior. + ### File kinds and binary previews `VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d78bea47..51165de8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -110,6 +110,8 @@ The registered vault list can act as a mounted-workspace set. `useVaultSwitcher` Git surfaces resolve repository paths explicitly. `useGitRepositories` derives the active repository set from the mounted available workspaces, keeps separate selected repositories for Changes, Pulse/history, and manual commits, and exposes the combined modified-file count for status/commands. AutoGit checkpoints iterate that repository set, while manual commit, history, diff, and discard operations use the selected surface or the note's workspace provenance. +Renderer git file workflows stay behind `useGitFileWorkflows`. The hook resolves per-note repository paths, queues editor diff requests, opens Pulse history entries including deleted-file previews, and keeps discard/reload handling close to the selected Git surface while `App.tsx` only wires the resulting callbacks into `NoteList`, `PulseView`, and `Editor`. + Cross-workspace note reads and writes keep the disk-first invariant. When an absolute note path is saved or read without an explicit `vaultPath`, the Tauri boundary resolves the deepest registered vault root that contains the path and validates against that root before touching disk. This lets an editor tab opened from a mounted workspace save back to its source repository while preserving the same path-escape protections as active-vault operations. #### Note Opening Fast Path @@ -841,6 +843,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` | | `useNoteWindowLifecycle` | note-window open/title side effects | Opens `tauri://` note windows without full vault scans and keeps the native title current | | `useStartupScreenState` | startup visibility booleans | Keeps onboarding, telemetry-consent, missing-vault, and initial indexing decisions out of `App.tsx` | +| `useGitFileWorkflows` | git diff/history/discard callbacks | Resolves note-scoped repository paths and owns deleted-file preview and queued diff side effects | | `useVaultRenameDetection` | detected rename banner state | Detects external Git renames on focus and owns the wikilink update callback | | `useNoteCreation` | — | Note/type creation with optimistic persistence | | `useNoteRename` | — | Note renaming and folder moves with wikilink update | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 9432d84f..a5af1ccd 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -274,6 +274,7 @@ tolaria/ | `src/hooks/useNoteWindowLifecycle.ts` | Note-window URL opening, asset-scope sync, and window-title updates. | | `src/hooks/useVaultRenameDetection.ts` | Focus-triggered Git rename detection and wikilink update action wiring. | | `src/hooks/useStartupScreenState.ts` | Startup-screen and vault-content loading visibility decisions. | +| `src/hooks/useGitFileWorkflows.ts` | Git diff/history/discard wiring and deleted-note preview workflow. | | `src/components/AddRemoteModal.tsx` | Modal UI for connecting a local-only vault to a compatible remote. | | `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. | diff --git a/src/App.tsx b/src/App.tsx index 0cfb12f6..a23e94a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' -import type { DeletedNoteEntry } from './components/note-list/noteListUtils' import { Editor } from './components/Editor' import { ResizeHandle } from './components/ResizeHandle' import { CreateTypeDialog } from './components/CreateTypeDialog' @@ -75,14 +74,13 @@ import { } from './hooks/useNeighborhoodSelection' import { createViewFilename } from './utils/viewFilename' import { nextViewOrder } from './utils/viewOrdering' -import type { CommitDiffRequest } from './hooks/useDiffMode' import { ConflictResolverModal } from './components/ConflictResolverModal' import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog' import { DeleteProgressNotice } from './components/DeleteProgressNotice' import { UpdateBanner } from './components/UpdateBanner' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' -import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition, GitCommit, GitRemoteStatus, WorkspaceIdentity } from './types' +import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition, GitRemoteStatus, WorkspaceIdentity } from './types' import type { NoteListItem } from './utils/ai-context' import { initializeNoteProperties } from './utils/initializeNoteProperties' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' @@ -105,10 +103,8 @@ import { normalizeReleaseChannel } from './lib/releaseChannel' import { buildVaultAiGuidanceRefreshKey, } from './lib/vaultAiGuidance' -import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils' import { isActiveVaultUnavailableError } from './utils/vaultErrors' import { hasNoteIconValue } from './utils/noteIcon' -import { filenameStemToTitle } from './utils/noteTitle' import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge' import { INBOX_SELECTION, @@ -129,6 +125,7 @@ import { syncVaultAssetScope, useNoteWindowLifecycle } from './hooks/useNoteWind import { useVaultRenameDetection } from './hooks/useVaultRenameDetection' import { useVaultOpenedTelemetry } from './hooks/useVaultOpenedTelemetry' import { useStartupScreenState } from './hooks/useStartupScreenState' +import { useGitFileWorkflows } from './hooks/useGitFileWorkflows' import './App.css' const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror' @@ -160,10 +157,6 @@ function shouldPreferOnboardingVaultPath( && !vaults.some((vault) => vault.path === onboardingState.vaultPath) } -function appTauriCall(command: string, args: Record): Promise { - return isTauri() ? invoke(command, args) : mockInvoke(command, args) -} - function canCustomizeColumnsForSelection( selection: SidebarSelection, explicitOrganizationEnabled: boolean, @@ -174,48 +167,6 @@ function canCustomizeColumnsForSelection( return explicitOrganizationEnabled && selection.filter === 'inbox' } -function createPulseDeletedNoteEntry(fullPath: string, relativePath: string): DeletedNoteEntry { - const filename = relativePath.split('/').pop() ?? relativePath - return { - path: fullPath, - filename, - title: filenameStemToTitle(filename), - isA: 'Note', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - archived: false, - modifiedAt: null, - createdAt: null, - fileSize: 0, - snippet: '', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - sidebarLabel: null, - template: null, - sort: null, - view: null, - visible: null, - organized: false, - favorite: false, - favoriteIndex: null, - listPropertiesDisplay: [], - outgoingLinks: [], - properties: {}, - hasH1: true, - fileKind: 'markdown', - __deletedNotePreview: true, - __deletedRelativePath: relativePath, - __changeAddedLines: null, - __changeDeletedLines: null, - __changeBinary: false, - } -} - /** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */ function App() { const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, []) @@ -473,7 +424,9 @@ function App() { if (vaultPath === resolvedPath) return refreshGitRemoteStatus() try { - return await appTauriCall('git_remote_status', { vaultPath }) + return await (isTauri() + ? invoke('git_remote_status', { vaultPath }) + : mockInvoke('git_remote_status', { vaultPath })) } catch { return null } @@ -681,9 +634,6 @@ function App() { }, onToast: (msg) => setToastMessage(msg), }) - const pendingDiffRequestIdRef = useRef(0) - const [pendingDiffRequest, setPendingDiffRequest] = useState(null) - // Keep note entry in sync with vault entries so banners (trash/archive) // and read-only state react immediately without reopening the note. useEffect(() => { @@ -707,47 +657,6 @@ function App() { onSelectNote: notes.handleSelectNote, }) - const queuePendingDiff = useCallback((path: string, commitHash?: string) => { - pendingDiffRequestIdRef.current += 1 - setPendingDiffRequest({ - requestId: pendingDiffRequestIdRef.current, - path, - commitHash, - }) - }, []) - - const handlePendingDiffHandled = useCallback((requestId: number) => { - setPendingDiffRequest((current) => - current?.requestId === requestId ? null : current, - ) - }, []) - - const handlePulseOpenNote = useCallback((relativePath: string, commitHash?: string) => { - const fullPath = `${gitSurfaces.historyRepositoryPath}/${relativePath}` - const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath) - - if (commitHash) { - const targetPath = entry?.path ?? fullPath - queuePendingDiff(targetPath, commitHash) - if (entry) { - void handleSelectNote(entry) - } else { - openTabWithContent(createPulseDeletedNoteEntry(fullPath, relativePath), 'Content not available') - } - return - } - - if (entry) { - void handleSelectNote(entry) - } - }, [ - entriesByPath, - gitSurfaces.historyRepositoryPath, - queuePendingDiff, - handleSelectNote, - openTabWithContent, - ]) - const handleOpenFavorite = useCallback(async (entry: VaultEntry) => { await handleReplaceActiveTab(entry) handleEnterNeighborhood(entry) @@ -958,106 +867,38 @@ function App() { const changesRepositoryPath = gitSurfaces.changesRepositoryPath const gitModifiedCount = gitSurfaces.totalModifiedCount - const findEntryForPath = useCallback((path: string) => { - const openTabEntry = notes.tabs.find((tab) => tab.entry.path === path)?.entry - if (openTabEntry) return openTabEntry - - const visibleEntry = visibleEntries.find((entry) => entry.path === path) - if (visibleEntry) return visibleEntry - - return vault.entries.find((entry) => entry.path === path) ?? null - }, [notes.tabs, vault.entries, visibleEntries]) - - const vaultPathForNotePath = useCallback((path: string) => { - const entry = findEntryForPath(path) - if (entry) return vaultPathForEntry(entry, resolvedPath) - - const modifiedFile = allGitModifiedFiles.find((file) => - file.path === path || file.relativePath === path || path.endsWith('/' + file.relativePath), - ) - return modifiedFile?.vaultPath ?? resolvedPath - }, [allGitModifiedFiles, findEntryForPath, resolvedPath]) - - const loadGitHistoryForPath = useCallback(async (path: string): Promise => { - try { - return await appTauriCall('get_file_history', { - vaultPath: vaultPathForNotePath(path), - path, - }) - } catch (err) { - console.warn('Failed to load git history:', err) - return [] - } - }, [vaultPathForNotePath]) - - const loadDiffForPath = useCallback((path: string): Promise => - appTauriCall('get_file_diff', { - vaultPath: vaultPathForNotePath(path), - path, - }), [vaultPathForNotePath]) - - const loadDiffAtCommitForPath = useCallback((path: string, commitHash: string): Promise => - appTauriCall('get_file_diff_at_commit', { - vaultPath: vaultPathForNotePath(path), - path, - commitHash, - }), [vaultPathForNotePath]) - - const handleDiscardFile = useCallback(async (relativePath: string) => { - const targetVaultPath = changesRepositoryPath - const targetFile = selectedChangesModifiedFiles.find((file) => file.relativePath === relativePath) - const activePathBefore = notes.activeTabPath - try { - await appTauriCall('git_discard_file', { vaultPath: targetVaultPath, relativePath }) - await loadModifiedFilesForRepository(targetVaultPath) - const reloadedEntries = await vault.reloadVault() - const affectedActiveTab = !!activePathBefore - && (activePathBefore === targetFile?.path || activePathBefore.endsWith('/' + relativePath)) - if (!affectedActiveTab) return - const refreshedEntry = reloadedEntries.find((entry) => - entry.path === targetFile?.path || entry.path.endsWith('/' + relativePath), - ) - if (refreshedEntry) { - await notes.handleReplaceActiveTab(refreshedEntry) - } else { - notes.closeAllTabs() - } - } catch (err) { - setToastMessage(typeof err === 'string' ? err : 'Failed to discard changes') - } - }, [ + const { + activeDeletedFile, + activeNoteModified, + handleDiscardFile, + handleOpenDeletedNote, + handlePendingDiffHandled, + handlePulseOpenNote, + handleReplaceActiveTabWithQueuedDiff, + loadDiffAtCommitForPath, + loadDiffForPath, + loadGitHistoryForPath, + pendingDiffRequest, + } = useGitFileWorkflows({ + activeTabPath: notes.activeTabPath, + allGitModifiedFiles, changesRepositoryPath, + effectiveSelection, + entriesByPath, + historyRepositoryPath: gitSurfaces.historyRepositoryPath, loadModifiedFilesForRepository, - notes, + onCloseAllTabs: notes.closeAllTabs, + onOpenTabWithContent: notes.openTabWithContent, + onReplaceActiveTab: notes.handleReplaceActiveTab, + onSelectNote: notes.handleSelectNote, + reloadVault: vault.reloadVault, + resolvedPath, selectedChangesModifiedFiles, setToastMessage, - vault, - ]) - - const handleOpenDeletedNote = useCallback(async (entry: DeletedNoteEntry) => { - let previewContent = 'Content not available (untracked)' - let hasDiff = false - try { - const diff = await loadDiffForPath(entry.path) - hasDiff = diff.length > 0 - previewContent = extractDeletedContentFromDiff(diff) ?? previewContent - } catch (err) { - console.warn('Failed to load deleted note preview:', err) - } - notes.openTabWithContent(entry, previewContent) - if (hasDiff) { - queuePendingDiff(entry.path) - } else { - setToastMessage('Content not available (untracked)') - } - }, [loadDiffForPath, notes, queuePendingDiff, setToastMessage]) - - const handleReplaceActiveTabWithQueuedDiff = useCallback((entry: VaultEntry) => { - notes.handleReplaceActiveTab(entry) - if (effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes') { - queuePendingDiff(entry.path) - } - }, [effectiveSelection, notes, queuePendingDiff]) + tabs: notes.tabs, + vaultEntries: vault.entries, + visibleEntries, + }) const commitFlow = useCommitFlow({ savePending: appSave.savePending, @@ -1444,15 +1285,6 @@ function App() { } }, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage]) - const activeDeletedFile = useMemo(() => { - const activeTabPath = notes.activeTabPath - if (!activeTabPath) return null - return allGitModifiedFiles.find((file) => - file.status === 'deleted' - && (file.path === activeTabPath || activeTabPath.endsWith('/' + file.relativePath)), - ) ?? null - }, [allGitModifiedFiles, notes.activeTabPath]) - const activeCommandEntry = useMemo(() => { if (!notes.activeTabPath) return null return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry @@ -1514,10 +1346,6 @@ function App() { onToast: setToastMessage, locale: appLocale, }) - const activeNoteModified = useMemo( - () => allGitModifiedFiles.some((file) => file.path === notes.activeTabPath), - [allGitModifiedFiles, notes.activeTabPath], - ) const toggleDiffCommand = useCallback(() => diffToggleRef.current(), []) const toggleRawEditorCommand = useMemo( () => canToggleRichEditor ? () => rawToggleRef.current() : undefined, diff --git a/src/hooks/useGitFileWorkflows.ts b/src/hooks/useGitFileWorkflows.ts new file mode 100644 index 00000000..9e740fbc --- /dev/null +++ b/src/hooks/useGitFileWorkflows.ts @@ -0,0 +1,461 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { DeletedNoteEntry } from '../components/note-list/noteListUtils' +import { extractDeletedContentFromDiff } from '../components/note-list/noteListUtils' +import type { CommitDiffRequest } from './useDiffMode' +import type { GitCommit, ModifiedFile, SidebarSelection, VaultEntry } from '../types' +import { filenameStemToTitle } from '../utils/noteTitle' +import { vaultPathForEntry } from '../utils/workspaces' + +type AppTab = { + entry: VaultEntry + content: string +} + +interface GitFileWorkflowParams { + activeTabPath: string | null + allGitModifiedFiles: ModifiedFile[] + changesRepositoryPath: string + effectiveSelection: SidebarSelection + entriesByPath: Map + historyRepositoryPath: string + loadModifiedFilesForRepository: (vaultPath: string) => Promise + onCloseAllTabs: () => void + onOpenTabWithContent: (entry: DeletedNoteEntry, content: string) => void + onReplaceActiveTab: (entry: VaultEntry) => Promise | unknown + onSelectNote: (entry: VaultEntry) => Promise | unknown + reloadVault: () => Promise + resolvedPath: string + selectedChangesModifiedFiles: ModifiedFile[] + setToastMessage: (message: string) => void + tabs: AppTab[] + vaultEntries: VaultEntry[] + visibleEntries: VaultEntry[] +} + +interface QueuedDiffRequest { + pendingDiffRequest: CommitDiffRequest | null + queuePendingDiff: (path: string, commitHash?: string) => void + handlePendingDiffHandled: (requestId: number) => void +} + +const DELETED_NOTE_PREVIEW_DEFAULTS = { + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: null, + createdAt: null, + fileSize: 0, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', + __deletedNotePreview: true, + __changeAddedLines: null, + __changeDeletedLines: null, + __changeBinary: false, +} satisfies Omit + +function appTauriCall(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(command, args) +} + +function createPulseDeletedNoteEntry(fullPath: string, relativePath: string): DeletedNoteEntry { + const filename = relativePath.split('/').pop() ?? relativePath + return { + ...DELETED_NOTE_PREVIEW_DEFAULTS, + path: fullPath, + filename, + title: filenameStemToTitle(filename), + __deletedRelativePath: relativePath, + } +} + +function useQueuedDiffRequest(): QueuedDiffRequest { + const pendingDiffRequestIdRef = useRef(0) + const [pendingDiffRequest, setPendingDiffRequest] = useState(null) + + const queuePendingDiff = useCallback((path: string, commitHash?: string) => { + pendingDiffRequestIdRef.current += 1 + setPendingDiffRequest({ + requestId: pendingDiffRequestIdRef.current, + path, + commitHash, + }) + }, []) + + const handlePendingDiffHandled = useCallback((requestId: number) => { + setPendingDiffRequest((current) => + current?.requestId === requestId ? null : current, + ) + }, []) + + return { + pendingDiffRequest, + queuePendingDiff, + handlePendingDiffHandled, + } +} + +function useVaultPathResolver({ + allGitModifiedFiles, + resolvedPath, + tabs, + vaultEntries, + visibleEntries, +}: Pick) { + const findEntryForPath = useCallback((path: string) => { + const openTabEntry = tabs.find((tab) => tab.entry.path === path)?.entry + if (openTabEntry) return openTabEntry + + const visibleEntry = visibleEntries.find((entry) => entry.path === path) + if (visibleEntry) return visibleEntry + + return vaultEntries.find((entry) => entry.path === path) ?? null + }, [tabs, vaultEntries, visibleEntries]) + + return useCallback((path: string) => { + const entry = findEntryForPath(path) + if (entry) return vaultPathForEntry(entry, resolvedPath) + + const modifiedFile = allGitModifiedFiles.find((file) => + file.path === path || file.relativePath === path || path.endsWith('/' + file.relativePath), + ) + return modifiedFile?.vaultPath ?? resolvedPath + }, [allGitModifiedFiles, findEntryForPath, resolvedPath]) +} + +function useGitDiffLoaders(vaultPathForNotePath: (path: string) => string) { + const loadGitHistoryForPath = useCallback(async (path: string): Promise => { + try { + return await appTauriCall('get_file_history', { + vaultPath: vaultPathForNotePath(path), + path, + }) + } catch (err) { + console.warn('Failed to load git history:', err) + return [] + } + }, [vaultPathForNotePath]) + + const loadDiffForPath = useCallback((path: string): Promise => + appTauriCall('get_file_diff', { + vaultPath: vaultPathForNotePath(path), + path, + }), [vaultPathForNotePath]) + + const loadDiffAtCommitForPath = useCallback((path: string, commitHash: string): Promise => + appTauriCall('get_file_diff_at_commit', { + vaultPath: vaultPathForNotePath(path), + path, + commitHash, + }), [vaultPathForNotePath]) + + return { + loadGitHistoryForPath, + loadDiffForPath, + loadDiffAtCommitForPath, + } +} + +function usePulseNoteOpen({ + entriesByPath, + historyRepositoryPath, + onOpenTabWithContent, + onSelectNote, + queuePendingDiff, +}: Pick & { + queuePendingDiff: (path: string, commitHash?: string) => void +}) { + return useCallback((relativePath: string, commitHash?: string) => { + const fullPath = `${historyRepositoryPath}/${relativePath}` + const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath) + + if (commitHash) { + const targetPath = entry?.path ?? fullPath + queuePendingDiff(targetPath, commitHash) + if (entry) { + void onSelectNote(entry) + } else { + onOpenTabWithContent(createPulseDeletedNoteEntry(fullPath, relativePath), 'Content not available') + } + return + } + + if (entry) { + void onSelectNote(entry) + } + }, [ + entriesByPath, + historyRepositoryPath, + onOpenTabWithContent, + onSelectNote, + queuePendingDiff, + ]) +} + +function isDiscardedFileActive( + activePath: string | null, + targetFile: ModifiedFile | undefined, + relativePath: string, +) { + return !!activePath + && (activePath === targetFile?.path || activePath.endsWith('/' + relativePath)) +} + +function findReloadedDiscardTarget( + entries: VaultEntry[], + targetFile: ModifiedFile | undefined, + relativePath: string, +) { + return entries.find((entry) => + entry.path === targetFile?.path || entry.path.endsWith('/' + relativePath), + ) +} + +async function syncActiveTabAfterDiscard({ + activePathBefore, + onCloseAllTabs, + onReplaceActiveTab, + reloadedEntries, + relativePath, + targetFile, +}: { + activePathBefore: string | null + onCloseAllTabs: () => void + onReplaceActiveTab: (entry: VaultEntry) => Promise | unknown + reloadedEntries: VaultEntry[] + relativePath: string + targetFile: ModifiedFile | undefined +}) { + if (!isDiscardedFileActive(activePathBefore, targetFile, relativePath)) return + + const refreshedEntry = findReloadedDiscardTarget(reloadedEntries, targetFile, relativePath) + if (refreshedEntry) { + await onReplaceActiveTab(refreshedEntry) + } else { + onCloseAllTabs() + } +} + +function useDiscardFileAction({ + activeTabPath, + changesRepositoryPath, + loadModifiedFilesForRepository, + onCloseAllTabs, + onReplaceActiveTab, + reloadVault, + selectedChangesModifiedFiles, + setToastMessage, +}: Pick) { + return useCallback(async (relativePath: string) => { + const targetFile = selectedChangesModifiedFiles.find((file) => file.relativePath === relativePath) + try { + await appTauriCall('git_discard_file', { vaultPath: changesRepositoryPath, relativePath }) + await loadModifiedFilesForRepository(changesRepositoryPath) + await syncActiveTabAfterDiscard({ + activePathBefore: activeTabPath, + onCloseAllTabs, + onReplaceActiveTab, + reloadedEntries: await reloadVault(), + relativePath, + targetFile, + }) + } catch (err) { + setToastMessage(typeof err === 'string' ? err : 'Failed to discard changes') + } + }, [ + activeTabPath, + changesRepositoryPath, + loadModifiedFilesForRepository, + onCloseAllTabs, + onReplaceActiveTab, + reloadVault, + selectedChangesModifiedFiles, + setToastMessage, + ]) +} + +function useOpenDeletedNoteAction({ + loadDiffForPath, + onOpenTabWithContent, + queuePendingDiff, + setToastMessage, +}: Pick & { + loadDiffForPath: (path: string) => Promise + queuePendingDiff: (path: string, commitHash?: string) => void +}) { + return useCallback(async (entry: DeletedNoteEntry) => { + let previewContent = 'Content not available (untracked)' + let hasDiff = false + try { + const diff = await loadDiffForPath(entry.path) + hasDiff = diff.length > 0 + previewContent = extractDeletedContentFromDiff(diff) ?? previewContent + } catch (err) { + console.warn('Failed to load deleted note preview:', err) + } + onOpenTabWithContent(entry, previewContent) + if (hasDiff) { + queuePendingDiff(entry.path) + } else { + setToastMessage('Content not available (untracked)') + } + }, [loadDiffForPath, onOpenTabWithContent, queuePendingDiff, setToastMessage]) +} + +function useDeletedNoteWorkflow({ + activeTabPath, + changesRepositoryPath, + loadDiffForPath, + loadModifiedFilesForRepository, + onCloseAllTabs, + onOpenTabWithContent, + onReplaceActiveTab, + queuePendingDiff, + reloadVault, + selectedChangesModifiedFiles, + setToastMessage, +}: Pick & { + loadDiffForPath: (path: string) => Promise + queuePendingDiff: (path: string, commitHash?: string) => void +}) { + const handleDiscardFile = useDiscardFileAction({ + activeTabPath, + changesRepositoryPath, + loadModifiedFilesForRepository, + onCloseAllTabs, + onReplaceActiveTab, + reloadVault, + selectedChangesModifiedFiles, + setToastMessage, + }) + const handleOpenDeletedNote = useOpenDeletedNoteAction({ + loadDiffForPath, + onOpenTabWithContent, + queuePendingDiff, + setToastMessage, + }) + + return { + handleDiscardFile, + handleOpenDeletedNote, + } +} + +function useReplaceActiveTabWithQueuedDiff({ + effectiveSelection, + onReplaceActiveTab, + queuePendingDiff, +}: Pick & { + queuePendingDiff: (path: string, commitHash?: string) => void +}) { + return useCallback((entry: VaultEntry) => { + onReplaceActiveTab(entry) + if (effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes') { + queuePendingDiff(entry.path) + } + }, [effectiveSelection, onReplaceActiveTab, queuePendingDiff]) +} + +function useActiveDeletedFile(activeTabPath: string | null, allGitModifiedFiles: ModifiedFile[]) { + return useMemo(() => { + if (!activeTabPath) return null + return allGitModifiedFiles.find((file) => + file.status === 'deleted' + && (file.path === activeTabPath || activeTabPath.endsWith('/' + file.relativePath)), + ) ?? null + }, [activeTabPath, allGitModifiedFiles]) +} + +function useActiveNoteModified(activeTabPath: string | null, allGitModifiedFiles: ModifiedFile[]) { + return useMemo( + () => allGitModifiedFiles.some((file) => file.path === activeTabPath), + [activeTabPath, allGitModifiedFiles], + ) +} + +function useDeletedWorkflowForParams( + params: GitFileWorkflowParams, + loadDiffForPath: (path: string) => Promise, + queuePendingDiff: (path: string, commitHash?: string) => void, +) { + return useDeletedNoteWorkflow({ + activeTabPath: params.activeTabPath, + changesRepositoryPath: params.changesRepositoryPath, + loadDiffForPath, + loadModifiedFilesForRepository: params.loadModifiedFilesForRepository, + onCloseAllTabs: params.onCloseAllTabs, + onOpenTabWithContent: params.onOpenTabWithContent, + onReplaceActiveTab: params.onReplaceActiveTab, + queuePendingDiff, + reloadVault: params.reloadVault, + selectedChangesModifiedFiles: params.selectedChangesModifiedFiles, + setToastMessage: params.setToastMessage, + }) +} + +export function useGitFileWorkflows(params: GitFileWorkflowParams) { + const { + pendingDiffRequest, + queuePendingDiff, + handlePendingDiffHandled, + } = useQueuedDiffRequest() + const vaultPathForNotePath = useVaultPathResolver(params) + const { + loadGitHistoryForPath, + loadDiffForPath, + loadDiffAtCommitForPath, + } = useGitDiffLoaders(vaultPathForNotePath) + const handlePulseOpenNote = usePulseNoteOpen({ + entriesByPath: params.entriesByPath, + historyRepositoryPath: params.historyRepositoryPath, + onOpenTabWithContent: params.onOpenTabWithContent, + onSelectNote: params.onSelectNote, + queuePendingDiff, + }) + const { + handleDiscardFile, + handleOpenDeletedNote, + } = useDeletedWorkflowForParams(params, loadDiffForPath, queuePendingDiff) + const handleReplaceActiveTabWithQueuedDiff = useReplaceActiveTabWithQueuedDiff({ + effectiveSelection: params.effectiveSelection, + onReplaceActiveTab: params.onReplaceActiveTab, + queuePendingDiff, + }) + const activeDeletedFile = useActiveDeletedFile(params.activeTabPath, params.allGitModifiedFiles) + const activeNoteModified = useActiveNoteModified(params.activeTabPath, params.allGitModifiedFiles) + + return { + activeDeletedFile, + activeNoteModified, + handleDiscardFile, + handleOpenDeletedNote, + handlePendingDiffHandled, + handlePulseOpenNote, + handleReplaceActiveTabWithQueuedDiff, + loadDiffAtCommitForPath, + loadDiffForPath, + loadGitHistoryForPath, + pendingDiffRequest, + } +}