462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
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, options?: { includeStats?: boolean }) => 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, { includeStats: true })
|
|
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,
|
|
}
|
|
}
|