Add edit mode actions

More edit mode
This commit is contained in:
Caleb Owens 2024-08-19 11:03:51 +02:00
parent daa285f41e
commit 7ab7731a31
16 changed files with 524 additions and 15 deletions

19
Cargo.lock generated
View File

@ -2194,6 +2194,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "gitbutler-edit-mode"
version = "0.0.0"
dependencies = [
"anyhow",
"bstr",
"git2",
"gitbutler-branch",
"gitbutler-branch-actions",
"gitbutler-command-context",
"gitbutler-commit",
"gitbutler-operating-modes",
"gitbutler-project",
"gitbutler-reference",
"gitbutler-repo",
"gitbutler-time",
]
[[package]]
name = "gitbutler-error"
version = "0.0.0"
@ -2445,6 +2463,7 @@ dependencies = [
"gitbutler-command-context",
"gitbutler-config",
"gitbutler-diff",
"gitbutler-edit-mode",
"gitbutler-error",
"gitbutler-feedback",
"gitbutler-id",

View File

@ -29,6 +29,7 @@ members = [
"crates/gitbutler-url",
"crates/gitbutler-diff",
"crates/gitbutler-operating-modes",
"crates/gitbutler-edit-mode",
]
resolver = "2"
@ -79,6 +80,7 @@ gitbutler-tagged-string = { path = "crates/gitbutler-tagged-string" }
gitbutler-url = { path = "crates/gitbutler-url" }
gitbutler-diff = { path = "crates/gitbutler-diff" }
gitbutler-operating-modes = { path = "crates/gitbutler-operating-modes" }
gitbutler-edit-mode = { path = "crates/gitbutler-edit-mode" }
[profile.release]
codegen-units = 1 # Compile crates one after another so the compiler can optimize better

View File

@ -7,8 +7,9 @@
import { draggableCommit } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import BranchFilesList from '$lib/file/BranchFilesList.svelte';
import { ModeService } from '$lib/modes/service';
import { copyToClipboard } from '$lib/utils/clipboard';
import { getContext, getContextStore } from '$lib/utils/context';
import { getContext, getContextStore, maybeGetContext } from '$lib/utils/context';
import { openExternalUrl } from '$lib/utils/url';
import { BranchController } from '$lib/vbranches/branchController';
import { createCommitStore } from '$lib/vbranches/contexts';
@ -37,9 +38,12 @@
export let type: CommitStatus;
export let lines: Snippet<[number]> | undefined = undefined;
$: console.log(branch);
const branchController = getContext(BranchController);
const baseBranch = getContextStore(BaseBranch);
const project = getContext(Project);
const modeService = maybeGetContext(ModeService);
const commitStore = createCommitStore(commit);
$: commitStore.set(commit);
@ -116,6 +120,20 @@
let dragDirection: 'up' | 'down' | undefined;
let isDragTargeted = false;
function canEdit() {
if (isUnapplied) return false;
if (!modeService) return false;
if (!branch) return false;
return true;
}
async function edit() {
if (!canEdit()) return;
modeService!.enterEditMode(commit.id, branch!.refname);
}
</script>
<Modal bind:this={commitMessageModal} width="small">
@ -318,6 +336,9 @@
onclick={openCommitMessageModal}>Edit message</Button
>
{/if}
{#if canEdit()}
<Button size="tag" style="ghost" outline onclick={edit}>Edit patch</Button>
{/if}
</div>
{/if}
</div>

View File

@ -0,0 +1,78 @@
<script lang="ts">
import DecorativeSplitView from './DecorativeSplitView.svelte';
import ProjectNameLabel from '../shared/ProjectNameLabel.svelte';
import dzenPc from '$lib/assets/dzen-pc.svg?raw';
import { Project } from '$lib/backend/projects';
import { ModeService, type EditModeMetadata } from '$lib/modes/service';
import { getContext } from '$lib/utils/context';
import Button from '@gitbutler/ui/Button.svelte';
interface Props {
editModeMetadata: EditModeMetadata;
}
const { editModeMetadata }: Props = $props();
const project = getContext(Project);
const modeService = getContext(ModeService);
let modeServiceSaving = $state<'inert' | 'loading' | 'completed'>('inert');
async function save() {
modeServiceSaving = 'loading';
await modeService.saveEditAndReturnToWorkspace();
modeServiceSaving = 'completed';
}
</script>
<DecorativeSplitView img={dzenPc}>
<div class="switchrepo">
<div class="project-name">
<ProjectNameLabel projectName={project?.title} />
</div>
<p class="switchrepo__title text-18 text-body text-bold">
You are currently editing commit <span class="code-string">
{editModeMetadata.editeeCommitSha.slice(0, 7)}
</span>
</p>
<p class="switchrepo__message text-13 text-body">Bla bla bla</p>
<div class="switchrepo__actions">
<Button
style="pop"
kind="solid"
icon="undo-small"
reversedDirection
onclick={save}
loading={modeServiceSaving === 'loading'}
>
Save changes
</Button>
</div>
</div>
</DecorativeSplitView>
<style lang="postcss">
.project-name {
margin-bottom: 12px;
}
.switchrepo__title {
color: var(--clr-scale-ntrl-30);
margin-bottom: 12px;
}
.switchrepo__message {
color: var(--clr-scale-ntrl-50);
margin-bottom: 20px;
}
.switchrepo__actions {
display: flex;
gap: 8px;
padding-bottom: 24px;
flex-wrap: wrap;
}
</style>

View File

@ -1,7 +1,18 @@
import { invoke, listen } from '$lib/backend/ipc';
import { derived, writable } from 'svelte/store';
type Mode = { type: 'OpenWorkspace' } | { type: 'OutsideWorksapce' } | { type: 'Edit' };
export interface EditModeMetadata {
editeeCommitSha: string;
editeeBranch: string;
}
type Mode =
| { type: 'OpenWorkspace' }
| { type: 'OutsideWorkspace' }
| {
type: 'Edit';
subject: EditModeMetadata;
};
interface HeadAndMode {
head?: string;
operatingMode?: Mode;
@ -29,6 +40,20 @@ export class ModeService {
this.headAndMode.set({ head, operatingMode });
}
async enterEditMode(editeeCommitId: string, editeeBranchRef: string) {
await invoke('enter_edit_mode', {
projectId: this.projectId,
editee: editeeCommitId,
editeeBranch: editeeBranchRef
});
}
async saveEditAndReturnToWorkspace() {
await invoke('save_edit_and_return_to_workspace', {
projectId: this.projectId
});
}
}
function subscribeToHead(projectId: string, callback: (headAndMode: HeadAndMode) => void) {

View File

@ -132,6 +132,7 @@ export class VirtualBranch {
forkPoint!: string;
allowRebasing!: boolean;
pr?: PullRequest;
refname!: string;
get localCommits() {
return this.commits.filter((c) => c.status === 'local');

View File

@ -9,6 +9,7 @@
import { getNameNormalizationServiceContext } from '$lib/branches/nameNormalizationService';
import { BranchService, createBranchServiceStore } from '$lib/branches/service';
import { CommitDragActionsFactory } from '$lib/commits/dragActions';
import EditMode from '$lib/components/EditMode.svelte';
import NoBaseBranch from '$lib/components/NoBaseBranch.svelte';
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
@ -20,6 +21,7 @@
import History from '$lib/history/History.svelte';
import { HistoryService } from '$lib/history/history';
import MetricsReporter from '$lib/metrics/MetricsReporter.svelte';
import { ModeService } from '$lib/modes/service';
import Navigation from '$lib/navigation/Navigation.svelte';
import { persisted } from '$lib/persisted/persisted';
import { RemoteBranchService } from '$lib/stores/remoteBranches';
@ -69,6 +71,7 @@
setContext(ReorderDropzoneManagerFactory, data.reorderDropzoneManagerFactory);
setContext(RemoteBranchService, data.remoteBranchService);
setContext(BranchListingService, data.branchListingService);
setContext(ModeService, data.modeService);
});
let intervalId: any;
@ -98,7 +101,6 @@
// Refresh base branch if git fetch event is detected.
const mode = $derived(modeService.mode);
const head = $derived(modeService.head);
const openWorkspace = $derived($mode?.type === 'OpenWorkspace');
// We end up with a `state_unsafe_mutation` when switching projects if we
// don't use $effect.pre here.
@ -160,6 +162,8 @@
onDestroy(() => {
clearFetchInterval();
});
$inspect($mode);
</script>
<!-- forces components to be recreated when projectId changes -->
@ -180,9 +184,8 @@
<ProblemLoadingRepo error={$branchesError} />
{:else if $projectError}
<ProblemLoadingRepo error={$projectError} />
{:else if !openWorkspace && $baseBranch}
<NotOnGitButlerBranch baseBranch={$baseBranch} />
{:else if $baseBranch}
{#if $mode?.type === 'OpenWorkspace'}
<div class="view-wrap" role="group" ondragover={(e) => e.preventDefault()}>
<Navigation />
{#if $showHistoryView}
@ -190,6 +193,11 @@
{/if}
{@render children()}
</div>
{:else if $mode?.type === 'OutsideWorkspace'}
<NotOnGitButlerBranch baseBranch={$baseBranch} />
{:else if $mode?.type === 'Edit'}
<EditMode editModeMetadata={$mode.subject} />
{/if}
{/if}
<MetricsReporter {projectMetrics} />
{/key}

View File

@ -76,6 +76,7 @@ pub struct VirtualBranch {
/// The fork point between the target branch and the virtual branch
#[serde(with = "gitbutler_serde::oid_opt", default)]
pub fork_point: Option<git2::Oid>,
pub refname: Refname,
}
#[derive(Debug, PartialEq, Clone, Serialize)]
@ -366,6 +367,8 @@ pub fn list_virtual_branches(
.and_then(|c| c.parent(0).ok())
.map(|c| c.id());
let refname = branch.refname()?.into();
let branch = VirtualBranch {
id: branch.id,
name: branch.name,
@ -388,6 +391,7 @@ pub fn list_virtual_branches(
head: branch.head,
merge_base,
fork_point,
refname,
};
branches.push(branch);
}

View File

@ -0,0 +1,20 @@
[package]
name = "gitbutler-edit-mode"
version = "0.0.0"
edition = "2021"
authors = ["GitButler <gitbutler@gitbutler.com>"]
publish = false
[dependencies]
git2.workspace = true
anyhow.workspace = true
bstr.workspace = true
gitbutler-branch.workspace = true
gitbutler-commit.workspace = true
gitbutler-repo.workspace = true
gitbutler-command-context.workspace = true
gitbutler-operating-modes.workspace = true
gitbutler-project.workspace = true
gitbutler-branch-actions.workspace = true
gitbutler-reference.workspace = true
gitbutler-time.workspace = true

View File

@ -0,0 +1,42 @@
use anyhow::{Context, Result};
use gitbutler_command_context::CommandContext;
use gitbutler_operating_modes::{assure_edit_mode, assure_open_workspace_mode, EditModeMetadata};
use gitbutler_project::{access::WriteWorkspaceGuard, Project};
use gitbutler_reference::ReferenceName;
pub fn enter_edit_mode(
project: &Project,
editee: git2::Oid,
editee_branch: ReferenceName,
) -> Result<EditModeMetadata> {
let (ctx, mut guard) = open_with_permission(project)?;
assure_open_workspace_mode(&ctx)
.context("Entering edit mode may only be done when the workspace is open")?;
let editee = ctx
.repository()
.find_commit(editee)
.context("Failed to find editee commit")?;
let editee_branch = ctx
.repository()
.find_reference(&editee_branch)
.context("Failed to find editee branch reference")?;
crate::enter_edit_mode(&ctx, &editee, &editee_branch, guard.write_permission())
}
pub fn save_and_return_to_workspace(project: &Project) -> Result<()> {
let (ctx, mut guard) = open_with_permission(project)?;
assure_edit_mode(&ctx).context("Edit mode may only be left while in edit mode")?;
crate::save_and_return_to_workspace(&ctx, guard.write_permission())
}
fn open_with_permission(project: &Project) -> Result<(CommandContext, WriteWorkspaceGuard)> {
let ctx = CommandContext::open(project)?;
let guard = project.exclusive_worktree_access();
Ok((ctx, guard))
}

View File

@ -0,0 +1,250 @@
use std::str::FromStr;
use anyhow::{bail, Context, Result};
use bstr::ByteSlice;
use git2::build::CheckoutBuilder;
use gitbutler_branch::{signature, Branch, SignaturePurpose, VirtualBranchesHandle};
use gitbutler_branch_actions::{list_virtual_branches, update_gitbutler_integration};
use gitbutler_command_context::CommandContext;
use gitbutler_commit::{commit_ext::CommitExt, commit_headers::HasCommitHeaders};
use gitbutler_operating_modes::{
read_edit_mode_metadata, write_edit_mode_metadata, EditModeMetadata, EDIT_BRANCH_REF,
INTEGRATION_BRANCH_REF,
};
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_reference::{ReferenceName, Refname};
use gitbutler_repo::{
rebase::{cherry_rebase, cherry_rebase_group},
RepositoryExt,
};
pub mod commands;
pub const EDIT_UNCOMMITED_FILES_REF: &str = "refs/gitbutler/edit_uncommited_files";
fn save_uncommited_files(ctx: &CommandContext) -> Result<()> {
let repository = ctx.repository();
// Create a tree of all uncommited files
let mut index = repository.index().context("Failed to get index")?;
index
.add_all(["*"], git2::IndexAddOption::DEFAULT, None)
.context("Failed to add all to index")?;
index.write().context("Failed to write index")?;
let tree_oid = index
.write_tree()
.context("Failed to create tree from index")?;
let tree = repository
.find_tree(tree_oid)
.context("Failed to find tree")?;
// Commit tree and reference it
let author_signature =
signature(SignaturePurpose::Author).context("Failed to get gitbutler signature")?;
let committer_signature =
signature(SignaturePurpose::Committer).context("Failed to get gitbutler signature")?;
let head = repository.head().context("Failed to get head")?;
let head_commit = head.peel_to_commit().context("Failed to get head commit")?;
let commit = repository
.commit(
None,
&author_signature,
&committer_signature,
"Edit mode saved changes",
&tree,
&[&head_commit],
)
.context("Failed to write stash commit")?;
repository
.reference(EDIT_UNCOMMITED_FILES_REF, commit, true, "")
.context("Failed to reference uncommited files")?;
Ok(())
}
fn checkout_edit_branch(ctx: &CommandContext, editee: &git2::Commit) -> Result<()> {
let repository = ctx.repository();
// Checkout editee's parent
let editee_parent = editee.parent(0).context("Failed to get editee's parent")?;
repository
.reference(EDIT_BRANCH_REF, editee_parent.id(), true, "")
.context("Failed to update edit branch reference")?;
repository
.set_head(EDIT_BRANCH_REF)
.context("Failed to set head reference")?;
repository
.checkout_head(Some(CheckoutBuilder::new().force().remove_untracked(true)))
.context("Failed to checkout head")?;
// Checkout the editee as unstaged changes
let editee_tree = editee.tree().context("Failed to get editee's tree")?;
repository
.checkout_tree(
editee_tree.as_object(),
Some(CheckoutBuilder::new().force().remove_untracked(true)),
)
.context("Failed to checkout editee")?;
Ok(())
}
fn find_virtual_branch_by_reference(
ctx: &CommandContext,
reference: &ReferenceName,
) -> Result<Option<Branch>> {
let vb_state = VirtualBranchesHandle::new(ctx.project().gb_dir());
let all_virtual_branches = vb_state
.list_branches_in_workspace()
.context("Failed to read virtual branches")?;
Ok(all_virtual_branches.into_iter().find(|virtual_branch| {
let Ok(refname) = virtual_branch.refname() else {
return false;
};
let Ok(editee_refname) = Refname::from_str(reference) else {
return false;
};
editee_refname == refname.into()
}))
}
pub(crate) fn enter_edit_mode(
ctx: &CommandContext,
editee: &git2::Commit,
editee_branch: &git2::Reference,
_perm: &mut WorktreeWritePermission,
) -> Result<EditModeMetadata> {
let Some(editee_branch) = editee_branch.name() else {
bail!("Failed to get editee branch name");
};
let edit_mode_metadata = EditModeMetadata {
editee_commit_sha: editee.id(),
editee_branch: editee_branch.to_string().into(),
};
if find_virtual_branch_by_reference(ctx, &edit_mode_metadata.editee_branch)?.is_none() {
bail!("Can not enter edit mode for a reference which does not have a cooresponding virtual branch")
}
save_uncommited_files(ctx).context("Failed to save uncommited files")?;
checkout_edit_branch(ctx, editee).context("Failed to checkout edit branch")?;
write_edit_mode_metadata(ctx, &edit_mode_metadata).context("Failed to persist metadata")?;
Ok(edit_mode_metadata)
}
pub(crate) fn save_and_return_to_workspace(
ctx: &CommandContext,
perm: &mut WorktreeWritePermission,
) -> Result<()> {
let edit_mode_metadata = read_edit_mode_metadata(ctx).context("Failed to read metadata")?;
let repository = ctx.repository();
let vb_state = VirtualBranchesHandle::new(ctx.project().gb_dir());
// Get important references
let editee = repository
.find_commit(edit_mode_metadata.editee_commit_sha)
.context("Failed to find editee")?;
let editee_parent = editee.parent(0).context("Failed to get editee's parent")?;
let stashed_integration_changes_reference = repository
.find_reference(EDIT_UNCOMMITED_FILES_REF)
.context("Failed to find stashed integration changes")?;
let stashed_integration_changes_commit = stashed_integration_changes_reference
.peel_to_commit()
.context("Failed to get stashed changes commit")?;
let Some(mut editee_virtual_branch) =
find_virtual_branch_by_reference(ctx, &edit_mode_metadata.editee_branch)?
else {
bail!("Failed to find virtual branch for this reference. Entering and leaving edit mode for non-virtual branches is unsupported")
};
// Recommit editee
let mut index = repository.index().context("Failed to get index")?;
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.context("Failed to add all to index")?;
index.write().context("Failed to write index")?;
let tree_oid = index
.write_tree()
.context("Failed to create tree from index")?;
let tree = repository
.find_tree(tree_oid)
.context("Failed to find tree")?;
let new_editee_oid = ctx
.repository()
.commit_with_signature(
None,
&editee.author(),
&editee.committer(),
&editee.message_bstr().to_str_lossy(),
&tree,
&[&editee_parent],
editee.gitbutler_headers(),
)
.context("Failed to commit new editee")?;
// Rebase all all commits on top of the new editee and update reference
let new_editee_branch_head =
cherry_rebase(ctx, new_editee_oid, editee.id(), editee_virtual_branch.head)
.context("Failed to rebase commits onto new editee")?
.unwrap_or(new_editee_oid);
repository
.reference(
&edit_mode_metadata.editee_branch,
new_editee_branch_head,
true,
"",
)
.context("Failed to reference new editee branch head")?;
// Move back to gitbutler/integration and restore stashed changes
{
repository
.set_head(INTEGRATION_BRANCH_REF)
.context("Failed to set head reference")?;
repository
.checkout_head(Some(CheckoutBuilder::new().force().remove_untracked(true)))
.context("Failed to checkout gitbutler/integration")?;
editee_virtual_branch.head = new_editee_branch_head;
editee_virtual_branch.updated_timestamp_ms = gitbutler_time::time::now_ms();
vb_state
.set_branch(editee_virtual_branch)
.context("Failed to update vbstate")?;
let integration_commit_oid = update_gitbutler_integration(&vb_state, ctx)
.context("Failed to update gitbutler integration")?;
let rebased_stashed_integration_changes_commit = cherry_rebase_group(
ctx,
integration_commit_oid,
&mut [stashed_integration_changes_commit.id()],
)
.context("Failed to rebase stashed integration commit changes")?;
let commit_thing = repository
.find_commit(rebased_stashed_integration_changes_commit)
.context("Failed to find commit of rebased stashed integration changes commit oid")?;
let tree_thing = commit_thing
.tree()
.context("Failed to get tree of commit of rebased stashed integration changes")?;
repository
.checkout_tree(
tree_thing.as_object(),
Some(CheckoutBuilder::new().force().remove_untracked(true)),
)
.context("Failed to checkout stashed changes tree")?;
list_virtual_branches(ctx, perm).context("Failed to list virtual branches")?;
}
Ok(())
}

View File

@ -42,6 +42,7 @@ pub fn write_edit_mode_metadata(
/// Holds relevant state required to switch to and from edit mode
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EditModeMetadata {
/// The sha of the commit getting edited.
#[serde(with = "gitbutler_serde::oid")]

View File

@ -9,14 +9,18 @@ use crate::{LogUntil, RepoActionsExt, RepositoryExt};
/// cherry-pick based rebase, which handles empty commits
/// this function takes a commit range and generates a Vector of commit oids
/// and then passes them to `cherry_rebase_group` to rebase them onto the target commit
///
/// Returns the new head commit id
pub fn cherry_rebase(
ctx: &CommandContext,
target_commit_oid: git2::Oid,
start_commit_oid: git2::Oid,
end_commit_oid: git2::Oid,
to_commit_oid: git2::Oid,
from_commit_oid: git2::Oid,
) -> Result<Option<git2::Oid>> {
// get a list of the commits to rebase
let mut ids_to_rebase = ctx.l(end_commit_oid, LogUntil::Commit(start_commit_oid))?;
let mut ids_to_rebase = ctx.l(from_commit_oid, LogUntil::Commit(to_commit_oid))?;
dbg!(&ids_to_rebase);
if ids_to_rebase.is_empty() {
return Ok(None);

View File

@ -64,6 +64,7 @@ gitbutler-id.workspace = true
gitbutler-storage.workspace = true
gitbutler-diff.workspace = true
gitbutler-operating-modes.workspace = true
gitbutler-edit-mode.workspace = true
open = "5"
[dependencies.tauri]

View File

@ -195,6 +195,8 @@ fn main() {
remotes::list_remotes,
remotes::add_remote,
modes::operating_mode,
modes::enter_edit_mode,
modes::save_edit_and_return_to_workspace
])
.menu(menu::build(tauri_context.package_info()))
.on_menu_event(|event| menu::handle_event(&event))

View File

@ -1,11 +1,15 @@
use anyhow::Context;
use gitbutler_operating_modes::EditModeMetadata;
use gitbutler_operating_modes::OperatingMode;
use gitbutler_project::Controller;
use gitbutler_project::ProjectId;
use tauri::State;
use tracing::instrument;
use crate::error::Error;
#[tauri::command(async)]
#[instrument(skip(projects), err(Debug))]
pub fn operating_mode(
projects: State<'_, Controller>,
project_id: ProjectId,
@ -13,3 +17,30 @@ pub fn operating_mode(
let project = projects.get(project_id)?;
gitbutler_operating_modes::commands::operating_mode(&project).map_err(Into::into)
}
#[tauri::command(async)]
#[instrument(skip(projects), err(Debug))]
pub fn enter_edit_mode(
projects: State<'_, Controller>,
project_id: ProjectId,
editee: String,
editee_branch: String,
) -> Result<EditModeMetadata, Error> {
let project = projects.get(project_id)?;
let editee = git2::Oid::from_str(&editee).context("Failed to parse editee oid")?;
gitbutler_edit_mode::commands::enter_edit_mode(&project, editee, editee_branch.into())
.map_err(Into::into)
}
#[tauri::command(async)]
#[instrument(skip(projects), err(Debug))]
pub fn save_edit_and_return_to_workspace(
projects: State<'_, Controller>,
project_id: ProjectId,
) -> Result<(), Error> {
let project = projects.get(project_id)?;
gitbutler_edit_mode::commands::save_and_return_to_workspace(&project).map_err(Into::into)
}