Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 1 addition & 116 deletions src/diff-preview.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<number>>} - 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();
}
98 changes: 38 additions & 60 deletions src/document-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>} 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<Object>} The document handle
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
}
Expand Down
66 changes: 4 additions & 62 deletions src/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<![*_])\*([^*\n]+)\*(?![*])/g, '$1');
prefixPlain = prefixPlain.replace(/(?<![_*])_([^_\n]+)_(?![_])/g, '$1');
// Remove strikethrough
prefixPlain = prefixPlain.replace(/~~([^~]+)~~/g, '$1');
// Remove inline code
prefixPlain = prefixPlain.replace(/`([^`]+)`/g, '$1');
// Remove heading markers
prefixPlain = prefixPlain.replace(/^(#{1,6})\s+/gm, '');
// Remove blockquote markers
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;
Expand All @@ -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(/(?<![*_])\*([^*\n]+)\*(?![*])/g, '$1');
deleteTextPlain = deleteTextPlain.replace(/(?<![_*])_([^_\n]+)_(?![_])/g, '$1');
deleteTextPlain = deleteTextPlain.replace(/~~([^~]+)~~/g, '$1');
deleteTextPlain = deleteTextPlain.replace(/`([^`]+)`/g, '$1');
deleteTextPlain = deleteTextPlain.replace(/^(#{1,6})\s+/gm, '');
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(/(?<![*_])\*([^*\n]+)\*(?![*])/g, '$1');
deleteTextPlain = deleteTextPlain.replace(/(?<![_*])_([^_\n]+)_(?![_])/g, '$1');
deleteTextPlain = deleteTextPlain.replace(/~~([^~]+)~~/g, '$1');
deleteTextPlain = deleteTextPlain.replace(/`([^`]+)`/g, '$1');
deleteTextPlain = deleteTextPlain.replace(/^(#{1,6})\s+/gm, '');
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);
}
Expand Down