Files
tolaria/src/components/EditorContent.tsx
Test 05dca72ef3 feat: handle git divergence, conflicts, and manual pull from within Laputa
When push is rejected due to remote having newer commits, the bottom bar
now shows "Pull required" (orange). Clicking it pulls then auto-pushes.
Conflicted notes show an inline banner with "Keep mine" / "Keep theirs"
buttons. A new "Pull from Remote" command is available in Cmd+K and the
Vault menu. Clicking the sync badge opens a popup showing branch name,
ahead/behind counts, and a Pull button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:51:06 +01:00

226 lines
8.4 KiB
TypeScript

import type React from 'react'
import { useCallback } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
import { BreadcrumbBar } from './BreadcrumbBar'
import { TitleField } from './TitleField'
import { NoteIcon } from './NoteIcon'
import { TrashedNoteBanner } from './TrashedNoteBanner'
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
import { ConflictNoteBanner } from './ConflictNoteBanner'
import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
import { isEmoji } from '../utils/emoji'
interface Tab {
entry: VaultEntry
content: string
}
interface EditorContentProps {
activeTab: Tab | null
isLoadingNewTab: boolean
entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean
diffContent: string | null
diffLoading: boolean
onToggleDiff: () => void
rawMode: boolean
onToggleRaw: () => void
onRawContentChange?: (path: string, content: string) => void
onSave?: () => void
activeStatus: NoteStatus
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
onEditorChange?: () => void
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
isDarkTheme?: boolean
/** Ref updated by RawEditorView on every keystroke with the latest doc. */
rawLatestContentRef?: React.MutableRefObject<string | null>
/** Called when the user edits the dedicated title field. */
onTitleChange?: (path: string, newTitle: string) => void
/** Called when user sets or changes an emoji icon via the picker. */
onSetNoteIcon?: (path: string, emoji: string) => void
/** Called when user removes an emoji icon. */
onRemoveNoteIcon?: (path: string) => void
/** Whether the active note has a merge conflict. */
isConflicted?: boolean
/** Resolve conflict by keeping the local version. */
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
}
function EditorLoadingSkeleton() {
return (
<div className="flex flex-1 flex-col gap-3 p-8 animate-pulse" style={{ minHeight: 0 }}>
<div className="h-6 w-2/5 rounded bg-muted" />
<div className="h-4 w-4/5 rounded bg-muted" />
<div className="h-4 w-3/5 rounded bg-muted" />
<div className="h-4 w-4/5 rounded bg-muted" />
<div className="h-4 w-2/5 rounded bg-muted" />
</div>
)
}
function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | null; onToggleDiff: () => void }) {
return (
<div className="flex-1 overflow-auto">
<button
className="flex items-center gap-1.5 px-4 py-2 text-xs text-primary bg-muted border-b border-border cursor-pointer hover:bg-accent transition-colors w-full border-t-0 border-l-0 border-r-0"
onClick={onToggleDiff}
title="Back to editor"
>
<span style={{ fontSize: 14, lineHeight: 1 }}>&larr;</span>
Back to editor
</button>
<DiffView diff={diffContent ?? ''} />
</div>
)
}
function RawModeEditorSection({
rawMode, activeTab, entries, onContentChange, onSave, isDark, latestContentRef,
}: {
rawMode: boolean
activeTab: Tab | null
entries: VaultEntry[]
onContentChange?: (path: string, content: string) => void
onSave?: () => void
isDark?: boolean
latestContentRef?: React.MutableRefObject<string | null>
}) {
if (!rawMode || !activeTab) return null
return (
<RawEditorView
key={activeTab.entry.path}
content={activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
isDark={isDark}
latestContentRef={latestContentRef}
/>
)
}
/** Bind an optional callback to a path, returning undefined if callback is absent */
function bindPath(cb: ((path: string) => void) | undefined, path: string) {
return cb ? () => cb(path) : undefined
}
function ActiveTabBreadcrumb({ activeTab, props }: {
activeTab: Tab
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
}) {
const wordCount = countWords(activeTab.content)
const path = activeTab.entry.path
return (
<BreadcrumbBar
entry={activeTab.entry}
wordCount={wordCount}
noteStatus={props.activeStatus}
showDiffToggle={props.showDiffToggle}
diffMode={props.diffMode}
diffLoading={props.diffLoading}
onToggleDiff={props.onToggleDiff}
rawMode={props.rawMode}
onToggleRaw={props.onToggleRaw}
showAIChat={props.showAIChat}
onToggleAIChat={props.onToggleAIChat}
inspectorCollapsed={props.inspectorCollapsed}
onToggleInspector={props.onToggleInspector}
onTrash={bindPath(props.onTrashNote, path)}
onRestore={bindPath(props.onRestoreNote, path)}
onArchive={bindPath(props.onArchiveNote, path)}
onUnarchive={bindPath(props.onUnarchiveNote, path)}
/>
)
}
export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
rawMode, onToggleRaw, onRawContentChange, onSave,
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
onDeleteNote, rawLatestContentRef, onTitleChange,
onSetNoteIcon, onRemoveNoteIcon,
isConflicted, onKeepMine, onKeepTheirs,
...breadcrumbProps
}: EditorContentProps) {
const isTrashed = activeTab?.entry.trashed ?? false
const showEditor = !diffMode && !rawMode
const entryIcon = activeTab?.entry.icon ?? null
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
const handleSetIcon = useCallback((emoji: string) => {
if (activeTab) onSetNoteIcon?.(activeTab.entry.path, emoji)
}, [activeTab, onSetNoteIcon])
const handleRemoveIcon = useCallback(() => {
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
}, [activeTab, onRemoveNoteIcon])
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
{activeTab && (
<ActiveTabBreadcrumb
activeTab={activeTab}
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
/>
)}
{activeTab && isTrashed && (
<TrashedNoteBanner
onRestore={() => breadcrumbProps.onRestoreNote?.(activeTab.entry.path)}
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
/>
)}
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
{activeTab && isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(activeTab.entry.path)}
onKeepTheirs={() => onKeepTheirs?.(activeTab.entry.path)}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
{showEditor && activeTab && (
<div className="editor-scroll-area">
<div className="title-section">
<NoteIcon
icon={emojiIcon}
editable={!isTrashed}
onSetIcon={handleSetIcon}
onRemoveIcon={handleRemoveIcon}
/>
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable={!isTrashed}
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
/>
<div className="title-section__separator" />
</div>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
</div>
)}
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)
}