diff --git a/src/diff-preview.js b/src/diff-preview.js index 30e78d8..9d900f1 100644 --- a/src/diff-preview.js +++ b/src/diff-preview.js @@ -1,15 +1,10 @@ // src/diff-preview.js // Visual diff preview using inline ghost decorations (track-changes style) -import { invoke } from '@tauri-apps/api/core'; import { calculateCharDiff } from './diff-highlighter.js'; -import { getCurrentUserInfo } from './profile-service.js'; -import { getActiveDocumentId } from './document-manager.js'; -import { mergeText } from './three-way-merge.js'; -import { getMarkdown, showDiffPreview, clearEditorHighlight, getMarkdownToPmMapping } from './editor.js'; +import { showDiffPreview, clearEditorHighlight, getMarkdownToPmMapping } from './editor.js'; import { getConflictState, restoreToPatch } from './timeline.js'; import { getConflictGroup } from './conflict-detection.js'; -import { stripMarkdown } from './utils.js'; let previewState = { active: false, @@ -204,113 +199,3 @@ export function isPreviewActive() { return previewState.active; } -/** - * Get pending patch IDs in the current conflict group - * @param {number|null} excludePatchId - Optional patch ID to exclude from results - * @returns {Promise>} - Array of pending patch IDs - */ -async function getPendingConflictPatchIds(excludePatchId = null) { - if (!previewState.conflictGroup || previewState.conflictGroup.length <= 1) { - return []; - } - - const { fetchPatchList } = await import('./timeline.js'); - const allPatches = await fetchPatchList(); - const docId = getActiveDocumentId(); - const { id: currentUserId } = getCurrentUserInfo(); - - // Build a map of patch ID to patch for quick lookup - const patchMap = new Map(allPatches.map(p => [p.id, p])); - - // Filter conflict group to only include pending patches - const pendingIds = []; - - for (const patchId of previewState.conflictGroup) { - if (excludePatchId !== null && patchId === excludePatchId) continue; - - const patch = patchMap.get(patchId); - if (!patch) continue; - - // Patches by current user are implicitly accepted (not pending) - if (patch.author === currentUserId) continue; - - // Check if current user has already reviewed this patch - if (patch.uuid && docId) { - const reviews = await invoke("get_document_patch_reviews", { - docId, - patchUuid: patch.uuid - }).catch(() => []); - - const hasReviewed = reviews.some(r => r.reviewer_id === currentUserId); - if (hasReviewed) continue; - } - - pendingIds.push(patchId); - } - - return pendingIds; -} - -/** - * Update the conflict tabs in the preview banner - */ -/** - * Update the conflict tabs in the preview banner - Disabled - */ -async function updateConflictTabs() { - // Conflict tabs and merge wizard disabled per user request - return; -} - -/** - * Switch preview to a different patch in the conflict group - * @param {number} patchId - The patch ID to switch to - */ -async function switchToConflictPatch(patchId) { - if (patchId === previewState.patchId) return; - - const { fetchPatch, fetchPatchList } = await import('./timeline.js'); - - const patch = await fetchPatch(patchId); - if (!patch) { - alert("Failed to load patch"); - return; - } - - // Get current editor content as markdown (the "old" state) - const currentContent = getMarkdown(); - - // Calculate what the merged result would be (3-way merge simulation) - const allPatches = await fetchPatchList(); - const savePatchesOnly = allPatches - .filter(p => p.kind === "Save" && p.data?.snapshot) - .sort((a, b) => a.timestamp - b.timestamp); - - const baseSnapshot = savePatchesOnly.length > 0 - ? savePatchesOnly[0].data.snapshot - : ''; - - const patchContent = patch.data?.snapshot || ''; - - // Simulate what the merge would produce - const mergedResult = mergeText(baseSnapshot, currentContent, patchContent); - - // Update preview state - previewState.patchId = patchId; - previewState.oldText = currentContent; - previewState.newText = mergedResult; - - // Update UI - const patchIdEl = document.querySelector('#preview-patch-id'); - if (patchIdEl) { - patchIdEl.textContent = patchId; - } - - // Update tab states - document.querySelectorAll('.conflict-tab').forEach(tab => { - tab.classList.toggle('active', parseInt(tab.dataset.patchId) === patchId); - }); - - // Re-render ghost preview - renderGhostPreview(); -} diff --git a/src/document-manager.js b/src/document-manager.js index d9f5ef9..dc6b7e8 100644 --- a/src/document-manager.js +++ b/src/document-manager.js @@ -9,6 +9,42 @@ let activeDocumentId = null; let openDocuments = new Map(); let documentChangeListeners = []; +/** + * Create a Save patch if content has changed from last save + * @param {string} docId - Document ID + * @param {string} content - Current editor content + * @returns {Promise} True if patch was created + */ +async function createSavePatchIfChanged(docId, content) { + if (!content) return false; + + try { + const patches = await invoke("list_document_patches", { id: docId }).catch(() => []); + const lastSavePatch = patches + .filter(p => p.kind === "Save" && p.data?.snapshot) + .sort((a, b) => b.timestamp - a.timestamp)[0]; + + if (!lastSavePatch || lastSavePatch.data.snapshot !== content) { + const profile = getCachedProfile(); + const patch = { + timestamp: Date.now(), + author: profile?.id || "local", + kind: "Save", + data: { + snapshot: content, + authorName: profile?.name || "Local User", + authorColor: profile?.color || "#3498db" + } + }; + await invoke("record_document_patch", { id: docId, patch }); + return true; + } + } catch (err) { + console.error("Failed to create save patch:", err); + } + return false; +} + /** * Create a new empty document * @returns {Promise} The document handle @@ -97,37 +133,7 @@ export async function saveDocument(id = null, path = null) { // Record a patch with the saved content BEFORE saving the file // so the patch is included in the bundled history.sqlite - if (editorContent) { - try { - // Check if content has changed from last save - const patches = await invoke("list_document_patches", { id: docId }).catch(() => []); - const lastSavePatch = patches - .filter(p => p.kind === "Save" && p.data?.snapshot) - .sort((a, b) => b.timestamp - a.timestamp)[0]; - - // Only create a new patch if content has actually changed - if (!lastSavePatch || lastSavePatch.data.snapshot !== editorContent) { - const timestamp = Date.now(); - const profile = getCachedProfile(); - const author = profile?.id || "local"; - const authorName = profile?.name || "Local User"; - const authorColor = profile?.color || "#3498db"; - const patch = { - timestamp, - author, - kind: "Save", - data: { - snapshot: editorContent, - authorName, // Store author name for display - authorColor // Store author color for multi-author highlighting - } - }; - await invoke("record_document_patch", { id: docId, patch }); - } - } catch (err) { - console.error("Failed to record save patch:", err); - } - } + await createSavePatchIfChanged(docId, editorContent); // Save the document (bundles the history.sqlite with the patch we just recorded) const handle = await invoke("save_document", { id: docId, path }); @@ -176,35 +182,7 @@ export async function closeDocument(id, force = false) { // Auto-create a Save patch before closing to preserve changes for reconciliation try { const { getMarkdown } = await import("./editor.js"); - const editorContent = getMarkdown(); - - if (editorContent) { - // Check if content has changed from last save - const patches = await invoke("list_document_patches", { id }).catch(() => []); - const lastSavePatch = patches - .filter(p => p.kind === "Save" && p.data?.snapshot) - .sort((a, b) => b.timestamp - a.timestamp)[0]; - - // Create a Save patch if content changed - if (!lastSavePatch || lastSavePatch.data.snapshot !== editorContent) { - const timestamp = Date.now(); - const profile = getCachedProfile(); - const author = profile?.id || "local"; - const authorName = profile?.name || "Local User"; - const authorColor = profile?.color || "#3498db"; - const patch = { - timestamp, - author, - kind: "Save", - data: { - snapshot: editorContent, - authorName, - authorColor - } - }; - await invoke("record_document_patch", { id, patch }); - } - } + await createSavePatchIfChanged(id, getMarkdown()); } catch (err) { console.warn("Could not create auto-save patch on close:", err); } diff --git a/src/editor.js b/src/editor.js index 0c2ed8d..9783ccb 100644 --- a/src/editor.js +++ b/src/editor.js @@ -632,34 +632,8 @@ export function previewGhostHunkByPosition(text, kind, markdownPos, markdownCont // We need to find what plain text position corresponds to markdownPos in markdown const prefixMarkdown = markdownContent.substring(0, markdownPos); - // Strip markdown from the prefix - this gives us the plain text before the insert point - // We need stripMarkdown here - let's inline a simple version - let prefixPlain = prefixMarkdown; - // Remove images: ![alt](url) -> alt - prefixPlain = prefixPlain.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1'); - // Remove links: [text](url) -> text - prefixPlain = prefixPlain.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); - // Remove bold: **text** -> text - prefixPlain = prefixPlain.replace(/\*\*([^*]+)\*\*/g, '$1'); - prefixPlain = prefixPlain.replace(/__([^_]+)__/g, '$1'); - // Remove italic (careful not to match list items) - prefixPlain = prefixPlain.replace(/(?\s*/gm, ''); - // Remove horizontal rules - prefixPlain = prefixPlain.replace(/^[-*_]{3,}\s*$/gm, ''); - // Remove list markers - prefixPlain = prefixPlain.replace(/^[\s]*[-*+]\s+/gm, ''); - prefixPlain = prefixPlain.replace(/^[\s]*\d+\.\s+/gm, ''); - // Normalize newlines - prefixPlain = prefixPlain.replace(/\n{2,}/g, '\n'); + // Strip markdown from the prefix to get plain text length + const prefixPlain = stripMarkdown(prefixMarkdown); // The plain text offset is the length of the stripped prefix const plainOffset = prefixPlain.length; @@ -674,46 +648,14 @@ export function previewGhostHunkByPosition(text, kind, markdownPos, markdownCont to = posPm; } else if (kind === 'delete') { // Delete: we need the range of text being deleted - // Strip markdown from the delete text to get its plain length - let deleteTextPlain = text; - deleteTextPlain = deleteTextPlain.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1'); - deleteTextPlain = deleteTextPlain.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); - deleteTextPlain = deleteTextPlain.replace(/\*\*([^*]+)\*\*/g, '$1'); - deleteTextPlain = deleteTextPlain.replace(/__([^_]+)__/g, '$1'); - deleteTextPlain = deleteTextPlain.replace(/(?\s*/gm, ''); - deleteTextPlain = deleteTextPlain.replace(/^[-*_]{3,}\s*$/gm, ''); - deleteTextPlain = deleteTextPlain.replace(/^[\s]*[-*+]\s+/gm, ''); - deleteTextPlain = deleteTextPlain.replace(/^[\s]*\d+\.\s+/gm, ''); - deleteTextPlain = deleteTextPlain.replace(/\n{2,}/g, '\n'); - + const deleteTextPlain = stripMarkdown(text); const fromPm = charToPm(plainOffset); const toPm = charToPm(plainOffset + deleteTextPlain.length); from = fromPm; to = toPm; } else if (kind === 'replace') { // Replace: delete the old text and insert new text at that position - const deleteText = deleteTextOrInsertText; - let deleteTextPlain = deleteText || ''; - deleteTextPlain = deleteTextPlain.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1'); - deleteTextPlain = deleteTextPlain.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); - deleteTextPlain = deleteTextPlain.replace(/\*\*([^*]+)\*\*/g, '$1'); - deleteTextPlain = deleteTextPlain.replace(/__([^_]+)__/g, '$1'); - deleteTextPlain = deleteTextPlain.replace(/(?\s*/gm, ''); - deleteTextPlain = deleteTextPlain.replace(/^[-*_]{3,}\s*$/gm, ''); - deleteTextPlain = deleteTextPlain.replace(/^[\s]*[-*+]\s+/gm, ''); - deleteTextPlain = deleteTextPlain.replace(/^[\s]*\d+\.\s+/gm, ''); - deleteTextPlain = deleteTextPlain.replace(/\n{2,}/g, '\n'); - + const deleteTextPlain = stripMarkdown(deleteTextOrInsertText || ''); deleteFrom = charToPm(plainOffset); deleteTo = charToPm(plainOffset + deleteTextPlain.length); }