From 82b58b6875d6aacfdda2d8edb4943f48dee5704e Mon Sep 17 00:00:00 2001 From: Caleb Owens Date: Wed, 9 Oct 2024 15:26:43 +0200 Subject: [PATCH] Make edit mode fantastic --- .../src/lib/components/EditMode.svelte | 70 ++++++++++++++----- apps/desktop/src/lib/modes/service.ts | 17 +++-- apps/desktop/src/lib/vbranches/types.ts | 20 ------ crates/gitbutler-edit-mode/src/commands.rs | 6 +- crates/gitbutler-edit-mode/src/lib.rs | 62 +++++++++++++--- crates/gitbutler-tauri/src/modes.rs | 3 +- packages/ui/src/lib/file/FileListItem.svelte | 12 ++-- 7 files changed, 132 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/lib/components/EditMode.svelte b/apps/desktop/src/lib/components/EditMode.svelte index 19816d123..fba157b5b 100644 --- a/apps/desktop/src/lib/components/EditMode.svelte +++ b/apps/desktop/src/lib/components/EditMode.svelte @@ -3,7 +3,11 @@ import { CommitService } from '$lib/commits/service'; import { editor } from '$lib/editorLink/editorLink'; import FileContextMenu from '$lib/file/FileContextMenu.svelte'; - import { ModeService, type EditModeMetadata } from '$lib/modes/service'; + import { + ModeService, + type ConflictEntryPresence, + type EditModeMetadata + } from '$lib/modes/service'; import ScrollableContainer from '$lib/scroll/ScrollableContainer.svelte'; import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher'; import { getContext } from '$lib/utils/context'; @@ -33,7 +37,7 @@ let modeServiceAborting = $state<'inert' | 'loading' | 'completed'>('inert'); let modeServiceSaving = $state<'inert' | 'loading' | 'completed'>('inert'); - let initialFiles = $state([]); + let initialFiles = $state<[RemoteFile, ConflictEntryPresence | undefined][]>([]); let commit = $state(undefined); let filesList = $state(undefined); @@ -55,9 +59,34 @@ name: string; path: string; conflicted: boolean; + conflictHint?: string; status?: FileStatus; } + function conflictEntryHint(presence: ConflictEntryPresence): string { + let defaultVerb = 'added'; + + if (presence.ancestor) { + defaultVerb = 'modified'; + } + + let oursVerb = defaultVerb; + + if (!presence.ours) { + oursVerb = 'deleted'; + } + + let theirsVerb = defaultVerb; + + if (!presence.theirs) { + theirsVerb = 'deleted'; + } + + let output = `You have ${theirsVerb} this file, They have ${oursVerb} this file.`; + + return output; + } + const files = $derived.by(() => { const initialFileMap = new Map(); const uncommitedFileMap = new Map(); @@ -65,7 +94,7 @@ // Build maps of files { - initialFiles.forEach((initialFile) => { + initialFiles.forEach(([initialFile]) => { initialFileMap.set(initialFile.path, initialFile); }); @@ -76,14 +105,21 @@ // Create output { - initialFiles.forEach((initialFile) => { + initialFiles.forEach(([initialFile, conflictEntryPresence]) => { const isDeleted = uncommitedFileMap.has(initialFile.path); + if (conflictEntryPresence) { + console.log(initialFile.path, conflictEntryPresence); + } + outputMap.set(initialFile.path, { name: initialFile.filename, path: initialFile.path, - conflicted: initialFile.looksConflicted, - status: isDeleted ? undefined : 'D' + conflicted: !!conflictEntryPresence, + conflictHint: conflictEntryPresence + ? conflictEntryHint(conflictEntryPresence) + : undefined, + status: isDeleted || !!conflictEntryPresence ? undefined : 'D' }); }); @@ -95,18 +131,13 @@ (hunk) => !uncommitedFile.hunks.map((hunk) => hunk.diff).includes(hunk.diff) ); - if (fileChanged && !uncommitedFile.looksConflicted) { + if (fileChanged) { // All initial entries should have been added to the map, // so we can safely assert that it will be present const outputFile = outputMap.get(uncommitedFile.path)!; - outputFile.status = 'M'; - outputFile.conflicted = false; - return; - } - - if (uncommitedFile.looksConflicted) { - const outputFile = outputMap.get(uncommitedFile.path)!; - outputFile.conflicted = true; + if (!outputFile.conflicted) { + outputFile.status = 'M'; + } return; } @@ -122,8 +153,8 @@ }); } - const files = Array.from(outputMap.values()); - files.sort((a, b) => { + const orderedOutput = Array.from(outputMap.values()); + orderedOutput.sort((a, b) => { // Float conflicted files to the top if (a.conflicted && !b.conflicted) { return -1; @@ -134,7 +165,7 @@ return a.path.localeCompare(b.path); }); - return files; + return orderedOutput; }); const conflictedFiles = $derived(files.filter((file) => file.conflicted)); @@ -201,12 +232,13 @@ - {#each files as file} + {#each files as file (file.path)}
{ contextMenu?.open(e, { files: [file] }); diff --git a/apps/desktop/src/lib/modes/service.ts b/apps/desktop/src/lib/modes/service.ts index 5cbbe7efe..e6e644c67 100644 --- a/apps/desktop/src/lib/modes/service.ts +++ b/apps/desktop/src/lib/modes/service.ts @@ -8,6 +8,12 @@ export interface EditModeMetadata { branchReference: string; } +export interface ConflictEntryPresence { + ours: boolean; + theirs: boolean; + ancestor: boolean; +} + type Mode = | { type: 'OpenWorkspace' } | { type: 'OutsideWorkspace' } @@ -64,10 +70,13 @@ export class ModeService { } async getInitialIndexState() { - return plainToInstance( - RemoteFile, - await invoke('edit_initial_index_state', { projectId: this.projectId }) - ); + const rawOutput = await invoke('edit_initial_index_state', { + projectId: this.projectId + }); + + return rawOutput.map((entry) => { + return [plainToInstance(RemoteFile, entry[0]), entry[1] as ConflictEntryPresence | undefined]; + }) as [RemoteFile, ConflictEntryPresence | undefined][]; } } diff --git a/apps/desktop/src/lib/vbranches/types.ts b/apps/desktop/src/lib/vbranches/types.ts index 1a13c5840..4eb42c159 100644 --- a/apps/desktop/src/lib/vbranches/types.ts +++ b/apps/desktop/src/lib/vbranches/types.ts @@ -84,10 +84,6 @@ export class LocalFile { .filter(notNull) .filter(isDefined); } - - get looksConflicted(): boolean { - return fileLooksConflicted(this); - } } export class SkippedFile { @@ -312,22 +308,6 @@ export class RemoteFile { get locked(): boolean { return false; } - - get looksConflicted(): boolean { - return fileLooksConflicted(this); - } -} - -function fileLooksConflicted(file: AnyFile) { - const hasStartingMarker = file.hunks.some((hunk) => - hunk.diff.split('\n').some((line) => line.startsWith('>>>>>>> theirs', 1)) - ); - - const hasEndingMarker = file.hunks.some((hunk) => - hunk.diff.split('\n').some((line) => line.startsWith('<<<<<<< ours', 1)) - ); - - return hasStartingMarker && hasEndingMarker; } export interface Author { diff --git a/crates/gitbutler-edit-mode/src/commands.rs b/crates/gitbutler-edit-mode/src/commands.rs index 4b5faf08c..8c9935b77 100644 --- a/crates/gitbutler-edit-mode/src/commands.rs +++ b/crates/gitbutler-edit-mode/src/commands.rs @@ -9,6 +9,8 @@ use gitbutler_oplog::{ use gitbutler_project::{access::WriteWorkspaceGuard, Project}; use gitbutler_reference::ReferenceName; +use crate::ConflictEntryPresence; + pub fn enter_edit_mode( project: &Project, commit_oid: git2::Oid, @@ -61,7 +63,9 @@ pub fn abort_and_return_to_workspace(project: &Project) -> Result<()> { crate::abort_and_return_to_workspace(&ctx, guard.write_permission()) } -pub fn starting_index_state(project: &Project) -> Result> { +pub fn starting_index_state( + project: &Project, +) -> Result)>> { let (ctx, guard) = open_with_permission(project)?; assure_edit_mode(&ctx)?; diff --git a/crates/gitbutler-edit-mode/src/lib.rs b/crates/gitbutler-edit-mode/src/lib.rs index 8fe7e61c6..3a3eda84c 100644 --- a/crates/gitbutler-edit-mode/src/lib.rs +++ b/crates/gitbutler-edit-mode/src/lib.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; +use std::path::PathBuf; use std::str::FromStr; use anyhow::{bail, Context, Result}; @@ -25,6 +27,7 @@ use gitbutler_reference::{ReferenceName, Refname}; use gitbutler_repo::{rebase::cherry_rebase, RepositoryExt}; use gitbutler_stack::{Stack, VirtualBranchesHandle}; use gitbutler_stack_api::StackExt; +use serde::Serialize; pub mod commands; @@ -76,7 +79,7 @@ fn checkout_edit_branch(ctx: &CommandContext, commit: &git2::Commit) -> Result<( // Checkout commits's parent let commit_parent = if commit.is_conflicted() { - let base_tree = repository.find_real_tree(commit, ConflictedTreeKey::Base)?; + let base_tree = repository.find_real_tree(commit, ConflictedTreeKey::Ours)?; let base = repository.commit( None, @@ -243,10 +246,18 @@ pub(crate) fn save_and_return_to_workspace( Ok(()) } +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConflictEntryPresence { + pub ours: bool, + pub theirs: bool, + pub ancestor: bool, +} + pub(crate) fn starting_index_state( ctx: &CommandContext, _perm: &WorktreeReadPermission, -) -> Result> { +) -> Result)>> { let OperatingMode::Edit(metadata) = operating_mode(ctx) else { bail!("Starting index state can only be fetched while in edit mode") }; @@ -254,22 +265,55 @@ pub(crate) fn starting_index_state( let repository = ctx.repository(); let commit = repository.find_commit(metadata.commit_oid)?; - let commit_parent = commit.parent(0)?; - let commit_parent_tree = repository.find_real_tree(&commit_parent, Default::default())?; + let commit_parent_tree = if commit.is_conflicted() { + repository.find_real_tree(&commit, ConflictedTreeKey::Ours)? + } else { + commit.parent(0)?.tree()? + }; let index = get_commit_index(repository, &commit)?; + let conflicts = index + .conflicts()? + .filter_map(|conflict| { + let Ok(conflict) = conflict else { + return None; + }; + + let path = conflict + .ancestor + .as_ref() + .or(conflict.our.as_ref()) + .or(conflict.their.as_ref()) + .map(|entry| PathBuf::from(entry.path.to_str_lossy().to_string()))?; + + Some(( + path, + ConflictEntryPresence { + ours: conflict.our.is_some(), + theirs: conflict.their.is_some(), + ancestor: conflict.ancestor.is_some(), + }, + )) + }) + .collect::>(); + + dbg!(&conflicts); + let diff = repository.diff_tree_to_index(Some(&commit_parent_tree), Some(&index), None)?; let diff_files = hunks_by_filepath(Some(repository), &diff)? .into_iter() .map(|(path, file)| { let binary = file.hunks.iter().any(|h| h.binary); - RemoteBranchFile { - path, - hunks: file.hunks, - binary, - } + ( + RemoteBranchFile { + path: path.clone(), + hunks: file.hunks, + binary, + }, + conflicts.get(&path).cloned(), + ) }) .collect(); diff --git a/crates/gitbutler-tauri/src/modes.rs b/crates/gitbutler-tauri/src/modes.rs index ffdfd3a30..9aa989b29 100644 --- a/crates/gitbutler-tauri/src/modes.rs +++ b/crates/gitbutler-tauri/src/modes.rs @@ -1,5 +1,6 @@ use anyhow::Context; use gitbutler_branch_actions::RemoteBranchFile; +use gitbutler_edit_mode::ConflictEntryPresence; use gitbutler_operating_modes::EditModeMetadata; use gitbutler_operating_modes::OperatingMode; use gitbutler_project::Controller; @@ -62,7 +63,7 @@ pub fn save_edit_and_return_to_workspace( pub fn edit_initial_index_state( projects: State<'_, Controller>, project_id: ProjectId, -) -> Result, Error> { +) -> Result)>, Error> { let project = projects.get(project_id)?; gitbutler_edit_mode::commands::starting_index_state(&project).map_err(Into::into) diff --git a/packages/ui/src/lib/file/FileListItem.svelte b/packages/ui/src/lib/file/FileListItem.svelte index e7acfd238..a363c8d31 100644 --- a/packages/ui/src/lib/file/FileListItem.svelte +++ b/packages/ui/src/lib/file/FileListItem.svelte @@ -20,6 +20,7 @@ checked?: boolean; indeterminate?: boolean; conflicted?: boolean; + conflictHint?: string; locked?: boolean; lockText?: string; stacking?: boolean; @@ -47,6 +48,7 @@ checked = $bindable(), indeterminate, conflicted, + conflictHint, locked, lockText, stacking = false, @@ -57,7 +59,7 @@ oncontextmenu }: Props = $props(); - const fileInfo = splitFilePath(filePath); + const fileInfo = $derived(splitFilePath(filePath));
- -
+ +
+ +
+
{/if} {#if fileStatus}