refactor: extract git file workflows

This commit is contained in:
lucaronin
2026-05-12 04:04:31 +02:00
parent 7709255fb3
commit 66dfc26bc9
5 changed files with 500 additions and 205 deletions

View File

@@ -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:

View File

@@ -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 |

View File

@@ -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. |

View File

@@ -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<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(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<GitRemoteStatus>('git_remote_status', { vaultPath })
return await (isTauri()
? invoke<GitRemoteStatus>('git_remote_status', { vaultPath })
: mockInvoke<GitRemoteStatus>('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<CommitDiffRequest | null>(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<GitCommit[]> => {
try {
return await appTauriCall<GitCommit[]>('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<string> =>
appTauriCall<string>('get_file_diff', {
vaultPath: vaultPathForNotePath(path),
path,
}), [vaultPathForNotePath])
const loadDiffAtCommitForPath = useCallback((path: string, commitHash: string): Promise<string> =>
appTauriCall<string>('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,

View File

@@ -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<string, VaultEntry>
historyRepositoryPath: string
loadModifiedFilesForRepository: (vaultPath: string) => Promise<unknown>
onCloseAllTabs: () => void
onOpenTabWithContent: (entry: DeletedNoteEntry, content: string) => void
onReplaceActiveTab: (entry: VaultEntry) => Promise<unknown> | unknown
onSelectNote: (entry: VaultEntry) => Promise<unknown> | unknown
reloadVault: () => Promise<VaultEntry[]>
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<DeletedNoteEntry, 'path' | 'filename' | 'title' | '__deletedRelativePath'>
function appTauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(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<CommitDiffRequest | null>(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<GitFileWorkflowParams, 'allGitModifiedFiles' | 'resolvedPath' | 'tabs' | 'vaultEntries' | 'visibleEntries'>) {
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<GitCommit[]> => {
try {
return await appTauriCall<GitCommit[]>('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<string> =>
appTauriCall<string>('get_file_diff', {
vaultPath: vaultPathForNotePath(path),
path,
}), [vaultPathForNotePath])
const loadDiffAtCommitForPath = useCallback((path: string, commitHash: string): Promise<string> =>
appTauriCall<string>('get_file_diff_at_commit', {
vaultPath: vaultPathForNotePath(path),
path,
commitHash,
}), [vaultPathForNotePath])
return {
loadGitHistoryForPath,
loadDiffForPath,
loadDiffAtCommitForPath,
}
}
function usePulseNoteOpen({
entriesByPath,
historyRepositoryPath,
onOpenTabWithContent,
onSelectNote,
queuePendingDiff,
}: Pick<GitFileWorkflowParams, 'entriesByPath' | 'historyRepositoryPath' | 'onOpenTabWithContent' | 'onSelectNote'> & {
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> | 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<GitFileWorkflowParams, 'activeTabPath' | 'changesRepositoryPath' | 'loadModifiedFilesForRepository' | 'onCloseAllTabs' | 'onReplaceActiveTab' | 'reloadVault' | 'selectedChangesModifiedFiles' | 'setToastMessage'>) {
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<GitFileWorkflowParams, 'onOpenTabWithContent' | 'setToastMessage'> & {
loadDiffForPath: (path: string) => Promise<string>
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<GitFileWorkflowParams, 'activeTabPath' | 'changesRepositoryPath' | 'loadModifiedFilesForRepository' | 'onCloseAllTabs' | 'onOpenTabWithContent' | 'onReplaceActiveTab' | 'reloadVault' | 'selectedChangesModifiedFiles' | 'setToastMessage'> & {
loadDiffForPath: (path: string) => Promise<string>
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<GitFileWorkflowParams, 'effectiveSelection' | 'onReplaceActiveTab'> & {
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<string>,
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,
}
}